mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(views): route plain attachment download links through the re-sign path (WK-11) A plain markdown link to the API-host attachment endpoint (MULTICA_PUBLIC_URL/api/attachments/<id>/download, persisted by buildMarkdownURL in private-storage deployments) opened with a bare navigation. The browser sent no credential there — web's session cookie is host-locked to the API origin and SameSite=Strict, and Desktop/mobile carry a bearer token no <a> can send — so the request returned 401 "missing authorization" and the user downloaded the error body, not the file. File-card/image and the download button already route through useDownloadAttachment, which refreshes authenticated metadata and navigates to the credential-bearing URL the server mints per click (capability / presign / CloudFront). Plain links missed that path. Intercept attachment download URLs (UUID-guarded via attachmentIdFromDownloadURL) in RichLink and hand the click to the same download hook, so all three attachment entry points behave alike. Modified clicks still open natively; the raw URL remains the durable ref. Add a routing test covering the UUID-link interception and the non-UUID non-interception. Fixes WK-11. * fix(attachments): scope Markdown downloads to their deployment and workspace Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Dev (multica) <agent@multica> Co-authored-by: Yushen <[email protected]> Co-authored-by: multica-agent <[email protected]>
3174 lines
107 KiB
TypeScript
3174 lines
107 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createAuthStore } from "../auth";
|
|
import { configStore } from "../config";
|
|
import type { StorageAdapter, User } from "../types";
|
|
import { ApiClient, ApiError, CHAT_DRAFT_RESTORE_CAPABILITY, clientErrorMessage } from "./client";
|
|
import { EMPTY_PLUGIN_PACKAGE_LIST, EMPTY_PLUGIN_PREVIEW, EMPTY_PLUGIN_SURFACE_LAUNCH } from "./schemas";
|
|
|
|
afterEach(() => {
|
|
configStore.getState().setAgentConversationStartersSupported(false);
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("ApiClient status reorder", () => {
|
|
it("opts into built-in ordering and tolerates malformed responses", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ statuses: "invalid" }), {
|
|
status: 200, headers: { "Content-Type": "application/json" },
|
|
}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.reorderIssueStatuses("started", ["review", "qa", "progress"], true);
|
|
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
|
|
category: "started", ids: ["review", "qa", "progress"], include_system: true,
|
|
});
|
|
expect(result.statuses).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient agent conversation-starter compatibility", () => {
|
|
const prompt = {
|
|
label: "Review a PR",
|
|
prompt: "Review the open pull request.",
|
|
};
|
|
|
|
it("rejects create writes before an older backend can drop them", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await expect(
|
|
client.createAgent({
|
|
name: "Reviewer",
|
|
runtime_id: "runtime-1",
|
|
conversation_starters: [prompt],
|
|
}),
|
|
).rejects.toThrow(/server version does not support agent conversation starters/i);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects update writes before an older backend can drop them", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await expect(
|
|
client.updateAgent("agent-1", { conversation_starters: [prompt] }),
|
|
).rejects.toThrow(/server version does not support agent conversation starters/i);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows declared-capability create and update writes through", async () => {
|
|
configStore.getState().setAgentConversationStartersSupported(true);
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ id: "agent-1" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.createAgent({
|
|
name: "Reviewer",
|
|
runtime_id: "runtime-1",
|
|
conversation_starters: [prompt],
|
|
});
|
|
await client.updateAgent("agent-1", {
|
|
conversation_starters: [prompt],
|
|
});
|
|
|
|
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
|
|
name: "Reviewer",
|
|
runtime_id: "runtime-1",
|
|
conversation_starters: [prompt],
|
|
});
|
|
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
|
|
conversation_starters: [prompt],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ApiClient edit guards", () => {
|
|
it("serializes field baselines for issue and comment writes", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(
|
|
new Response("{}", {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await client.updateIssue("issue-1", { title: "Latest", title_base: "Original" });
|
|
await client.updateComment("comment-1", "Latest", [], undefined, "Original");
|
|
|
|
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
|
|
title: "Latest",
|
|
title_base: "Original",
|
|
});
|
|
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
|
|
content: "Latest",
|
|
content_base: "Original",
|
|
});
|
|
});
|
|
|
|
it("accepts an older issue response without revision and rejects a malformed revision", async () => {
|
|
const legacyIssue = {
|
|
id: "issue-1",
|
|
workspace_id: "ws-1",
|
|
number: 1,
|
|
identifier: "MUL-1",
|
|
title: "Legacy issue",
|
|
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: "2026-08-16T00:00:00Z",
|
|
updated_at: "2026-08-16T00:00:00Z",
|
|
};
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ issues: [legacyIssue], total: 1 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
issues: [{ ...legacyIssue, revision: "invalid" }],
|
|
total: 1,
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
const parsedLegacy = await client.listIssues();
|
|
expect(parsedLegacy).toMatchObject({ issues: [{ id: "issue-1" }], total: 1 });
|
|
expect(parsedLegacy.issues[0]).not.toHaveProperty("revision");
|
|
await expect(client.listIssues()).resolves.toEqual({ issues: [], total: 0 });
|
|
});
|
|
});
|
|
|
|
describe("ApiClient pull-request response schema", () => {
|
|
const validPR = {
|
|
id: "pr-1",
|
|
provider: "github",
|
|
workspace_id: "ws-1",
|
|
repo_owner: "acme",
|
|
repo_name: "widget",
|
|
number: 7,
|
|
title: "MUL-1: fix",
|
|
state: "open",
|
|
html_url: "https://github.example/acme/widget/pull/7",
|
|
branch: "fix/mul-1",
|
|
author_login: "octocat",
|
|
author_avatar_url: null,
|
|
merged_at: null,
|
|
closed_at: null,
|
|
pr_created_at: "2026-01-01T00:00:00Z",
|
|
pr_updated_at: "2026-01-01T00:00:00Z",
|
|
snapshot_available: true,
|
|
checks_rollup: "failure",
|
|
failed_check_names: ["backend"],
|
|
};
|
|
|
|
it("parses and defaults a valid pull-request list", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ pull_requests: [validPR] }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await new ApiClient("https://api.example.test").listIssuePullRequests("issue-1");
|
|
expect(result.pull_requests[0]).toMatchObject({
|
|
id: "pr-1",
|
|
failed_check_names: ["backend"],
|
|
checks_total: 0,
|
|
});
|
|
});
|
|
|
|
it("falls back safely when failed_check_names is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
pull_requests: [{ ...validPR, failed_check_names: "backend" }],
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new ApiClient("https://api.example.test").listIssuePullRequests("issue-1"),
|
|
).resolves.toEqual({ pull_requests: [], auto_complete: null });
|
|
});
|
|
|
|
function stubPullRequests(body: unknown) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
it("parses the auto-complete decision and link source", async () => {
|
|
stubPullRequests({
|
|
pull_requests: [{ ...validPR, link_source: "title" }],
|
|
auto_complete: { state: "waiting", pull_request_ids: ["pr-1"], issue_disabled: false, workspace_enabled: true, target_status: "in_review" },
|
|
});
|
|
const result = await new ApiClient("https://api.example.test").listIssuePullRequests("issue-1");
|
|
expect(result.pull_requests[0]?.link_source).toBe("title");
|
|
expect(result.auto_complete).toEqual({
|
|
state: "waiting",
|
|
pull_request_ids: ["pr-1"],
|
|
issue_disabled: false,
|
|
workspace_enabled: true,
|
|
target_status: "in_review",
|
|
});
|
|
});
|
|
|
|
it("treats a missing auto-complete block (older backend) as null", async () => {
|
|
stubPullRequests({ pull_requests: [validPR] });
|
|
const result = await new ApiClient("https://api.example.test").listIssuePullRequests("issue-1");
|
|
expect(result.auto_complete).toBeNull();
|
|
expect(result.pull_requests).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps the PR list when only the auto-complete block or link source is malformed", async () => {
|
|
stubPullRequests({
|
|
pull_requests: [{ ...validPR, link_source: "psychic" }],
|
|
auto_complete: { state: 42 },
|
|
});
|
|
const result = await new ApiClient("https://api.example.test").listIssuePullRequests("issue-1");
|
|
expect(result.auto_complete).toBeNull();
|
|
expect(result.pull_requests).toHaveLength(1);
|
|
expect(result.pull_requests[0]?.link_source).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("ApiClient Plugin preview response schema", () => {
|
|
it("degrades a malformed preview so a blank scope list is never shown as approval", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ scopes: "issues:read" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(new ApiClient("https://api.example.test").previewPlugin(
|
|
"workspace-1",
|
|
{ version_id: "version-1" },
|
|
)).resolves.toEqual(EMPTY_PLUGIN_PREVIEW);
|
|
});
|
|
|
|
// A malformed launch must not become a partly trusted frame URL.
|
|
it("falls back to an empty surface launch when the response is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ url: 42, bridge_token: "proof" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(new ApiClient("https://api.example.test").getPluginSurfaceLaunch(
|
|
"workspace-1",
|
|
"installation-1",
|
|
"hello",
|
|
)).resolves.toEqual(EMPTY_PLUGIN_SURFACE_LAUNCH);
|
|
});
|
|
|
|
it("falls back to an empty package list when the response is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ packages: "nope" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(new ApiClient("https://api.example.test").listPluginPackages("workspace-1"))
|
|
.resolves.toEqual(EMPTY_PLUGIN_PACKAGE_LIST);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient Plugin surface bridge routes", () => {
|
|
it("relays Action API calls through the session-only bridge prefix", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").callPluginAction(
|
|
"installation-1",
|
|
{ method: "GET", path: "/context", issueId: "MUL-42" },
|
|
);
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
"https://api.example.test/api/plugin-bridge/v1/context?issue_id=MUL-42",
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: "GET",
|
|
headers: expect.objectContaining({
|
|
"X-Multica-Plugin-Installation": "installation-1",
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("invokes UI hooks through the session-only bridge prefix", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({
|
|
status: "ok",
|
|
hook_key: "summarize",
|
|
trigger: "ui",
|
|
latency_ms: 1,
|
|
attempts: 1,
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").invokePluginHook(
|
|
"installation-1",
|
|
"summarize",
|
|
{ trigger: "ui", issueId: "issue-1" },
|
|
);
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
"https://api.example.test/api/plugin-bridge/v1/hooks/summarize",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient server Table query", () => {
|
|
it("posts the canonical query to the group and branch endpoints", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
query_fingerprint: "sha256:query",
|
|
total: 1001,
|
|
groups: [
|
|
{
|
|
key: "status:todo",
|
|
value: { kind: "status", status: "todo" },
|
|
count: 1001,
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
query_fingerprint: "sha256:query",
|
|
group_key: "status:todo",
|
|
parent_id: null,
|
|
total: 0,
|
|
rows: [],
|
|
branch_total: 0,
|
|
next_cursor: "next-page",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
query_fingerprint: "sha256:query",
|
|
total: 1001,
|
|
facets: [
|
|
{
|
|
kind: "status",
|
|
values: [
|
|
{ key: "todo", count: 501 },
|
|
{ key: "done", count: 500 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const query = {
|
|
scope: { kind: "workspace" as const },
|
|
filters: { priorities: ["high" as const] },
|
|
sort: { field: "title" as const, direction: "asc" as const },
|
|
};
|
|
|
|
await expect(
|
|
client.listIssueTableGroups({
|
|
query,
|
|
group: { kind: "status" },
|
|
page: { limit: 100, cursor: null },
|
|
}),
|
|
).resolves.toMatchObject({ total: 1001, groups: [{ count: 1001 }] });
|
|
await expect(
|
|
client.listIssueTableRows({
|
|
query,
|
|
group: { kind: "status" },
|
|
group_key: "status:todo",
|
|
hierarchy: { enabled: true },
|
|
parent_id: null,
|
|
page: { limit: 50, cursor: null },
|
|
}),
|
|
).resolves.toMatchObject({ branch_total: 0, next_cursor: "next-page" });
|
|
await expect(
|
|
client.listIssueTableFacets({
|
|
query,
|
|
facets: [{ kind: "status" }],
|
|
}),
|
|
).resolves.toMatchObject({
|
|
total: 1001,
|
|
facets: [{ values: [{ key: "todo", count: 501 }, { key: "done", count: 500 }] }],
|
|
});
|
|
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
"https://api.example.test/api/issues/table/groups",
|
|
"https://api.example.test/api/issues/table/rows",
|
|
"https://api.example.test/api/issues/table/facets",
|
|
]);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: "POST",
|
|
body: expect.stringContaining('"kind":"status"'),
|
|
});
|
|
});
|
|
|
|
it("falls back safely when Table responses are malformed", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ total: "not-a-number" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const query = {
|
|
scope: { kind: "workspace" as const },
|
|
filters: {},
|
|
sort: { field: "position" as const, direction: "asc" as const },
|
|
};
|
|
|
|
await expect(
|
|
client.listIssueTableGroups({
|
|
query,
|
|
group: { kind: "status" },
|
|
page: { limit: 100, cursor: null },
|
|
}),
|
|
).resolves.toEqual({
|
|
query_fingerprint: "",
|
|
total: 0,
|
|
groups: [],
|
|
next_cursor: null,
|
|
});
|
|
await expect(
|
|
client.listIssueTableRows({
|
|
query,
|
|
group: { kind: "none" },
|
|
group_key: null,
|
|
hierarchy: { enabled: true },
|
|
parent_id: null,
|
|
page: { limit: 50, cursor: null },
|
|
}),
|
|
).resolves.toEqual({
|
|
query_fingerprint: "",
|
|
group_key: null,
|
|
parent_id: null,
|
|
total: 0,
|
|
rows: [],
|
|
branch_total: 0,
|
|
next_cursor: null,
|
|
});
|
|
await expect(
|
|
client.listIssueTableFacets({
|
|
query,
|
|
facets: [{ kind: "status" }],
|
|
}),
|
|
).resolves.toEqual({
|
|
query_fingerprint: "",
|
|
total: 0,
|
|
facets: [],
|
|
});
|
|
});
|
|
|
|
it("preserves future Table status and actor enum values", async () => {
|
|
const responses = [
|
|
{
|
|
query_fingerprint: "sha256:future-status",
|
|
total: 1,
|
|
groups: [
|
|
{
|
|
key: "status:paused",
|
|
value: { kind: "status", status: "paused" },
|
|
count: 1,
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
},
|
|
{
|
|
query_fingerprint: "sha256:future-actor",
|
|
total: 1,
|
|
groups: [
|
|
{
|
|
key: "service:bot-1",
|
|
value: {
|
|
kind: "assignee",
|
|
actor: { type: "service", id: "bot-1" },
|
|
},
|
|
count: 1,
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
},
|
|
];
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify(responses.shift()), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const query = {
|
|
scope: { kind: "workspace" as const },
|
|
filters: {},
|
|
sort: { field: "position" as const, direction: "asc" as const },
|
|
};
|
|
|
|
await expect(
|
|
client.listIssueTableGroups({ query, group: { kind: "status" } }),
|
|
).resolves.toMatchObject({
|
|
total: 1,
|
|
groups: [{ value: { kind: "status", status: "paused" } }],
|
|
});
|
|
await expect(
|
|
client.listIssueTableGroups({ query, group: { kind: "assignee" } }),
|
|
).resolves.toMatchObject({
|
|
total: 1,
|
|
groups: [
|
|
{ value: { kind: "assignee", actor: { type: "service", id: "bot-1" } } },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("parses compound lane descriptors and posts the additive union", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
query_fingerprint: "sha256:compound",
|
|
total: 2,
|
|
groups: [
|
|
{
|
|
key: "parent:parent-1",
|
|
value: {
|
|
kind: "parent",
|
|
parent_id: "parent-1",
|
|
parent: {
|
|
id: "parent-1",
|
|
number: 10,
|
|
identifier: "MUL-10",
|
|
title: "Parent",
|
|
status: "todo",
|
|
},
|
|
value_state: "value",
|
|
},
|
|
count: 2,
|
|
secondary_groups: [
|
|
{
|
|
key: "compound:opaque:status:todo",
|
|
value: { kind: "status", status: "todo" },
|
|
count: 2,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const query = {
|
|
scope: { kind: "workspace" as const },
|
|
filters: {},
|
|
sort: { field: "position" as const, direction: "asc" as const },
|
|
};
|
|
|
|
await expect(
|
|
client.listIssueTableGroups({
|
|
query,
|
|
group: {
|
|
kind: "compound",
|
|
primary: "parent",
|
|
secondary: "status",
|
|
secondary_values: ["todo"],
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
groups: [
|
|
{
|
|
value: { kind: "parent", parent: { title: "Parent" } },
|
|
secondary_groups: [
|
|
{ value: { kind: "status", status: "todo" }, count: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
body: expect.stringContaining(
|
|
'"kind":"compound","primary":"parent","secondary":"status","secondary_values":["todo"]',
|
|
),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ApiClient issue move intent", () => {
|
|
it("posts relative anchors without a client-authored position", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "issue-1", position: 15 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await client.moveIssue("issue-1", {
|
|
status: "in_progress",
|
|
before_id: "issue-0",
|
|
after_id: "issue-2",
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://api.example.test/api/issues/issue-1/move",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
status: "in_progress",
|
|
before_id: "issue-0",
|
|
after_id: "issue-2",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient workspace working agents", () => {
|
|
it("supports an optional source-type filter", async () => {
|
|
const payload = [
|
|
{
|
|
id: "agent-1",
|
|
name: "Agent 1",
|
|
avatar_url: null,
|
|
running_task_count: 2,
|
|
issue_ids: ["issue-1"],
|
|
},
|
|
];
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.getWorkspaceWorkingAgents("issue", "assigned"),
|
|
).resolves.toEqual(payload);
|
|
await expect(
|
|
client.getWorkspaceWorkingAgents("issue"),
|
|
).resolves.toEqual(payload);
|
|
await expect(client.getWorkspaceWorkingAgents()).resolves.toEqual(payload);
|
|
await expect(
|
|
client.getWorkspaceWorkingAgents("issue", undefined, "parent-1"),
|
|
).resolves.toEqual(payload);
|
|
// The server rejects parent alongside scope, so a My Issues relation wins
|
|
// and the parent is dropped rather than sent into a 400.
|
|
await expect(
|
|
client.getWorkspaceWorkingAgents("issue", "assigned", "parent-1"),
|
|
).resolves.toEqual(payload);
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
"https://api.example.test/api/working-agents?type=issue&scope=mine&relation=assigned",
|
|
"https://api.example.test/api/working-agents?type=issue",
|
|
"https://api.example.test/api/working-agents",
|
|
"https://api.example.test/api/working-agents?type=issue&parent=parent-1",
|
|
"https://api.example.test/api/working-agents?type=issue&scope=mine&relation=assigned",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient label response schemas", () => {
|
|
it("falls back safely for malformed label catalog, label, and resource responses", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ labels: "not-an-array", total: "not-a-number" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await expect(client.listLabels("agent")).resolves.toEqual({ labels: [], total: 0 });
|
|
await expect(client.getLabel("label-1")).resolves.toMatchObject({ id: "" });
|
|
await expect(
|
|
client.createLabel({ resource_type: "agent", name: "Ops", color: "#3b82f6" }),
|
|
).resolves.toMatchObject({ id: "" });
|
|
await expect(
|
|
client.updateLabel("label-1", { name: "Operations" }),
|
|
).resolves.toMatchObject({ id: "" });
|
|
|
|
await expect(client.listLabelsForIssue("issue-1")).resolves.toEqual({ labels: [] });
|
|
await expect(client.attachLabel("issue-1", "label-1")).resolves.toEqual({ labels: [] });
|
|
await expect(client.detachLabel("issue-1", "label-1")).resolves.toEqual({ labels: [] });
|
|
|
|
await expect(client.listLabelsForResource("agent", "agent-1")).resolves.toEqual({ labels: [] });
|
|
await expect(
|
|
client.attachLabelToResource("agent", "agent-1", "label-1"),
|
|
).resolves.toEqual({ labels: [] });
|
|
await expect(
|
|
client.detachLabelFromResource("agent", "agent-1", "label-1"),
|
|
).resolves.toEqual({ labels: [] });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(10);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient agent builder runtime switch", () => {
|
|
it("PATCHes the session runtime endpoint and returns the runtime the server bound", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ runtime_id: "runtime-b" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.switchAgentBuilderRuntime("session-1", { runtime_id: "runtime-b" }),
|
|
).resolves.toEqual({ runtime_id: "runtime-b" });
|
|
|
|
const call = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(call[0]).toContain("/api/agent-builder/sessions/session-1/runtime");
|
|
expect(call[1].method).toBe("PATCH");
|
|
expect(JSON.parse(String(call[1].body))).toEqual({ runtime_id: "runtime-b" });
|
|
});
|
|
|
|
it("falls back to the requested runtime id for a malformed success body", async () => {
|
|
// A 2xx means the rebind committed onto the runtime we asked for, so the
|
|
// fallback must say so. Reporting "unknown" here would leave the picker on
|
|
// the old runtime while the conversation executes on the new one — the very
|
|
// split this endpoint exists to close.
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ runtime_id: 42 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.switchAgentBuilderRuntime("session-1", { runtime_id: "runtime-b" }),
|
|
).resolves.toEqual({ runtime_id: "runtime-b" });
|
|
});
|
|
|
|
it("rejects without a fallback when the switch is refused", async () => {
|
|
// 409 (a reply in flight) means nothing was committed, so the caller must
|
|
// see a rejection and keep the old runtime selected.
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ error: "stop the current reply before switching runtime" }), {
|
|
status: 409,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.switchAgentBuilderRuntime("session-1", { runtime_id: "runtime-b" }),
|
|
).rejects.toBeInstanceOf(ApiError);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient notification preferences", () => {
|
|
it("sends atomic preference updates with PATCH", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
workspace_id: "workspace-1",
|
|
preferences: {
|
|
status_changes: "muted",
|
|
comments: "muted",
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.updateNotificationPreferences(
|
|
{ comments: "muted" },
|
|
"workspace-one",
|
|
),
|
|
).resolves.toEqual({
|
|
workspace_id: "workspace-1",
|
|
preferences: {
|
|
status_changes: "muted",
|
|
comments: "muted",
|
|
},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
"https://api.example.test/api/notification-preferences",
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: "PATCH",
|
|
headers: expect.objectContaining({
|
|
"X-Workspace-Slug": "workspace-one",
|
|
}),
|
|
body: JSON.stringify({ preferences: { comments: "muted" } }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back safely when a preference response is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({ workspace_id: "workspace-1", preferences: [] }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.getNotificationPreferences()).resolves.toEqual({
|
|
workspace_id: "",
|
|
preferences: {},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ApiClient Inbox response schemas", () => {
|
|
const legacyRow = {
|
|
id: "inbox-1",
|
|
workspace_id: "ws-1",
|
|
recipient_type: "member",
|
|
recipient_id: "member-1",
|
|
type: "new_comment",
|
|
severity: "info",
|
|
issue_id: "issue-1",
|
|
title: "Legacy Inbox row",
|
|
body: null,
|
|
read: false,
|
|
archived: false,
|
|
created_at: "2026-08-24T00:00:00Z",
|
|
};
|
|
|
|
it("schema-parses the main Inbox and preserves omitted legacy projections", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify([legacyRow]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await new ApiClient("https://api.example.test").listInbox();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).not.toHaveProperty("issue_status");
|
|
expect(result[0]).not.toHaveProperty("issue_priority");
|
|
});
|
|
|
|
it("falls back safely when the main Inbox projection is wrong-typed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([{ ...legacyRow, issue_priority: 3 }]),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new ApiClient("https://api.example.test").listInbox(),
|
|
).resolves.toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient", () => {
|
|
it("preserves HTTP status on failed requests", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ error: "workspace slug already exists" }), {
|
|
status: 409,
|
|
statusText: "Conflict",
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
try {
|
|
await client.createWorkspace({ name: "Test", slug: "test" });
|
|
throw new Error("expected createWorkspace to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect(error).toMatchObject({
|
|
message: "workspace slug already exists",
|
|
status: 409,
|
|
statusText: "Conflict",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("preserves planned and delivered comment coverage from issue task runs", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([
|
|
{
|
|
id: "task-1",
|
|
status: "queued",
|
|
trigger_comment_id: "comment-3",
|
|
coalesced_comment_ids: ["comment-1", "comment-2"],
|
|
delivered_comment_ids: ["comment-1", "comment-2", "comment-3"],
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tasks = await client.listTasksByIssue("issue-1");
|
|
|
|
expect(tasks[0]?.trigger_comment_id).toBe("comment-3");
|
|
expect(tasks[0]?.coalesced_comment_ids).toEqual([
|
|
"comment-1",
|
|
"comment-2",
|
|
]);
|
|
expect(tasks[0]?.delivered_comment_ids).toEqual([
|
|
"comment-1",
|
|
"comment-2",
|
|
"comment-3",
|
|
]);
|
|
});
|
|
|
|
it("keeps task runs when optional comment coverage is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([
|
|
{
|
|
id: "task-1",
|
|
status: "queued",
|
|
coalesced_comment_ids: ["comment-1", 2],
|
|
delivered_comment_ids: "not-an-array",
|
|
},
|
|
{
|
|
id: "task-2",
|
|
status: "completed",
|
|
delivered_comment_ids: ["comment-2", "comment-3"],
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tasks = await client.listTasksByIssue("issue-1");
|
|
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks[0]?.coalesced_comment_ids).toBeUndefined();
|
|
expect(tasks[0]?.delivered_comment_ids).toBeUndefined();
|
|
expect(tasks[1]?.delivered_comment_ids).toEqual([
|
|
"comment-2",
|
|
"comment-3",
|
|
]);
|
|
});
|
|
|
|
it("parses per-run token usage on task runs", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([
|
|
{
|
|
id: "task-1",
|
|
status: "completed",
|
|
usage: [
|
|
{
|
|
provider: "anthropic",
|
|
model: "claude-opus-5",
|
|
input_tokens: 96_000,
|
|
output_tokens: 34_000,
|
|
cache_read_tokens: 712_000,
|
|
cache_write_tokens: 50_000,
|
|
cost_usd_ticks: 19_990_000_000,
|
|
},
|
|
],
|
|
},
|
|
// No usage at all — a run from before usage reporting.
|
|
{ id: "task-2", status: "completed" },
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tasks = await client.listTasksByIssue("issue-1");
|
|
|
|
expect(tasks[0]?.usage).toHaveLength(1);
|
|
expect(tasks[0]?.usage?.[0]).toMatchObject({
|
|
model: "claude-opus-5",
|
|
input_tokens: 96_000,
|
|
cache_read_tokens: 712_000,
|
|
cost_usd_ticks: 19_990_000_000,
|
|
});
|
|
// Absent, not [] — "we have no figure" must stay distinguishable from
|
|
// "the figure is zero" all the way to the UI.
|
|
expect(tasks[1]?.usage).toBeUndefined();
|
|
});
|
|
|
|
it("keeps task runs when per-run usage is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([
|
|
// Usage is not an array at all.
|
|
{ id: "task-1", status: "completed", usage: "1.2M" },
|
|
// Usage is an array, but an entry has a string where a count belongs.
|
|
{
|
|
id: "task-2",
|
|
status: "completed",
|
|
usage: [{ model: "claude-opus-5", input_tokens: "many" }],
|
|
},
|
|
{
|
|
id: "task-3",
|
|
status: "completed",
|
|
usage: [{ model: "gpt-5.6-terra", input_tokens: 31_000 }],
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tasks = await client.listTasksByIssue("issue-1");
|
|
|
|
// A bad usage payload costs that row its figure and nothing else — the
|
|
// execution log still lists every run.
|
|
expect(tasks).toHaveLength(3);
|
|
expect(tasks[0]?.usage).toBeUndefined();
|
|
expect(tasks[1]?.usage).toBeUndefined();
|
|
expect(tasks[2]?.usage?.[0]?.input_tokens).toBe(31_000);
|
|
expect(tasks[2]?.usage?.[0]?.output_tokens).toBe(0);
|
|
});
|
|
|
|
it("keeps agent detail task history on the lightweight endpoint", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([
|
|
{ id: "task-1", status: "completed", created_at: "2026-08-27T03:00:00Z" },
|
|
{ id: "task-2", status: "completed", created_at: "2026-08-27T02:00:00Z" },
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const { tasks, nextCursor } = await client.listAgentTasksPage("agent-1");
|
|
|
|
expect(tasks.map((task) => task.id)).toEqual(["task-1", "task-2"]);
|
|
expect(nextCursor).toBeNull();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
"https://api.example.test/api/agents/agent-1/tasks?limit=200",
|
|
);
|
|
});
|
|
|
|
it("reads a lossless task cursor and passes it to the next bounded request", async () => {
|
|
const cursor = "2026-09-24T01:02:03.123456Z|00000000-0000-0000-0000-000000000001";
|
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(
|
|
JSON.stringify([{ id: "task-1", status: "completed" }]),
|
|
{ headers: { "X-Agent-Tasks-Next-Cursor": cursor } },
|
|
)));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const page = await client.listAgentTasksPage("agent-1", { limit: 7 });
|
|
expect(page.nextCursor).toBe(cursor);
|
|
const controller = new AbortController();
|
|
await client.listAgentTasksPage("agent-1", { limit: 7, before: page.nextCursor!, signal: controller.signal });
|
|
const request = new URL(fetchMock.mock.calls[1]![0]);
|
|
expect(request.searchParams.get("limit")).toBe("7");
|
|
expect(request.searchParams.get("before")).toBe(cursor);
|
|
expect(fetchMock.mock.calls[1]![1].signal).toBe(controller.signal);
|
|
});
|
|
|
|
it("drops the continuation when the task page is malformed", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
|
|
JSON.stringify({ tasks: "not-an-array" }),
|
|
{ headers: { "X-Agent-Tasks-Next-Cursor": "cursor" } },
|
|
)));
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listAgentTasksPage("agent-1")).resolves.toEqual({ tasks: [], nextCursor: null });
|
|
});
|
|
|
|
it("falls back to an empty agent task history for a malformed response", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ tasks: "not-an-array" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listAgentTasksPage("agent-1")).resolves.toEqual({ tasks: [], nextCursor: null });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("uses the expected HTTP contract for autopilot endpoints", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(
|
|
new Response(JSON.stringify({ autopilots: [], runs: [], total: 0 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await client.listAutopilots({ status: "active" });
|
|
await client.getAutopilot("ap-1");
|
|
await client.createAutopilot({
|
|
title: "Daily triage",
|
|
project_id: "project-1",
|
|
assignee_id: "agent-1",
|
|
execution_mode: "create_issue",
|
|
});
|
|
await client.updateAutopilot("ap-1", { status: "paused", project_id: null });
|
|
await client.deleteAutopilot("ap-1");
|
|
await client.triggerAutopilot("ap-1");
|
|
await client.getAutopilotQuotaUsage();
|
|
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
|
|
await client.createAutopilotTrigger("ap-1", {
|
|
kind: "schedule",
|
|
cron_expression: "0 9 * * *",
|
|
timezone: "UTC",
|
|
});
|
|
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
|
|
await client.deleteAutopilotTrigger("ap-1", "tr-1");
|
|
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
|
|
|
|
const calls = fetchMock.mock.calls.map(([url, init]) => ({
|
|
url,
|
|
method: init?.method ?? "GET",
|
|
body: init?.body,
|
|
idempotencyKey: (init?.headers as Record<string, string> | undefined)?.["Idempotency-Key"],
|
|
}));
|
|
|
|
expect(calls).toMatchObject([
|
|
{ url: "https://api.example.test/api/autopilots?status=active", method: "GET" },
|
|
{ url: "https://api.example.test/api/autopilots/ap-1", method: "GET" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots",
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: "Daily triage",
|
|
project_id: "project-1",
|
|
assignee_id: "agent-1",
|
|
execution_mode: "create_issue",
|
|
}),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1",
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status: "paused", project_id: null }),
|
|
},
|
|
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/trigger",
|
|
method: "POST",
|
|
idempotencyKey: expect.any(String),
|
|
},
|
|
{ url: "https://api.example.test/api/autopilots/usage", method: "GET" },
|
|
{ url: "https://api.example.test/api/autopilots/ap-1/runs?limit=10&offset=20", method: "GET" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/triggers",
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
kind: "schedule",
|
|
cron_expression: "0 9 * * *",
|
|
timezone: "UTC",
|
|
}),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1",
|
|
method: "PATCH",
|
|
body: JSON.stringify({ enabled: false }),
|
|
},
|
|
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
|
|
method: "POST",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("emits X-Client-* headers when identity is configured", 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", {
|
|
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
|
|
});
|
|
await client.listWorkspaces();
|
|
|
|
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
|
expect(headers["X-Client-Platform"]).toBe("desktop");
|
|
expect(headers["X-Client-Version"]).toBe("1.2.3");
|
|
expect(headers["X-Client-OS"]).toBe("macos");
|
|
});
|
|
|
|
it("omits X-Client-* headers when identity is not configured", 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 client.listWorkspaces();
|
|
|
|
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
|
expect(headers["X-Client-Platform"]).toBeUndefined();
|
|
expect(headers["X-Client-Version"]).toBeUndefined();
|
|
expect(headers["X-Client-OS"]).toBeUndefined();
|
|
});
|
|
|
|
it("posts feedback kind and parses the response through the schema", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "feedback-1", created_at: "2026-06-26T00:00:00Z" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const response = await client.createFeedback({
|
|
message: "Desktop route crashed",
|
|
url: "app://desktop/acme/issues",
|
|
workspace_id: "ws-1",
|
|
kind: "bug",
|
|
context: {
|
|
kind: "desktop_route_error",
|
|
trigger: "route-errorElement",
|
|
error: {
|
|
name: "TypeError",
|
|
message: "Cannot read properties of undefined",
|
|
stack: "TypeError: Cannot read properties of undefined",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
id: "feedback-1",
|
|
created_at: "2026-06-26T00:00:00Z",
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://api.example.test/api/feedback",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
message: "Desktop route crashed",
|
|
url: "app://desktop/acme/issues",
|
|
workspace_id: "ws-1",
|
|
kind: "bug",
|
|
context: {
|
|
kind: "desktop_route_error",
|
|
trigger: "route-errorElement",
|
|
error: {
|
|
name: "TypeError",
|
|
message: "Cannot read properties of undefined",
|
|
stack: "TypeError: Cannot read properties of undefined",
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back to an empty feedback response when the server shape drifts", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: 42, created_at: "2026-06-26T00:00:00Z" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.createFeedback({ message: "hello" })).resolves.toEqual({
|
|
id: "",
|
|
created_at: "",
|
|
});
|
|
});
|
|
|
|
it("uses the expected HTTP contract for comment trigger preview and suppress", async () => {
|
|
const fetchMock = vi.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ agents: [] }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
id: "comment-1",
|
|
issue_id: "issue-1",
|
|
author_type: "member",
|
|
author_id: "user-1",
|
|
content: "hello",
|
|
type: "comment",
|
|
parent_id: null,
|
|
reactions: [],
|
|
attachments: [],
|
|
created_at: "2026-06-05T00:00:00Z",
|
|
updated_at: "2026-06-05T00:00:00Z",
|
|
}), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
id: "comment-1",
|
|
issue_id: "issue-1",
|
|
author_type: "member",
|
|
author_id: "user-1",
|
|
content: "updated",
|
|
type: "comment",
|
|
parent_id: null,
|
|
reactions: [],
|
|
attachments: [],
|
|
created_at: "2026-06-05T00:00:00Z",
|
|
updated_at: "2026-06-05T00:01:00Z",
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.previewCommentTriggers("issue-1", "hello", "parent-1", "comment-1");
|
|
await client.createComment(
|
|
"issue-1",
|
|
"hello",
|
|
"comment",
|
|
"parent-1",
|
|
["attachment-1"],
|
|
["agent-1"],
|
|
);
|
|
await client.updateComment("comment-1", "updated", ["attachment-1"], ["agent-1"]);
|
|
|
|
expect(fetchMock.mock.calls.map(([url, init]) => ({
|
|
url,
|
|
method: init?.method,
|
|
body: init?.body,
|
|
}))).toMatchObject([
|
|
{
|
|
url: "https://api.example.test/api/issues/issue-1/comments/trigger-preview",
|
|
method: "POST",
|
|
body: JSON.stringify({ content: "hello", parent_id: "parent-1", editing_comment_id: "comment-1" }),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/issues/issue-1/comments",
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
content: "hello",
|
|
type: "comment",
|
|
parent_id: "parent-1",
|
|
attachment_ids: ["attachment-1"],
|
|
suppress_agent_ids: ["agent-1"],
|
|
}),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/comments/comment-1",
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
content: "updated",
|
|
attachment_ids: ["attachment-1"],
|
|
suppress_agent_ids: ["agent-1"],
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses the Cloud Runtime node API contract", async () => {
|
|
const node = {
|
|
id: "node-1",
|
|
owner_id: "user-1",
|
|
instance_id: "i-0123456789abcdef0",
|
|
region: "us-west-2",
|
|
instance_type: "g5.xlarge",
|
|
image_id: "ami-1",
|
|
subnet_id: "subnet-1",
|
|
name: "gpu-dev-01",
|
|
status: "launching",
|
|
tags: {},
|
|
metadata: {},
|
|
created_at: "2026-05-21T08:30:00Z",
|
|
updated_at: "2026-05-21T08:30:00Z",
|
|
};
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify(node), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.listCloudRuntimeNodes({ limit: 20, offset: 5 });
|
|
await client.createCloudRuntimeNode(
|
|
{ instance_type: "g5.xlarge", name: "gpu-dev-01" },
|
|
);
|
|
|
|
const listCall = fetchMock.mock.calls[0]!;
|
|
const createCall = fetchMock.mock.calls[1]!;
|
|
expect(listCall[0]).toBe(
|
|
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
|
|
);
|
|
expect(createCall[0]).toBe(
|
|
"https://api.example.test/api/cloud-runtime/nodes",
|
|
);
|
|
expect(createCall[1]).toMatchObject({
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
instance_type: "g5.xlarge",
|
|
name: "gpu-dev-01",
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("falls back when Cloud Runtime node responses drift", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ id: 123 }]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ id: 123 }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await expect(client.listCloudRuntimeNodes()).resolves.toEqual([]);
|
|
await expect(
|
|
client.createCloudRuntimeNode({ instance_type: "g5.xlarge" }),
|
|
).resolves.toMatchObject({ id: "", status: "" });
|
|
});
|
|
|
|
it("deleteCloudRuntimeNode sends DELETE with JSON body containing instance id", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(null, { status: 204 }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.deleteCloudRuntimeNode("i-0123456789abcdef0");
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, opts] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe("https://api.example.test/api/cloud-runtime/nodes");
|
|
expect(opts).toMatchObject({
|
|
method: "DELETE",
|
|
body: JSON.stringify({ instance_id: "i-0123456789abcdef0" }),
|
|
});
|
|
expect((opts.headers as Record<string, string>)["Content-Type"]).toBe(
|
|
"application/json",
|
|
);
|
|
});
|
|
|
|
describe("getAttachment", () => {
|
|
it("uses the attachment-owned workspace endpoint for Markdown downloads", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
id: "att-1",
|
|
workspace_id: "ws-b",
|
|
uploader_type: "member",
|
|
uploader_id: "u-1",
|
|
filename: "report.md",
|
|
url: "https://static.example.test/report.md",
|
|
download_url: "/api/attachments/att-1/signed-download?sig=abc",
|
|
attachment_download_url: "/api/attachments/att-1/signed-download?sig=def&dl=1",
|
|
content_type: "text/markdown",
|
|
size_bytes: 123,
|
|
created_at: "2026-05-11T00:00:00Z",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
const att = await client.getAttachmentForDownload("att-1");
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
"https://api.example.test/api/attachments/att-1/download-metadata",
|
|
);
|
|
expect(att.attachment_download_url).toContain("dl=1");
|
|
});
|
|
|
|
it("returns the parsed attachment for a well-formed response", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
id: "att-1",
|
|
workspace_id: "ws-1",
|
|
issue_id: null,
|
|
comment_id: null,
|
|
uploader_type: "member",
|
|
uploader_id: "u-1",
|
|
filename: "report.md",
|
|
url: "https://static.example.test/ws/att-1.md",
|
|
download_url:
|
|
"https://static.example.test/ws/att-1.md?Policy=p&Signature=s&Key-Pair-Id=k",
|
|
content_type: "text/markdown",
|
|
size_bytes: 123,
|
|
created_at: "2026-05-11T00:00:00Z",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const att = await client.getAttachment("att-1");
|
|
|
|
expect(att.id).toBe("att-1");
|
|
expect(att.download_url).toContain("Policy=");
|
|
});
|
|
|
|
it("falls back to an empty attachment when the response is missing download_url", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "att-1" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const att = await client.getAttachment("att-1");
|
|
|
|
// parseWithFallback returns the EMPTY_ATTACHMENT record so callers can
|
|
// safely read `download_url` without crashing — they'll see "" and
|
|
// surface a user-facing error instead of opening `undefined`.
|
|
expect(att.id).toBe("");
|
|
expect(att.download_url).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("getAttachmentTextContent", () => {
|
|
it("returns body text and the original content type from the X-* header", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("# heading\n\nbody\n", {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
"X-Original-Content-Type": "text/markdown",
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const { text, originalContentType } =
|
|
await client.getAttachmentTextContent("att-1");
|
|
|
|
expect(text).toBe("# heading\n\nbody\n");
|
|
expect(originalContentType).toBe("text/markdown");
|
|
});
|
|
|
|
it("throws PreviewTooLargeError on 413", async () => {
|
|
const { PreviewTooLargeError } = await import("./client");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("", { status: 413, statusText: "Payload Too Large" }),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
|
PreviewTooLargeError,
|
|
);
|
|
});
|
|
|
|
it("throws PreviewUnsupportedError on 415", async () => {
|
|
const { PreviewUnsupportedError } = await import("./client");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("", { status: 415, statusText: "Unsupported Media Type" }),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
|
PreviewUnsupportedError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("listChatMessagesPage deployment-order fallback", () => {
|
|
const jsonResponse = (body: unknown, status: number, statusText = "") =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
statusText,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
it("falls back to the legacy full-list endpoint when the paged route 404s", async () => {
|
|
const legacy = [
|
|
{
|
|
id: "m1",
|
|
chat_session_id: "session-1",
|
|
role: "user",
|
|
content: "hi",
|
|
task_id: null,
|
|
created_at: "2026-06-01T00:00:00Z",
|
|
quick_actions: [],
|
|
},
|
|
{
|
|
id: "m2",
|
|
chat_session_id: "session-1",
|
|
role: "assistant",
|
|
content: "yo",
|
|
task_id: "task-1",
|
|
created_at: "2026-06-01T00:00:01Z",
|
|
quick_actions: [
|
|
{ label: "Continue", prompt: "Continue with the next step", primary: true },
|
|
],
|
|
},
|
|
];
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ error: "not found" }, 404, "Not Found"))
|
|
.mockResolvedValueOnce(jsonResponse(legacy, 200));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const page = await client.listChatMessagesPage("session-1", { limit: 50 });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(fetchMock.mock.calls[0]![0]).toBe(
|
|
"https://api.example.test/api/chat/sessions/session-1/messages/page?limit=50",
|
|
);
|
|
expect(fetchMock.mock.calls[1]![0]).toBe(
|
|
"https://api.example.test/api/chat/sessions/session-1/messages",
|
|
);
|
|
expect(page).toEqual({ messages: legacy, limit: 50, has_more: false, next_cursor: null });
|
|
});
|
|
|
|
it("keeps a valid reply when its optional quick actions are malformed", async () => {
|
|
const malformed = [{
|
|
id: "m1",
|
|
chat_session_id: "session-1",
|
|
role: "assistant",
|
|
content: "safe reply",
|
|
task_id: "task-1",
|
|
created_at: "2026-06-01T00:00:00Z",
|
|
quick_actions: [{ label: 42, prompt: false }],
|
|
}];
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(malformed, 200)));
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listChatMessages("session-1")).resolves.toEqual([
|
|
expect.objectContaining({ content: "safe reply", quick_actions: [] }),
|
|
]);
|
|
});
|
|
|
|
it("falls back to an empty page for a malformed paged response", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(jsonResponse({ messages: "broken" }, 200)),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.listChatMessagesPage("session-1", { limit: 25 }),
|
|
).resolves.toEqual({ messages: [], limit: 25, has_more: false, next_cursor: null });
|
|
});
|
|
|
|
it("does NOT fall back on a cursor request — a 404 there propagates", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(jsonResponse({ error: "not found" }, 404, "Not Found"));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.listChatMessagesPage("session-1", {
|
|
before: { created_at: "2026-06-01T00:00:00Z", id: "m1" },
|
|
}),
|
|
).rejects.toBeInstanceOf(ApiError);
|
|
// Only the paged request fires; no legacy full-list call that would duplicate messages.
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("propagates non-404 errors instead of masking them with the legacy list", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(jsonResponse({ error: "boom" }, 500, "Internal Server Error"));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listChatMessagesPage("session-1")).rejects.toMatchObject({
|
|
status: 500,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("cancelTaskById response parsing", () => {
|
|
const taskResponse = {
|
|
id: "task-1",
|
|
agent_id: "agent-1",
|
|
runtime_id: "runtime-1",
|
|
issue_id: "",
|
|
status: "cancelled",
|
|
priority: 0,
|
|
dispatched_at: null,
|
|
started_at: null,
|
|
completed_at: "2026-06-12T06:40:00Z",
|
|
result: null,
|
|
error: null,
|
|
created_at: "2026-06-12T06:39:00Z",
|
|
};
|
|
|
|
it("parses the cancelled chat message payload", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({
|
|
...taskResponse,
|
|
cancelled_chat_message: {
|
|
chat_session_id: "session-1",
|
|
message_id: "message-1",
|
|
content: "restore me",
|
|
restore_to_input: true,
|
|
},
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(fetchMock.mock.calls[0]).toMatchObject([
|
|
"https://api.example.test/api/tasks/task-1/cancel",
|
|
{ method: "POST" },
|
|
]);
|
|
expect(result.cancelled_chat_message).toEqual({
|
|
chat_session_id: "session-1",
|
|
message_id: "message-1",
|
|
content: "restore me",
|
|
restore_to_input: true,
|
|
});
|
|
});
|
|
|
|
it("parses task attribution when the backend enriches it", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({
|
|
...taskResponse,
|
|
attribution: {
|
|
source: "direct_human",
|
|
precise: true,
|
|
initiator: { id: "user-1", name: "Ada", avatar_url: "https://x/a.png" },
|
|
originator: { id: "user-1", name: "Ada" },
|
|
evidence: { kind: "comment", ref_id: "comment-1" },
|
|
},
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(result.attribution).toEqual({
|
|
source: "direct_human",
|
|
precise: true,
|
|
initiator: { id: "user-1", name: "Ada", avatar_url: "https://x/a.png" },
|
|
originator: { id: "user-1", name: "Ada" },
|
|
evidence: { kind: "comment", ref_id: "comment-1" },
|
|
});
|
|
});
|
|
|
|
it("leaves attribution absent on servers that predate it", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(taskResponse), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(result.attribution).toBeUndefined();
|
|
});
|
|
|
|
// The server only defers the empty-transcript judgment — and so only
|
|
// withholds the synchronous restore — for clients that advertise this
|
|
// capability (#5219). Drop the header and this client is treated as a
|
|
// pre-#5219 build, quietly losing the deferred path it actually implements.
|
|
it("advertises the durable draft-restore capability", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(taskResponse), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").cancelTaskById("task-1");
|
|
|
|
const init = fetchMock.mock.calls[0]?.[1] as { headers: Record<string, string> };
|
|
expect(init.headers["X-Client-Capabilities"]).toBe(CHAT_DRAFT_RESTORE_CAPABILITY);
|
|
});
|
|
|
|
it("scopes queued edit cancellation to the expected chat session", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(taskResponse), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").cancelTaskById("task-1", {
|
|
queuedAction: "edit",
|
|
sessionId: "session-1",
|
|
});
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
"https://api.example.test/api/tasks/task-1/cancel" +
|
|
"?expected_status=queued&chat_session_id=session-1&queue_action=edit",
|
|
);
|
|
});
|
|
|
|
it("treats a null cancelled chat message as absent", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({
|
|
...taskResponse,
|
|
cancelled_chat_message: null,
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(result.id).toBe("task-1");
|
|
expect(result.cancelled_chat_message).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["a missing task id", { ...taskResponse, id: undefined }],
|
|
[
|
|
"a malformed cancelled chat message",
|
|
{
|
|
...taskResponse,
|
|
cancelled_chat_message: {
|
|
chat_session_id: "session-1",
|
|
message_id: "message-1",
|
|
content: "restore me",
|
|
restore_to_input: "true",
|
|
},
|
|
},
|
|
],
|
|
["a null body", null],
|
|
])("falls back for %s", async (_label, body) => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(result.id).toBe("");
|
|
expect(result.cancelled_chat_message).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("clears a chat queue with one session-scoped request", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").clearQueuedChatTasks("session-1");
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://api.example.test/api/chat/sessions/session-1/queued-tasks",
|
|
expect.objectContaining({ method: "DELETE" }),
|
|
);
|
|
});
|
|
|
|
describe("chat attachment wiring", () => {
|
|
it("uploadFile includes chat_session_id in the FormData body", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
|
await client.uploadFile(file, { chatSessionId: "session-123" });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe("https://api.example.test/api/upload-file");
|
|
expect(init?.method).toBe("POST");
|
|
const body = init?.body as FormData;
|
|
expect(body).toBeInstanceOf(FormData);
|
|
expect(body.get("chat_session_id")).toBe("session-123");
|
|
expect(body.get("issue_id")).toBeNull();
|
|
expect(body.get("comment_id")).toBeNull();
|
|
});
|
|
|
|
it("threads an AbortSignal into fetch so the coordinator can cancel it (MUL-5181)", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const controller = new AbortController();
|
|
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
|
await client.uploadFile(file, { issueId: "issue-1" }, controller.signal);
|
|
|
|
const [, init] = fetchMock.mock.calls[0]!;
|
|
expect(init?.signal).toBe(controller.signal);
|
|
});
|
|
|
|
it("rejects with the fetch AbortError when the signal is already aborted", async () => {
|
|
const fetchMock = vi.fn().mockImplementation((_url, init?: RequestInit) => {
|
|
if (init?.signal?.aborted) {
|
|
const err = new Error("The operation was aborted");
|
|
err.name = "AbortError";
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve(new Response("{}", { status: 200 }));
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
|
|
|
await expect(
|
|
client.uploadFile(file, undefined, controller.signal),
|
|
).rejects.toMatchObject({ name: "AbortError" });
|
|
});
|
|
|
|
it("sendChatMessage serialises attachment_ids onto the JSON body when present", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "2026-08-01T00:00:00Z" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.sendChatMessage("session-1", "hello", ["att-1", "att-2"]);
|
|
|
|
const [, init] = fetchMock.mock.calls[0]!;
|
|
expect(JSON.parse(init?.body as string)).toEqual({
|
|
content: "hello",
|
|
attachment_ids: ["att-1", "att-2"],
|
|
});
|
|
});
|
|
|
|
it("sendChatMessage omits attachment_ids when the list is empty or undefined", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "2026-08-01T00:00:00Z" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.sendChatMessage("session-1", "hello");
|
|
await client.sendChatMessage("session-1", "again", []);
|
|
|
|
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({ content: "hello" });
|
|
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({ content: "again" });
|
|
});
|
|
|
|
it("sendChatMessage accepts the server's null attachment_ids for text-only sends", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({
|
|
message_id: "m1",
|
|
task_id: "t1",
|
|
created_at: "2026-08-01T00:00:00Z",
|
|
attachment_ids: null,
|
|
}), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new ApiClient("https://api.example.test").sendChatMessage("session-1", "hello"),
|
|
).resolves.toMatchObject({ attachment_ids: undefined });
|
|
});
|
|
|
|
it("sendChatMessage rejects a malformed response", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ message_id: "m1", task_id: 42 }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new ApiClient("https://api.example.test").sendChatMessage("session-1", "hello"),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
// The onboarding flow acts on a workspace the app has not navigated to, so
|
|
// these calls pass the slug explicitly. The server reads X-Workspace-Slug
|
|
// before ?workspace_id, so the header — not the param — is what has to carry
|
|
// the target workspace.
|
|
describe("ApiClient explicit workspace targeting", () => {
|
|
function stubOk(body: unknown) {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
function slugHeaderOf(fetchMock: ReturnType<typeof vi.fn>): unknown {
|
|
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
|
return (init.headers as Record<string, string>)["X-Workspace-Slug"];
|
|
}
|
|
|
|
it("sends the given slug on Mika creation", async () => {
|
|
const fetchMock = stubOk({ id: "agent-1" });
|
|
await new ApiClient("https://api.example.test").createMikaAgent(
|
|
{ runtime_id: "runtime-1", language: "en" },
|
|
"proxima-centauri",
|
|
);
|
|
expect(slugHeaderOf(fetchMock)).toBe("proxima-centauri");
|
|
});
|
|
|
|
it("sends the given slug when listing another workspace's runtimes", async () => {
|
|
const fetchMock = stubOk([]);
|
|
await new ApiClient("https://api.example.test").listRuntimes(
|
|
{ workspace_id: "ws-2", owner: "me" },
|
|
"proxima-centauri",
|
|
);
|
|
expect(slugHeaderOf(fetchMock)).toBe("proxima-centauri");
|
|
});
|
|
|
|
it("omits the header when no slug is given, leaving the ambient one", async () => {
|
|
const fetchMock = stubOk([]);
|
|
await new ApiClient("https://api.example.test").listRuntimes({
|
|
workspace_id: "ws-2",
|
|
});
|
|
expect(slugHeaderOf(fetchMock)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("ApiClient model discovery response schema", () => {
|
|
const completed = {
|
|
id: "req-1",
|
|
runtime_id: "rt-1",
|
|
status: "completed",
|
|
supported: true,
|
|
created_at: "2026-07-29T00:00:00Z",
|
|
updated_at: "2026-07-29T00:00:01Z",
|
|
models: [{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }],
|
|
};
|
|
|
|
function stubJSON(body: unknown) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
it("parses a live completed discovery", async () => {
|
|
stubJSON(completed);
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.initiateListModels("rt-1");
|
|
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
supported: true,
|
|
models: [{ id: "claude-sonnet-4-6" }],
|
|
});
|
|
});
|
|
|
|
it("keeps the cache markers on a server-cached snapshot", async () => {
|
|
stubJSON({ ...completed, cached: true, cached_at: "2026-07-29T00:00:00Z" });
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.initiateListModels("rt-1");
|
|
|
|
expect(result.cached).toBe(true);
|
|
expect(result.cached_at).toBe("2026-07-29T00:00:00Z");
|
|
});
|
|
|
|
it("requests a live model list when force refresh is selected", async () => {
|
|
stubJSON(completed);
|
|
|
|
await new ApiClient("https://api.example.test").initiateListModels("rt-1", {
|
|
force: true,
|
|
});
|
|
|
|
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe(
|
|
"https://api.example.test/api/runtimes/rt-1/models?force=true",
|
|
);
|
|
expect(vi.mocked(fetch).mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
|
|
});
|
|
|
|
// The picker drives a state machine off `status`, so a malformed body must
|
|
// become an explicit failure — not a fabricated empty catalog, and not an
|
|
// endless "discovering models" spinner.
|
|
it("degrades a malformed initiate response to an explicit failure", async () => {
|
|
stubJSON({ status: 7, models: "nope" });
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.initiateListModels("rt-1");
|
|
|
|
expect(result.status).toBe("failed");
|
|
expect(result.supported).toBe(true);
|
|
expect(result.error).toBe("invalid model discovery response");
|
|
expect(result.runtime_id).toBe("rt-1");
|
|
});
|
|
|
|
it("degrades a malformed poll response to an explicit failure", async () => {
|
|
stubJSON("not-an-object");
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.getListModelsResult("rt-1", "req-9");
|
|
|
|
expect(result.status).toBe("failed");
|
|
expect(result.id).toBe("req-9");
|
|
expect(result.runtime_id).toBe("rt-1");
|
|
});
|
|
|
|
it("stays usable against a backend that omits supported", async () => {
|
|
const { supported: _omitted, ...withoutSupported } = completed;
|
|
stubJSON(withoutSupported);
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.getListModelsResult("rt-1", "req-1");
|
|
|
|
expect(result.supported).toBe(true);
|
|
expect(result.status).toBe("completed");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Mixed-version contract for subtree unsubscribe (MUL-5483).
|
|
*
|
|
* Web/desktop staging deploys on merge while the backend is deployed by hand,
|
|
* so this client routinely runs against an older server. Subtree unsubscribe
|
|
* must therefore be carried by its own PATH, never by a body field: Go's JSON
|
|
* decoder drops unknown fields, so an old server would unsubscribe only the
|
|
* root and still answer 200 — telling the user the whole tree was muted while
|
|
* every child kept notifying. An unknown path 404s, which surfaces as a
|
|
* rejected mutation the user can act on.
|
|
*/
|
|
describe("ApiClient unsubscribe endpoints", () => {
|
|
function stubOK() {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
function requestOf(fetchMock: ReturnType<typeof vi.fn>) {
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
return { url, body: JSON.parse(String(init.body ?? "{}")) as Record<string, unknown> };
|
|
}
|
|
|
|
it("sends the subtree variant to its own endpoint", async () => {
|
|
const fetchMock = stubOK();
|
|
|
|
await new ApiClient("https://api.example.test")
|
|
.unsubscribeFromIssueSubtree("issue-1", "user-1", "member");
|
|
|
|
const { url, body } = requestOf(fetchMock);
|
|
expect(url).toBe("https://api.example.test/api/issues/issue-1/unsubscribe/subtree");
|
|
expect(body).toEqual({ user_id: "user-1", user_type: "member" });
|
|
});
|
|
|
|
it("never encodes subtree as a body field on the shared endpoint", async () => {
|
|
const fetchMock = stubOK();
|
|
|
|
await new ApiClient("https://api.example.test")
|
|
.unsubscribeFromIssue("issue-1", "user-1", "member");
|
|
|
|
const { url, body } = requestOf(fetchMock);
|
|
expect(url).toBe("https://api.example.test/api/issues/issue-1/unsubscribe");
|
|
// A `subtree` key here would be silently ignored by an older backend,
|
|
// which is the exact silent-success failure this split exists to prevent.
|
|
expect(body).not.toHaveProperty("subtree");
|
|
});
|
|
|
|
it("rejects when the backend does not know the subtree route", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ error: "not found" }), {
|
|
status: 404,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new ApiClient("https://api.example.test")
|
|
.unsubscribeFromIssueSubtree("issue-1", "user-1", "member"),
|
|
).rejects.toBeInstanceOf(ApiError);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient startMikaOnboarding", () => {
|
|
it("returns the opening a well-formed response reports", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
started: true,
|
|
message_id: "message-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new ApiClient("https://api.example.test").startMikaOnboarding("session-1", {
|
|
language: "en",
|
|
}),
|
|
).resolves.toEqual({
|
|
started: true,
|
|
message_id: "message-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
});
|
|
});
|
|
|
|
it("falls back to started=false when the response is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ started: "yes" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
// started=false is the safe reading: the flow treats it as "someone else
|
|
// already opened this conversation" and navigates, rather than acting on a
|
|
// body it could not understand.
|
|
await expect(
|
|
new ApiClient("https://api.example.test").startMikaOnboarding("session-1", {
|
|
language: "en",
|
|
}),
|
|
).resolves.toEqual({ started: false });
|
|
});
|
|
|
|
it("tolerates a backend that omits the optional opening fields", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ started: false }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new ApiClient("https://api.example.test").startMikaOnboarding("session-1", {
|
|
language: "en",
|
|
}),
|
|
).resolves.toEqual({ started: false });
|
|
});
|
|
});
|
|
|
|
describe("ApiClient refreshSkill response schema", () => {
|
|
const validSkill = {
|
|
id: "skill-1",
|
|
workspace_id: "ws-1",
|
|
name: "review-helper",
|
|
description: "refreshed",
|
|
content: "# refreshed",
|
|
config: { origin: { type: "github", source_url: "https://github.com/acme/skills" } },
|
|
created_by: "user-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-02T00:00:00Z",
|
|
files: [
|
|
{
|
|
id: "file-1",
|
|
skill_id: "skill-1",
|
|
path: "ref.md",
|
|
content: "ref",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-02T00:00:00Z",
|
|
},
|
|
],
|
|
};
|
|
|
|
it("parses a valid refreshed skill", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(validSkill), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await new ApiClient("https://api.example.test").refreshSkill("skill-1");
|
|
expect(result).toMatchObject({
|
|
id: "skill-1",
|
|
name: "review-helper",
|
|
files: [{ path: "ref.md" }],
|
|
});
|
|
});
|
|
|
|
it("falls back to the empty skill when the response is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ skill: 42 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await new ApiClient("https://api.example.test").refreshSkill("skill-1");
|
|
expect(result).toMatchObject({ id: "", name: "", files: [] });
|
|
});
|
|
|
|
it("defaults optional fields the server may omit", async () => {
|
|
const { description, content, files, ...minimal } = validSkill;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(minimal), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await new ApiClient("https://api.example.test").refreshSkill("skill-1");
|
|
expect(result).toMatchObject({
|
|
id: "skill-1",
|
|
description: "",
|
|
content: "",
|
|
files: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ApiClient workspace MCP servers", () => {
|
|
function stubJSON(body: unknown) {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
const server = {
|
|
id: "srv-1",
|
|
workspace_id: "ws-1",
|
|
name: "linear",
|
|
transport: "http",
|
|
created_at: "2026-08-14T00:00:00Z",
|
|
updated_at: "2026-08-14T00:00:00Z",
|
|
};
|
|
|
|
it("parses the workspace library", async () => {
|
|
stubJSON([server]);
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.listWorkspaceMcpServers("ws-1");
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toMatchObject({ id: "srv-1", name: "linear", transport: "http" });
|
|
// No binding in the library listing, so no toggle to report.
|
|
expect(result[0]?.enabled).toBeUndefined();
|
|
});
|
|
|
|
it("falls back safely when the library response is malformed", async () => {
|
|
stubJSON({ nope: true });
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.listWorkspaceMcpServers("ws-1");
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
// The write-only boundary is enforced on the client too: a server that
|
|
// regressed to returning the stored entry must not have it land in the
|
|
// parsed object or the query cache.
|
|
it("strips secret-bearing fields the server should never have sent", async () => {
|
|
stubJSON([{
|
|
...server,
|
|
config: { headers: { Authorization: "Bearer sk-live-doc" } },
|
|
url: "https://mcp.example/sk-live-url",
|
|
headers: { Authorization: "Bearer sk-live-header" },
|
|
}]);
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.listWorkspaceMcpServers("ws-1");
|
|
|
|
expect(JSON.stringify(result)).not.toContain("sk-live");
|
|
expect(result[0]).toEqual(server);
|
|
});
|
|
|
|
it("keeps an unknown transport rather than dropping the server", async () => {
|
|
// Enum drift from a newer backend must degrade, not disappear: the row
|
|
// still renders and the UI's default branch labels it.
|
|
stubJSON([{ ...server, transport: "websocket" }]);
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.listWorkspaceMcpServers("ws-1");
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]?.transport).toBe("websocket");
|
|
});
|
|
|
|
it("keeps the agent count and degrades a malformed one to unknown", async () => {
|
|
stubJSON([
|
|
{ ...server, id: "srv-1", agent_count: 3 },
|
|
{ ...server, id: "srv-2", agent_count: "many" },
|
|
{ ...server, id: "srv-3" },
|
|
]);
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.listWorkspaceMcpServers("ws-1");
|
|
|
|
expect(result.map((item) => item.agent_count)).toEqual([3, undefined, undefined]);
|
|
});
|
|
|
|
it("POSTs a name and entry when creating a library server", async () => {
|
|
const fetchMock = stubJSON(server);
|
|
|
|
await new ApiClient("https://api.example.test")
|
|
.createWorkspaceMcpServer("ws-1", "linear", { url: "https://mcp.example" });
|
|
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toContain("/api/workspaces/ws-1/mcp-servers");
|
|
expect(init.method).toBe("POST");
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
name: "linear",
|
|
config: { url: "https://mcp.example" },
|
|
});
|
|
});
|
|
|
|
it("sends only what changed when updating a library server", async () => {
|
|
const fetchMock = stubJSON(server);
|
|
|
|
await new ApiClient("https://api.example.test")
|
|
.updateWorkspaceMcpServer("ws-1", "srv-1", { name: "linear-v2" });
|
|
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toContain("/api/workspaces/ws-1/mcp-servers/srv-1");
|
|
expect(init.method).toBe("PUT");
|
|
// A rename must not blank the stored entry.
|
|
expect(JSON.parse(String(init.body))).toEqual({ name: "linear-v2" });
|
|
});
|
|
|
|
it("assigns and un-assigns a server on the agent routes", async () => {
|
|
const assigned = [{ ...server, enabled: true }];
|
|
let fetchMock = stubJSON(assigned);
|
|
|
|
const added = await new ApiClient("https://api.example.test")
|
|
.addAgentMcpServer("agent-1", "srv-1");
|
|
expect(added[0]?.enabled).toBe(true);
|
|
let [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toContain("/api/agents/agent-1/mcp-servers");
|
|
expect(init.method).toBe("POST");
|
|
expect(JSON.parse(String(init.body))).toEqual({ server_id: "srv-1" });
|
|
|
|
fetchMock = stubJSON([{ ...server, enabled: false }]);
|
|
const toggled = await new ApiClient("https://api.example.test")
|
|
.setAgentMcpServerEnabled("agent-1", "srv-1", false);
|
|
expect(toggled[0]?.enabled).toBe(false);
|
|
[url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toContain("/api/agents/agent-1/mcp-servers/srv-1/enabled");
|
|
expect(init.method).toBe("PUT");
|
|
|
|
fetchMock = stubJSON([]);
|
|
await new ApiClient("https://api.example.test").removeAgentMcpServer("agent-1", "srv-1");
|
|
[url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toContain("/api/agents/agent-1/mcp-servers/srv-1");
|
|
expect(init.method).toBe("DELETE");
|
|
});
|
|
});
|
|
|
|
describe("importSkillArchive", () => {
|
|
it("POSTs multipart form data without a JSON content-type", async () => {
|
|
const skill = {
|
|
id: "skill-1",
|
|
workspace_id: "ws-1",
|
|
name: "review-helper",
|
|
description: "Reviews code",
|
|
content: "# Review",
|
|
config: {},
|
|
files: [],
|
|
created_by: "user-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
};
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ status: "created", skill }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const file = new File(["pk"], "review-helper.skill", { type: "application/zip" });
|
|
const result = await new ApiClient("https://api.example.test").importSkillArchive(
|
|
file,
|
|
"fail",
|
|
);
|
|
|
|
expect(result.id).toBe("skill-1");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe("https://api.example.test/api/skills/import");
|
|
expect(init?.method).toBe("POST");
|
|
const headers = init?.headers as Record<string, string>;
|
|
expect(headers["Content-Type"]).toBeUndefined();
|
|
const body = init?.body as FormData;
|
|
expect(body).toBeInstanceOf(FormData);
|
|
expect(body.get("on_conflict")).toBe("fail");
|
|
const uploaded = body.get("file");
|
|
expect(uploaded).toBeInstanceOf(File);
|
|
expect((uploaded as File).name).toBe("review-helper.skill");
|
|
});
|
|
|
|
it("falls back to Import failed when the archive envelope is malformed", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ not_a_status: true, skill: 42 }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const file = new File(["pk"], "review-helper.skill");
|
|
await expect(
|
|
new ApiClient("https://api.example.test").importSkillArchive(file),
|
|
).rejects.toThrow(/import failed/i);
|
|
});
|
|
|
|
it("keeps the server reason for a status this client does not know", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({ status: "quarantined", reason: "pending review" }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const file = new File(["pk"], "review-helper.skill");
|
|
await expect(
|
|
new ApiClient("https://api.example.test").importSkillArchive(file),
|
|
).rejects.toThrow("pending review");
|
|
});
|
|
|
|
it("throws the structured reason on a name conflict", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
status: "conflict",
|
|
reason: "a skill with this name already exists",
|
|
}),
|
|
{ status: 409, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const file = new File(["pk"], "review-helper.skill");
|
|
await expect(
|
|
new ApiClient("https://api.example.test").importSkillArchive(file),
|
|
).rejects.toMatchObject({
|
|
message: "a skill with this name already exists",
|
|
status: 409,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clientErrorMessage", () => {
|
|
it("returns a 4xx message, which handlers write for the user", () => {
|
|
expect(clientErrorMessage(new ApiError("autopilot is not active", 400, "Bad Request")))
|
|
.toBe("autopilot is not active");
|
|
expect(clientErrorMessage(new ApiError("forbidden", 403, "Forbidden"))).toBe("forbidden");
|
|
});
|
|
|
|
it("withholds a 5xx message, which carries internal server detail", () => {
|
|
// MUL-6472: the pre-fix body for a failed autopilot trigger looked like
|
|
// this, and it was rendered verbatim in the run-now toast.
|
|
const leaky = new ApiError(
|
|
'failed to trigger autopilot: create run: ERROR: duplicate key value violates unique constraint "autopilot_run_pkey" (SQLSTATE 23505)',
|
|
500,
|
|
"Internal Server Error",
|
|
);
|
|
expect(clientErrorMessage(leaky)).toBeUndefined();
|
|
expect(clientErrorMessage(new ApiError("internal error", 503, "Service Unavailable"))).toBeUndefined();
|
|
});
|
|
|
|
it("withholds a transport failure, whose message says nothing actionable", () => {
|
|
expect(clientErrorMessage(new TypeError("Failed to fetch"))).toBeUndefined();
|
|
expect(clientErrorMessage(undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// The wiring this exercises is the one CoreProvider installs: the client's
|
|
// 401 hook drives the auth store's session teardown. Before MUL-7028 the hook
|
|
// only dropped the stored token, so the shell stayed mounted with a live
|
|
// `user` and every following request came back "missing authorization" with
|
|
// no way for the user to get to the login page.
|
|
describe("ApiClient session expiry", () => {
|
|
function makeStorage(
|
|
initial: Record<string, string> = {},
|
|
): StorageAdapter {
|
|
const values = { ...initial };
|
|
return {
|
|
getItem: (key) => values[key] ?? null,
|
|
setItem: (key, value) => {
|
|
values[key] = value;
|
|
},
|
|
removeItem: (key) => {
|
|
delete values[key];
|
|
},
|
|
};
|
|
}
|
|
|
|
it("ends the session when the server rejects the credential mid-flight", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ error: "missing authorization" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
const storage = makeStorage({ multica_token: "live-token" });
|
|
// The client is constructed before the store it notifies, exactly as
|
|
// CoreProvider's initCore does; the hook only ever runs from a request.
|
|
const session: { store?: ReturnType<typeof createAuthStore> } = {};
|
|
const client = new ApiClient("https://api.example.test", {
|
|
onUnauthorized: () => session.store?.getState().sessionExpired(),
|
|
});
|
|
const store = createAuthStore({ api: client, storage });
|
|
session.store = store;
|
|
store.setState({
|
|
user: { id: "u1", email: "[email protected]" } as User,
|
|
isLoading: false,
|
|
status: "authenticated",
|
|
});
|
|
client.setToken("live-token");
|
|
|
|
await expect(client.listProjects()).rejects.toBeInstanceOf(ApiError);
|
|
|
|
expect(store.getState().user).toBeNull();
|
|
expect(store.getState().status).toBe("unauthenticated");
|
|
expect(store.getState().expired).toBe(true);
|
|
expect(storage.getItem("multica_token")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("ApiClient sliding session renewal", () => {
|
|
function jsonResponse(body: unknown, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
it("validates the refresh response and falls back to 'not renewed'", async () => {
|
|
// A malformed body must never be read as a renewal: acting on it would
|
|
// hand `undefined` to the code that persists the token.
|
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ renewed: "yes" }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const result = await new ApiClient("https://api.example.test").refreshSession();
|
|
|
|
expect(result.renewed).toBe(false);
|
|
expect(result.token).toBeUndefined();
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.example.test/api/auth/refresh");
|
|
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST");
|
|
});
|
|
|
|
it("returns the renewed token for a bearer client", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
jsonResponse({
|
|
token: "token-v2",
|
|
expires_at: "2026-10-16T00:00:00Z",
|
|
renewed: true,
|
|
check_again_in_seconds: 259200,
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await new ApiClient("https://api.example.test").refreshSession();
|
|
|
|
expect(result).toMatchObject({
|
|
token: "token-v2",
|
|
renewed: true,
|
|
check_again_in_seconds: 259200,
|
|
});
|
|
});
|
|
|
|
// The one case where a session renewal can invalidate a CSRF token another
|
|
// tab is already holding: the first renewal of a session that predates the
|
|
// session-bound binding. The cookie has already been replaced by then, so
|
|
// re-reading it and sending again is enough (MUL-7436).
|
|
it("retries once when a CSRF token turns out to be stale", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ error: "CSRF validation failed" }, 403))
|
|
.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.markOnboardingComplete()).resolves.toBeDefined();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not retry a 403 that is a real authorization failure", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(jsonResponse({ error: "forbidden" }, 403));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.markOnboardingComplete()).rejects.toBeInstanceOf(ApiError);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("gives up after one retry rather than looping", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(jsonResponse({ error: "CSRF validation failed" }, 403));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.markOnboardingComplete()).rejects.toBeInstanceOf(ApiError);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
// This file runs in the node environment, so `document` is stubbed rather
|
|
// than relying on jsdom — readCookie only ever reads `document.cookie`, and a
|
|
// stub keeps these tests next to the rest of the client's coverage.
|
|
// This file runs in the node environment, so `document` is stubbed rather
|
|
// than relying on jsdom — readCookie only ever reads `document.cookie`, and a
|
|
// stub keeps these tests next to the rest of the client's coverage.
|
|
describe("ApiClient CSRF headers", () => {
|
|
function stubCookies(cookie: string) {
|
|
vi.stubGlobal("document", { cookie });
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function capturedHeaders(fetchMock: ReturnType<typeof vi.fn>, call = 0) {
|
|
return (fetchMock.mock.calls[call]?.[1]?.headers ?? {}) as Record<string, string>;
|
|
}
|
|
|
|
// One header name, deliberately. A second one would have to be in the
|
|
// server's CORS allowlist, and a rolled-back server allowlists only the
|
|
// names it shipped with — the preflight would fail and no retry could help.
|
|
it("sends exactly one CSRF header, preferring the session-bound value", async () => {
|
|
stubCookies("multica_csrf=token-bound; multica_csrf_session=session-bound");
|
|
const fetchMock = vi.fn().mockImplementation(async () => jsonResponse({}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").markOnboardingComplete();
|
|
|
|
const headers = capturedHeaders(fetchMock);
|
|
expect(headers["X-CSRF-Token"]).toBe("session-bound");
|
|
expect(headers["X-CSRF-Session"]).toBeUndefined();
|
|
});
|
|
|
|
// A session that predates the session-bound cookie, and every request after
|
|
// a rollback, has only the token-bound value to offer.
|
|
it("falls back to the token-bound value when no session cookie exists", async () => {
|
|
stubCookies("multica_csrf=token-bound");
|
|
const fetchMock = vi.fn().mockImplementation(async () => jsonResponse({}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").markOnboardingComplete();
|
|
|
|
expect(capturedHeaders(fetchMock)["X-CSRF-Token"]).toBe("token-bound");
|
|
});
|
|
|
|
it("sends no CSRF header when there is no cookie to echo", async () => {
|
|
stubCookies("");
|
|
const fetchMock = vi.fn().mockImplementation(async () => jsonResponse({}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").markOnboardingComplete();
|
|
|
|
expect(capturedHeaders(fetchMock)["X-CSRF-Token"]).toBeUndefined();
|
|
});
|
|
|
|
// The rollback path end to end: a server running the previous release
|
|
// cannot verify the session-bound value, and the client has to discover that
|
|
// and switch — otherwise the user is authenticated for reads and rejected
|
|
// for every write.
|
|
it("retries with the token-bound value when the server rejects the session-bound one", async () => {
|
|
stubCookies("multica_csrf=token-bound; multica_csrf_session=session-bound");
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ error: "CSRF validation failed" }, 403))
|
|
// A factory, not a fixed value: a Response body can only be read once,
|
|
// so a shared instance breaks on the second call.
|
|
.mockImplementation(async () => jsonResponse({}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.markOnboardingComplete();
|
|
|
|
expect(capturedHeaders(fetchMock, 0)["X-CSRF-Token"]).toBe("session-bound");
|
|
expect(capturedHeaders(fetchMock, 1)["X-CSRF-Token"]).toBe("token-bound");
|
|
});
|
|
|
|
// ...and it stays switched, so a rolled-back server does not cost two
|
|
// requests per write for the rest of the session.
|
|
it("keeps using the token-bound value while the rejected cookie is current", async () => {
|
|
stubCookies("multica_csrf=token-bound; multica_csrf_session=session-bound");
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ error: "CSRF validation failed" }, 403))
|
|
// A factory, not a fixed value: a Response body can only be read once,
|
|
// so a shared instance breaks on the second call.
|
|
.mockImplementation(async () => jsonResponse({}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.markOnboardingComplete();
|
|
await client.markOnboardingComplete();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
expect(capturedHeaders(fetchMock, 2)["X-CSRF-Token"]).toBe("token-bound");
|
|
});
|
|
|
|
// But only while it is current. A renewal or a new login replaces the
|
|
// session cookie, and the preferred binding is worth trying again — keying
|
|
// on the value rather than a boolean is what stops this oscillating once
|
|
// per renewal against a server that understands it perfectly well.
|
|
it("prefers the session-bound value again once the cookie changes", async () => {
|
|
stubCookies("multica_csrf=token-bound; multica_csrf_session=session-bound");
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ error: "CSRF validation failed" }, 403))
|
|
// A factory, not a fixed value: a Response body can only be read once,
|
|
// so a shared instance breaks on the second call.
|
|
.mockImplementation(async () => jsonResponse({}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.markOnboardingComplete();
|
|
|
|
stubCookies("multica_csrf=token-bound-2; multica_csrf_session=session-bound-2");
|
|
await client.markOnboardingComplete();
|
|
|
|
expect(capturedHeaders(fetchMock, 2)["X-CSRF-Token"]).toBe("session-bound-2");
|
|
});
|
|
});
|
|
|
|
// Desktop runs one ApiClient per window over one shared localStorage. Before
|
|
// MUL-7436 each instance cached the bearer token, so a session renewed in one
|
|
// window left every other window sending the credential it happened to be
|
|
// holding — until that one expired and took the whole session down with it,
|
|
// clearing tabs and drafts on the way (MUL-7028).
|
|
describe("ApiClient shared credential across windows", () => {
|
|
function sharedStorage(initial: string | null) {
|
|
let token = initial;
|
|
return {
|
|
read: () => token,
|
|
write: (next: string | null) => {
|
|
token = next;
|
|
},
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function authHeaderOf(fetchMock: ReturnType<typeof vi.fn>, call: number) {
|
|
const headers = (fetchMock.mock.calls[call]?.[1]?.headers ?? {}) as Record<string, string>;
|
|
return headers["Authorization"];
|
|
}
|
|
|
|
it("reads the current token per request, so a renewal in one window reaches the others", async () => {
|
|
const storage = sharedStorage("token-v1");
|
|
const windowA = new ApiClient("https://api.example.test", { getToken: storage.read });
|
|
const windowB = new ApiClient("https://api.example.test", { getToken: storage.read });
|
|
|
|
const fetchMock = vi.fn().mockImplementation(async () => jsonResponse({}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await windowB.markOnboardingComplete();
|
|
expect(authHeaderOf(fetchMock, 0)).toBe("Bearer token-v1");
|
|
|
|
// Window A renews; only shared storage is updated, exactly as the renewal
|
|
// controller does it.
|
|
storage.write("token-v2");
|
|
windowA.setToken("token-v2");
|
|
|
|
await windowB.markOnboardingComplete();
|
|
expect(authHeaderOf(fetchMock, 1)).toBe("Bearer token-v2");
|
|
});
|
|
|
|
// The other half: a request that went out with the previous credential can
|
|
// 401 AFTER the renewal landed. Ending the session on that would tear down
|
|
// one that is demonstrably alive.
|
|
it("ignores a 401 for a credential that has since been replaced", async () => {
|
|
const storage = sharedStorage("token-v1");
|
|
const onUnauthorized = vi.fn();
|
|
const client = new ApiClient("https://api.example.test", {
|
|
getToken: storage.read,
|
|
onUnauthorized,
|
|
});
|
|
|
|
const fetchMock = vi.fn().mockImplementation(async () => {
|
|
// The renewal lands while this request is in flight.
|
|
storage.write("token-v2");
|
|
return jsonResponse({ error: "invalid token" }, 401);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(client.markOnboardingComplete()).rejects.toBeInstanceOf(ApiError);
|
|
|
|
expect(onUnauthorized).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ...but a genuine expiry still ends the session: nothing replaced the
|
|
// credential, so the 401 is about the one still in use.
|
|
it("still ends the session on a 401 for the credential in use", async () => {
|
|
const storage = sharedStorage("token-v1");
|
|
const onUnauthorized = vi.fn();
|
|
const client = new ApiClient("https://api.example.test", {
|
|
getToken: storage.read,
|
|
onUnauthorized,
|
|
});
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockImplementation(async () => jsonResponse({ error: "invalid token" }, 401)),
|
|
);
|
|
|
|
await expect(client.markOnboardingComplete()).rejects.toBeInstanceOf(ApiError);
|
|
|
|
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Cookie mode has no bearer token to compare, so the guard must not swallow
|
|
// its expiries.
|
|
it("ends the session on a 401 in cookie mode", async () => {
|
|
const onUnauthorized = vi.fn();
|
|
const client = new ApiClient("https://api.example.test", { onUnauthorized });
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockImplementation(async () => jsonResponse({ error: "invalid token" }, 401)),
|
|
);
|
|
|
|
await expect(client.markOnboardingComplete()).rejects.toBeInstanceOf(ApiError);
|
|
|
|
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|