Files
multica/packages/core/issues/ws-updaters.test.ts
Multica Eve 8065cead85 MUL-5198: Restore server-backed issue table grouping (MUL-5100) (#5817)
* revert(issues): restore server-backed table grouping

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

* test(skills): stabilize import completion coverage

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 12:03:27 +08:00

1044 lines
35 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import {
agentActivityKeys,
agentRunCountsKeys,
agentTaskSnapshotKeys,
agentTasksKeys,
} from "../agents/queries";
import {
onIssueCreated,
onIssueDeleted,
onIssueLabelsChanged,
onIssueMetadataChanged,
onIssuePropertiesChanged,
onIssueUpdated,
patchIssueLabels,
patchIssueProperties,
} from "./ws-updaters";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import { projectKeys } from "../projects/queries";
import type {
AgentActivityBucket,
AgentRunCount,
AgentTask,
Attachment,
Issue,
IssueReaction,
IssueLabelsResponse,
IssueTableRowsResponse,
IssueSubscriber,
IssueUsageSummary,
Label,
ListIssuesCache,
TimelineEntry,
} from "../types";
const WS_ID = "ws-1";
const ISSUE_ID = "issue-1";
const OTHER_ISSUE_ID = "issue-2";
const PARENT_ISSUE_ID = "parent-1";
const AGENT_ID = "agent-1";
const PROJECT_ID = "project-1";
const labelA: Label = {
id: "label-a",
workspace_id: WS_ID,
name: "bug",
color: "#ef4444",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const labelB: Label = {
id: "label-b",
workspace_id: WS_ID,
name: "feature",
color: "#22c55e",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const baseIssue: Issue = {
id: ISSUE_ID,
workspace_id: WS_ID,
number: 1,
identifier: "MUL-1",
title: "Test",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
stage: null,
start_date: null,
due_date: null,
metadata: {},
properties: {},
labels: [labelA],
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const parentedIssue: Issue = {
...baseIssue,
parent_issue_id: PARENT_ISSUE_ID,
};
const otherIssue: Issue = {
...baseIssue,
id: OTHER_ISSUE_ID,
identifier: "MUL-2",
title: "Other",
};
function makeListCache(...issues: Issue[]): ListIssuesCache {
return {
byStatus: {
todo: { issues, total: issues.length },
},
};
}
const tableRowKey = [
...issueKeys.tableRows(
WS_ID,
{
scope: { kind: "workspace" },
filters: {},
sort: { field: "position", direction: "asc" },
},
{ kind: "none" },
null,
false,
null,
),
"page",
null,
] as const;
function seedTableRow(qc: QueryClient, issue = baseIssue) {
qc.setQueryData<IssueTableRowsResponse>(tableRowKey, {
query_fingerprint: "sha256:table",
group_key: null,
parent_id: null,
total: 1,
rows: [{ issue, direct_child_count: 0 }],
branch_total: 1,
next_cursor: null,
});
}
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: "2025-01-01T00:00:00Z",
completed_at: "2025-01-01T00:01:00Z",
result: null,
error: null,
created_at: "2025-01-01T00:00:00Z",
};
}
function expectInvalidated(qc: QueryClient, queryKey: readonly unknown[]) {
expect(qc.getQueryState(queryKey)?.isInvalidated).toBe(true);
}
describe("onIssueLabelsChanged", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("patches the per-issue label cache when present (LabelPicker source)", () => {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
labels: [labelA],
});
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expect(
qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID)),
).toEqual({ labels: [labelB] });
});
it("leaves the per-issue label cache untouched when the picker has not fetched", () => {
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
});
it("still patches the list, Table row, and detail caches", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: { todo: { issues: [baseIssue], total: 1 } },
});
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
seedTableRow(qc);
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.todo?.issues[0]?.labels).toEqual([labelB]);
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
expect(detail?.labels).toEqual([labelB]);
expect(
qc.getQueryData<IssueTableRowsResponse>(tableRowKey)?.rows[0]?.issue
.labels,
).toEqual([labelB]);
});
it("patches the Project Gantt cache so label filters react in place", () => {
const PROJECT_ID = "project-1";
qc.setQueryData<Issue[]>(issueKeys.projectGantt(WS_ID, PROJECT_ID), [
baseIssue,
otherIssue,
]);
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
const gantt = qc.getQueryData<Issue[]>(
issueKeys.projectGantt(WS_ID, PROJECT_ID),
);
expect(gantt?.find((i) => i.id === ISSUE_ID)?.labels).toEqual([labelB]);
// Other issues in the same cache must not have their labels mutated.
expect(gantt?.find((i) => i.id === OTHER_ISSUE_ID)?.labels).toEqual([
labelA,
]);
});
it("defers label-filtered flat-window invalidation until commit", () => {
const flatKey = issueKeys.flat(
WS_ID,
"workspace:all",
{ label_ids: [labelB.id] },
{ sort_by: "position" },
);
qc.setQueryData(flatKey, {
pages: [{ issues: [baseIssue], total: 1 }],
pageParams: [0],
});
patchIssueLabels(qc, WS_ID, ISSUE_ID, [labelB]);
expect(qc.getQueryState(flatKey)?.isInvalidated).toBe(false);
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expectInvalidated(qc, flatKey);
});
it("patches the parent's children cache so the sub-issues panel stays fresh", () => {
const child = { ...baseIssue, parent_issue_id: PARENT_ISSUE_ID };
const childrenKey = issueKeys.children(WS_ID, PARENT_ISSUE_ID);
qc.setQueryData<Issue[]>(childrenKey, [
child,
otherIssue,
]);
// A children cache that does NOT hold the issue must keep its reference
// (no pointless rerender of unrelated sub-issue panels).
const unrelated = [otherIssue];
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, "parent-9"), unrelated);
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
const children = qc.getQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
);
expect(children?.find((i) => i.id === ISSUE_ID)?.labels).toEqual([labelB]);
expect(children?.find((i) => i.id === OTHER_ISSUE_ID)?.labels).toEqual([
labelA,
]);
expect(qc.getQueryData<Issue[]>(issueKeys.children(WS_ID, "parent-9"))).toBe(
unrelated,
);
// The committed WS/mutation snapshot also marks active children queries
// stale, preventing an older in-flight response from overwriting the patch.
expectInvalidated(qc, childrenKey);
});
it("invalidates batched children caches (Map-shaped, not patchable)", () => {
const batchedKey = issueKeys.childrenByParents(WS_ID, [PARENT_ISSUE_ID]);
qc.setQueryData(batchedKey, new Map([[PARENT_ISSUE_ID, [baseIssue]]]));
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expectInvalidated(qc, batchedKey);
});
});
describe("onIssueMetadataChanged", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("replaces metadata in both detail and list caches (no merge)", () => {
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), {
...baseIssue,
metadata: { pr_number: 1, stale: "yes" },
});
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: {
todo: {
issues: [{ ...baseIssue, metadata: { pr_number: 1 } }],
total: 1,
},
},
});
seedTableRow(qc, {
...baseIssue,
metadata: { pr_number: 1, stale: "yes" },
});
onIssueMetadataChanged(qc, WS_ID, ISSUE_ID, { pr_number: 2 });
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
expect(detail?.metadata).toEqual({ pr_number: 2 });
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.todo?.issues[0]?.metadata).toEqual({ pr_number: 2 });
expect(
qc.getQueryData<IssueTableRowsResponse>(tableRowKey)?.rows[0]?.issue
.metadata,
).toEqual({ pr_number: 2 });
});
it("leaves untouched caches as undefined (no spurious writes)", () => {
onIssueMetadataChanged(qc, WS_ID, ISSUE_ID, { foo: "bar" });
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.list(WS_ID))).toBeUndefined();
});
it("re-sorts an updated_at-sorted board but not a position-sorted one", () => {
// A metadata write bumps updated_at server-side (MUL-5016), so a board
// sorted by "Updated date" must refetch; a position-sorted board must not.
const boardUpdatedKey = issueKeys.listSorted(WS_ID, {
sort_by: "updated_at",
sort_direction: "desc",
});
const boardPositionKey = issueKeys.listSorted(WS_ID, { sort_by: "position" });
qc.setQueryData<ListIssuesCache>(boardUpdatedKey, makeListCache(baseIssue));
qc.setQueryData<ListIssuesCache>(boardPositionKey, makeListCache(baseIssue));
onIssueMetadataChanged(qc, WS_ID, ISSUE_ID, { foo: "bar" });
expectInvalidated(qc, boardUpdatedKey);
expect(qc.getQueryState(boardPositionKey)?.isInvalidated).toBe(false);
});
});
describe("issue property snapshots", () => {
it("patches per-parent children and invalidates every children projection on commit", () => {
const qc = new QueryClient();
const childrenKey = issueKeys.children(WS_ID, PARENT_ISSUE_ID);
const unrelatedKey = issueKeys.children(WS_ID, "parent-9");
const batchedKey = issueKeys.childrenByParents(WS_ID, [PARENT_ISSUE_ID]);
const child = {
...parentedIssue,
properties: { estimate: 1, environment: "staging" },
};
const unrelated = [otherIssue];
qc.setQueryData<Issue[]>(childrenKey, [child, otherIssue]);
qc.setQueryData<Issue[]>(unrelatedKey, unrelated);
qc.setQueryData(batchedKey, new Map([[PARENT_ISSUE_ID, [child]]]));
// The optimistic leg is deterministic: patch immediately without a
// premature refetch that could still return the pre-mutation value.
patchIssueProperties(qc, WS_ID, ISSUE_ID, {
estimate: 2,
environment: "staging",
});
expect(
qc.getQueryData<Issue[]>(childrenKey)?.find((candidate) => candidate.id === ISSUE_ID)
?.properties,
).toEqual({ estimate: 2, environment: "staging" });
expect(qc.getQueryState(childrenKey)?.isInvalidated).toBe(false);
expect(qc.getQueryData<Issue[]>(unrelatedKey)).toBe(unrelated);
// The committed response/event keeps the immediate patch, then marks both
// per-parent and batched projections stale for authoritative convergence.
onIssuePropertiesChanged(qc, WS_ID, ISSUE_ID, {
estimate: 3,
environment: "staging",
});
expect(
qc.getQueryData<Issue[]>(childrenKey)?.find((candidate) => candidate.id === ISSUE_ID)
?.properties,
).toEqual({ estimate: 3, environment: "staging" });
expectInvalidated(qc, childrenKey);
expectInvalidated(qc, batchedKey);
expect(qc.getQueryData<Issue[]>(unrelatedKey)).toBe(unrelated);
});
it("keeps optimistic patches local, then invalidates property windows after commit", () => {
const qc = new QueryClient();
const flatKey = issueKeys.flat(
WS_ID,
"workspace:all",
{},
{ sort_by: "property:estimate", properties: { estimate: ["3"] } },
);
qc.setQueryData(flatKey, {
pages: [{ issues: [baseIssue], total: 1 }],
pageParams: [0],
});
seedTableRow(qc);
patchIssueProperties(qc, WS_ID, ISSUE_ID, { estimate: 3 });
expect(qc.getQueryState(flatKey)?.isInvalidated).toBe(false);
expect(
qc.getQueryData<{ pages: { issues: Issue[] }[] }>(flatKey)?.pages[0]
?.issues[0]?.properties,
).toEqual({ estimate: 3 });
expect(
qc.getQueryData<IssueTableRowsResponse>(tableRowKey)?.rows[0]?.issue
.properties,
).toEqual({ estimate: 3 });
onIssuePropertiesChanged(qc, WS_ID, ISSUE_ID, { estimate: 4 });
expectInvalidated(qc, flatKey);
});
it("re-sorts an updated_at-sorted board but not a position-sorted one after commit", () => {
// A property write also bumps updated_at server-side (MUL-5016), so a board
// sorted by "Updated date" (no property param) must refetch on commit while
// a position-sorted board stays put.
const qc = new QueryClient();
const boardUpdatedKey = issueKeys.listSorted(WS_ID, {
sort_by: "updated_at",
sort_direction: "desc",
});
const boardPositionKey = issueKeys.listSorted(WS_ID, { sort_by: "position" });
qc.setQueryData<ListIssuesCache>(boardUpdatedKey, makeListCache(baseIssue));
qc.setQueryData<ListIssuesCache>(boardPositionKey, makeListCache(baseIssue));
// Optimistic leg patches only — no premature refetch of either board.
patchIssueProperties(qc, WS_ID, ISSUE_ID, { estimate: 3 });
expect(qc.getQueryState(boardUpdatedKey)?.isInvalidated).toBe(false);
onIssuePropertiesChanged(qc, WS_ID, ISSUE_ID, { estimate: 4 });
expectInvalidated(qc, boardUpdatedKey);
expect(qc.getQueryState(boardPositionKey)?.isInvalidated).toBe(false);
});
});
describe("project progress invalidation", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
qc.setQueryData(projectKeys.list(WS_ID), [
{
id: PROJECT_ID,
workspace_id: WS_ID,
title: "Project",
description: null,
icon: null,
status: "in_progress",
priority: "none",
lead_type: null,
lead_id: null,
issue_count: 1,
done_count: 0,
resource_count: 0,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
]);
});
it("invalidates project queries when an issue status changes", () => {
onIssueUpdated(qc, WS_ID, {
id: ISSUE_ID,
status: "done",
});
expectInvalidated(qc, projectKeys.list(WS_ID));
});
it("invalidates project queries when a project issue is created", () => {
onIssueCreated(qc, WS_ID, {
...baseIssue,
project_id: PROJECT_ID,
});
expectInvalidated(qc, projectKeys.list(WS_ID));
});
});
describe("onIssueCreated — carries the label snapshot into list cache", () => {
it("keeps the created issue's labels so members other than the creator render it already labeled", () => {
// The backend now attaches labels in the create transaction and echoes
// them on the issue:created event. Guard that the cache insert doesn't
// strip them — otherwise online members would see the new issue blank
// until a refetch (staleTime: Infinity means no self-heal).
const qc = new QueryClient();
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), makeListCache());
onIssueCreated(qc, WS_ID, { ...baseIssue, labels: [labelA, labelB] });
const cache = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
const cached = cache?.byStatus.todo?.issues.find((i) => i.id === ISSUE_ID);
expect(cached?.labels).toEqual([labelA, labelB]);
});
});
describe("onIssueUpdated — position move is surgical, not a list refetch", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
const issueA: Issue = { ...baseIssue, id: "issue-1", position: 0 };
const issueB: Issue = { ...baseIssue, id: "issue-2", position: 10 };
it("reorders the moved card in place and does NOT invalidate the workspace list", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), makeListCache(issueA, issueB));
// issue-1 moves below issue-2 (position 0 -> 20) — a remote/echoed drag.
onIssueUpdated(qc, WS_ID, { ...issueA, position: 20 });
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
// Surgically reordered into its new slot: proof the patch alone suffices.
expect(list?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-2", "issue-1"]);
// The old redundant `position -> invalidate(list)` is gone — no full-board
// refetch on top of the surgical patch (that was the flicker source).
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
});
it("surgically patches the filtered myAll lists on a non-membership change (no refetch)", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), makeListCache(issueA, issueB));
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA, issueB));
// Pure position move: membership cannot change, so myAll is patched in place.
onIssueUpdated(qc, WS_ID, { ...issueA, position: 20 });
const my = qc.getQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID));
expect(my?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-2", "issue-1"]);
// Reconciled in place — no full-list refetch on My Issues (that was the
// remaining drag flicker on filtered boards).
expect(qc.getQueryState(issueKeys.myAll(WS_ID))?.isInvalidated).toBe(false);
});
it("removes the card from an assignee-filtered list when the assignee changes (membership-aware, no blanket refetch)", () => {
const assignedKey = issueKeys.myListSorted(
WS_ID,
"assigned",
{ assignee_id: "user-1" },
undefined,
);
const mine: Issue = { ...issueA, assignee_type: "member", assignee_id: "user-1" };
qc.setQueryData<ListIssuesCache>(assignedKey, makeListCache(mine));
onIssueUpdated(
qc,
WS_ID,
{ ...mine, assignee_type: "member", assignee_id: "user-2" },
{ assigneeChanged: true },
);
// The card LEAVES the loaded list surgically — the fix for the residue
// that used to wait on a refetch (and on the mutation path never came).
const list = qc.getQueryData<ListIssuesCache>(assignedKey);
expect(list?.byStatus.todo?.issues).toEqual([]);
expect(list?.byStatus.todo?.total).toBe(0);
expect(qc.getQueryState(assignedKey)?.isInvalidated).toBe(false);
});
it("flags union-scope (my:all) lists stale on an assignee change instead of guessing membership", () => {
const myAllListKey = issueKeys.myListSorted(WS_ID, "all", {}, undefined);
const mine: Issue = { ...issueA, assignee_type: "member", assignee_id: "user-1" };
qc.setQueryData<ListIssuesCache>(myAllListKey, makeListCache(mine));
onIssueUpdated(
qc,
WS_ID,
{ ...mine, assignee_type: "member", assignee_id: "user-2" },
{ assigneeChanged: true },
);
// Union membership (assigned created involved) is server knowledge:
// the card is patched in place and the list refetches to reconcile.
const list = qc.getQueryData<ListIssuesCache>(myAllListKey);
expect(list?.byStatus.todo?.issues[0]?.assignee_id).toBe("user-2");
expectInvalidated(qc, myAllListKey);
});
it("moves the card out of the old project's list and flags the loaded target list (legacy diff fallback, no server flag)", () => {
// issueA.project_id is null; moving it into project-9 must reconcile both
// ends. No server flag here — this exercises the legacy cache-diff
// fallback that keeps a new frontend working against an older backend.
const targetKey = issueKeys.myListSorted(
WS_ID,
"project:project-9",
{ project_id: "project-9" },
undefined,
);
qc.setQueryData<ListIssuesCache>(targetKey, makeListCache());
onIssueUpdated(qc, WS_ID, { ...issueA, project_id: "project-9" });
// Never hard-inserted (its page/slot is server knowledge) — the loaded
// target list is refetched instead.
expect(
qc.getQueryData<ListIssuesCache>(targetKey)?.byStatus.todo?.issues,
).toEqual([]);
expectInvalidated(qc, targetKey);
});
it("drops the card from the old project's list on a server project_changed flag even when the cached project_id already matches", () => {
// Reproduces the drift state behind MUL-3669: the detail cache already
// carries the NEW project (e.g. a local optimistic write), so a cache
// diff would compute projectChanged=false — the authoritative server
// flag must still drive the membership reconcile for any list where the
// card lingers.
const moved: Issue = { ...issueA, project_id: "project-9" };
const oldProjectKey = issueKeys.myListSorted(
WS_ID,
"project:project-1",
{ project_id: "project-1" },
undefined,
);
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, moved.id), moved);
qc.setQueryData<ListIssuesCache>(oldProjectKey, makeListCache(moved));
onIssueUpdated(qc, WS_ID, moved, { projectChanged: true });
expect(
qc.getQueryData<ListIssuesCache>(oldProjectKey)?.byStatus.todo?.issues,
).toEqual([]);
});
it("does NOT touch project lists when the server flag says project_changed=false (flag overrides the legacy diff)", () => {
// No detail/list cache for the issue, so the legacy diff would resolve
// oldProjectId=null and fire on the non-null incoming project_id. An explicit
// false flag from the server is authoritative and must suppress that.
const projectKey = issueKeys.myListSorted(
WS_ID,
"project:project-9",
{ project_id: "project-9" },
undefined,
);
qc.setQueryData<ListIssuesCache>(projectKey, makeListCache());
onIssueUpdated(
qc,
WS_ID,
{ ...issueA, project_id: "project-9" },
{ projectChanged: false },
);
expect(qc.getQueryState(projectKey)?.isInvalidated).toBe(false);
});
});
// A board column header shows `byStatus[status].total`. On a status change the
// surgical patch shifts both bucket totals — but only if it can find the card in
// a loaded page. A paginated column loads just its first page, so an off-screen
// issue (very common when an agent flips the status of something the viewer
// never scrolled to) is absent: patchIssueInBuckets no-ops and the count would
// silently drift, with no refetch to recover it. The status-changed no-op has to
// fall back to a single-list refetch.
describe("onIssueUpdated — off-screen status change reconciles column counts", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("refetches the workspace list when a status-changed issue is not in the loaded page", () => {
// First page only: the totals say these columns have items, but the issues
// arrays are the loaded window — the moved issue lives beyond it.
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: {
in_review: { issues: [], total: 1 },
done: { issues: [], total: 60 },
},
});
onIssueUpdated(
qc,
WS_ID,
{ id: "off-screen", status: "done" },
{ statusChanged: true },
);
expectInvalidated(qc, issueKeys.list(WS_ID));
});
it("refetches the filtered myAll list under the same condition", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), {
byStatus: { done: { issues: [], total: 60 } },
});
onIssueUpdated(
qc,
WS_ID,
{ id: "off-screen", status: "done" },
{ statusChanged: true },
);
expectInvalidated(qc, issueKeys.myAll(WS_ID));
});
it("moves the bucket counts AND refetches when the off-screen issue's base entity is known", () => {
// The detail cache knows the pre-change entity, so the coordinator moves
// one unit of total between the buckets instantly — but the row itself
// can only be placed into done's loaded window by the server, and with
// staleTime: Infinity this invalidation is the only reconcile channel.
// (Before the fix this branch moved counts with no stale key: an open
// board showed "done 61" with 60 visible rows until something else
// happened to invalidate the list.)
const offScreen: Issue = { ...baseIssue, id: "off-screen", status: "in_review" };
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, "off-screen"), offScreen);
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: {
in_review: { issues: [], total: 1 },
done: { issues: [], total: 60 },
},
});
onIssueUpdated(
qc,
WS_ID,
{ ...offScreen, status: "done" },
{ statusChanged: true },
);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.in_review?.total).toBe(0);
expect(list?.byStatus.done?.total).toBe(61);
expectInvalidated(qc, issueKeys.list(WS_ID));
});
it("does NOT refetch when the status-changed issue is loaded (surgical patch suffices)", () => {
const loaded: Issue = { ...baseIssue, id: "loaded", status: "in_review" };
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: {
in_review: { issues: [loaded], total: 1 },
done: { issues: [], total: 60 },
},
});
onIssueUpdated(
qc,
WS_ID,
{ ...loaded, status: "done" },
{ statusChanged: true },
);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.in_review?.total).toBe(0);
expect(list?.byStatus.done?.total).toBe(61);
// Reconciled in place — the no-flicker fast path from #4415 must hold.
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
});
it("does NOT refetch an absent issue when the status did not change", () => {
// A title/label edit of an off-screen issue cannot affect any count, so it
// must not trigger a fallback refetch.
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: { done: { issues: [], total: 60 } },
});
onIssueUpdated(qc, WS_ID, { id: "off-screen", title: "renamed" });
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
});
});
describe("onIssueDeleted", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("removes every cache entry scoped directly to the deleted issue", () => {
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(ISSUE_ID), [
{
type: "activity",
id: "activity-1",
actor_type: "member",
actor_id: "user-1",
action: "created",
created_at: "2025-01-01T00:00:00Z",
},
]);
qc.setQueryData<IssueReaction[]>(issueKeys.reactions(ISSUE_ID), [
{
id: "reaction-1",
issue_id: ISSUE_ID,
actor_type: "member",
actor_id: "user-1",
emoji: "+1",
created_at: "2025-01-01T00:00:00Z",
},
]);
qc.setQueryData<IssueSubscriber[]>(issueKeys.subscribers(ISSUE_ID), [
{
issue_id: ISSUE_ID,
user_type: "member",
user_id: "user-1",
reason: "manual",
created_at: "2025-01-01T00:00:00Z",
},
]);
qc.setQueryData<IssueUsageSummary>(issueKeys.usage(ISSUE_ID), {
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
task_count: 1,
});
qc.setQueryData<Attachment[]>(issueKeys.attachments(ISSUE_ID), [
{
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: "user-1",
filename: "evidence.png",
url: "s3://bucket/evidence.png",
download_url: "https://example.test/evidence.png",
markdown_url: "https://example.test/api/attachments/att-1/download",
content_type: "image/png",
size_bytes: 1,
created_at: "2025-01-01T00:00:00Z",
},
]);
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [otherIssue]);
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
labels: [labelA],
});
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, OTHER_ISSUE_ID), otherIssue);
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(OTHER_ISSUE_ID), []);
qc.setQueryData<IssueLabelsResponse>(
labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID),
{ labels: [labelB] },
);
onIssueDeleted(qc, WS_ID, ISSUE_ID);
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.timeline(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.reactions(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.subscribers(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.usage(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.attachments(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.detail(WS_ID, OTHER_ISSUE_ID))).toEqual(
otherIssue,
);
expect(qc.getQueryData(issueKeys.timeline(OTHER_ISSUE_ID))).toEqual([]);
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID))).toEqual({
labels: [labelB],
});
});
it("removes the deleted issue from workspace and my-issues list caches immediately", () => {
const myFilter = { assignee_id: AGENT_ID };
qc.setQueryData<ListIssuesCache>(
issueKeys.list(WS_ID),
makeListCache(baseIssue, otherIssue),
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", myFilter),
makeListCache(baseIssue, otherIssue),
);
onIssueDeleted(qc, WS_ID, ISSUE_ID);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
const myList = qc.getQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", myFilter),
);
expect(list?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
OTHER_ISSUE_ID,
]);
expect(list?.byStatus.todo?.total).toBe(1);
expect(myList?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
OTHER_ISSUE_ID,
]);
expect(myList?.byStatus.todo?.total).toBe(1);
expectInvalidated(qc, issueKeys.list(WS_ID));
expectInvalidated(qc, issueKeys.myList(WS_ID, "assigned", myFilter));
});
it("invalidates parent progress when the parent id only exists in detail cache", () => {
qc.setQueryData<Issue>(
issueKeys.detail(WS_ID, ISSUE_ID),
parentedIssue,
);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
parentedIssue,
otherIssue,
]);
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
onIssueDeleted(qc, WS_ID, ISSUE_ID);
const parentChildren = qc.getQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
);
expect(parentChildren?.map((i) => i.id)).toEqual([OTHER_ISSUE_ID]);
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("invalidates parent progress when the deleted issue is only present in a children cache", () => {
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
parentedIssue,
otherIssue,
]);
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
onIssueDeleted(qc, WS_ID, ISSUE_ID);
const parentChildren = qc.getQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
);
expect(parentChildren?.map((i) => i.id)).toEqual([OTHER_ISSUE_ID]);
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("invalidates parent progress when the parent id only exists in a my-issues cache", () => {
const myFilter = { assignee_id: AGENT_ID };
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", myFilter),
makeListCache(parentedIssue, otherIssue),
);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
otherIssue,
]);
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
onIssueDeleted(qc, WS_ID, ISSUE_ID);
const myList = qc.getQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", myFilter),
);
expect(myList?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
OTHER_ISSUE_ID,
]);
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("invalidates child progress when the deleted issue is itself a parent", () => {
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [
{
...otherIssue,
parent_issue_id: ISSUE_ID,
},
]);
qc.setQueryData(
issueKeys.childProgress(WS_ID),
new Map([[ISSUE_ID, { done: 0, total: 1 }]]),
);
onIssueDeleted(qc, WS_ID, ISSUE_ID);
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("invalidates agent task and activity caches that can reference the deleted issue", () => {
qc.setQueryData<AgentTask[]>(
agentTaskSnapshotKeys.list(WS_ID),
[makeTask()],
);
qc.setQueryData<AgentActivityBucket[]>(
agentActivityKeys.last30d(WS_ID),
[
{
agent_id: AGENT_ID,
bucket_at: "2025-01-01T00:00:00Z",
task_count: 1,
failed_count: 0,
},
],
);
qc.setQueryData<AgentRunCount[]>(agentRunCountsKeys.last30d(WS_ID), [
{ agent_id: AGENT_ID, run_count: 1 },
]);
qc.setQueryData<AgentTask[]>(agentTasksKeys.detail(WS_ID, AGENT_ID), [
makeTask(),
]);
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
onIssueDeleted(qc, WS_ID, ISSUE_ID);
expectInvalidated(qc, agentTaskSnapshotKeys.list(WS_ID));
expectInvalidated(qc, agentActivityKeys.last30d(WS_ID));
expectInvalidated(qc, agentRunCountsKeys.last30d(WS_ID));
expectInvalidated(qc, agentTasksKeys.detail(WS_ID, AGENT_ID));
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
});
});
// Regression coverage for the Project Gantt cache. The Gantt view rides its
// own dedicated cache (server-filtered to `scheduled=true`); every WS-driven
// path that can shift Gantt membership has to invalidate the prefix or the
// timeline goes stale.
describe("project gantt cache invalidation", () => {
const PROJECT_ID = "project-1";
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
qc.setQueryData<Issue[]>(
issueKeys.projectGantt(WS_ID, PROJECT_ID),
[baseIssue],
);
});
it("invalidates the project Gantt cache on issue:created", () => {
onIssueCreated(qc, WS_ID, otherIssue);
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
it("invalidates the project Gantt cache on issue:updated", () => {
onIssueUpdated(qc, WS_ID, {
id: ISSUE_ID,
start_date: "2026-01-01T00:00:00Z",
});
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
it("invalidates the project Gantt cache on issue:deleted", () => {
onIssueDeleted(qc, WS_ID, ISSUE_ID);
expectInvalidated(qc, issueKeys.projectGantt(WS_ID, PROJECT_ID));
});
});