Files
multica/packages/views/issues/hooks/issue-delete-mutations.test.tsx
Bohan Jiang a123dfc2df MUL-3508: stage sub-issues so the parent wakes per stage, not per child (#4410)
* feat(issues): stage sub-issues so the parent wakes per stage, not per child

Sub-issues under a parent can be grouped into ordered stages (issue.stage).
The child-done -> parent notification + assignee wake now fire only when a
stage barrier closes: every sub-issue in the lowest unfinished stage has
reached a terminal status (done/cancelled). An unstaged sibling set is one
implicit stage, so the parent is woken once when the last sub-issue finishes
instead of on every child — the default fix for the fire-on-every-child
cascade reported in discussion #4320 / MUL-3508.

Stage advancement stays agent-driven: the server only detects the closed
barrier and wakes the parent assignee, who decides whether to promote the
next stage.

- DB: nullable issue.stage (CHECK >= 1) + sqlc regen
- API: stage on issue create/update/response and batch update
- CLI: `issue create`/`issue update` --stage; new `issue children` command
  that lists sub-issues grouped by stage (table + json)
- stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the
  wake comment now stage-aware, plus unit tests
- skill docs (multica-working-on-issues SKILL.md + source map)

Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a
follow-up; the API already returns stage for it to consume.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): address review on stage barrier (cancel, batch, unstaged)

Resolves the three blockers from the PR review:

1. Cancel can close a stage. The child-done barrier now fires on any
   non-terminal -> terminal transition (done OR cancelled), not just done.
   isTerminalChildStatus already treats cancelled as terminal (a cancelled
   sibling never finishes, so it must not hold a stage open), so a cancelled
   last-open child now closes its stage and wakes the parent. Keying on the
   transition also makes a later cancelled -> done edit a no-op, avoiding a
   lagging duplicate wake.

2. Batch update of stage no longer no-ops. `hasMutation` now includes
   "stage", so `{"updates":{"stage":N}}` persists instead of returning
   {"updated": 0}.

3. Unstaged children no longer participate in the staged frontier. In a
   staged sibling set, NULL-stage children neither hold a stage open nor fire
   on their own completion, and the wake comment no longer renders "Stage 0".
   This matches migration 123 ("NULL does not participate in staged
   grouping") and the CLI's separate unstaged group, removing the footgun
   where an unstaged backlog child silently blocked Stage 1.

Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a
staged set, stage summary skips unstaged, and a stage-only batch update
persists.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage

Frontend for the sub-issue stage feature (web + desktop, shared via packages):

- core: `stage` on the Issue type + create/update request types; zod
  IssueSchema parses it (defaults to null for older backends) with schema
  tests for the numeric and omitted cases.
- StagePicker component (mirrors the other property pickers): "No stage" +
  Stage 1..N, offering one beyond the current/sibling max.
- Create-issue modal: a Stage pill, shown only when a parent is selected,
  threaded into the create payload.
- Issue detail sidebar: an editable Stage row + "add property" entry, gated to
  sub-issues (issues with a parent).
- Sub-issue list grouped by stage with per-stage headers (flat when unstaged).
- i18n: stage keys across en / zh-Hans / ja / ko (parity test passes).

Verified: full typecheck (6/6), core (591) + views (1433) vitest suites,
lint clean (no new findings). Backend/CLI shipped earlier in this PR.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* test(issues): add stage to Issue fixtures merged from main

The merge brought in new Issue fixtures that predate the required `stage`
field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and
the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy
the Issue type (mobile reuses core's IssueSchema for parsing, so only the
sentinel needs it).

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): feed StagePicker the sibling max stage so higher stages stay selectable

The StagePicker accepts maxStage to extend its option list beyond the
floored Stage 1-3, but neither call site passed it, so a parent with an
existing Stage 4/5 child could not pick that stage when creating a new
sub-issue or editing one in the sidebar.

- Compute the sibling max stage at both call sites: the create modal now
  loads the parent's children (childIssuesOptions) and the detail sidebar
  reuses the already-loaded parentChildIssues.
