Files
multica/packages/core/inbox/ws-updaters.test.ts
Bohan Jiang 54145ad72e feat(sidebar): dot the workspace switcher when other workspaces have unread inbox (MUL-3695) (#4577)
Adds a cross-workspace unread summary so the workspace switcher shows the
existing brand dot when a workspace OTHER than the active one has unread
inbox items. The active workspace's own unread stays on the Inbox nav
count to avoid a duplicate signal, and the dot is shared with the pending-
invitation indicator.

Backend: new GET /api/inbox/unread-summary returns per-workspace unread
counts for the user, scoped via a member join so a left workspace can't
light the dot. One account-level query instead of N per-workspace inbox
fetches.

Frontend: schema-guarded api.getInboxUnreadSummary, a single account-level
TanStack Query, and a derived "other workspace has unread" boolean in
AppSidebar (shared by web + desktop). Inbox WS events (new/read/archived/
batch) and reconnect invalidate the summary, so the dot appears and clears
in realtime even for events from a non-active workspace.

Closes multica-ai/multica#3773

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 12:09:15 +08:00

97 lines
3.0 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { 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("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("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");
});
});