Files
multica/packages/core/inbox/ws-updaters.test.ts
Naiyuan Qing 6b2097ccbb feat(inbox): archived notifications sub-view (MUL-3736) (#5518)
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.

Backend:
- GET /api/inbox/archived and POST /api/inbox/{id}/unarchive. Kept off the
  existing GET /api/inbox so installed clients keep their contract and the
  unbounded archive never rides along with the main list.
- The archived query excludes any issue that still has an active row. Archiving
  is issue-level, so a new notification on an archived issue leaves old archived
  rows beside a fresh active one — without the guard the issue renders in BOTH
  lists. The exclusion lives in SQL so neither list depends on the other's cache.
- Unarchive is issue-level (mirroring archive) and leaves `read` untouched, so a
  restored unread item raises the unread badge again.
- v1 ships no pagination: LIMIT 200, newest-first, so truncation drops the
  oldest rows and never hides a group's newest one.
- inbox:unarchived event, fanned out to the recipient like the other personal
  inbox events.
- Two CONCURRENTLY-built indexes; inbox_item previously had none covering
  workspace/archived/created_at.

Frontend:
- Separate TanStack cache per list; every inbox event invalidates the workspace
  prefix, since any of them can move an item across the boundary.
- View persisted as ?view=archived, so refresh, back/forward, and the mobile
  detail-back all return to the list the user was in.
- Batch actions stay main-view only — they archive from the MAIN inbox, so
  offering them over the archived list would do the opposite of what it reads.
- Mobile subscribes to inbox:unarchived (its list gains the restored row); its
  own archived view remains follow-up.

Known debt: no pagination, so an archive past ~200 rows is truncated silently
in the UI; the entry's count is the deduplicated count of the rows returned.

Verified: pnpm typecheck/test/lint (0 errors), go build/vet, Go inbox suite
against a real Postgres, migrations up+down, and EXPLAIN confirming both new
indexes serve the query.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:58:42 +08:00

158 lines
5.0 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import {
onInboxInvalidate,
onInboxIssueDeleted,
onInboxIssueStatusChanged,
onInboxSummaryInvalidate,
} from "./ws-updaters";
import { inboxKeys } from "./queries";
import type { InboxItem } from "../types";
const wsId = "ws-1";
function makeItem(
id: string,
issueId: string | null,
overrides: Partial<InboxItem> = {},
): InboxItem {
return {
id,
workspace_id: wsId,
recipient_type: "member",
recipient_id: "user-1",
actor_type: null,
actor_id: null,
type: "mentioned",
severity: "info",
issue_id: issueId,
title: `item ${id}`,
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2025-01-01T00:00:00Z",
details: null,
...overrides,
};
}
describe("onInboxIssueDeleted", () => {
it("removes all inbox items referencing the deleted issue", () => {
const qc = new QueryClient();
const items = [
makeItem("i1", "issue-a"),
makeItem("i2", "issue-a"),
makeItem("i3", "issue-b"),
makeItem("i4", null),
];
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
onInboxIssueDeleted(qc, wsId, "issue-a");
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
expect(after?.map((i) => i.id)).toEqual(["i3", "i4"]);
});
it("also strips the issue from the archived list", () => {
// Deleting an issue removes its rows whether they were archived or not, so
// leaving them in the archived cache would render a row that 404s on tap.
const qc = new QueryClient();
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), [
makeItem("a1", "issue-a", { archived: true }),
makeItem("a2", "issue-b", { archived: true }),
]);
onInboxIssueDeleted(qc, wsId, "issue-a");
expect(
qc.getQueryData<InboxItem[]>(inboxKeys.archived(wsId))?.map((i) => i.id),
).toEqual(["a2"]);
});
it("is a no-op when the inbox cache is empty", () => {
const qc = new QueryClient();
expect(() => onInboxIssueDeleted(qc, wsId, "issue-a")).not.toThrow();
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))).toBeUndefined();
});
});
describe("onInboxInvalidate", () => {
it("invalidates the workspace prefix, covering both the main and archived lists", () => {
// Every inbox event can move an item across the two lists, so they are
// always refreshed together (MUL-3736).
const qc = new QueryClient();
const spy = vi.spyOn(qc, "invalidateQueries");
onInboxInvalidate(qc, wsId);
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.all(wsId) });
});
it("does not reach the account-level summary key", () => {
// The summary is keyed ["inbox", "unread-summary"], NOT under the
// workspace prefix — its own updater owns it.
const qc = new QueryClient();
qc.setQueryData(inboxKeys.unreadSummary(), [{ workspace_id: wsId, count: 3 }]);
const summaryQuery = qc
.getQueryCache()
.find({ queryKey: inboxKeys.unreadSummary() });
onInboxInvalidate(qc, wsId);
expect(summaryQuery?.state.isInvalidated).toBe(false);
});
});
describe("onInboxSummaryInvalidate", () => {
it("invalidates the account-level summary key regardless of active workspace", () => {
const qc = new QueryClient();
const spy = vi.spyOn(qc, "invalidateQueries");
onInboxSummaryInvalidate(qc);
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.unreadSummary() });
});
it("does not disturb a workspace-scoped inbox list cache", () => {
const qc = new QueryClient();
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), [makeItem("i1", "issue-a")]);
onInboxSummaryInvalidate(qc);
// The list cache entry is untouched (different key); only the summary
// query is marked stale.
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))?.[0]?.id).toBe("i1");
});
});
describe("onInboxIssueStatusChanged", () => {
it("updates issue_status only for items referencing the issue", () => {
const qc = new QueryClient();
const items = [
makeItem("i1", "issue-a", { issue_status: "todo" }),
makeItem("i2", "issue-b", { issue_status: "todo" }),
];
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
onInboxIssueStatusChanged(qc, wsId, "issue-a", "done");
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
expect(after?.find((i) => i.id === "i1")?.issue_status).toBe("done");
expect(after?.find((i) => i.id === "i2")?.issue_status).toBe("todo");
});
it("patches archived rows too, which render the same status icon", () => {
const qc = new QueryClient();
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), [
makeItem("a1", "issue-a", { archived: true, issue_status: "todo" }),
]);
onInboxIssueStatusChanged(qc, wsId, "issue-a", "done");
expect(
qc.getQueryData<InboxItem[]>(inboxKeys.archived(wsId))?.[0]?.issue_status,
).toBe("done");
});
});