From bd29ac517c3e2c5466b55b9ddeb27747d62c17d2 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 29 Jun 2026 12:32:52 +0200 Subject: [PATCH] feat(backend): rename/group/archive/unarchive/delete/documents routes + resume read-only guard Co-Authored-By: Claude Opus 4.8 --- backend/src/app.ts | 2 +- backend/src/routes/sessions.ts | 27 ++++++++++ backend/test/routes-sessions.test.ts | 73 ++++++++++++++++++++++++++-- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index ca3b1762..2cd24ef3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -26,7 +26,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc app.register(cors, { origin: true, credentials: true, - methods: ["GET", "POST", "PUT", "OPTIONS"], + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], }); const tht = deps?.thtRunner ?? new ThtRunner({ diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 5f0bc0c3..5afad862 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -48,6 +48,10 @@ export function sessionRoutes( }); app.post("/sessions/:id/resume", async (req, reply) => { const id = (req.params as any).id; + const manifest = (await d.tht.sessionShow(id)) as { status?: string; archived?: boolean } | null; + if (manifest?.status === "finalized" || manifest?.archived) { + return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" }); + } const rt = await d.mgr.resume(id, d.tht); rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e)); return reply.code(200).send({ id }); @@ -73,4 +77,27 @@ export function sessionRoutes( const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null); req.raw.on("close", off); }); + app.post("/sessions/:id/rename", async (req, reply) => { + await d.tht.setName((req.params as any).id, (req.body as any).name); + return reply.code(204).send(); + }); + app.post("/sessions/:id/group", async (req, reply) => { + await d.tht.setGroup((req.params as any).id, (req.body as any).group); + return reply.code(204).send(); + }); + app.post("/sessions/:id/archive", async (req, reply) => { + await d.tht.archive((req.params as any).id); + return reply.code(204).send(); + }); + app.post("/sessions/:id/unarchive", async (req, reply) => { + await d.tht.unarchive((req.params as any).id); + return reply.code(204).send(); + }); + app.delete("/sessions/:id", async (req, reply) => { + const id = (req.params as any).id; + d.mgr.teardown(id); // drop any live runtime before deleting on disk + await d.tht.deleteSession(id); + return reply.code(204).send(); + }); + app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id)); } diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 7338ebf5..7b79d0b0 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { spawn } from "node:child_process"; +import { spawn as nodeSpawn } from "node:child_process"; import path from "node:path"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; @@ -7,6 +7,14 @@ import { loadConfig } from "../src/config.js"; const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs"); const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json"); +function mutApp(thtRunner: any) { + return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner, + getSettings: () => ({ workspace: "w" }) as any, + spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any, + }); +} + test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => { let sessionNewArg: any; let spawnArg: any; @@ -16,7 +24,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a sessionList: async () => [{ id: "s1" }], } as any, getSettings: () => ({ workspace: "w", provider: "zai", model: "glm-5.2", thinking: "high" }), - spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any, + spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any, }); const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); expect(created.json()).toEqual({ id: "s1" }); @@ -31,12 +39,69 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a test("POST /sessions/:id/response inoltra al bridge (no error)", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { - thtRunner: { sessionNew: async () => ({ id: "s1" }) } as any, + thtRunner: { + sessionNew: async () => ({ id: "s1" }), + sessionList: async () => [], + } as any, getSettings: () => ({ workspace: "w" }), - spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any, + spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any, }); await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); const res = await app.inject({ method: "POST", url: "/sessions/s1/response", payload: { ui_response: { id: "u1", choices: ["a"] } } }); expect(res.statusCode).toBe(204); }); + +test("POST /sessions/:id/rename calls setName", async () => { + let arg: any; + const app = mutApp({ setName: async (id: string, name: string) => { arg = { id, name }; } }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/rename", payload: { name: "N" } }); + expect(res.statusCode).toBe(204); + expect(arg).toEqual({ id: "s1", name: "N" }); +}); + +test("POST /sessions/:id/group calls setGroup", async () => { + let arg: any; + const app = mutApp({ setGroup: async (id: string, group: string) => { arg = { id, group }; } }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/group", payload: { group: "G" } }); + expect(res.statusCode).toBe(204); + expect(arg).toEqual({ id: "s1", group: "G" }); +}); + +test("POST archive / unarchive call the runner", async () => { + const seen: string[] = []; + const app = mutApp({ + archive: async (id: string) => { seen.push(`a:${id}`); }, + unarchive: async (id: string) => { seen.push(`u:${id}`); }, + }); + expect((await app.inject({ method: "POST", url: "/sessions/s1/archive" })).statusCode).toBe(204); + expect((await app.inject({ method: "POST", url: "/sessions/s1/unarchive" })).statusCode).toBe(204); + expect(seen).toEqual(["a:s1", "u:s1"]); +}); + +test("DELETE /sessions/:id calls deleteSession", async () => { + let deleted: string | null = null; + const app = mutApp({ deleteSession: async (id: string) => { deleted = id; } }); + const res = await app.inject({ method: "DELETE", url: "/sessions/s1" }); + expect(res.statusCode).toBe(204); + expect(deleted).toBe("s1"); +}); + +test("GET /sessions/:id/documents returns the runner output", async () => { + const app = mutApp({ documents: async () => [{ phase: "—", key: "question", title: "t", format: "text", content: "q" }] }); + const res = await app.inject({ method: "GET", url: "/sessions/s1/documents" }); + expect(res.statusCode).toBe(200); + expect(res.json()[0].key).toBe("question"); +}); + +test("POST resume on a finalized session is refused with 409", async () => { + const app = mutApp({ sessionShow: async () => ({ status: "finalized", archived: false }) }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" }); + expect(res.statusCode).toBe(409); +}); + +test("POST resume on an archived session is refused with 409", async () => { + const app = mutApp({ sessionShow: async () => ({ status: "open", archived: true }) }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" }); + expect(res.statusCode).toBe(409); +});