Files
multica/packages/core/api/schema.test.ts
3d2592a9a2 MUL-7754: feat: local search index for web and desktop (#8891)
* feat(server): add a change log and sync endpoints for local search (MUL-7754)

Web and Desktop will keep a per-workspace copy of issue, comment, and
project text for instant search. Triggers on issue/comment/project record
the xid of each entity's latest write in search_index_change, covering
every write path including hard and cascaded deletes. Clients bootstrap
with GET /api/search-index/manifest + /snapshot pages and catch up with
POST /api/search-index/changes, whose cursor is a pg_snapshot: a change is
returned once it is visible in the target snapshot but not in the one the
client holds, so late commits are never skipped and long transactions do
not stall catch-up.

Workspace teardown skips the triggers and clears the table, and an hourly
scheduler job prunes rows older than 30 days; a cursor older than the
prune mark gets 410 and rebuilds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(core): add a synced local search index for web and desktop (MUL-7754)

Each (user, workspace) gets an IndexedDB copy of its issue, comment, and
project text, kept in a worker (SharedWorker when available, otherwise a
dedicated worker per tab) and searched in memory. Matching, ranking, and
snippets port the server's issue/project search, so local rows have the
server's shape and order.

The worker bootstraps from /api/search-index/manifest + snapshot pages,
catches up through /changes after realtime events, reconnects, focus, and
every five minutes, and resumes an interrupted bootstrap. It has no
credentials of its own: tabs run its requests through their API client.
Local results are served only while the copy is complete and recently
confirmed; otherwise searchIssues/searchProjects fall back to the server.
Workspaces over 500 MB of text, lost access, and expired cursors are
handled by declining, wiping, and rebuilding respectively. Session cleanup
deletes every local index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): search through the local index first (MUL-7754)

The command palette, @mention suggestions, the issue picker, and duplicate
suggestions now call the core local-first search. The palette drops its
300 ms debounce while the local index is serving, since no request leaves
the tab.

An E2E spec checks that palette results match server ranking without any
server search request, follow issues created and deleted through the API,
and that logging out deletes the local copy. .env.example documents the
FF_LOCAL_SEARCH_INDEX kill switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(core): close local search gaps found in review (MUL-7754)

- Access loss: deleting, leaving, or being removed from a workspace now
  destroys its local copies right away (worker and tab both delete the
  database, since a full-page relocate may end the worker first). The sync
  hook also prunes every copy outside the user's current workspace list,
  which covers access lost while offline or on another device; copies a
  tab is showing are kept in case the list is stale.
- Memory budget: the engine counts UTF-8 bytes the way the manifest does,
  and the budget is checked on every load, snapshot page, and catch-up
  batch, not only at bootstrap. An over-budget copy is dropped and search
  falls back to the server until the workspace is measured again.
- Frozen tabs: a port the sweep dropped is restored with its workspace the
  next time the tab sends anything, a page restored from the bfcache
  attaches again, and a null local answer clears the tab's "serving" flag
  so the palette goes back to debounced server search.

E2E now also covers being removed from a shared workspace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-27 22:02:13 +08:00

