mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
fix(queue): reject non-persistable summary/evidence on non-park updates (OPR.0.5.1 slice-51-06 D2)
rig queue update accepted --summary/--evidence-ref on any transition, but the daemon persists
them ONLY at a human-seat park (state=blocked on a human seat); on every other transition they
were SILENTLY dropped — the operator believed metadata was saved when it was lost. Guard-bound
design: HARD REJECT before any mutation (not a post-commit marker, which would report a partial
write / data loss).
- daemon queue-repository.ts (before any UPDATE/log/event): a non-park transition carrying
summary/evidence_ref (null/undefined=absent, ''=present) throws
QueueRepositoryError('summary_evidence_not_persistable', { invalidFields }).
- route routes/queue.ts: the code maps to HTTP 400; err.meta.invalidFields rides the body.
- CLI: NO change — printResult already exits nonzero on 4xx (no warning branch, per the binding).
- human-park + metadata-free updates byte-for-byte unchanged; NO schema change (+23/-0 production).
RED-first: queue-update-metadata-reject.test.ts (reject + zero-mutation across state/log/event,
null-absent/empty-present, human-park control, route 400; migration-faithful with 044+048).
CLI acceptance is a REAL, HERMETIC loopback boundary: queue-update-metadata-reject-cli.test.ts drives
an actual queueCommand -> real DaemonClient -> @hono/node-server loopback -> real queueRoutes/
QueueRepository (migrations 044+048). It neutralizes ambient OPENRIG_URL/RIGGED_URL/OPENRIG_PORT (saved
+ restored even on failure) so status resolution binds to the fresh loopback state file, and a
clientFactory guard refuses any non-loopback URL BEFORE constructing a client (no accidental canonical-
daemon contact). Covers normal + --json reject (nonzero exit), UNCHANGED item/transition/event state
after the HTTP call, and the successful human-park control — passing with ambient OPENRIG_URL present.
Flipped the one existing pin that encoded the old silent-drop (human-route-enforcer.test.ts). tsc clean
both; queue regression green. Guard PRE-EDIT CLEAR artifact sha256 728690e1.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
20991e3cc1
commit
b48c2242f5
5 files changed
+286
-9
No files matched your search
@@ -0,0 +1,132 @@
|
||||
// 51-06 D2 — REAL CLI acceptance boundary (Guard implementation-verdict correction). A genuine
|
||||
// `queueCommand` invocation talks over LOOPBACK HTTP to the real candidate queueRoutes +
|
||||
// QueueRepository, backed by a migration-faithful test DB (incl. 044 summary + 048 evidence_ref).
|
||||
// No pre-baked response: if the daemon route/repo stopped rejecting (or mutated before responding),
|
||||
// these tests would fail. Proves normal + --json reject, UNCHANGED item/transition/event state after
|
||||
// the HTTP call, and the successful human-park control — all end to end.
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { coreSchema } from "../../daemon/src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../../daemon/src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../../daemon/src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../../daemon/src/db/migrations/025_queue_transitions.js";
|
||||
import { inboxEntriesSchema } from "../../daemon/src/db/migrations/026_inbox_entries.js";
|
||||
import { outboxEntriesSchema } from "../../daemon/src/db/migrations/027_outbox_entries.js";
|
||||
import { queueTargetRepoSchema } from "../../daemon/src/db/migrations/039_queue_target_repo.js";
|
||||
import { queueItemSummarySchema } from "../../daemon/src/db/migrations/044_queue_item_summary.js";
|
||||
import { queueItemEvidenceRefSchema } from "../../daemon/src/db/migrations/048_queue_item_evidence_ref.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { queueRoutes } from "../../daemon/src/routes/queue.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { queueCommand } from "../src/commands/queue.js";
|
||||
import { STATE_FILE } from "../src/daemon-lifecycle.js";
|
||||
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let repo: QueueRepository;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let server: any;
|
||||
let port = 0;
|
||||
|
||||
// HERMETIC ISOLATION (Guard test-isolation binding): getDaemonStatus() resolves ambient
|
||||
// OPENRIG_URL / RIGGED_URL (and OPENRIG_PORT) BEFORE the injected state file. The managed seat
|
||||
// exports OPENRIG_URL=http://127.0.0.1:7433 (the canonical daemon), so without neutralizing these
|
||||
// the CLI would talk to the real daemon, not this test's loopback repo. Save the originals, delete
|
||||
// them so the injected state file supplies the fresh loopback port, and restore them in teardown
|
||||
// even on failure. The env is neutralized only for the CLI-invocation window (runCli).
|
||||
const AMBIENT_KEYS = ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT"] as const;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
function neutralizeAmbient(): void { for (const k of AMBIENT_KEYS) { savedEnv[k] = process.env[k]; delete process.env[k]; } }
|
||||
function restoreAmbient(): void { for (const k of AMBIENT_KEYS) { if (savedEnv[k] === undefined) delete process.env[k]; else process.env[k] = savedEnv[k]; } }
|
||||
|
||||
beforeAll(async () => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, queueTargetRepoSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]);
|
||||
bus = new EventBus(db);
|
||||
repo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); await next(); });
|
||||
app.route("/api/queue", queueRoutes());
|
||||
await new Promise<void>((resolve) => { server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); }); });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
restoreAmbient(); // belt: ensure ambient aliases are never left neutralized
|
||||
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db?.close();
|
||||
});
|
||||
|
||||
function loopbackUrl(): string { return `http://127.0.0.1:${port}`; }
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function deps(): any {
|
||||
return {
|
||||
// state file carries an explicit 127.0.0.1 host + the fresh loopback port, so getDaemonUrl()
|
||||
// yields exactly loopbackUrl() (with the aliases neutralized during runCli).
|
||||
lifecycleDeps: { spawn: () => ({ pid: 1, unref() {} }), fetch: async () => ({ ok: true }), kill: () => true, readFile: (p: string) => (p === STATE_FILE ? JSON.stringify({ pid: 1, port, host: "127.0.0.1", db: "x", startedAt: "x" }) : null), writeFile() {}, removeFile() {}, exists: (p: string) => p === STATE_FILE, mkdirp() {}, openForAppend: () => 3, isProcessAlive: () => true },
|
||||
clientFactory: (url: string) => {
|
||||
// SAFETY GUARD: refuse any URL that is not this test's fresh loopback, BEFORE constructing a
|
||||
// client or touching the network — makes accidental canonical-daemon contact impossible.
|
||||
if (url !== loopbackUrl()) throw new Error(`test-isolation guard: clientFactory url '${url}' !== loopback '${loopbackUrl()}'`);
|
||||
return new DaemonClient(url);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runCli(args: string[]): Promise<{ out: string; exit: number | undefined }> {
|
||||
const out: string[] = [];
|
||||
const ol = console.log, oe = console.error;
|
||||
console.log = (...a: unknown[]) => { out.push(a.join(" ")); };
|
||||
console.error = (...a: unknown[]) => { out.push(a.join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
neutralizeAmbient(); // bind CLI status resolution to the injected loopback state file
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
try { await queueCommand(deps() as any).parseAsync(["node", "rig", ...args]); }
|
||||
finally { restoreAmbient(); console.log = ol; console.error = oe; }
|
||||
const exit = process.exitCode; process.exitCode = 0;
|
||||
return { out: out.join("\n"), exit };
|
||||
}
|
||||
|
||||
const txnCount = (id: string) => (db.prepare("SELECT count(*) c FROM queue_transitions WHERE qitem_id = ?").get(id) as { c: number }).c;
|
||||
const eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() as { c: number }).c;
|
||||
|
||||
describe("51-06 D2 — real CLI->loopback HTTP->queueRoutes/QueueRepository acceptance", () => {
|
||||
let id: string;
|
||||
beforeEach(async () => { id = (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "e2e" })).qitemId; });
|
||||
|
||||
it("normal: --summary on a non-park update -> real 400, nonzero exit, item/transition/event UNCHANGED", async () => {
|
||||
const beforeTxns = txnCount(id), beforeEvents = eventCount();
|
||||
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--summary", "DROPME"]);
|
||||
expect(exit).toBe(1);
|
||||
expect(out).toContain("summary_evidence_not_persistable");
|
||||
expect(out).toContain("invalidFields");
|
||||
// unchanged item/metadata/transition/event state AFTER the real HTTP call
|
||||
const item = repo.getByIdOrThrow(id);
|
||||
expect(item.state).toBe("pending");
|
||||
expect(item.summary).toBeNull();
|
||||
expect(txnCount(id)).toBe(beforeTxns);
|
||||
expect(eventCount()).toBe(beforeEvents);
|
||||
});
|
||||
|
||||
it("--json: same reject as structured JSON with nonzero exit, state UNCHANGED", async () => {
|
||||
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--evidence-ref", "/e.md", "--json"]);
|
||||
expect(exit).toBe(1);
|
||||
const body = JSON.parse(out) as { error: string; invalidFields?: string[] };
|
||||
expect(body.error).toBe("summary_evidence_not_persistable");
|
||||
expect(body.invalidFields).toEqual(["evidenceRef"]);
|
||||
expect(repo.getByIdOrThrow(id).evidenceRef).toBeNull();
|
||||
});
|
||||
|
||||
it("CONTROL: real-CLI human-seat park PERSISTS summary + evidence (exit 0)", async () => {
|
||||
const { exit } = await runCli(["update", id, "--actor", "orch@rig", "--state", "blocked", "--blocked-on", "human@kernel", "--summary", "PARK-KEEP", "--evidence-ref", "/proof/park.md"]);
|
||||
expect(exit).toBeFalsy(); // success
|
||||
const item = repo.getByIdOrThrow(id);
|
||||
expect(item.summary).toBe("PARK-KEEP");
|
||||
expect(item.evidenceRef).toBe("/proof/park.md");
|
||||
});
|
||||
});
|
||||
@@ -1283,6 +1283,26 @@ export class QueueRepository {
|
||||
// enforcement). Blocking on another qitem requires nothing new (BR-1).
|
||||
const effectiveBlockedOn = input.blockedOn ?? qitem.blockedOn;
|
||||
const isHumanPark = input.state === "blocked" && isHumanSeatSession(effectiveBlockedOn);
|
||||
|
||||
// OPR.0.5.1 slice-51-06 D2 — summary/evidence_ref are persist-able ONLY at a human-seat park
|
||||
// (see the FR-6 note below). Silently ignoring them on any other transition is a data-loss trap
|
||||
// (the operator believes the metadata was saved). HARD-REJECT before ANY UPDATE/log/event so the
|
||||
// caller learns immediately and nothing is half-applied. null/undefined = absent (allowed);
|
||||
// empty string = present (a deliberate value → rejected on a non-park transition).
|
||||
if (!isHumanPark) {
|
||||
const invalidFields: Array<"summary" | "evidenceRef"> = [];
|
||||
if (input.summary != null) invalidFields.push("summary");
|
||||
if (input.evidenceRef != null) invalidFields.push("evidenceRef");
|
||||
if (invalidFields.length > 0) {
|
||||
const flags = invalidFields.map((f) => (f === "summary" ? "--summary" : "--evidence-ref")).join(" / ");
|
||||
throw new QueueRepositoryError(
|
||||
"summary_evidence_not_persistable",
|
||||
`${invalidFields.join(" + ")} persist only on a human-seat park (state=blocked on a human seat); the '${input.state}' transition cannot store them. Remove ${flags}, or park the item (rig queue block --on <human-seat> --summary … --evidence-ref …).`,
|
||||
{ invalidFields },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let effectiveSummary = qitem.summary;
|
||||
let effectiveEvidenceRef = qitem.evidenceRef;
|
||||
if (isHumanPark) {
|
||||
|
||||
@@ -148,6 +148,9 @@ export function queueRoutes(): Hono {
|
||||
: err.code === "cross_host_close_conflict" ? 409
|
||||
: err.code === "unknown_destination_rig" ? 400
|
||||
: err.code === "human_route_fields_required" ? 400
|
||||
// OPR.0.5.1 slice-51-06 D2: summary/evidence_ref on a non-park transition — a client
|
||||
// input error surfaced as a structured 400 (the daemon rejects before any mutation).
|
||||
: err.code === "summary_evidence_not_persistable" ? 400
|
||||
// OPR.0.4.6.WF3 FR-6: the frontier close-path guard — operator
|
||||
// misuse of a queue verb on a live workflow packet; structured
|
||||
// 400 with the what/why/fix message, never a 500.
|
||||
|
||||
@@ -307,17 +307,26 @@ describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention",
|
||||
expect(ev!.toState).toBe("blocked");
|
||||
});
|
||||
|
||||
it("NEGATIVE: non-park updates never persist summary/evidence_ref inputs (surface stays tight)", async () => {
|
||||
// OPR.0.5.1 slice-51-06 D2: non-park summary/evidence used to be SILENTLY dropped (a data-loss
|
||||
// trap); it is now a HARD REJECT before any mutation. The surface still stays tight (nothing
|
||||
// persists on a non-park item) — now enforced loudly instead of silently.
|
||||
it("NEGATIVE: non-park updates REJECT summary/evidence_ref inputs (surface stays tight, loudly)", async () => {
|
||||
const item = await inProgressItem();
|
||||
repo.update({
|
||||
qitemId: item.qitemId,
|
||||
actorSession: "driver@rig",
|
||||
state: "in-progress",
|
||||
summary: "should NOT persist",
|
||||
evidenceRef: "should-not-persist.md",
|
||||
});
|
||||
let err: unknown;
|
||||
try {
|
||||
repo.update({
|
||||
qitemId: item.qitemId,
|
||||
actorSession: "driver@rig",
|
||||
state: "in-progress",
|
||||
summary: "should NOT persist",
|
||||
evidenceRef: "should-not-persist.md",
|
||||
});
|
||||
} catch (e) { err = e; }
|
||||
expect(err).toBeInstanceOf(QueueRepositoryError);
|
||||
expect((err as QueueRepositoryError).code).toBe("summary_evidence_not_persistable");
|
||||
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["summary", "evidenceRef"]);
|
||||
const read = repo.getById(item.qitemId)!;
|
||||
expect(read.summary).toBeNull();
|
||||
expect(read.summary).toBeNull(); // nothing persisted (reject was before any write)
|
||||
expect(read.evidenceRef).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// 51-06 atom D2 — non-persistable queue update metadata (summary/evidence_ref) must HARD-REJECT
|
||||
// BEFORE any mutation, per the Guard-bound design (no post-commit marker; reject-before-write).
|
||||
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref) so a drop is
|
||||
// provably the non-park rule, NOT a missing column (the test-suite parity gap this atom flags).
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { inboxEntriesSchema } from "../src/db/migrations/026_inbox_entries.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_repo.js";
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, QueueRepositoryError } from "../src/domain/queue-repository.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
|
||||
const REJECT_CODE = "summary_evidence_not_persistable";
|
||||
|
||||
function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, queueRepo); await next(); });
|
||||
app.route("/api/queue", queueRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("51-06 D2 — non-park queue update metadata reject", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let repo: QueueRepository;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, queueTargetRepoSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]);
|
||||
bus = new EventBus(db);
|
||||
repo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
app = buildApp(bus, repo);
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
const mkItem = async () => (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item" })).qitemId;
|
||||
const txnCount = (id: string) => (db.prepare("SELECT count(*) c FROM queue_transitions WHERE qitem_id = ?").get(id) as { c: number }).c;
|
||||
const eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() as { c: number }).c;
|
||||
|
||||
async function expectRejectZeroMutation(input: Record<string, unknown>, invalidFields: string[]) {
|
||||
const id = await mkItem();
|
||||
const beforeState = repo.getByIdOrThrow(id).state;
|
||||
const beforeTxns = txnCount(id);
|
||||
const beforeEvents = eventCount();
|
||||
let err: unknown;
|
||||
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input }); }
|
||||
catch (e) { err = e; }
|
||||
expect(err).toBeInstanceOf(QueueRepositoryError);
|
||||
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
|
||||
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(invalidFields);
|
||||
// zero mutation: no UPDATE (state), no log (transition), no event
|
||||
expect(repo.getByIdOrThrow(id).state).toBe(beforeState);
|
||||
expect(txnCount(id)).toBe(beforeTxns);
|
||||
expect(eventCount()).toBe(beforeEvents);
|
||||
}
|
||||
|
||||
it("rejects --summary on a non-park transition, before any mutation", async () => {
|
||||
await expectRejectZeroMutation({ summary: "DROPME" }, ["summary"]);
|
||||
});
|
||||
it("rejects --evidence-ref on a non-park transition, before any mutation", async () => {
|
||||
await expectRejectZeroMutation({ evidenceRef: "/proof/x.md" }, ["evidenceRef"]);
|
||||
});
|
||||
it("rejects BOTH, naming both invalidFields, before any mutation", async () => {
|
||||
await expectRejectZeroMutation({ summary: "S", evidenceRef: "/e" }, ["summary", "evidenceRef"]);
|
||||
});
|
||||
it("treats empty-string as PRESENT (null=absent, ''=present) -> rejects", async () => {
|
||||
await expectRejectZeroMutation({ summary: "" }, ["summary"]);
|
||||
});
|
||||
|
||||
it("also rejects on a terminal non-park transition (in-progress -> done + closure)", async () => {
|
||||
const id = await mkItem();
|
||||
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" }); // claim (metadata-free, ok)
|
||||
let err: unknown;
|
||||
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", summary: "S" }); }
|
||||
catch (e) { err = e; }
|
||||
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
|
||||
expect(repo.getByIdOrThrow(id).state).toBe("in-progress"); // unchanged by the rejected close
|
||||
});
|
||||
|
||||
it("metadata-free non-park update is UNCHANGED (null=absent, no reject)", async () => {
|
||||
const id = await mkItem();
|
||||
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: null, evidenceRef: null });
|
||||
expect(res.state).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("CONTROL: human-seat park still PERSISTS summary + evidence byte-for-byte", async () => {
|
||||
const id = await mkItem();
|
||||
const res = repo.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: "PARK-KEEP", evidenceRef: "/proof/park.md" });
|
||||
expect(res.summary).toBe("PARK-KEEP");
|
||||
expect(res.evidenceRef).toBe("/proof/park.md");
|
||||
});
|
||||
|
||||
it("ROUTE: POST /:id/update non-park + summary -> HTTP 400 naming invalidFields", async () => {
|
||||
const created = await app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }) });
|
||||
const id = ((await created.json()) as { qitemId: string }).qitemId;
|
||||
const res = await app.request(`/api/queue/${id}/update`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ actorSession: "dev-x@rig", state: "in-progress", summary: "DROPME" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string; invalidFields?: string[] };
|
||||
expect(body.error).toBe(REJECT_CODE);
|
||||
expect(body.invalidFields).toEqual(["summary"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user