Files
next-tjwater-agent/tests/routes/interactionRequestRoutes.test.ts
T

70 lines
5.2 KiB
TypeScript

import { expect, it } from "bun:test";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getLocalAgentContext } from "../../src/context/localContext.js";
import { INTERACTION_VERSION } from "../../src/chat/interaction-request-contract.js";
import { registerInteractionRequestRoutes } from "../../src/routes/interaction-request-routes.js";
import { InteractionRequestStore } from "../../src/sessions/interaction-request-store.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
it("authorizes session ownership, validates CAS, persists drafts and answers without inference", async () => {
const root = await mkdtemp(join(tmpdir(), "interaction-routes-"));
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const interactions = new InteractionRequestStore(workspaces);
const context = { ...getLocalAgentContext(), sessionId: "session" };
await sessions.ensure(context);
const record = await interactions.create(context, { message_id: "message", turn_id: "turn", call_id: "call", questions: [{ question_id: "q", title: "Choose", body: "Which one?", options: [{ id: "a", label: "A" }], mode: "single", allow_custom: false, required: true }] });
const app = express();
app.use(express.json());
registerInteractionRequestRoutes(app, { sessions, interactions });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}/session/session/interactions`;
const headers = { "Content-Type": "application/json" };
const send = (path: string, body: unknown, method = "POST", override = headers) => fetch(`${base}/${record.interaction_id}${path}`, { method, headers: override, body: JSON.stringify(body) });
const answers = [{ question_id: "q", selected_option_ids: ["a"], custom_text: "" }];
const mutation = { version: INTERACTION_VERSION, request_id: "reply", expected_revision: 0, answers };
try {
for (const bad of [{ ...headers, "X-Agent-Local-User": "other" }, { ...headers, "X-Agent-Local-Project": "other" }]) {
expect((await fetch(base, { headers: bad })).status).toBe(404);
expect((await send("/reply", mutation, "POST", bad)).status).toBe(404);
}
const list = await fetch(`${base}?limit=1`);
expect(list.headers.get("Cache-Control")).toBe("private, no-store");
expect(await list.json()).toMatchObject({ items: [{ interaction_id: record.interaction_id }] });
for (const query of ["limit=51", "limit=1.5", "limit=01", "unexpected=1", "cursor=unknown"]) expect((await fetch(`${base}?${query}`)).status).toBe(400);
expect((await send("/draft", { version: INTERACTION_VERSION, request_id: "draft", expected_draft_revision: 0, answers }, "PUT")).status).toBe(200);
expect((await send("/reply", { ...mutation, expected_revision: Number.MAX_SAFE_INTEGER + 1 })).status).toBe(400);
expect((await send("/reply", { ...mutation, unexpected: true })).status).toBe(400);
expect((await send("/skip", { version: INTERACTION_VERSION, request_id: "skip-required", expected_revision: 0 })).status).toBe(400);
const reply = await send("/reply", mutation);
expect(reply.status).toBe(200);
const saved = await reply.json() as { delivery: { native_message_id: string; revision: number } };
expect(saved).toMatchObject({ status: "answered", delivery: { status: "queued" } });
expect(await (await send("/reply", mutation)).json()).toEqual(saved);
expect((await send("/reply", { ...mutation, request_id: "competing" })).status).toBe(409);
expect((await send("/draft", { version: INTERACTION_VERSION, request_id: "late", expected_draft_revision: 1, answers: [] }, "PUT")).status).toBe(409);
const resume = await send("/resume", { version: INTERACTION_VERSION, request_id: "resume", expected_revision: saved.delivery.revision });
expect(resume.status).toBe(200);
expect(await resume.json()).toMatchObject({ delivery: { native_message_id: saved.delivery.native_message_id } });
const second = await interactions.create(context, { message_id: "second", turn_id: "second", call_id: "second", questions: record.questions.map((q) => ({ ...q, required: false })) }).catch(() => null);
// Saved queued replies block creation until their delivery is settled.
expect(second).toBeNull();
const originalGet = interactions.get.bind(interactions);
interactions.get = async () => { throw new Error(`${root}/private-answer`); };
const unavailable = await fetch(`${base}/${record.interaction_id}`);
expect(unavailable.status).toBe(503);
expect(await unavailable.json()).toEqual({ code: "INTERACTION_UNAVAILABLE", detail: "interaction unavailable" });
interactions.get = originalGet;
await sessions.remove((await sessions.get(context, "session"))!);
expect((await send("/reply", mutation)).status).toBe(404);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
});