Files
multica/packages/core/inbox/queries.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

218 lines
6.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
import {
deduplicateArchivedInboxItems,
deduplicateInboxItems,
hasOtherWorkspaceUnread,
inboxKeys,
unreadWorkspaceIds,
} from "./queries";
function item(overrides: Partial<InboxItem>): InboxItem {
return {
id: "inbox-1",
workspace_id: "workspace-1",
recipient_type: "member",
recipient_id: "member-1",
actor_type: "agent",
actor_id: "agent-1",
type: "new_comment",
severity: "info",
issue_id: "issue-1",
title: "Issue title",
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2026-06-15T08:00:00Z",
details: null,
...overrides,
};
}
describe("deduplicateInboxItems", () => {
it("keeps the newest issue row while preserving an older comment anchor", () => {
const merged = deduplicateInboxItems([
item({
id: "comment-notification",
type: "new_comment",
created_at: "2026-06-15T08:00:00Z",
details: { comment_id: "comment-1" },
}),
item({
id: "status-notification",
type: "status_changed",
created_at: "2026-06-15T08:01:00Z",
details: { from: "in_progress", to: "in_review" },
}),
]);
expect(merged).toHaveLength(1);
expect(merged[0]).toMatchObject({
id: "status-notification",
type: "status_changed",
details: {
from: "in_progress",
to: "in_review",
comment_id: "comment-1",
},
});
});
it("preserves the newest row's own comment anchor", () => {
const merged = deduplicateInboxItems([
item({
id: "older-comment",
created_at: "2026-06-15T08:00:00Z",
details: { comment_id: "comment-1" },
}),
item({
id: "newer-comment",
created_at: "2026-06-15T08:02:00Z",
details: { comment_id: "comment-2" },
}),
]);
expect(merged).toHaveLength(1);
expect(merged[0]?.id).toBe("newer-comment");
expect(merged[0]?.details?.comment_id).toBe("comment-2");
});
it("drops archived rows so an optimistic archive leaves the list at once", () => {
const merged = deduplicateInboxItems([
item({ id: "active", issue_id: "issue-1" }),
item({ id: "filed-away", issue_id: "issue-2", archived: true }),
]);
expect(merged.map((i) => i.id)).toEqual(["active"]);
});
});
describe("deduplicateArchivedInboxItems", () => {
it("keeps only archived rows, one per issue, newest first", () => {
const merged = deduplicateArchivedInboxItems([
item({
id: "archived-older",
issue_id: "issue-1",
archived: true,
created_at: "2026-06-15T08:00:00Z",
}),
item({
id: "archived-newer",
issue_id: "issue-1",
archived: true,
created_at: "2026-06-15T09:00:00Z",
}),
item({
id: "archived-other-issue",
issue_id: "issue-2",
archived: true,
created_at: "2026-06-15T07:00:00Z",
}),
item({ id: "still-active", issue_id: "issue-3" }),
]);
expect(merged.map((i) => i.id)).toEqual([
"archived-newer",
"archived-other-issue",
]);
});
it("drops a row the moment an optimistic unarchive flips it back", () => {
// What useUnarchiveInbox's onMutate does: flip `archived` on the archived
// cache. The row must leave this list without waiting for the refetch.
const restored = item({ id: "restored", archived: false });
expect(deduplicateArchivedInboxItems([restored])).toEqual([]);
});
it("groups issue-less notifications on their own id rather than merging them", () => {
const merged = deduplicateArchivedInboxItems([
item({ id: "standalone-1", issue_id: null, archived: true }),
item({ id: "standalone-2", issue_id: null, archived: true }),
]);
expect(merged).toHaveLength(2);
});
});
describe("hasOtherWorkspaceUnread", () => {
const summary = (entries: InboxWorkspaceUnread[]) => entries;
it("is true when a workspace other than the active one has unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-2", count: 3 }]),
"ws-1",
),
).toBe(true);
});
it("excludes the active workspace's own unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-1", count: 5 }]),
"ws-1",
),
).toBe(false);
});
it("ignores other workspaces whose count is zero", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-2", count: 0 }]),
"ws-1",
),
).toBe(false);
});
it("is true when at least one non-active workspace has unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([
{ workspace_id: "ws-1", count: 4 },
{ workspace_id: "ws-2", count: 1 },
]),
"ws-1",
),
).toBe(true);
});
it("is false for an empty summary", () => {
expect(hasOtherWorkspaceUnread([], "ws-1")).toBe(false);
});
it("counts every workspace as 'other' when there is no active workspace", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-1", count: 2 }]),
null,
),
).toBe(true);
});
});
describe("unreadWorkspaceIds", () => {
it("collects only workspaces with a non-zero count", () => {
const ids = unreadWorkspaceIds([
{ workspace_id: "ws-1", count: 0 },
{ workspace_id: "ws-2", count: 3 },
{ workspace_id: "ws-3", count: 1 },
]);
expect(ids.has("ws-1")).toBe(false);
expect(ids.has("ws-2")).toBe(true);
expect(ids.has("ws-3")).toBe(true);
expect(ids.size).toBe(2);
});
it("returns an empty set for an empty summary", () => {
expect(unreadWorkspaceIds([]).size).toBe(0);
});
});
describe("inboxKeys.unreadSummary", () => {
it("is a stable account-level key independent of any workspace", () => {
expect(inboxKeys.unreadSummary()).toEqual(["inbox", "unread-summary"]);
});
});