mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
Before: the Overview tab of the project sidebar was an empty placeholder, so seeing which issues waited on you, which had agents at work and which were quiet meant scanning the whole issue surface. After: the tab lists the project's top-level issues in four collapsible groups with counts: Needs you, Running, Idle and Resolved (collapsed by default). Each row shows the status icon, title and relative time of the latest reply or activity, then the newest reply excerpt, sub-issue progress (x/y) and the file count. Clicking a row opens the issue; empty groups and an empty project say so in one line. How: groupProjectIssues is a pure function; each issue lands in the first matching group, Resolved (done/closed category) before Needs you (an agent question for the caller, or an unread thread someone else spoke last in) before Running (a running or queued agent task from the shared task snapshot) before Idle. The panel reads one fixed project-scoped Table page (top-level issues, last activity first, up to 100) because the issue surface's own lists follow its filters and grouping, then thread summaries and child progress for those ids. Rows open through useOpenFeedIssue; the sidebar sits outside the surface's peek host, so that navigates to the issue page. Core gains last_reply_excerpt on IssueThreadSummary (schema default null), a commentExcerpt twin of the server flattener, and the realtime comment patch sets the excerpt. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
2024 lines
74 KiB
TypeScript
2024 lines
74 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("listTimeline (root-paged)", () => {
|
|
const entry = {
|
|
type: "comment",
|
|
id: "e-1",
|
|
actor_type: "member",
|
|
actor_id: "u-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
};
|
|
const emptyPage = {
|
|
entries: [],
|
|
has_more_before: false,
|
|
has_more_after: false,
|
|
next_before: null,
|
|
next_before_id: null,
|
|
};
|
|
|
|
it("sends the page size as roots and encodes the composite cursor", async () => {
|
|
stubFetchJson(emptyPage);
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.listTimeline("issue-1", {
|
|
limit: 50,
|
|
before: "2026-01-01T08:00:00.123456+08:00",
|
|
beforeId: "root-7",
|
|
});
|
|
const url = new URL(String(vi.mocked(fetch).mock.calls[0]![0]));
|
|
expect(url.pathname).toBe("/api/issues/issue-1/timeline");
|
|
expect(url.searchParams.get("roots")).toBe("50");
|
|
// '+' must survive as a plus sign, not decode to a space.
|
|
expect(url.searchParams.get("before")).toBe("2026-01-01T08:00:00.123456+08:00");
|
|
expect(url.searchParams.get("before_id")).toBe("root-7");
|
|
expect(url.searchParams.has("around")).toBe(false);
|
|
});
|
|
|
|
it("sends only roots for a first page and around for an anchor", async () => {
|
|
const client = new ApiClient("https://api.example.test");
|
|
stubFetchJson(emptyPage);
|
|
await client.listTimeline("issue-1", { limit: 50 });
|
|
const first = new URL(String(vi.mocked(fetch).mock.calls[0]![0]));
|
|
stubFetchJson(emptyPage);
|
|
await client.listTimeline("issue-1", { limit: 50, around: "c-9" });
|
|
const second = new URL(String(vi.mocked(fetch).mock.calls[0]![0]));
|
|
expect([...first.searchParams.keys()]).toEqual(["roots"]);
|
|
expect(second.searchParams.get("around")).toBe("c-9");
|
|
expect(second.searchParams.has("before")).toBe(false);
|
|
});
|
|
|
|
it("keeps the unpaged request unchanged", async () => {
|
|
stubFetchJson([]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.listTimeline("issue-1");
|
|
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
|
|
"https://api.example.test/api/issues/issue-1/timeline",
|
|
);
|
|
});
|
|
|
|
it("parses a full page", async () => {
|
|
stubFetchJson({
|
|
entries: [entry],
|
|
has_more_before: true,
|
|
has_more_after: false,
|
|
next_before: "2026-01-01T00:00:00.000001Z",
|
|
next_before_id: "e-1",
|
|
target_index: 0,
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const page = await client.listTimeline("issue-1", { limit: 50 });
|
|
expect(page.entries).toHaveLength(1);
|
|
expect(page.has_more_before).toBe(true);
|
|
expect(page.next_before).toBe("2026-01-01T00:00:00.000001Z");
|
|
expect(page.next_before_id).toBe("e-1");
|
|
expect(page.target_index).toBe(0);
|
|
});
|
|
|
|
it("defaults every optional field of a minimal page", async () => {
|
|
stubFetchJson({ entries: [entry] });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const page = await client.listTimeline("issue-1", { limit: 50 });
|
|
expect(page).toMatchObject({
|
|
has_more_before: false,
|
|
has_more_after: false,
|
|
next_before: null,
|
|
next_before_id: null,
|
|
});
|
|
expect(page.target_index).toBeUndefined();
|
|
});
|
|
|
|
it("treats a wrongly typed cursor or flag as absent instead of failing the page", async () => {
|
|
stubFetchJson({
|
|
entries: [entry],
|
|
has_more_before: "yes",
|
|
next_before: 12,
|
|
next_before_id: { id: "e-1" },
|
|
target_index: "0",
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const page = await client.listTimeline("issue-1", { limit: 50 });
|
|
expect(page.entries).toHaveLength(1);
|
|
expect(page).toMatchObject({ has_more_before: false, next_before: null, next_before_id: null });
|
|
expect(page.target_index).toBeUndefined();
|
|
});
|
|
|
|
it("falls back to an empty page for a malformed body", async () => {
|
|
const client = new ApiClient("https://api.example.test");
|
|
for (const body of [null, JSON.stringify("nope"), 7, { entries: "not-an-array" }, { entries: [{ id: 1 }] }]) {
|
|
stubFetchJson(body);
|
|
await expect(client.listTimeline("issue-1", { limit: 50 })).resolves.toEqual(emptyPage);
|
|
}
|
|
});
|
|
|
|
it("reads a bare timeline array from an older server as one page with nothing older", async () => {
|
|
stubFetchJson([entry]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const page = await client.listTimeline("issue-1", { limit: 50 });
|
|
expect(page.entries).toHaveLength(1);
|
|
expect(page).toMatchObject({ has_more_before: false, next_before: null });
|
|
});
|
|
|
|
it("preserves fields the schema does not list", async () => {
|
|
stubFetchJson({
|
|
entries: [{ ...entry, future_field: 1 }],
|
|
future_page_field: "kept",
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const page = (await client.listTimeline("issue-1", { limit: 50 })) as unknown as Record<string, unknown>;
|
|
expect(page.future_page_field).toBe("kept");
|
|
expect((page.entries as Array<Record<string, unknown>>)[0]!.future_field).toBe(1);
|
|
});
|
|
});
|
|
|
|
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("listThreadSummaries", () => {
|
|
it("falls back to no summaries when the body is malformed", async () => {
|
|
stubFetchJson({ summaries: "invalid" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listThreadSummaries(["issue-1"])).resolves.toEqual({
|
|
summaries: [],
|
|
});
|
|
});
|
|
|
|
it("defaults missing counters, participants and last reply", async () => {
|
|
stubFetchJson({ summaries: [{ issue_id: "issue-1" }] });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1"]);
|
|
expect(res.summaries).toEqual([
|
|
{
|
|
issue_id: "issue-1",
|
|
reply_count: 0,
|
|
last_reply_at: null,
|
|
last_reply_excerpt: null,
|
|
open_thread_count: 0,
|
|
participants: [],
|
|
last_read_at: null,
|
|
awaiting_me: [],
|
|
latest_progress: null,
|
|
pull_requests: [],
|
|
deliverable_count: 0,
|
|
last_deliverable_at: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps the output strip fields and defaults a PR's optional ones", async () => {
|
|
stubFetchJson({
|
|
summaries: [
|
|
{
|
|
issue_id: "issue-1",
|
|
deliverable_count: 2,
|
|
last_deliverable_at: "2026-05-01T10:00:00.5Z",
|
|
pull_requests: [
|
|
{
|
|
id: "pr-1",
|
|
number: 12,
|
|
title: "Fix it",
|
|
url: "https://github.com/o/r/pull/12",
|
|
state: "merged",
|
|
additions: 40,
|
|
deletions: 3,
|
|
checks_state: "passed",
|
|
},
|
|
{ id: "pr-2" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1"]);
|
|
expect(res.summaries[0]?.deliverable_count).toBe(2);
|
|
expect(res.summaries[0]?.last_deliverable_at).toBe("2026-05-01T10:00:00.5Z");
|
|
expect(res.summaries[0]?.pull_requests).toEqual([
|
|
{
|
|
id: "pr-1",
|
|
number: 12,
|
|
title: "Fix it",
|
|
url: "https://github.com/o/r/pull/12",
|
|
state: "merged",
|
|
additions: 40,
|
|
deletions: 3,
|
|
checks_state: "passed",
|
|
},
|
|
{
|
|
id: "pr-2",
|
|
number: 0,
|
|
title: "",
|
|
url: "",
|
|
state: "open",
|
|
additions: 0,
|
|
deletions: 0,
|
|
checks_state: "none",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps the rows when a PR carries an unknown state or checks_state", async () => {
|
|
stubFetchJson({
|
|
summaries: [
|
|
{
|
|
issue_id: "issue-1",
|
|
reply_count: 4,
|
|
pull_requests: [
|
|
{ id: "pr-1", state: "queued", checks_state: "flaky" },
|
|
{ id: "pr-2", state: "open", checks_state: null },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1"]);
|
|
expect(res.summaries).toHaveLength(1);
|
|
expect(res.summaries[0]?.reply_count).toBe(4);
|
|
expect(res.summaries[0]?.pull_requests.map((p) => [p.state, p.checks_state])).toEqual([
|
|
["queued", "none"],
|
|
["open", "none"],
|
|
]);
|
|
});
|
|
|
|
it("keeps the last reply excerpt and nulls an explicit null", async () => {
|
|
stubFetchJson({
|
|
summaries: [
|
|
{ issue_id: "issue-1", last_reply_excerpt: "Looks good" },
|
|
{ issue_id: "issue-2", last_reply_excerpt: null },
|
|
],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1", "issue-2"]);
|
|
expect(res.summaries.map((s) => s.last_reply_excerpt)).toEqual(["Looks good", null]);
|
|
});
|
|
|
|
it("falls back to no summaries when last_reply_excerpt is malformed", async () => {
|
|
stubFetchJson({ summaries: [{ issue_id: "issue-1", last_reply_excerpt: 5 }] });
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listThreadSummaries(["issue-1"])).resolves.toEqual({
|
|
summaries: [],
|
|
});
|
|
});
|
|
|
|
it("keeps an explicit null last_deliverable_at", async () => {
|
|
stubFetchJson({
|
|
summaries: [{ issue_id: "issue-1", last_deliverable_at: null, deliverable_count: 1 }],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1"]);
|
|
expect(res.summaries[0]?.last_deliverable_at).toBeNull();
|
|
expect(res.summaries[0]?.deliverable_count).toBe(1);
|
|
});
|
|
|
|
it("falls back to no summaries when pull_requests is malformed", async () => {
|
|
stubFetchJson({ summaries: [{ issue_id: "issue-1", pull_requests: "invalid" }] });
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listThreadSummaries(["issue-1"])).resolves.toEqual({
|
|
summaries: [],
|
|
});
|
|
});
|
|
|
|
it("keeps the caller's read cursor and nulls an explicit null", async () => {
|
|
stubFetchJson({
|
|
summaries: [
|
|
{ issue_id: "issue-1", last_read_at: "2026-05-01T10:00:00.123456789Z" },
|
|
{ issue_id: "issue-2", last_read_at: null },
|
|
],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1", "issue-2"]);
|
|
expect(res.summaries.map((s) => s.last_read_at)).toEqual([
|
|
"2026-05-01T10:00:00.123456789Z",
|
|
null,
|
|
]);
|
|
});
|
|
|
|
it("keeps the caller's awaiting_me entries and defaults their optional fields", async () => {
|
|
stubFetchJson({
|
|
summaries: [
|
|
{
|
|
issue_id: "issue-1",
|
|
awaiting_me: [
|
|
{
|
|
comment_id: "c-1",
|
|
actor_id: "agent-1",
|
|
excerpt: "Ship @Me?",
|
|
created_at: "2026-05-01T10:00:00Z",
|
|
},
|
|
{ comment_id: "c-2", actor_id: "agent-2" },
|
|
],
|
|
},
|
|
{ issue_id: "issue-2" },
|
|
],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1", "issue-2"]);
|
|
expect(res.summaries[0]?.awaiting_me).toEqual([
|
|
{
|
|
comment_id: "c-1",
|
|
actor_id: "agent-1",
|
|
excerpt: "Ship @Me?",
|
|
created_at: "2026-05-01T10:00:00Z",
|
|
},
|
|
{ comment_id: "c-2", actor_id: "agent-2", excerpt: "", created_at: "" },
|
|
]);
|
|
expect(res.summaries[1]?.awaiting_me).toEqual([]);
|
|
});
|
|
|
|
it("keeps latest_progress and nulls a missing, null or content-less one", async () => {
|
|
stubFetchJson({
|
|
summaries: [
|
|
{
|
|
issue_id: "issue-1",
|
|
latest_progress: {
|
|
comment_id: "c-1",
|
|
content: "- [x] a\n- [ ] b",
|
|
created_at: "2026-05-01T10:00:00Z",
|
|
},
|
|
},
|
|
{ issue_id: "issue-2" },
|
|
{ issue_id: "issue-3", latest_progress: null },
|
|
{ issue_id: "issue-4", latest_progress: { comment_id: "c-4" } },
|
|
],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1", "issue-2", "issue-3", "issue-4"]);
|
|
expect(res.summaries[0]?.latest_progress).toEqual({
|
|
comment_id: "c-1",
|
|
content: "- [x] a\n- [ ] b",
|
|
created_at: "2026-05-01T10:00:00Z",
|
|
});
|
|
expect(res.summaries[1]?.latest_progress).toBeNull();
|
|
expect(res.summaries[2]?.latest_progress).toBeNull();
|
|
expect(res.summaries[3]?.latest_progress).toEqual({
|
|
comment_id: "c-4",
|
|
content: "",
|
|
created_at: "",
|
|
});
|
|
});
|
|
|
|
it("falls back to an empty list for a malformed latest_progress", async () => {
|
|
stubFetchJson({ summaries: [{ issue_id: "issue-1", latest_progress: "oops" }] });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listThreadSummaries(["issue-1"]);
|
|
expect(res.summaries).toEqual([]);
|
|
});
|
|
});
|
|
|
|
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: [],
|
|
});
|
|
});
|
|
|
|
it("requests the workspace-wide map when no parent ids are given", async () => {
|
|
stubFetchJson({ progress: [] });
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await client.getChildIssueProgress();
|
|
|
|
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
|
|
"https://api.example.test/api/issues/child-progress",
|
|
);
|
|
});
|
|
|
|
it("narrows the request with parent_ids and falls back on a malformed body", async () => {
|
|
stubFetchJson({ progress: "invalid" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await expect(client.getChildIssueProgress(["p-1", "p-2"])).resolves.toEqual({
|
|
progress: [],
|
|
});
|
|
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
|
|
"https://api.example.test/api/issues/child-progress?parent_ids=p-1,p-2",
|
|
);
|
|
});
|
|
});
|
|
|
|
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" }),
|
|
}),
|
|
);
|
|
});
|
|
});
|