70 lines
5.2 KiB
TypeScript
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 });
|
|
}
|
|
});
|