mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* fix(issues): attach labels in the create transaction (MUL-4832) Labels chosen at issue creation were attached in a second, non-atomic round-trip after the issue was already committed (web modal looped attachLabelToIssue per label; the create endpoint took no labels). A partial failure of that follow-up left the issue committed but mis-categorized, surfaced only as a toast. Carry label_ids through the create request instead: the service validates them (workspace + resource_type='issue') and attaches them inside the same transaction as the issue insert, so the issue and its labels commit together or not at all. An unknown or wrong-scope label id now fails the whole create with 400 rather than being silently dropped. Duplicate ids are idempotent (dedupe + ON CONFLICT DO NOTHING). - server: IssueCreateParams.LabelIDs + ErrIssueLabelNotFound; validate and attach in IssueService.Create; handler parses label_ids and maps the error to 400. - web: create-issue modal forwards label_ids and drops the post-create attach loop and its dead toast key. - tests: handler coverage for atomic attach, stale-id 400 (no issue left behind), duplicate-id idempotency, wrong-scope rejection; web test asserts label_ids is forwarded. Scope is deliberately labels-only. The contributor PR #5475 also auto-parsed an acceptance_criteria field from description text; that introduces a new user-facing data contract with no defined edit/display rules and is left out for a separate product decision. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): echo created labels + guard old-backend compat (MUL-4832) Addresses review of #5510. 1. Create response + issue:created event now carry the labels attached in the create transaction. IssueService.Create returns the authoritative label snapshot; the handler sets it on both the HTTP response and the issue:created broadcast payload. Without this, online members other than the creator saw the new issue unlabeled indefinitely (staleTime: Infinity, no invalidation) because this PR removed the old post-create issue_labels:changed broadcast. 2. New web + old backend compatibility. During the rolling deploy window the web app can run ahead of the backend (web auto-deploys on merge, backend deploys manually). An older backend ignores label_ids and returns an issue with no labels field. The create modal now falls back to the legacy per-label attach only when the response omits labels, so labels aren't silently dropped; when labels is present the atomic path already ran and no fallback fires. The backend always returns an explicit labels array (empty when none) as the detection signal. Tests: handler issue:created-carries-labels + response-carries-labels + response-always-includes-labels; web modal no-fallback (new backend) and fallback (old backend); ws-updaters keeps the label snapshot in list cache. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): validate create response through a schema (MUL-4832) Addresses review of #5510 (must-fix 3). The create modal keys its label-attach compatibility fallback off `issue.labels` being absent (older backend) vs a validated Label[] (current backend). But api.createIssue cast the raw JSON straight to Issue, so a malformed labels value (null, an object, a garbage array) would be !== undefined and wrongly suppress the fallback, caching a bad shape — violating the repo's API Compatibility rule. Parse the create response through CreateIssueResponseSchema: - labels absent -> undefined (older-backend signal; fallback runs) - labels valid -> Label[] (fully validated elements, not z.unknown()) - labels malformed -> undefined via .catch (safe: never masquerades as handled; worst case a redundant re-attach, never a silent drop) A whole-body parse failure degrades to EMPTY_ISSUE (never throws into React), matching the existing parseWithFallback contract. Tests: schema.test.ts covers absent / valid / null / wrong-element-shape labels and the empty-issue degrade. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): reject a malformed create response instead of faking success (MUL-4832) Follow-up to review of #5510. The prior fix degraded a schema-failed create response to EMPTY_ISSUE, which React Query treats as a successful mutation: the empty issue is written into the list cache, a blank "created" toast shows, the "View issue" link points at an empty id, and with labels the fallback attach runs against an empty issue id. A create that returns an unusable body is a failed mutation, not a safe-empty read. Fall back to null and reject: mutateAsync is already inside the create modal's try/catch, so a controlled rejection preserves the draft and shows the failure toast, and onSettled still refreshes the list so a genuinely-created issue can still surface. - CreateIssueResponseSchema tightens id to non-empty; an id-less body routes to the same reject path. - createIssue throws on parse failure (empty-message Error so the modal renders its localized "failed to create" toast; parseWithFallback already logged the schema issues + raw body). - Dropped the EMPTY_ISSUE fallback constant. - Tests: whole-body malformed and empty-id now assert createIssue rejects; only-labels-malformed still returns the real issue with labels undefined. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
515 lines
20 KiB
TypeScript
515 lines
20 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import { ApiClient } from "./client";
|
|
import { parseWithFallback } from "./schema";
|
|
|
|
// Helper: stub fetch with a single JSON response. Status defaults to 200.
|
|
function stubFetchJson(body: unknown, status = 200) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
// These tests cover the five failure modes that white-screened the desktop
|
|
// app in past incidents. The contract is: a malformed response degrades to
|
|
// an empty/safe shape, never throws into React.
|
|
describe("ApiClient schema fallback", () => {
|
|
describe("listTimeline", () => {
|
|
it("falls back to an empty array when the body is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it("falls back when the body is not an array", async () => {
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it("accepts a new entry type rather than crashing on enum drift", async () => {
|
|
stubFetchJson([
|
|
{
|
|
type: "future_kind", // not in TS union
|
|
id: "e-1",
|
|
actor_type: "member",
|
|
actor_id: "u-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
},
|
|
]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]?.type).toBe("future_kind");
|
|
});
|
|
|
|
// Forward-compat: when the server adds a new field to an existing
|
|
// shape, `.loose()` lets it pass through unchanged. Without `.loose()`
|
|
// zod 4 strips it, which would silently break a future TS type that
|
|
// adopts the field — see schemas.ts header comment.
|
|
it("preserves unknown fields the schema didn't list", async () => {
|
|
stubFetchJson([
|
|
{
|
|
type: "comment",
|
|
id: "e-1",
|
|
actor_type: "member",
|
|
actor_id: "u-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
// New server-side field not present in TimelineEntrySchema:
|
|
future_field: { nested: "value" },
|
|
},
|
|
]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
const entry = entries[0] as unknown as Record<string, unknown>;
|
|
expect(entry.future_field).toEqual({ nested: "value" });
|
|
});
|
|
});
|
|
|
|
describe("listIssues", () => {
|
|
it("falls back to an empty list when the response is malformed", async () => {
|
|
// `issues` having the wrong type triggers the fallback. An object
|
|
// with only unexpected keys would *succeed* parsing now (every
|
|
// declared field has a default) and just pass the extras through
|
|
// via `.loose()`, so we use a wrong-type payload here instead.
|
|
stubFetchJson({ issues: "not-an-array", total: 0 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listIssues();
|
|
expect(res).toEqual({ issues: [], total: 0 });
|
|
});
|
|
});
|
|
|
|
describe("createIssue", () => {
|
|
// The create modal decides whether to run its label-attach fallback by
|
|
// reading `labels` off the parsed response, and treats a rejection as a
|
|
// failed create (keep the draft, failure toast). So: a valid issue with
|
|
// any labels shape resolves (labels absent → undefined, valid → Label[],
|
|
// malformed → undefined), but a body that isn't a usable issue rejects
|
|
// rather than fabricating a blank "success".
|
|
const validIssue = {
|
|
id: "issue-1",
|
|
workspace_id: "ws-1",
|
|
number: 1,
|
|
identifier: "MUL-1",
|
|
title: "Created",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "none",
|
|
assignee_type: null,
|
|
assignee_id: null,
|
|
creator_type: "member",
|
|
creator_id: "user-1",
|
|
parent_issue_id: null,
|
|
project_id: null,
|
|
position: 0,
|
|
stage: null,
|
|
start_date: null,
|
|
due_date: null,
|
|
metadata: {},
|
|
properties: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
};
|
|
const label = {
|
|
id: "label-1",
|
|
workspace_id: "ws-1",
|
|
name: "bug",
|
|
color: "#ef4444",
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
};
|
|
|
|
it("keeps labels undefined when the backend omits the field (older backend)", async () => {
|
|
stubFetchJson(validIssue, 201);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const issue = await client.createIssue({ title: "Created" });
|
|
expect(issue.id).toBe("issue-1");
|
|
expect(issue.labels).toBeUndefined();
|
|
});
|
|
|
|
it("validates a well-formed labels array", async () => {
|
|
stubFetchJson({ ...validIssue, labels: [label] }, 201);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const issue = await client.createIssue({ title: "Created" });
|
|
expect(issue.labels?.map((l) => l.id)).toEqual(["label-1"]);
|
|
});
|
|
|
|
it("degrades a null labels field to undefined so the client falls back", async () => {
|
|
stubFetchJson({ ...validIssue, labels: null }, 201);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const issue = await client.createIssue({ title: "Created" });
|
|
// The issue itself still parses; only the malformed labels degrade.
|
|
expect(issue.id).toBe("issue-1");
|
|
expect(issue.labels).toBeUndefined();
|
|
});
|
|
|
|
it("degrades a labels array of the wrong element shape to undefined", async () => {
|
|
stubFetchJson({ ...validIssue, labels: [{ nope: true }] }, 201);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const issue = await client.createIssue({ title: "Created" });
|
|
expect(issue.id).toBe("issue-1");
|
|
expect(issue.labels).toBeUndefined();
|
|
});
|
|
|
|
it("rejects when the whole response body is not a usable issue (no fake success)", async () => {
|
|
stubFetchJson({ not: "an issue" }, 201);
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.createIssue({ title: "Created" })).rejects.toThrow();
|
|
});
|
|
|
|
it("rejects when the created issue has an empty id", async () => {
|
|
stubFetchJson({ ...validIssue, id: "" }, 201);
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.createIssue({ title: "Created" })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("searchIssues", () => {
|
|
it("falls back to an empty result when the response is malformed", async () => {
|
|
stubFetchJson({ issues: "not-an-array", total: 0 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.searchIssues({ q: "bug" });
|
|
expect(res).toEqual({ issues: [], total: 0 });
|
|
});
|
|
});
|
|
|
|
describe("searchProjects", () => {
|
|
it("falls back to an empty result when the response is malformed", async () => {
|
|
stubFetchJson({ projects: "not-an-array", total: 0 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.searchProjects({ q: "roadmap" });
|
|
expect(res).toEqual({ projects: [], total: 0 });
|
|
});
|
|
});
|
|
|
|
describe("listAutopilots", () => {
|
|
const baseAutopilot = {
|
|
id: "ap-1",
|
|
workspace_id: "ws-1",
|
|
title: "Daily triage",
|
|
description: null,
|
|
assignee_id: "agent-1",
|
|
status: "active",
|
|
execution_mode: "run_only",
|
|
issue_title_template: null,
|
|
created_by_type: "member",
|
|
created_by_id: "user-1",
|
|
last_run_at: null,
|
|
created_at: "2026-06-01T00:00:00Z",
|
|
updated_at: "2026-06-01T00:00:00Z",
|
|
};
|
|
|
|
it("falls back to an empty list when the response is malformed", async () => {
|
|
stubFetchJson({ autopilots: "not-an-array", total: 1 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilots();
|
|
expect(res).toEqual({ autopilots: [], total: 0 });
|
|
});
|
|
|
|
it("accepts an old-server row without assignee_type or derived fields", async () => {
|
|
// Pre-MUL-2429 servers omit assignee_type; servers older than the
|
|
// list-derived-fields change omit trigger_kinds/next_run_at/
|
|
// last_run_status. Both must parse, not fall back.
|
|
stubFetchJson({ autopilots: [baseAutopilot], total: 1 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilots();
|
|
expect(res.autopilots).toHaveLength(1);
|
|
expect(res.autopilots[0]?.assignee_type).toBe("agent");
|
|
expect(res.autopilots[0]?.trigger_kinds).toBeUndefined();
|
|
expect(res.autopilots[0]?.last_run_status).toBeUndefined();
|
|
});
|
|
|
|
it("passes derived fields through and tolerates enum drift", async () => {
|
|
stubFetchJson({
|
|
autopilots: [
|
|
{
|
|
...baseAutopilot,
|
|
assignee_type: "squad",
|
|
trigger_kinds: ["schedule", "some_future_kind"],
|
|
next_run_at: "2026-06-13T09:00:00Z",
|
|
last_run_status: "some_future_status",
|
|
},
|
|
],
|
|
total: 1,
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilots();
|
|
expect(res.autopilots[0]?.trigger_kinds).toEqual([
|
|
"schedule",
|
|
"some_future_kind",
|
|
]);
|
|
expect(res.autopilots[0]?.next_run_at).toBe("2026-06-13T09:00:00Z");
|
|
expect(res.autopilots[0]?.last_run_status).toBe("some_future_status");
|
|
});
|
|
});
|
|
|
|
describe("getConfig", () => {
|
|
it("drops malformed daemon setup URLs instead of throwing", async () => {
|
|
stubFetchJson({
|
|
cdn_domain: "cdn.example.com",
|
|
allow_signup: true,
|
|
daemon_server_url: { wrong: "shape" },
|
|
daemon_app_url: 123,
|
|
workspace_creation_disabled: false,
|
|
feature_flags: { composio_mcp_apps: true },
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const config = await client.getConfig();
|
|
expect(config.cdn_domain).toBe("cdn.example.com");
|
|
expect(config.allow_signup).toBe(true);
|
|
expect(config.daemon_server_url).toBeUndefined();
|
|
expect(config.daemon_app_url).toBeUndefined();
|
|
expect(config.feature_flags?.composio_mcp_apps).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("listGroupedIssues", () => {
|
|
it("falls back to empty groups when the response is malformed", async () => {
|
|
stubFetchJson({ groups: "not-an-array" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listGroupedIssues({ group_by: "assignee" });
|
|
expect(res).toEqual({ groups: [] });
|
|
});
|
|
});
|
|
|
|
describe("listComments", () => {
|
|
it("returns [] when the response is not an array", async () => {
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const comments = await client.listComments("issue-1");
|
|
expect(comments).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("previewCommentTriggers", () => {
|
|
it("returns an empty agent list when the response is malformed", async () => {
|
|
stubFetchJson({ agents: "not-an-array" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const preview = await client.previewCommentTriggers("issue-1", "hello");
|
|
expect(preview).toEqual({ agents: [] });
|
|
});
|
|
});
|
|
|
|
describe("listIssueSubscribers", () => {
|
|
it("returns [] when the response is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const subs = await client.listIssueSubscribers("issue-1");
|
|
expect(subs).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("listChildIssues", () => {
|
|
it("returns { issues: [] } when the issues field is missing", async () => {
|
|
stubFetchJson({});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listChildIssues("issue-1");
|
|
expect(res).toEqual({ issues: [] });
|
|
});
|
|
});
|
|
|
|
// Agent template catalog is hit by the desktop create-agent picker.
|
|
// Installed desktop builds outlive any given server, so the shape MUST
|
|
// survive future field renames / wrapping without crashing. Each test
|
|
// here mirrors a concrete future drift we want to absorb.
|
|
describe("listAgentTemplates", () => {
|
|
it("falls back to [] when the body is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tmpls = await client.listAgentTemplates();
|
|
expect(tmpls).toEqual([]);
|
|
});
|
|
|
|
it("defaults skills to [] when the field is missing from a template", async () => {
|
|
// Future server: drops `skills` because the picker no longer reads
|
|
// them. Picker code calls `template.skills.length` — must not throw.
|
|
stubFetchJson([{ slug: "x", name: "X" }]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tmpls = await client.listAgentTemplates();
|
|
expect(tmpls).toHaveLength(1);
|
|
expect(tmpls[0]?.skills).toEqual([]);
|
|
});
|
|
|
|
it("accepts the bare-array shape (current contract)", async () => {
|
|
stubFetchJson([
|
|
{ slug: "a", name: "A", description: "", skills: [] },
|
|
{ slug: "b", name: "B", description: "", skills: [] },
|
|
]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tmpls = await client.listAgentTemplates();
|
|
expect(tmpls.map((t) => t.slug)).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("accepts a future {templates: [...]} envelope without breaking", async () => {
|
|
// Server migrates to a paginated envelope. We unwrap so the picker
|
|
// keeps working on the older bare-array consumer.
|
|
stubFetchJson({
|
|
templates: [{ slug: "a", name: "A", description: "", skills: [] }],
|
|
total: 1,
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tmpls = await client.listAgentTemplates();
|
|
expect(tmpls).toHaveLength(1);
|
|
expect(tmpls[0]?.slug).toBe("a");
|
|
});
|
|
});
|
|
|
|
describe("getAgentTemplate", () => {
|
|
it("falls back to a minimal record carrying the requested slug", async () => {
|
|
// Slug is part of the URL the user clicked — the fallback round-
|
|
// trips it so the page header still makes sense after a parse miss.
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const detail = await client.getAgentTemplate("code-reviewer");
|
|
expect(detail.slug).toBe("code-reviewer");
|
|
expect(detail.skills).toEqual([]);
|
|
expect(detail.instructions).toBe("");
|
|
});
|
|
|
|
it("defaults instructions to '' when the field is missing", async () => {
|
|
stubFetchJson({
|
|
slug: "code-reviewer",
|
|
name: "Code Reviewer",
|
|
description: "",
|
|
skills: [],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const detail = await client.getAgentTemplate("code-reviewer");
|
|
expect(detail.instructions).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("listAutopilotDeliveries", () => {
|
|
it("falls back to an empty list when the body is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilotDeliveries("ap-1");
|
|
expect(res).toEqual({ deliveries: [], total: 0 });
|
|
});
|
|
|
|
it("falls back to an empty list when `deliveries` is not an array", async () => {
|
|
stubFetchJson({ deliveries: "not-an-array", total: 0 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilotDeliveries("ap-1");
|
|
expect(res).toEqual({ deliveries: [], total: 0 });
|
|
});
|
|
|
|
it("accepts an unknown future status value rather than dropping the row", async () => {
|
|
// Server-side enum drift (e.g. new `quarantined` state). The list
|
|
// must still surface the row; downstream UI code's `default` arm
|
|
// handles unknown values with a generic visual.
|
|
stubFetchJson({
|
|
deliveries: [
|
|
{
|
|
id: "d-1",
|
|
workspace_id: "ws-1",
|
|
autopilot_id: "ap-1",
|
|
trigger_id: "t-1",
|
|
provider: "github",
|
|
event: "pull_request.opened",
|
|
dedupe_key: "abc",
|
|
dedupe_source: "x-github-delivery",
|
|
signature_status: "valid",
|
|
status: "quarantined",
|
|
attempt_count: 1,
|
|
content_type: "application/json",
|
|
response_status: 200,
|
|
autopilot_run_id: null,
|
|
replayed_from_delivery_id: null,
|
|
error: null,
|
|
received_at: "2026-01-01T00:00:00Z",
|
|
last_attempt_at: "2026-01-01T00:00:00Z",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
},
|
|
],
|
|
total: 1,
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilotDeliveries("ap-1");
|
|
expect(res.deliveries).toHaveLength(1);
|
|
expect(res.deliveries[0]?.status).toBe("quarantined");
|
|
expect(res.deliveries[0]?.dispatch_attempts).toBe(0);
|
|
expect(res.deliveries[0]?.available_at).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("getAutopilotDelivery", () => {
|
|
it("falls back to a placeholder carrying the requested id", async () => {
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
|
|
expect(detail.id).toBe("d-1");
|
|
expect(detail.autopilot_id).toBe("ap-1");
|
|
});
|
|
});
|
|
|
|
describe("createAgentFromTemplate", () => {
|
|
it("falls back to an empty agent when the response is malformed", async () => {
|
|
// The agent was created server-side even though the client can't
|
|
// parse the response — UI code reads `agent.id === ""` and skips
|
|
// the navigation step rather than landing on `/agents/`.
|
|
stubFetchJson({ unexpected: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const resp = await client.createAgentFromTemplate({
|
|
template_slug: "x",
|
|
name: "X",
|
|
runtime_id: "rt-1",
|
|
});
|
|
expect(resp.agent.id).toBe("");
|
|
expect(resp.imported_skill_ids).toEqual([]);
|
|
expect(resp.reused_skill_ids).toEqual([]);
|
|
});
|
|
|
|
it("defaults imported_skill_ids / reused_skill_ids to [] when missing", async () => {
|
|
stubFetchJson({ agent: { id: "agent-1" } });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const resp = await client.createAgentFromTemplate({
|
|
template_slug: "x",
|
|
name: "X",
|
|
runtime_id: "rt-1",
|
|
});
|
|
expect(resp.agent.id).toBe("agent-1");
|
|
expect(resp.imported_skill_ids).toEqual([]);
|
|
expect(resp.reused_skill_ids).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Direct tests for the helper, decoupled from any specific endpoint —
|
|
// guards against an endpoint refactor masking a regression in the helper.
|
|
describe("parseWithFallback", () => {
|
|
const opts = { endpoint: "TEST /unit" };
|
|
|
|
it("returns parsed data on success", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const out = parseWithFallback({ id: "x" }, schema, { id: "fallback" }, opts);
|
|
expect(out).toEqual({ id: "x" });
|
|
});
|
|
|
|
it("returns the fallback when validation fails", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const fallback = { id: "fallback" };
|
|
const out = parseWithFallback({ id: 123 }, schema, fallback, opts);
|
|
expect(out).toBe(fallback);
|
|
});
|
|
|
|
it("returns the fallback when data is null", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const fallback = { id: "fallback" };
|
|
const out = parseWithFallback(null, schema, fallback, opts);
|
|
expect(out).toBe(fallback);
|
|
});
|
|
});
|