- Extract maxSiblingStage + stageOptions as pure helpers on stage-picker
  and unit-test them (the regression: a Stage 5 sibling keeps Stage 5
  selectable and offers Stage 6).

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 00:14:42 +08:00

656 lines
22 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import {
QueryClient,
QueryClientProvider,
QueryObserver,
} from "@tanstack/react-query";
import { setApiInstance } from "@multica/core/api";
import { agentTaskSnapshotKeys, agentTasksKeys } from "@multica/core/agents/queries";
import { useBatchDeleteIssues, useDeleteIssue } from "@multica/core/issues/mutations";
import { issueKeys } from "@multica/core/issues/queries";
import { labelKeys } from "@multica/core/labels/queries";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { workspaceKeys } from "@multica/core/workspace/queries";
import type {
AgentTask,
Attachment,
Issue,
IssueLabelsResponse,
IssueUsageSummary,
Label,
ListIssuesCache,
TimelineEntry,
Workspace,
} from "@multica/core/types";
const WS_ID = "ws-1";
const SLUG = "test";
const ISSUE_ID = "issue-1";
const OTHER_ISSUE_ID = "issue-2";
const PARENT_ISSUE_ID = "parent-1";
const AGENT_ID = "agent-1";
const workspace: Workspace = {
id: WS_ID,
name: "Test",
slug: SLUG,
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "TST",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const baseIssue: Issue = {
id: ISSUE_ID,
workspace_id: WS_ID,
number: 1,
identifier: "TST-1",
title: "Deleted issue",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "member-1",
parent_issue_id: PARENT_ISSUE_ID,
project_id: null,
position: 0,
stage: null,
start_date: null,
due_date: null,
metadata: {},
labels: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const otherIssue: Issue = {
...baseIssue,
id: OTHER_ISSUE_ID,
number: 2,
identifier: "TST-2",
title: "Other issue",
parent_issue_id: null,
};
const usage: IssueUsageSummary = {
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_read_tokens: 1,
total_cache_write_tokens: 2,
task_count: 1,
};
const attachment: Attachment = {
id: "attachment-1",
workspace_id: WS_ID,
issue_id: ISSUE_ID,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "member-1",
filename: "evidence.png",
url: "s3://bucket/evidence.png",
download_url: "https://example.test/evidence.png",
markdown_url: "https://example.test/api/attachments/attachment-1/download",
content_type: "image/png",
size_bytes: 1,
created_at: "2026-01-01T00:00:00Z",
};
const timeline: TimelineEntry[] = [
{
type: "activity",
id: "activity-1",
actor_type: "member",
actor_id: "member-1",
action: "created",
created_at: "2026-01-01T00:00:00Z",
},
];
const label: Label = {
id: "label-1",
workspace_id: WS_ID,
name: "bug",
color: "#ef4444",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const issueLabels: IssueLabelsResponse = {
labels: [label],
};
function makeListCache(...issues: Issue[]): ListIssuesCache {
return {
byStatus: {
todo: { issues, total: issues.length },
},
};
}
function makeTask(issueId = ISSUE_ID): AgentTask {
return {
id: `task-${issueId}`,
agent_id: AGENT_ID,
runtime_id: "runtime-1",
issue_id: issueId,
status: "completed",
priority: 0,
dispatched_at: null,
started_at: "2026-01-01T00:00:00Z",
completed_at: "2026-01-01T00:01:00Z",
result: null,
error: null,
created_at: "2026-01-01T00:00:00Z",
};
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function setup(
deleteIssue = vi.fn().mockResolvedValue(undefined),
batchDeleteIssues = vi.fn().mockResolvedValue({ deleted: 0 }),
) {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
qc.setQueryData(workspaceKeys.list(), [workspace]);
setApiInstance({
listWorkspaces: vi.fn().mockResolvedValue([workspace]),
deleteIssue,
batchDeleteIssues,
} as any);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>
<WorkspaceSlugProvider slug={SLUG}>{children}</WorkspaceSlugProvider>
</QueryClientProvider>
);
return { qc, deleteIssue, batchDeleteIssues, wrapper };
}
function ids(cache: ListIssuesCache | undefined) {
return cache?.byStatus.todo?.issues.map((issue) => issue.id);
}
function expectInvalidated(qc: QueryClient, queryKey: readonly unknown[]) {
expect(qc.getQueryState(queryKey)?.isInvalidated).toBe(true);
}
describe("useDeleteIssue", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("cleans list, my-list, issue-scoped, and dependent agent caches after a successful single delete", async () => {
const { qc, deleteIssue, wrapper } = setup();
const assignedFilter = { assignee_id: AGENT_ID };
const createdFilter = { creator_id: "member-1" };
qc.setQueryData<ListIssuesCache>(
issueKeys.list(WS_ID),
makeListCache(baseIssue, otherIssue),
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", assignedFilter),
makeListCache(baseIssue, otherIssue),
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "created", createdFilter),
makeListCache(baseIssue),
);
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
[baseIssue, otherIssue],
);
qc.setQueryData<IssueUsageSummary>(issueKeys.usage(ISSUE_ID), usage);
qc.setQueryData<AgentTask[]>(agentTaskSnapshotKeys.list(WS_ID), [
makeTask(),
]);
qc.setQueryData<AgentTask[]>(agentTasksKeys.detail(WS_ID, AGENT_ID), [
makeTask(),
]);
const { result } = renderHook(() => useDeleteIssue(), { wrapper });
await act(async () => {
await result.current.mutateAsync(ISSUE_ID);
});
expect(deleteIssue).toHaveBeenCalledWith(ISSUE_ID);
expect(ids(qc.getQueryData(issueKeys.list(WS_ID)))).toEqual([
OTHER_ISSUE_ID,
]);
expect(
ids(qc.getQueryData(issueKeys.myList(WS_ID, "assigned", assignedFilter))),
).toEqual([OTHER_ISSUE_ID]);
expect(
ids(qc.getQueryData(issueKeys.myList(WS_ID, "created", createdFilter))),
).toEqual([]);
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.usage(ISSUE_ID))).toBeUndefined();
expect(
qc
.getQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID))
?.map((issue) => issue.id),
).toEqual([OTHER_ISSUE_ID]);
expectInvalidated(qc, agentTaskSnapshotKeys.list(WS_ID));
expectInvalidated(qc, agentTasksKeys.detail(WS_ID, AGENT_ID));
});
it("optimistically prunes parent children without invalidating them before delete settles", async () => {
const pendingDelete = deferred<void>();
const { qc, wrapper } = setup(vi.fn(() => pendingDelete.promise));
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
[baseIssue, otherIssue],
);
const { result } = renderHook(() => useDeleteIssue(), { wrapper });
let mutation!: Promise<void>;
await act(async () => {
mutation = result.current.mutateAsync(ISSUE_ID);
await Promise.resolve();
});
expect(
qc
.getQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID))
?.map((issue) => issue.id),
).toEqual([OTHER_ISSUE_ID]);
expect(
qc.getQueryState(issueKeys.children(WS_ID, PARENT_ISSUE_ID))
?.isInvalidated,
).not.toBe(true);
await act(async () => {
pendingDelete.resolve();
await mutation;
});
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
});
it("invalidates child progress when a single delete removes a parent issue", async () => {
const { qc, wrapper } = setup();
const parentIssue = { ...baseIssue, parent_issue_id: null };
const childIssue = { ...otherIssue, parent_issue_id: ISSUE_ID };
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), parentIssue);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [
childIssue,
]);
qc.setQueryData(
issueKeys.childProgress(WS_ID),
new Map([[ISSUE_ID, { done: 0, total: 1 }]]),
);
const { result } = renderHook(() => useDeleteIssue(), { wrapper });
await act(async () => {
await result.current.mutateAsync(ISSUE_ID);
});
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("restores optimistic snapshots when a single delete fails", async () => {
const error = new Error("delete failed");
const { qc, wrapper } = setup(vi.fn().mockRejectedValue(error));
const assignedFilter = { assignee_id: AGENT_ID };
const list = makeListCache(baseIssue, otherIssue);
const myList = makeListCache(baseIssue);
const children = [baseIssue, otherIssue];
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), list);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", assignedFilter),
myList,
);
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
children,
);
qc.setQueryData<IssueUsageSummary>(issueKeys.usage(ISSUE_ID), usage);
const { result } = renderHook(() => useDeleteIssue(), { wrapper });
await expect(
act(async () => {
await result.current.mutateAsync(ISSUE_ID);
}),
).rejects.toThrow("delete failed");
expect(qc.getQueryData(issueKeys.list(WS_ID))).toEqual(list);
expect(
qc.getQueryData(issueKeys.myList(WS_ID, "assigned", assignedFilter)),
).toEqual(myList);
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toEqual(
baseIssue,
);
expect(qc.getQueryData(issueKeys.children(WS_ID, PARENT_ISSUE_ID))).toEqual(
children,
);
expect(qc.getQueryData(issueKeys.usage(ISSUE_ID))).toEqual(usage);
});
});
describe("useBatchDeleteIssues", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("cleans list, my-list, issue-scoped, parent, and dependent agent caches after a fully successful batch delete", async () => {
const batchDeleteIssues = vi.fn().mockResolvedValue({ deleted: 2 });
const { qc, wrapper } = setup(undefined, batchDeleteIssues);
const assignedFilter = { assignee_id: AGENT_ID };
const createdFilter = { creator_id: "member-1" };
const idsToDelete = [ISSUE_ID, OTHER_ISSUE_ID];
const childIssue = { ...otherIssue, parent_issue_id: PARENT_ISSUE_ID };
qc.setQueryData<ListIssuesCache>(
issueKeys.list(WS_ID),
makeListCache(baseIssue, childIssue),
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", assignedFilter),
makeListCache(baseIssue, childIssue),
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "created", createdFilter),
makeListCache(baseIssue),
);
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, OTHER_ISSUE_ID), childIssue);
qc.setQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
[baseIssue, childIssue],
);
qc.setQueryData(issueKeys.childProgress(WS_ID), {});
qc.setQueryData<IssueUsageSummary>(issueKeys.usage(ISSUE_ID), usage);
qc.setQueryData<IssueUsageSummary>(
issueKeys.usage(OTHER_ISSUE_ID),
usage,
);
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
qc.setQueryData<AgentTask[]>(issueKeys.tasks(OTHER_ISSUE_ID), [
makeTask(OTHER_ISSUE_ID),
]);
qc.setQueryData<AgentTask[]>(agentTaskSnapshotKeys.list(WS_ID), [
makeTask(),
]);
qc.setQueryData<AgentTask[]>(agentTasksKeys.detail(WS_ID, AGENT_ID), [
makeTask(),
]);
const { result } = renderHook(() => useBatchDeleteIssues(), { wrapper });
await act(async () => {
await result.current.mutateAsync(idsToDelete);
});
expect(batchDeleteIssues).toHaveBeenCalledWith(idsToDelete);
expect(ids(qc.getQueryData(issueKeys.list(WS_ID)))).toEqual([]);
expect(
ids(qc.getQueryData(issueKeys.myList(WS_ID, "assigned", assignedFilter))),
).toEqual([]);
expect(
ids(qc.getQueryData(issueKeys.myList(WS_ID, "created", createdFilter))),
).toEqual([]);
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
expect(
qc.getQueryData(issueKeys.detail(WS_ID, OTHER_ISSUE_ID)),
).toBeUndefined();
expect(qc.getQueryData(issueKeys.usage(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.usage(OTHER_ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.tasks(OTHER_ISSUE_ID))).toBeUndefined();
expect(
qc.getQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID)),
).toEqual([]);
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
expectInvalidated(qc, agentTaskSnapshotKeys.list(WS_ID));
expectInvalidated(qc, agentTasksKeys.detail(WS_ID, AGENT_ID));
});
it("invalidates child progress when a full batch delete removes a parent issue", async () => {
const batchDeleteIssues = vi.fn().mockResolvedValue({ deleted: 1 });
const { qc, wrapper } = setup(undefined, batchDeleteIssues);
const parentIssue = { ...baseIssue, parent_issue_id: null };
const childIssue = { ...otherIssue, parent_issue_id: ISSUE_ID };
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), parentIssue);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [
childIssue,
]);
qc.setQueryData(
issueKeys.childProgress(WS_ID),
new Map([[ISSUE_ID, { done: 0, total: 1 }]]),
);
const { result } = renderHook(() => useBatchDeleteIssues(), { wrapper });
await act(async () => {
await result.current.mutateAsync([ISSUE_ID]);
});
expect(batchDeleteIssues).toHaveBeenCalledWith([ISSUE_ID]);
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("restores optimistic list snapshots on partial batch delete before invalidating caches", async () => {
const batchDeleteIssues = vi.fn().mockResolvedValue({ deleted: 1 });
const { qc, wrapper } = setup(undefined, batchDeleteIssues);
const assignedFilter = { assignee_id: AGENT_ID };
const createdFilter = { creator_id: "member-1" };
const idsToDelete = [ISSUE_ID, OTHER_ISSUE_ID];
const childIssue = { ...otherIssue, parent_issue_id: PARENT_ISSUE_ID };
const list = makeListCache(baseIssue, childIssue);
const assignedMyList = makeListCache(baseIssue, childIssue);
const createdMyList = makeListCache(baseIssue);
const children = [baseIssue, childIssue];
qc.setQueryData<ListIssuesCache>(
issueKeys.list(WS_ID),
list,
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", assignedFilter),
assignedMyList,
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "created", createdFilter),
createdMyList,
);
qc.setQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
children,
);
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, OTHER_ISSUE_ID), childIssue);
qc.setQueryData<IssueUsageSummary>(issueKeys.usage(ISSUE_ID), usage);
qc.setQueryData<Attachment[]>(issueKeys.attachments(ISSUE_ID), [
attachment,
]);
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(ISSUE_ID), timeline);
qc.setQueryData<IssueLabelsResponse>(
labelKeys.byIssue(WS_ID, ISSUE_ID),
issueLabels,
);
qc.setQueryData<IssueUsageSummary>(
issueKeys.usage(OTHER_ISSUE_ID),
usage,
);
qc.setQueryData<Attachment[]>(issueKeys.attachments(OTHER_ISSUE_ID), [
{ ...attachment, id: "attachment-2", issue_id: OTHER_ISSUE_ID },
]);
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(OTHER_ISSUE_ID), [
{ ...timeline[0]!, id: "activity-2" },
]);
qc.setQueryData<IssueLabelsResponse>(
labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID),
issueLabels,
);
qc.setQueryData<AgentTask[]>(agentTaskSnapshotKeys.list(WS_ID), [
makeTask(),
]);
qc.setQueryData<AgentTask[]>(agentTasksKeys.detail(WS_ID, AGENT_ID), [
makeTask(),
]);
const listRefetch = deferred<ListIssuesCache>();
const listObserver = new QueryObserver(qc, {
queryKey: issueKeys.list(WS_ID),
queryFn: () => listRefetch.promise,
refetchOnMount: false,
staleTime: Infinity,
});
const unsubscribeList = listObserver.subscribe(() => {});
const { result } = renderHook(() => useBatchDeleteIssues(), { wrapper });
try {
await act(async () => {
await result.current.mutateAsync(idsToDelete);
});
expect(batchDeleteIssues).toHaveBeenCalledWith(idsToDelete);
expect(qc.getQueryData(issueKeys.list(WS_ID))).toEqual(list);
expect(
qc.getQueryData(issueKeys.myList(WS_ID, "assigned", assignedFilter)),
).toEqual(assignedMyList);
expect(
qc.getQueryData(issueKeys.myList(WS_ID, "created", createdFilter)),
).toEqual(createdMyList);
expect(
qc.getQueryData(issueKeys.children(WS_ID, PARENT_ISSUE_ID)),
).toEqual(children);
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toEqual(
baseIssue,
);
expect(qc.getQueryData(issueKeys.detail(WS_ID, OTHER_ISSUE_ID))).toEqual(
childIssue,
);
expect(qc.getQueryData(issueKeys.usage(ISSUE_ID))).toEqual(usage);
expect(qc.getQueryData(issueKeys.attachments(ISSUE_ID))).toEqual([
attachment,
]);
expect(qc.getQueryData(issueKeys.timeline(ISSUE_ID))).toEqual(timeline);
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toEqual(
issueLabels,
);
expect(qc.getQueryData(issueKeys.usage(OTHER_ISSUE_ID))).toEqual(usage);
expect(qc.getQueryData(issueKeys.attachments(OTHER_ISSUE_ID))).toEqual([
{ ...attachment, id: "attachment-2", issue_id: OTHER_ISSUE_ID },
]);
expect(qc.getQueryData(issueKeys.timeline(OTHER_ISSUE_ID))).toEqual([
{ ...timeline[0]!, id: "activity-2" },
]);
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID))).toEqual(
issueLabels,
);
expectInvalidated(qc, issueKeys.detail(WS_ID, ISSUE_ID));
expectInvalidated(qc, issueKeys.detail(WS_ID, OTHER_ISSUE_ID));
expectInvalidated(qc, issueKeys.usage(ISSUE_ID));
expectInvalidated(qc, issueKeys.attachments(ISSUE_ID));
expectInvalidated(qc, issueKeys.timeline(ISSUE_ID));
expectInvalidated(qc, labelKeys.byIssue(WS_ID, ISSUE_ID));
expectInvalidated(qc, issueKeys.usage(OTHER_ISSUE_ID));
expectInvalidated(qc, issueKeys.attachments(OTHER_ISSUE_ID));
expectInvalidated(qc, issueKeys.timeline(OTHER_ISSUE_ID));
expectInvalidated(qc, labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID));
expectInvalidated(qc, agentTaskSnapshotKeys.list(WS_ID));
expectInvalidated(qc, agentTasksKeys.detail(WS_ID, AGENT_ID));
} finally {
unsubscribeList();
listRefetch.resolve(list);
}
});
it("restores optimistic workspace, my-list, and parent children snapshots when a batch delete fails", async () => {
const pendingDelete = deferred<{ deleted: number }>();
const { qc, wrapper } = setup(undefined, vi.fn(() => pendingDelete.promise));
const assignedFilter = { assignee_id: AGENT_ID };
const createdFilter = { creator_id: "member-1" };
const list = makeListCache(baseIssue, otherIssue);
const assignedMyList = makeListCache(baseIssue, otherIssue);
const createdMyList = makeListCache(baseIssue);
const children = [baseIssue, otherIssue];
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), list);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", assignedFilter),
assignedMyList,
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "created", createdFilter),
createdMyList,
);
qc.setQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
children,
);
const { result } = renderHook(() => useBatchDeleteIssues(), { wrapper });
let mutation!: Promise<{ deleted: number }>;
await act(async () => {
mutation = result.current.mutateAsync([ISSUE_ID]);
await Promise.resolve();
});
expect(ids(qc.getQueryData(issueKeys.list(WS_ID)))).toEqual([
OTHER_ISSUE_ID,
]);
expect(
ids(qc.getQueryData(issueKeys.myList(WS_ID, "assigned", assignedFilter))),
).toEqual([OTHER_ISSUE_ID]);
expect(
ids(qc.getQueryData(issueKeys.myList(WS_ID, "created", createdFilter))),
).toEqual([]);
expect(
qc
.getQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID))
?.map((issue) => issue.id),
).toEqual([OTHER_ISSUE_ID]);
await expect(
act(async () => {
pendingDelete.reject(new Error("batch delete failed"));
await mutation;
}),
).rejects.toThrow("batch delete failed");
expect(qc.getQueryData(issueKeys.list(WS_ID))).toEqual(list);
expect(
qc.getQueryData(issueKeys.myList(WS_ID, "assigned", assignedFilter)),
).toEqual(assignedMyList);
expect(
qc.getQueryData(issueKeys.myList(WS_ID, "created", createdFilter)),
).toEqual(createdMyList);
expect(qc.getQueryData(issueKeys.children(WS_ID, PARENT_ISSUE_ID))).toEqual(
children,
);
});
});