mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.
Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.
Server:
- AttachmentResponse gains a markdown_url field, computed by
buildMarkdownURL from the deployment policy:
• storage URL is already absolute + unsigned (public CDN, S3 public
bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
use it verbatim;
• CloudFront-signed mode → never expose the raw S3 URL (private
bucket); return cfg.PublicURL + /api/attachments/<id>/download so
the server can re-sign on every request;
• LocalStorage relative + cfg.PublicURL set → same prefixed API
endpoint;
• cfg.PublicURL unset → fall back to site-relative path so web's
Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
signature query params, so a freshly-signed download_url can never
leak into persistence — the original MUL-3130 bug stays closed.
Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
carry markdown_url. Schema lenient-defaults to '' so a backend old
enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
(1) att.markdown_url (modern server),
(2) attachmentDownloadPath(att.id) — legacy site-relative shape,
retained for backends old enough to omit markdown_url,
(3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
reframed as the legacy-compat fallback for already-persisted
/api/attachments/<id>/download or /uploads/<key> URLs in old
bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
AttachmentDownloadProvider so Quick Create's editor can swap the URL
via the resolver during the same session even before the server-side
binding lands.
Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
tests (public CDN passthrough, CloudFront-signed swap, relative
prefixing, PublicURL unset fallback, trailing-slash strip) + 15
table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.
Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.
Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.
Refs: MUL-3192
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
540 lines
16 KiB
TypeScript
540 lines
16 KiB
TypeScript
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,
|
|
onIssueUpdated,
|
|
} 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,
|
|
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,
|
|
start_date: null,
|
|
due_date: null,
|
|
metadata: {},
|
|
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 },
|
|
},
|
|
};
|
|
}
|
|
|
|
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 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);
|
|
|
|
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]);
|
|
});
|
|
|
|
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,
|
|
]);
|
|
});
|
|
});
|
|
|
|
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,
|
|
},
|
|
},
|
|
});
|
|
|
|
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 });
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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("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));
|
|
});
|
|
});
|