Files
multica/apps/mobile/data/inbox-schema.test.ts
Jiayuan Zhang 28b6105edc fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human
received no notifications for it at all. issue_subscriber modelled ACTOR
identity, so an agent-created, agent-assigned issue had a full subscriber list
and zero members to deliver to. The platform already knew who the work was for
(agent_task_queue.originator_user_id, MUL-4302); notification never asked.

- attribution.DelegatedSubscriber: one shared rule over the same origin
  waterfall ClassifyDirect uses. agent_create subscribes the originator as
  'delegated'; quick_create keeps the direct 'creator' tier; autopilot and
  degraded attribution subscribe nobody.
- Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus
  failures and mentions. Routine churn is suppressed, and the parent bubble
  cannot re-deliver what the tier dropped.
- Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later
  rule passes, and opt_out_scope distinguishes "this issue" from "this subtree"
  so a narrow opt-out no longer silently suppresses future children.
- Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly
  against an older backend (Go drops unknown fields); an unknown route 404s,
  which the UI now surfaces with a distinct message.
- Eligibility and the write share one statement under a (workspace, user)
  advisory lock that subtree unsubscribe and member revoke also take, closing
  the check-then-insert races. Revoke additionally clears the departing
  member's subscriptions in the same tx.
- UI explains a delegated subscription and offers both unsubscribe scopes.

Migrations 249/250 add the delegated reason, the opt-out tombstone, and the
opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does
not scan issue_subscriber under an exclusive lock.

Reviewed across eight rounds; an earlier write-time subtree roll-up was built
and then removed in full once it proved unfixable without serializing every
topology mutation. The parent's own status transition already carries that
signal.

Closes MUL-5483.
2026-07-31 16:52:17 +08:00

97 lines
3.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { InboxListSchema } from "./schemas";
/**
* Tests for mobile's CLIENT-SIDE parsing of GET /api/inbox.
*
* Scope, stated precisely because the name of this file used to overclaim:
* these are hand-written fixtures run against `InboxListSchema`. They pin how
* this client REACTS to a given payload. They cannot fail when the Go server
* starts sending something new — nothing here executes server code.
*
* The matching server-side guarantee is structural rather than a test: every
* `details` map in server/cmd/server/notification_listeners.go is typed
* `map[string]string`, so a non-string value is a compile error there.
*
* Why both halves exist: during MUL-5483 a new inbox type was added and the
* mobile label map was updated so `tsc` passed — but a NUMBER went into
* `details.child_count`, and `details` is `z.record(z.string(), z.string())`.
* Because the endpoint parses an ARRAY, one bad row fails the whole parse and
* `listInbox` falls back to `EMPTY_INBOX_LIST`: the entire mobile inbox
* renders empty, not just that row. The blast radius is what these tests
* document; the compile-time type is what prevents it.
*/
describe("inbox list schema", () => {
it("parses a row shaped like the documented server payload", () => {
const serverRow = {
id: "inbox-1",
workspace_id: "ws-1",
recipient_type: "member",
recipient_id: "user-1",
type: "status_changed",
severity: "info",
issue_id: "issue-1",
title: "P0: delegated subscription rule",
body: "",
actor_type: "agent",
actor_id: "agent-1",
read: false,
archived: false,
created_at: "2026-07-30T00:00:00Z",
// Every value is a string. A number here drops the whole list.
details: { from: "in_progress", to: "in_review" },
};
const parsed = InboxListSchema.safeParse([serverRow]);
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data[0]?.type).toBe("status_changed");
expect(parsed.success && parsed.data[0]?.details?.to).toBe("in_review");
});
it("rejects a numeric details value", () => {
const badRow = {
id: "inbox-2",
recipient_type: "member",
type: "status_changed",
details: { child_count: 3 },
};
expect(InboxListSchema.safeParse([badRow]).success).toBe(false);
});
it("keeps one malformed row from emptying the entire list observable", () => {
// Documents the blast radius that made this a P1 rather than a cosmetic bug:
// the schema is an array, so a single bad row invalidates every good one.
const good = {
id: "inbox-3",
recipient_type: "member",
type: "status_changed",
details: { from: "todo", to: "in_review" },
};
const bad = {
id: "inbox-4",
recipient_type: "member",
type: "status_changed",
details: { child_count: 3 },
};
expect(InboxListSchema.safeParse([good]).success).toBe(true);
expect(InboxListSchema.safeParse([good, bad]).success).toBe(false);
});
it("renders an unknown server type instead of dropping the row", () => {
// Mirrors the root CLAUDE.md API-compatibility rule and mobile's own
// "render every inbox type, never silently drop a category" parity rule: a
// type this build has never heard of must still parse.
const future = {
id: "inbox-5",
recipient_type: "member",
type: "some_future_type",
details: { anything: "still a string" },
};
const parsed = InboxListSchema.safeParse([future]);
expect(parsed.success).toBe(true);
});
});