1642 lines
60 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { ApiClient, ApiError } from "./client";
import { parseWithFallback } from "./schema";
// Helper: stub fetch with a single JSON response. Status defaults to 200.
function stubFetchJson(body: unknown, status = 200) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(typeof body === "string" ? body : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
),
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
// These tests cover the five failure modes that white-screened the desktop
// app in past incidents. The contract is: a malformed response degrades to
// an empty/safe shape, never throws into React.
describe("ApiClient schema fallback", () => {
describe("GitHub repository import", () => {
it("falls back safely when installation or repository responses are malformed", async () => {
stubFetchJson({ installations: "not-an-array", configured: true });
const client = new ApiClient("https://api.example.test");
await expect(client.listGitHubInstallations("ws-1")).resolves.toEqual({
installations: [],
configured: false,
repository_browse_configured: false,
can_manage: false,
});
stubFetchJson({ repositories: [{ id: "wrong-type" }] });
await expect(
client.listGitHubInstallationRepositories("ws-1", "installation-1"),
).resolves.toEqual({
repositories: [],
total_count: 0,
next_page: null,
});
});
it("adds the allowlisted repository return target to the connect request", async () => {
stubFetchJson({
configured: true,
url: "https://github.com/apps/multica/installations/new",
});
const client = new ApiClient("https://api.example.test");
await client.getGitHubConnectURL("ws-1", "repositories");
const fetchMock = vi.mocked(fetch);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.test/api/workspaces/ws-1/github/connect?return_to=repositories",
expect.any(Object),
);
});
});
describe("listTimeline", () => {
it("falls back to an empty array when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const entries = await client.listTimeline("issue-1");
expect(entries).toEqual([]);
});
it("falls back when the body is not an array", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const entries = await client.listTimeline("issue-1");
expect(entries).toEqual([]);
});
it("accepts a new entry type rather than crashing on enum drift", async () => {
stubFetchJson([
{
type: "future_kind", // not in TS union
id: "e-1",
actor_type: "member",
actor_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
},
]);
const client = new ApiClient("https://api.example.test");
const entries = await client.listTimeline("issue-1");
expect(entries).toHaveLength(1);
expect(entries[0]?.type).toBe("future_kind");
});
// Forward-compat: when the server adds a new field to an existing
// shape, `.loose()` lets it pass through unchanged. Without `.loose()`
// zod 4 strips it, which would silently break a future TS type that
// adopts the field — see schemas.ts header comment.
it("preserves unknown fields the schema didn't list", async () => {
stubFetchJson([
{
type: "comment",
id: "e-1",
actor_type: "member",
actor_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
// New server-side field not present in TimelineEntrySchema:
future_field: { nested: "value" },
},
]);
const client = new ApiClient("https://api.example.test");
const entries = await client.listTimeline("issue-1");
const entry = entries[0] as unknown as Record<string, unknown>;
expect(entry.future_field).toEqual({ nested: "value" });
});
});
describe("listIssues", () => {
it("falls back to an empty list when the response is malformed", async () => {
// `issues` having the wrong type triggers the fallback. An object
// with only unexpected keys would *succeed* parsing now (every
// declared field has a default) and just pass the extras through
// via `.loose()`, so we use a wrong-type payload here instead.
stubFetchJson({ issues: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.listIssues();
expect(res).toEqual({ issues: [], total: 0 });
});
});
describe("listSkills", () => {
const baseSkill = {
id: "skill-1",
workspace_id: "ws-1",
name: "review-helper",
description: "",
config: {},
created_by: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const skillLabel = {
id: "label-1",
workspace_id: "ws-1",
resource_type: "skill",
name: "mattpocock",
color: "#3b82f6",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
it("falls back to an empty list when the response is malformed", async () => {
stubFetchJson({ skills: "not-an-array" });
const client = new ApiClient("https://api.example.test");
const res = await client.listSkills();
expect(res).toEqual([]);
});
it("treats a missing labels field as an empty array (older backends)", async () => {
stubFetchJson([baseSkill]);
const client = new ApiClient("https://api.example.test");
const res = await client.listSkills();
expect(res).toHaveLength(1);
expect(res[0]?.id).toBe("skill-1");
expect(res[0]?.labels).toEqual([]);
// List parsing must not invent detail-only fields (GH #2174).
expect(res[0]).not.toHaveProperty("content");
expect(res[0]).not.toHaveProperty("files");
});
it("keeps a well-formed labels array", async () => {
stubFetchJson([{ ...baseSkill, labels: [skillLabel] }]);
const client = new ApiClient("https://api.example.test");
const res = await client.listSkills();
expect(res[0]?.labels?.map((l) => l.id)).toEqual(["label-1"]);
expect(res[0]?.labels?.[0]?.resource_type).toBe("skill");
});
it("catches malformed labels to [] without dropping the skill", async () => {
stubFetchJson([{ ...baseSkill, labels: [{ nope: true }] }]);
const client = new ApiClient("https://api.example.test");
const res = await client.listSkills();
expect(res).toHaveLength(1);
expect(res[0]?.id).toBe("skill-1");
expect(res[0]?.labels).toEqual([]);
});
});
describe("getIssue", () => {
// Unlike a list, a single issue has no safe-empty shape, and the bare
// identifier autolink caches this result for 5 minutes. A malformed 2xx
// must therefore fail the call instead of becoming a truthy issue with an
// `undefined` id — and it must fail with something OTHER than an
// ApiError 404, which `issueIdentifierOptions` maps to "no such issue".
const validIssue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Existing",
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,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
it("resolves a well-formed issue, defaulting the fields older servers omit", async () => {
stubFetchJson({ ...validIssue, unknown_field: "kept" });
const client = new ApiClient("https://api.example.test");
const issue = await client.getIssue("MUL-1");
expect(issue.id).toBe("issue-1");
expect(issue.stage).toBeNull();
expect(issue.metadata).toEqual({});
expect(issue.properties).toEqual({});
});
it("rejects a 200 body that is not a usable issue (no truthy issue with an undefined id)", async () => {
stubFetchJson({ not: "an issue" });
const client = new ApiClient("https://api.example.test");
await expect(client.getIssue("MUL-1")).rejects.toThrow();
});
it("rejects a 200 body whose required field drifted type", async () => {
stubFetchJson({ ...validIssue, number: "1" });
const client = new ApiClient("https://api.example.test");
await expect(client.getIssue("MUL-1")).rejects.toThrow();
});
it("does not disguise a malformed body as a 404", async () => {
stubFetchJson({ id: "issue-1" });
const client = new ApiClient("https://api.example.test");
await expect(client.getIssue("MUL-1")).rejects.not.toBeInstanceOf(
ApiError,
);
});
it("still surfaces a real 404 as an ApiError so autolink can resolve to null", async () => {
stubFetchJson({ error: "issue not found" }, 404);
const client = new ApiClient("https://api.example.test");
await expect(client.getIssue("TES-1")).rejects.toMatchObject({
status: 404,
});
});
});
describe("createIssue", () => {
// The create modal decides whether to run its label-attach fallback by
// reading `labels` off the parsed response, and treats a rejection as a
// failed create (keep the draft, failure toast). So: a valid issue with
// any labels shape resolves (labels absent → undefined, valid → Label[],
// malformed → undefined), but a body that isn't a usable issue rejects
// rather than fabricating a blank "success".
const validIssue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Created",
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,
stage: null,
start_date: null,
due_date: null,
metadata: {},
properties: {},
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const label = {
id: "label-1",
workspace_id: "ws-1",
name: "bug",
color: "#ef4444",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
it("keeps labels undefined when the backend omits the field (older backend)", async () => {
stubFetchJson(validIssue, 201);
const client = new ApiClient("https://api.example.test");
const issue = await client.createIssue({ title: "Created" });
expect(issue.id).toBe("issue-1");
expect(issue.labels).toBeUndefined();
});
it("validates a well-formed labels array", async () => {
stubFetchJson({ ...validIssue, labels: [label] }, 201);
const client = new ApiClient("https://api.example.test");
const issue = await client.createIssue({ title: "Created" });
expect(issue.labels?.map((l) => l.id)).toEqual(["label-1"]);
});
it("degrades a null labels field to undefined so the client falls back", async () => {
stubFetchJson({ ...validIssue, labels: null }, 201);
const client = new ApiClient("https://api.example.test");
const issue = await client.createIssue({ title: "Created" });
// The issue itself still parses; only the malformed labels degrade.
expect(issue.id).toBe("issue-1");
expect(issue.labels).toBeUndefined();
});
it("degrades a labels array of the wrong element shape to undefined", async () => {
stubFetchJson({ ...validIssue, labels: [{ nope: true }] }, 201);
const client = new ApiClient("https://api.example.test");
const issue = await client.createIssue({ title: "Created" });
expect(issue.id).toBe("issue-1");
expect(issue.labels).toBeUndefined();
});
it("rejects when the whole response body is not a usable issue (no fake success)", async () => {
stubFetchJson({ not: "an issue" }, 201);
const client = new ApiClient("https://api.example.test");
await expect(client.createIssue({ title: "Created" })).rejects.toThrow();
});
it("rejects when the created issue has an empty id", async () => {
stubFetchJson({ ...validIssue, id: "" }, 201);
const client = new ApiClient("https://api.example.test");
await expect(client.createIssue({ title: "Created" })).rejects.toThrow();
});
it("fails closed before POST when create properties are unsupported", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await expect(
client.createIssue({ title: "Created", properties: { "property-1": "value" } }),
).rejects.toThrow("does not support atomic custom properties");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.example.test/api/config");
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "POST")).toBe(false);
});
it("preflights properties and requires the canonical response snapshot", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ issue_create_properties_supported: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
...validIssue,
properties: { "property-1": ["first", "second"] },
}),
{ status: 201, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await expect(
client.createIssue({
title: "Created",
properties: { "property-1": ["second", "first", "second"] },
}),
).resolves.toMatchObject({ properties: { "property-1": ["first", "second"] } });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: "POST" });
});
it("reports a created identifier when the response property snapshot mismatches", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ issue_create_properties_supported: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(validIssue), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await expect(
client.createIssue({ title: "Created", properties: { "property-1": "value" } }),
).rejects.toThrow("Issue MUL-1 was created");
});
});
describe("comment source-context sub-issues", () => {
it("preflights manual sub-issue properties and validates their snapshot", async () => {
const issue = {
id: "issue-2",
workspace_id: "ws-1",
number: 2,
identifier: "MUL-2",
title: "Child",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: "issue-1",
project_id: null,
position: 0,
stage: null,
start_date: null,
due_date: null,
metadata: {},
properties: { "property-1": true },
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ issue_create_properties_supported: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(issue), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await expect(
client.createCommentSubIssue("comment-1", {
mode: "manual",
capture_token: "token",
issue: { title: "Child", properties: { "property-1": true } },
}),
).resolves.toMatchObject({ id: "issue-2" });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
"https://api.example.test/api/comments/comment-1/sub-issues",
);
});
it("uses the dedicated endpoint for agent creation", async () => {
stubFetchJson({ task_id: "task-1" }, 202);
const client = new ApiClient("https://api.example.test");
await expect(client.createCommentSubIssue("comment-1", {
mode: "agent",
capture_token: "sha256:digest:issue",
quick_create: { agent_id: "agent-1", prompt: "new work" },
})).resolves.toEqual({ task_id: "task-1" });
expect(fetch).toHaveBeenCalledWith(
"https://api.example.test/api/comments/comment-1/sub-issues",
expect.objectContaining({ method: "POST" }),
);
});
it.each([404, 405])("maps server status %s to source_context_server_unsupported", async (status) => {
stubFetchJson({ error: "not found" }, status);
const client = new ApiClient("https://api.example.test");
await expect(client.createCommentSubIssue("comment-1", {
mode: "agent",
capture_token: "token",
quick_create: { agent_id: "agent-1", prompt: "new work" },
})).rejects.toMatchObject({ body: { code: "source_context_server_unsupported" } });
});
it("rejects a malformed source-context retry response", async () => {
stubFetchJson({ id: 42 }, 202);
const client = new ApiClient("https://api.example.test");
await expect(client.retrySourceContextQuickCreate("task-1")).rejects.toThrow(
"Invalid source-context retry response",
);
});
});
describe("searchIssues", () => {
it("falls back to an empty result when the response is malformed", async () => {
stubFetchJson({ issues: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.searchIssues({ q: "bug" });
expect(res).toEqual({ issues: [] });
});
it("accepts a response without an exact total", async () => {
stubFetchJson({ issues: [] });
const client = new ApiClient("https://api.example.test");
const res = await client.searchIssues({ q: "bug" });
expect(res).toEqual({ issues: [] });
});
});
describe("searchProjects", () => {
it("falls back to an empty result when the response is malformed", async () => {
stubFetchJson({ projects: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.searchProjects({ q: "roadmap" });
expect(res).toEqual({ projects: [] });
});
it("accepts a response without an exact total", async () => {
stubFetchJson({ projects: [] });
const client = new ApiClient("https://api.example.test");
const res = await client.searchProjects({ q: "roadmap" });
expect(res).toEqual({ projects: [] });
});
});
// Local search index sync applies whatever these return to the local copy,
// so a malformed body rejects instead of degrading to an empty page.
describe("search index sync", () => {
it("sends the workspace slug and parses a snapshot page", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
issues: [],
comments: [{ id: "c1", issue_id: "i1", content: "hi", created_at: "2026-01-01T00:00:00.5Z" }],
projects: [],
next_after_number: 200,
done: false,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
const page = await client.getSearchIndexSnapshot({ workspaceSlug: "acme", afterNumber: 0, limit: 200 });
expect(page.comments).toHaveLength(1);
expect(page.done).toBe(false);
const [url, init] = fetchMock.mock.calls[0]!;
expect(String(url)).toBe("https://api.example.test/api/search-index/snapshot?after_number=0&limit=200");
expect((init as RequestInit).headers).toMatchObject({ "X-Workspace-Slug": "acme" });
});
it("rejects malformed manifest, snapshot, and changes bodies", async () => {
const client = new ApiClient("https://api.example.test");
stubFetchJson({ cursor: "", issue_count: 0, comment_count: 0, project_count: 0, text_bytes: 0 });
await expect(client.getSearchIndexManifest({ workspaceSlug: "acme" })).rejects.toThrow(/Malformed response/);
stubFetchJson({ issues: "nope", comments: [], projects: [], next_after_number: 0, done: true });
await expect(client.getSearchIndexSnapshot({ workspaceSlug: "acme", afterNumber: 0 })).rejects.toThrow(
/Malformed response/,
);
stubFetchJson({ issues: [], comments: [], projects: [], cursor: "c2", has_more: false });
await expect(client.getSearchIndexChanges({ workspaceSlug: "acme", cursor: "c1" })).rejects.toThrow(
/Malformed response/,
);
});
it("surfaces an expired cursor as a 410 ApiError", async () => {
stubFetchJson({ error: "search index cursor expired; rebuild the local index" }, 410);
const client = new ApiClient("https://api.example.test");
const err = await client.getSearchIndexChanges({ workspaceSlug: "acme", cursor: "c1" }).catch((e) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(410);
});
});
describe("listAutopilots", () => {
const baseAutopilot = {
id: "ap-1",
workspace_id: "ws-1",
title: "Daily triage",
description: null,
assignee_id: "agent-1",
status: "active",
execution_mode: "run_only",
issue_title_template: null,
created_by_type: "member",
created_by_id: "user-1",
last_run_at: null,
created_at: "2026-06-01T00:00:00Z",
updated_at: "2026-06-01T00:00:00Z",
};
it("falls back to an empty list when the response is malformed", async () => {
stubFetchJson({ autopilots: "not-an-array", total: 1 });
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilots();
expect(res).toEqual({ autopilots: [], total: 0 });
});
it("accepts an old-server row without assignee_type or derived fields", async () => {
// Pre-MUL-2429 servers omit assignee_type; servers older than the
// list-derived-fields change omit trigger_kinds/next_run_at/
// last_run_status. Both must parse, not fall back.
stubFetchJson({ autopilots: [baseAutopilot], total: 1 });
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilots();
expect(res.autopilots).toHaveLength(1);
expect(res.autopilots[0]?.assignee_type).toBe("agent");
expect(res.autopilots[0]?.trigger_kinds).toBeUndefined();
expect(res.autopilots[0]?.last_run_status).toBeUndefined();
});
it("passes derived fields through and tolerates enum drift", async () => {
stubFetchJson({
autopilots: [
{
...baseAutopilot,
assignee_type: "squad",
trigger_kinds: ["schedule", "some_future_kind"],
next_run_at: "2026-06-13T09:00:00Z",
last_run_status: "some_future_status",
},
],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilots();
expect(res.autopilots[0]?.trigger_kinds).toEqual([
"schedule",
"some_future_kind",
]);
expect(res.autopilots[0]?.next_run_at).toBe("2026-06-13T09:00:00Z");
expect(res.autopilots[0]?.last_run_status).toBe("some_future_status");
});
});
describe("listDingTalkInstallations", () => {
it("falls back to a safe empty shape when the response is malformed", async () => {
// `installations` with the wrong type triggers the fallback; the panel
// must not white-screen on `configured`/`install_supported`.
stubFetchJson({ installations: "not-an-array", configured: true });
const client = new ApiClient("https://api.example.test");
const res = await client.listDingTalkInstallations("ws-1");
expect(res).toEqual({ installations: [], configured: false });
});
it("tolerates an old-server row and a missing install_supported flag", async () => {
stubFetchJson({
installations: [{ id: "dt-1", status: "active" }],
configured: true,
// install_supported omitted (predates the flag) -> undefined, not a crash
future_field: true,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listDingTalkInstallations("ws-1");
expect(res.installations).toHaveLength(1);
expect(res.configured).toBe(true);
expect(res.install_supported).toBeUndefined();
expect(res.installations[0]?.agent_available).toBeUndefined();
});
it("preserves the orphaned-Agent marker from a new server", async () => {
stubFetchJson({
installations: [
{ id: "dt-orphan", status: "active", agent_available: false },
],
configured: true,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listDingTalkInstallations("ws-1");
expect(res.installations[0]?.agent_available).toBe(false);
});
it("parses linked DingTalk identities from a new-server row", async () => {
stubFetchJson({
installations: [
{
id: "dt-1",
status: "active",
bound_dingtalk_user_ids: ["staff-1001"],
},
],
configured: true,
install_supported: true,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listDingTalkInstallations("ws-1");
expect(res.installations[0]?.bound_dingtalk_user_ids).toEqual(["staff-1001"]);
});
it("defaults missing or malformed linked identities to an empty list", async () => {
stubFetchJson({
installations: [
{ id: "dt-old", status: "active" },
{
id: "dt-broken",
status: "active",
bound_dingtalk_user_ids: "staff-1001",
},
],
configured: true,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listDingTalkInstallations("ws-1");
expect(res.installations[0]?.bound_dingtalk_user_ids).toEqual([]);
expect(res.installations[1]?.bound_dingtalk_user_ids).toEqual([]);
});
});
describe("Telegram integration", () => {
it("falls back to a safe empty installation list when the response is malformed", async () => {
stubFetchJson({ installations: "not-an-array", configured: true });
const client = new ApiClient("https://api.example.test");
await expect(client.listTelegramInstallations("ws-1")).resolves.toEqual({
installations: [],
configured: false,
});
});
it("defaults fields omitted by an older server", async () => {
stubFetchJson({
installations: [{ id: "tg-1", status: "active" }],
configured: true,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listTelegramInstallations("ws-1");
expect(res.installations[0]).toMatchObject({
id: "tg-1",
workspace_id: "",
agent_id: "",
bot_id: "",
bot_username: "",
});
expect(res.install_supported).toBeUndefined();
});
it("falls back safely when register and redeem responses are malformed", async () => {
stubFetchJson({ id: 123 });
const client = new ApiClient("https://api.example.test");
await expect(
client.registerTelegramBot("ws-1", "agent-1", { bot_token: "token" }),
).resolves.toMatchObject({ id: "", status: "revoked" });
stubFetchJson({ workspace_id: 123 });
await expect(client.redeemTelegramBindingToken("bind-token")).resolves.toEqual({
workspace_id: "",
installation_id: "",
telegram_user_id: "",
});
});
});
describe("listDingTalkGroups", () => {
it("preserves bot activity metadata for each group relationship", async () => {
stubFetchJson({
groups: [
{
conversation_id: "cid-platform",
conversation_title: "Platform",
bots: [
{
installation_id: "inst-1",
agent_id: "agent-1",
bot_name: "Release Bot",
bot_identity_issue: "",
last_active_at: "2026-08-19T08:00:00Z",
mention_count: 18,
},
],
},
],
group_discovery_supported: true,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listDingTalkGroups("ws-1");
expect(res.groups[0]?.bots[0]).toMatchObject({
last_active_at: "2026-08-19T08:00:00Z",
mention_count: 18,
});
});
it("uses an agent-scoped endpoint for Agent detail group visibility", async () => {
stubFetchJson({ groups: [], group_discovery_supported: true });
const client = new ApiClient("https://api.example.test");
await client.listAgentDingTalkGroups("agent-1");
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
"https://api.example.test/api/agents/agent-1/dingtalk/groups",
expect.any(Object),
);
});
it("requests one inactive installation page and can forget an observation", async () => {
stubFetchJson({ groups: [], group_discovery_supported: true });
const client = new ApiClient("https://api.example.test");
await client.listDingTalkGroups("ws-1", {
activity: "inactive",
installationId: "inst-1",
offset: 20,
limit: 10,
});
expect(vi.mocked(fetch)).toHaveBeenLastCalledWith(
"https://api.example.test/api/workspaces/ws-1/dingtalk/groups?activity=inactive&installation_id=inst-1&offset=20&limit=10",
expect.any(Object),
);
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 204 }));
await client.forgetDingTalkGroup("ws-1", "inst-1", "cid/encoded");
expect(vi.mocked(fetch)).toHaveBeenLastCalledWith(
"https://api.example.test/api/workspaces/ws-1/dingtalk/installations/inst-1/groups/cid%2Fencoded",
expect.objectContaining({ method: "DELETE" }),
);
});
it("treats an older backend's missing endpoint as an empty group list", async () => {
stubFetchJson({ error: "not found" }, 404);
const client = new ApiClient("https://api.example.test");
await expect(client.listDingTalkGroups("ws-1")).resolves.toEqual({
groups: [],
group_discovery_supported: false,
});
});
it("treats an older backend's admin-only group inventory as unsupported for members", async () => {
stubFetchJson({ error: "forbidden" }, 403);
const client = new ApiClient("https://api.example.test");
await expect(client.listDingTalkGroups("ws-1")).resolves.toEqual({
groups: [],
group_discovery_supported: false,
});
});
it("does not hide a real group-list server failure", async () => {
stubFetchJson({ error: "unavailable" }, 503);
const client = new ApiClient("https://api.example.test");
await expect(client.listDingTalkGroups("ws-1")).rejects.toMatchObject({
status: 503,
});
});
it("falls back to an empty group list when the response is malformed", async () => {
stubFetchJson({ groups: "not-an-array" });
const client = new ApiClient("https://api.example.test");
await expect(client.listDingTalkGroups("ws-1")).resolves.toEqual({
groups: [],
group_discovery_supported: false,
});
});
it("defaults additive bot identity fields and isolates a malformed bot list", async () => {
stubFetchJson({
groups: [
{
conversation_id: "cid-platform",
conversation_title: "Platform",
bots: [{ installation_id: "inst-1", agent_id: "agent-1" }],
},
{
conversation_id: "cid-old-server",
bots: "not-an-array",
},
],
group_discovery_supported: true,
future_field: true,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listDingTalkGroups("ws-1");
expect(res.group_discovery_supported).toBe(true);
expect(res.groups).toEqual([
{
conversation_id: "cid-platform",
conversation_title: "Platform",
bots: [
{
installation_id: "inst-1",
agent_id: "agent-1",
bot_name: "",
bot_identity_issue: "",
},
],
},
{
conversation_id: "cid-old-server",
conversation_title: "",
bots: [],
},
]);
});
});
describe("getConfig", () => {
it("drops malformed daemon setup URLs instead of throwing", async () => {
stubFetchJson({
cdn_domain: "cdn.example.com",
allow_signup: true,
daemon_server_url: { wrong: "shape" },
daemon_app_url: 123,
workspace_creation_disabled: false,
feature_flags: { composio_mcp_apps: true },
});
const client = new ApiClient("https://api.example.test");
const config = await client.getConfig();
expect(config.cdn_domain).toBe("cdn.example.com");
expect(config.allow_signup).toBe(true);
expect(config.daemon_server_url).toBeUndefined();
expect(config.daemon_app_url).toBeUndefined();
expect(config.feature_flags?.composio_mcp_apps).toBe(true);
});
});
describe("listGroupedIssues", () => {
it("falls back to empty groups when the response is malformed", async () => {
stubFetchJson({ groups: "not-an-array" });
const client = new ApiClient("https://api.example.test");
const res = await client.listGroupedIssues({ group_by: "assignee" });
expect(res).toEqual({ groups: [] });
});
});
describe("listComments", () => {
it("returns [] when the response is not an array", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const comments = await client.listComments("issue-1");
expect(comments).toEqual([]);
});
});
describe("previewCommentTriggers", () => {
it("returns an empty agent list when the response is malformed", async () => {
stubFetchJson({ agents: "not-an-array" });
const client = new ApiClient("https://api.example.test");
const preview = await client.previewCommentTriggers("issue-1", "hello");
expect(preview).toEqual({ agents: [] });
});
});
describe("listIssueSubscribers", () => {
it("returns [] when the response is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const subs = await client.listIssueSubscribers("issue-1");
expect(subs).toEqual([]);
});
});
describe("listChildIssues", () => {
it("returns { issues: [] } when the issues field is missing", async () => {
stubFetchJson({});
const client = new ApiClient("https://api.example.test");
const res = await client.listChildIssues("issue-1");
expect(res).toEqual({ issues: [] });
});
});
describe("listIssueDuplicates", () => {
it("falls back to an empty relation when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const res = await client.listIssueDuplicates("issue-1");
expect(res).toEqual({ duplicate_of: null, duplicates: [] });
});
it("defaults missing fields instead of failing", async () => {
stubFetchJson({});
const client = new ApiClient("https://api.example.test");
const res = await client.listIssueDuplicates("issue-1");
expect(res).toEqual({ duplicate_of: null, duplicates: [] });
});
});
describe("getChildIssueProgress", () => {
it("validates the response before query selectors iterate it", async () => {
stubFetchJson({ progress: "invalid" });
const client = new ApiClient("https://api.example.test");
await expect(client.getChildIssueProgress()).resolves.toEqual({
progress: [],
});
});
});
describe("listAutopilotDeliveries", () => {
it("falls back to an empty list when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res).toEqual({ deliveries: [], total: 0 });
});
it("falls back to an empty list when `deliveries` is not an array", async () => {
stubFetchJson({ deliveries: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res).toEqual({ deliveries: [], total: 0 });
});
it("accepts an unknown future status value rather than dropping the row", async () => {
// Server-side enum drift (e.g. new `quarantined` state). The list
// must still surface the row; downstream UI code's `default` arm
// handles unknown values with a generic visual.
stubFetchJson({
deliveries: [
{
id: "d-1",
workspace_id: "ws-1",
autopilot_id: "ap-1",
trigger_id: "t-1",
provider: "github",
event: "pull_request.opened",
dedupe_key: "abc",
dedupe_source: "x-github-delivery",
signature_status: "valid",
status: "quarantined",
attempt_count: 1,
content_type: "application/json",
response_status: 200,
autopilot_run_id: null,
replayed_from_delivery_id: null,
error: null,
received_at: "2026-01-01T00:00:00Z",
last_attempt_at: "2026-01-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
},
],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res.deliveries).toHaveLength(1);
expect(res.deliveries[0]?.status).toBe("quarantined");
expect(res.deliveries[0]?.dispatch_attempts).toBe(0);
expect(res.deliveries[0]?.available_at).toBe("");
});
});
describe("getAutopilotDelivery", () => {
it("falls back to a placeholder carrying the requested id", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
expect(detail.id).toBe("d-1");
expect(detail.autopilot_id).toBe("ap-1");
});
});
describe("listAgentBuilderSessions", () => {
it("falls back to no drafts when the response is malformed", async () => {
stubFetchJson({ unexpected: "shape" });
const client = new ApiClient("https://api.example.test");
expect(await client.listAgentBuilderSessions()).toEqual([]);
});
// Losing the whole row would lose the conversation. A draft missing its
// runtime degrades to "let the user pick one" instead.
it("keeps a draft whose runtime the server omitted", async () => {
stubFetchJson({
sessions: [{ session_id: "s-1", title: "Create an agent" }],
});
const client = new ApiClient("https://api.example.test");
const sessions = await client.listAgentBuilderSessions();
expect(sessions).toHaveLength(1);
expect(sessions[0]?.session_id).toBe("s-1");
expect(sessions[0]?.runtime_id).toBe("");
});
// An installed desktop build can outrun a self-hosted backend. Without this
// the Agents page would error instead of simply offering no drafts.
it("treats a 404 from an older backend as no drafts", async () => {
stubFetchJson({ error: "not found" }, 404);
const client = new ApiClient("https://api.example.test");
expect(await client.listAgentBuilderSessions()).toEqual([]);
});
});
describe("cronPreview", () => {
it("returns the parsed next runs", async () => {
stubFetchJson({
next_runs: ["2026-07-14T01:00:00Z", "2026-07-14T03:00:00Z"],
});
const client = new ApiClient("https://api.example.test");
const res = await client.cronPreview({
expr: "0 9-21/2 * * *",
tz: "Asia/Shanghai",
});
expect(res).toEqual({
next_runs: ["2026-07-14T01:00:00Z", "2026-07-14T03:00:00Z"],
});
});
it("URL-encodes the expression and timezone", async () => {
stubFetchJson({ next_runs: [] });
const client = new ApiClient("https://api.example.test");
await client.cronPreview({ expr: "0 9-21/2 * * 2-4", tz: "Asia/Shanghai" });
const url = String(vi.mocked(fetch).mock.calls[0]?.[0]);
expect(url).toContain("/api/autopilots/cron-preview?");
expect(url).toContain("expr=0+9-21%2F2+*+*+2-4");
expect(url).toContain("tz=Asia%2FShanghai");
});
it("returns an empty list verbatim when the expression never fires", async () => {
stubFetchJson({ next_runs: [] });
const client = new ApiClient("https://api.example.test");
const res = await client.cronPreview({ expr: "0 0 30 2 *", tz: "UTC" });
expect(res).toEqual({ next_runs: [] });
});
it("falls back to null when the response is malformed", async () => {
// null, not [] — the caller must be able to tell "unreadable response"
// apart from "this expression never fires".
stubFetchJson({ next_runs: "not-an-array" });
const client = new ApiClient("https://api.example.test");
const res = await client.cronPreview({ expr: "0 9 * * *", tz: "UTC" });
expect(res).toEqual({ next_runs: null });
});
it("falls back to null when the field is missing entirely", async () => {
stubFetchJson({ runs: ["2026-07-14T01:00:00Z"] });
const client = new ApiClient("https://api.example.test");
const res = await client.cronPreview({ expr: "0 9 * * *", tz: "UTC" });
expect(res).toEqual({ next_runs: null });
});
});
describe("cloud billing", () => {
it("falls back to an empty balance when the response is malformed", async () => {
stubFetchJson({ balance_micro: "not-a-number" });
const client = new ApiClient("https://api.example.test");
await expect(client.getCloudBillingBalance()).resolves.toEqual({
owner_id: "",
balance_micro: 0,
balance_credit: 0,
updated_at: "",
});
});
it("falls back to an empty transactions page when items are malformed", async () => {
stubFetchJson({ items: "not-an-array", total: 1, page: 1, page_size: 20 });
const client = new ApiClient("https://api.example.test");
await expect(client.listCloudBillingTransactions()).resolves.toEqual({
items: [],
total: 0,
page: 1,
page_size: 20,
});
});
it("falls back to an empty batches page when items are malformed", async () => {
stubFetchJson({ items: "not-an-array", total: 1, page: 1, page_size: 20 });
const client = new ApiClient("https://api.example.test");
await expect(client.listCloudBillingBatches()).resolves.toEqual({
items: [],
total: 0,
page: 1,
page_size: 20,
});
});
it("falls back to an empty top-ups page when items are malformed", async () => {
stubFetchJson({ items: "not-an-array", total: 1, page: 1, page_size: 20 });
const client = new ApiClient("https://api.example.test");
await expect(client.listCloudBillingTopups()).resolves.toEqual({
items: [],
total: 0,
page: 1,
page_size: 20,
});
});
it("falls back to no price tiers when the response is not an array", async () => {
stubFetchJson({ tiers: [] });
const client = new ApiClient("https://api.example.test");
await expect(client.listCloudBillingPriceTiers()).resolves.toEqual([]);
});
it("falls back to an empty checkout session when the response is malformed", async () => {
stubFetchJson({ order_id: 123 });
const client = new ApiClient("https://api.example.test");
await expect(
client.createCloudBillingCheckoutSession({ tier_id: "starter" }),
).resolves.toEqual({
order_id: "",
session_id: "",
url: "",
});
});
it("falls back to a pending checkout status when the response is malformed", async () => {
stubFetchJson({ status: 123 });
const client = new ApiClient("https://api.example.test");
await expect(client.getCloudBillingCheckoutSession("cs_test")).resolves.toEqual({
order_id: "",
status: "pending",
amount_cents: 0,
credits: 0,
bonus_credits: 0,
currency: "usd",
tier_id: "",
});
});
it("falls back to an empty portal URL when the response is malformed", async () => {
stubFetchJson({ url: 123 });
const client = new ApiClient("https://api.example.test");
await expect(client.createCloudBillingPortalSession()).resolves.toEqual({
url: "",
});
});
it("sends the Checkout idempotency key in the header and body", async () => {
stubFetchJson(
{
request_id: "request-1",
session_id: "cs_test_1",
url: "https://checkout.stripe.com/test-session",
},
201,
);
const client = new ApiClient("https://api.example.test");
await expect(
client.createWorkspaceSubscriptionCheckout({
interval: "year",
idempotencyKey: "checkout-intent-1",
}),
).resolves.toEqual({
requestId: "request-1",
sessionId: "cs_test_1",
url: "https://checkout.stripe.com/test-session",
});
const fetchMock = vi.mocked(fetch);
const init = fetchMock.mock.calls[0]?.[1] as RequestInit & {
headers: Record<string, string>;
};
expect(init.headers["Idempotency-Key"]).toBe("checkout-intent-1");
expect(JSON.parse(String(init.body))).toEqual({
interval: "year",
idempotency_key: "checkout-intent-1",
});
});
it("rejects unreadable or non-HTTPS Stripe URLs at the schema boundary", async () => {
stubFetchJson(
{
request_id: "request-1",
session_id: "cs_test_1",
url: "javascript:alert(1)",
},
201,
);
const client = new ApiClient("https://api.example.test");
await expect(
client.createWorkspaceSubscriptionCheckout({
interval: "month",
idempotencyKey: "checkout-intent-1",
}),
).resolves.toBeNull();
stubFetchJson({ url: 123 });
await expect(
client.createWorkspaceSubscriptionPortal("portal-intent-1"),
).resolves.toBeNull();
});
it("parses seat reconciliation into camelCase", async () => {
stubFetchJson({
workspace_id: "workspace-1",
action: "restored_high_water",
version: 8,
});
const client = new ApiClient("https://api.example.test");
await expect(
client.reconcileWorkspaceSubscriptionSeats(),
).resolves.toEqual({
workspaceId: "workspace-1",
action: "restored_high_water",
version: 8,
});
});
});
});
// Direct tests for the helper, decoupled from any specific endpoint —
// guards against an endpoint refactor masking a regression in the helper.
describe("parseWithFallback", () => {
const opts = { endpoint: "TEST /unit" };
it("returns parsed data on success", () => {
const schema = z.object({ id: z.string() });
const out = parseWithFallback({ id: "x" }, schema, { id: "fallback" }, opts);
expect(out).toEqual({ id: "x" });
});
it("returns the fallback when validation fails", () => {
const schema = z.object({ id: z.string() });
const fallback = { id: "fallback" };
const out = parseWithFallback({ id: 123 }, schema, fallback, opts);
expect(out).toBe(fallback);
});
it("returns the fallback when data is null", () => {
const schema = z.object({ id: z.string() });
const fallback = { id: "fallback" };
const out = parseWithFallback(null, schema, fallback, opts);
expect(out).toBe(fallback);
});
});
// Workspace subscription reads carry a specific hazard the wallet schemas do
// not: the fallback for a paid workspace must never be a shape that reads as
// Free. An older/disabled cloud, an upstream 503, or a renamed field has to surface as "unknown"
// so the UI shows "unavailable" instead of quietly downgrading a paying team.
describe("workspace subscription contract", () => {
const entitlement = {
workspace_id: "11111111-1111-1111-1111-111111111111",
plan: "pro",
status: "active",
seats: 3,
limits: {
issue_count: { mode: "unlimited" },
autopilot_runs: { mode: "unlimited" },
},
current_period_end: "2026-09-01T00:00:00Z",
snapshot_expires_at: null,
version: 7,
};
it("maps limited issue usage and accepts a no-content unlimited response", async () => {
stubFetchJson({
used: 11,
limit: 17,
});
const client = new ApiClient("https://api.example.test");
await expect(client.getIssueLimitUsage()).resolves.toEqual({
used: 11,
limit: 17,
});
stubFetchJson({ used: 11 });
await expect(client.getIssueLimitUsage()).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 204 })),
);
await expect(client.getIssueLimitUsage()).resolves.toBeNull();
});
it("maps a full summary to camelCase without inventing values", async () => {
stubFetchJson({
entitlement,
billing_interval: "year",
human_members: 3,
seat_capacity: {
purchased: 5,
used: 3,
reserved: 2,
available: 0,
overcommitted: false,
version: 11,
pending_quantity: 3,
active_purchase: {
request_id: "22222222-2222-2222-2222-222222222222",
target_seats: 7,
status: "processing",
},
},
cancel_at_period_end: true,
grace_until: "2026-09-08T00:00:00Z",
has_stripe_customer: true,
available_actions: {
checkout: false,
portal: true,
purchase_seats: false,
},
});
const client = new ApiClient("https://api.example.test");
const summary = await client.getWorkspaceSubscriptionSummary();
expect(summary).not.toBeNull();
expect(summary?.entitlement.plan).toBe("pro");
expect(summary?.entitlement.version).toBe(7);
expect(summary?.billingInterval).toBe("year");
expect(summary?.humanMembers).toBe(3);
expect(summary?.seatCapacity?.purchased).toBe(5);
expect(summary?.seatCapacity?.pendingQuantity).toBe(3);
expect(summary?.seatCapacity?.used).toBe(3);
expect(summary?.seatCapacity?.reserved).toBe(2);
expect(summary?.seatCapacity?.available).toBe(0);
expect(summary?.seatCapacity?.version).toBe(11);
expect(summary?.seatCapacity?.activePurchase).toEqual({
requestId: "22222222-2222-2222-2222-222222222222",
targetSeats: 7,
status: "processing",
expiresAt: null,
});
expect(summary?.cancelAtPeriodEnd).toBe(true);
expect(summary?.graceUntil).toBe("2026-09-08T00:00:00Z");
expect(summary?.hasStripeCustomer).toBe(true);
expect(summary?.availableActions).toEqual({
checkout: false,
portal: true,
purchaseSeats: false,
});
});
it("represents canceled subscriptions without current seat capacity", async () => {
stubFetchJson({
entitlement: { ...entitlement, plan: "free", status: "canceled" },
billing_interval: "month",
human_members: 3,
seat_capacity: null,
cancel_at_period_end: false,
grace_until: null,
has_stripe_customer: true,
available_actions: {
checkout: true,
portal: true,
purchase_seats: false,
},
});
const client = new ApiClient("https://api.example.test");
const summary = await client.getWorkspaceSubscriptionSummary();
expect(summary?.entitlement.plan).toBe("free");
expect(summary?.entitlement.status).toBe("canceled");
expect(summary?.humanMembers).toBe(3);
expect(summary?.seatCapacity).toBeNull();
expect(summary?.hasStripeCustomer).toBe(true);
});
it("preserves unknown plans and statuses instead of coercing them", async () => {
stubFetchJson({
entitlement: { ...entitlement, plan: "business", status: "paused" },
billing_interval: null,
human_members: 9,
seat_capacity: null,
cancel_at_period_end: false,
grace_until: null,
has_stripe_customer: false,
available_actions: {
checkout: false,
portal: false,
purchase_seats: false,
},
});
const client = new ApiClient("https://api.example.test");
const summary = await client.getWorkspaceSubscriptionSummary();
expect(summary?.entitlement.plan).toBe("business");
expect(summary?.entitlement.status).toBe("paused");
});
it("returns null — never a Free-looking shape — for a malformed summary", async () => {
stubFetchJson({ entitlement: { plan: "pro" }, human_members: "many" });
const client = new ApiClient("https://api.example.test");
expect(await client.getWorkspaceSubscriptionSummary()).toBeNull();
});
it("throws ApiError for a non-2xx summary so the caller reports unavailable", async () => {
// An older cloud without the route, a 403, or a 503 never reaches schema
// parsing: fetch rejects first and a React Query caller sees isError. What
// matters for both paths is the same — no snapshot is produced, so nothing
// can be mistaken for a Free workspace.
for (const status of [403, 404, 503]) {
stubFetchJson({ error: "unavailable" }, status);
const client = new ApiClient("https://api.example.test");
await expect(client.getWorkspaceSubscriptionSummary()).rejects.toThrow();
}
});
it("returns null for a 2xx body that does not match the contract", async () => {
// The other half of the contract: a response that arrives successfully but
// does not conform degrades to null rather than throwing into React.
stubFetchJson({ unexpected: "shape" });
const client = new ApiClient("https://api.example.test");
expect(await client.getWorkspaceSubscriptionSummary()).toBeNull();
});
it("accepts additive cloud fields on summary and prices", async () => {
stubFetchJson({
entitlement: { ...entitlement, trial_ends_at: "2026-10-01T00:00:00Z" },
billing_interval: "month",
human_members: 3,
seat_capacity: null,
cancel_at_period_end: false,
grace_until: null,
has_stripe_customer: true,
available_actions: {
checkout: false,
portal: true,
purchase_seats: false,
},
future_field: { nested: true },
});
const client = new ApiClient("https://api.example.test");
expect(
(await client.getWorkspaceSubscriptionSummary())?.entitlement.plan,
).toBe("pro");
});
it("maps validated prices and rejects a non-positive amount", async () => {
stubFetchJson({
month: { currency: "usd", unit_amount: 2000, interval: "month", interval_count: 1 },
year: { currency: "usd", unit_amount: 20000, interval: "year", interval_count: 1 },
});
const client = new ApiClient("https://api.example.test");
const prices = await client.getWorkspaceSubscriptionPrices();
expect(prices?.month.unitAmount).toBe(2000);
expect(prices?.year.interval).toBe("year");
// A zero or missing amount must not be rendered next to a purchase button.
stubFetchJson({
month: { currency: "usd", unit_amount: 0, interval: "month", interval_count: 1 },
year: { currency: "usd", unit_amount: 20000, interval: "year", interval_count: 1 },
});
expect(await client.getWorkspaceSubscriptionPrices()).toBeNull();
});
it("rejects a prices response whose interval does not match its slot", async () => {
const client = new ApiClient("https://api.example.test");
// An interval outside the contract.
stubFetchJson({
month: { currency: "usd", unit_amount: 2000, interval: "month", interval_count: 1 },
year: { currency: "usd", unit_amount: 20000, interval: "week", interval_count: 1 },
});
expect(await client.getWorkspaceSubscriptionPrices()).toBeNull();
// Valid intervals in the wrong slots. Accepting this would let the UI quote
// a yearly amount as the monthly price.
stubFetchJson({
month: { currency: "usd", unit_amount: 20000, interval: "year", interval_count: 1 },
year: { currency: "usd", unit_amount: 2000, interval: "month", interval_count: 1 },
});
expect(await client.getWorkspaceSubscriptionPrices()).toBeNull();
});
it("rejects a prices response whose interval count is not one", async () => {
stubFetchJson({
month: { currency: "usd", unit_amount: 2000, interval: "month", interval_count: 3 },
year: { currency: "usd", unit_amount: 20000, interval: "year", interval_count: 1 },
});
const client = new ApiClient("https://api.example.test");
expect(await client.getWorkspaceSubscriptionPrices()).toBeNull();
});
it("maps seat purchase previews and confirmations without trusting client totals", async () => {
stubFetchJson({
current_seats: 5,
additional_seats: 2,
resulting_seats: 7,
purchase_version: 9,
currency: "usd",
proration_amount: 425,
next_invoice_amount: 14000,
quoted_at: "2026-08-21T06:00:00Z",
});
const client = new ApiClient("https://api.example.test");
await expect(
client.previewWorkspaceSeatPurchase({ additionalSeats: 2 }),
).resolves.toEqual({
currentSeats: 5,
additionalSeats: 2,
resultingSeats: 7,
purchaseVersion: 9,
currency: "usd",
prorationAmount: 425,
nextInvoiceAmount: 14000,
quotedAt: "2026-08-21T06:00:00Z",
});
expect(vi.mocked(fetch)).toHaveBeenLastCalledWith(
"https://api.example.test/api/cloud-subscriptions/seats/purchase-preview",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ additional_seats: 2 }),
}),
);
stubFetchJson({
request_id: "33333333-3333-3333-3333-333333333333",
current_seats: 5,
additional_seats: 2,
resulting_seats: 7,
currency: "usd",
proration_amount: 425,
next_invoice_amount: 14000,
status: "submitted",
}, 202);
await expect(
client.purchaseWorkspaceSeats({
additionalSeats: 2,
expectedCurrentSeats: 5,
expectedPurchaseVersion: 9,
acceptedProrationAmount: 425,
currency: "usd",
idempotencyKey: "seat-request-1",
}),
).resolves.toMatchObject({ resultingSeats: 7, status: "submitted" });
expect(vi.mocked(fetch)).toHaveBeenLastCalledWith(
"https://api.example.test/api/cloud-subscriptions/seats/purchases",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
additional_seats: 2,
expected_current_seats: 5,
expected_purchase_version: 9,
accepted_proration_amount: 425,
currency: "usd",
idempotency_key: "seat-request-1",
}),
headers: expect.objectContaining({ "Idempotency-Key": "seat-request-1" }),
}),
);
});
});