diff --git a/src/__tests__/ai.test.ts b/src/__tests__/ai.test.ts index 048a10b..6d1ce2d 100644 --- a/src/__tests__/ai.test.ts +++ b/src/__tests__/ai.test.ts @@ -14,27 +14,29 @@ import { getBudgetUsd, assertBudgetAvailable, isConfigured, - getChatHistory, - appendChatMessages, - clearChatHistory, + listConversations, + createConversation, + getConversationMessages, + appendConversationMessages, + renameConversation, + deleteConversation, } from "../services/ai.service"; const KIND = "test_ai"; // marker so we only clean our own rows -// Synthetic, FK-free user ids for chat-history service tests (no users row needed). -const HIST_UID_A = 999999991; -const HIST_UID_B = 999999992; -const HIST_MARKER = "「test-hist」"; // marker for HTTP-test rows on real users +const CONV_UID_A = 999999991; // synthetic, FK-free owner ids +const CONV_UID_B = 999999992; +const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users beforeEach(async () => { await prisma.ai_usage.deleteMany({ where: { kind: KIND } }); }); afterAll(async () => { await prisma.ai_usage.deleteMany({ where: { kind: KIND } }); - await prisma.ai_chat_messages.deleteMany({ - where: { user_id: { in: [HIST_UID_A, HIST_UID_B] } }, + await prisma.ai_conversations.deleteMany({ + where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } }, }); - await prisma.ai_chat_messages.deleteMany({ - where: { content: { contains: HIST_MARKER } }, + await prisma.ai_conversations.deleteMany({ + where: { title: { contains: HIST_MARKER } }, }); }); @@ -112,43 +114,90 @@ describe("ai.service cost + budget", () => { }); }); -describe("ai.service chat history", () => { +describe("ai.service conversations", () => { beforeEach(async () => { - await prisma.ai_chat_messages.deleteMany({ - where: { user_id: { in: [HIST_UID_A, HIST_UID_B] } }, + await prisma.ai_conversations.deleteMany({ + where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } }, }); }); - it("appends and returns a user's thread oldest → newest", async () => { - await appendChatMessages(HIST_UID_A, [ - { role: "user", content: "ahoj" }, - { role: "assistant", content: "Dobrý den" }, + // Narrow a service result to its error branch with a clear failure message. + const expectError = ( + r: { error: string; status: number } | { data: unknown }, + status: number, + ) => { + if (!("error" in r)) + throw new Error(`expected an error result, got ${JSON.stringify(r)}`); + expect(r.status).toBe(status); + }; + + it("creates, lists, appends (auto-title), and reads oldest→newest", async () => { + const conv = await createConversation(CONV_UID_A); + expect(conv.title).toBe("Nová konverzace"); + await appendConversationMessages(CONV_UID_A, conv.id, [ + { role: "user", content: "Kolik je hodin v Praze?" }, + { role: "assistant", content: "Nevím, ale…" }, ]); - await appendChatMessages(HIST_UID_A, [ - { role: "user", content: "jak se máš" }, + const list = await listConversations(CONV_UID_A); + expect(list).toHaveLength(1); + expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled + const msgs = await getConversationMessages(CONV_UID_A, conv.id); + if (!("data" in msgs)) throw new Error("expected messages, got an error"); + expect(msgs.data.map((m) => m.content)).toEqual([ + "Kolik je hodin v Praze?", + "Nevím, ale…", ]); - const hist = await getChatHistory(HIST_UID_A); - expect(hist.map((m) => m.content)).toEqual([ - "ahoj", - "Dobrý den", - "jak se máš", - ]); - expect(hist[0].role).toBe("user"); }); - it("isolates history per user", async () => { - await appendChatMessages(HIST_UID_A, [{ role: "user", content: "A1" }]); - await appendChatMessages(HIST_UID_B, [{ role: "user", content: "B1" }]); - const a = await getChatHistory(HIST_UID_A); - const b = await getChatHistory(HIST_UID_B); - expect(a.map((m) => m.content)).toEqual(["A1"]); - expect(b.map((m) => m.content)).toEqual(["B1"]); + it("does not overwrite a renamed title on later appends", async () => { + const conv = await createConversation(CONV_UID_A); + await renameConversation(CONV_UID_A, conv.id, "Moje téma"); + await appendConversationMessages(CONV_UID_A, conv.id, [ + { role: "user", content: "tohle by nemělo přepsat název" }, + ]); + const list = await listConversations(CONV_UID_A); + expect(list[0].title).toBe("Moje téma"); }); - it("clears a user's thread", async () => { - await appendChatMessages(HIST_UID_A, [{ role: "user", content: "x" }]); - await clearChatHistory(HIST_UID_A); - expect(await getChatHistory(HIST_UID_A)).toEqual([]); + it("bumps updated_at on append", async () => { + const conv = await createConversation(CONV_UID_A); + // Force an old timestamp, then prove append refreshes it (TIMESTAMP(0) is + // second-precision, so compare against a year rather than ms deltas). + await prisma.ai_conversations.update({ + where: { id: conv.id }, + data: { updated_at: new Date("2000-01-01T00:00:00Z") }, + }); + await appendConversationMessages(CONV_UID_A, conv.id, [ + { role: "user", content: "ping" }, + ]); + const after = (await listConversations(CONV_UID_A))[0].updated_at; + expect(after.getFullYear()).toBeGreaterThan(2000); + }); + + it("isolates conversations per user (404 on every op across owners)", async () => { + const a = await createConversation(CONV_UID_A); + expect(await listConversations(CONV_UID_B)).toHaveLength(0); + expectError(await getConversationMessages(CONV_UID_B, a.id), 404); + expectError( + await appendConversationMessages(CONV_UID_B, a.id, [ + { role: "user", content: "x" }, + ]), + 404, + ); + expectError(await renameConversation(CONV_UID_B, a.id, "hack"), 404); + expectError(await deleteConversation(CONV_UID_B, a.id), 404); + }); + + it("cascade-deletes messages with the conversation", async () => { + const conv = await createConversation(CONV_UID_A); + await appendConversationMessages(CONV_UID_A, conv.id, [ + { role: "user", content: "x" }, + ]); + await deleteConversation(CONV_UID_A, conv.id); + const remaining = await prisma.ai_chat_messages.count({ + where: { conversation_id: conv.id }, + }); + expect(remaining).toBe(0); }); }); @@ -266,82 +315,134 @@ describe("HTTP /api/admin/ai", () => { (appConfig.anthropic as { apiKey: string }).apiKey = savedKey; }); - it("GET /history requires ai.use", async () => { + it("GET /conversations requires ai.use", async () => { const res = await app.inject({ method: "GET", - url: "/api/admin/ai/history", + url: "/api/admin/ai/conversations", headers: { Authorization: `Bearer ${noPermToken}` }, }); expect(res.statusCode).toBe(403); }); - it("POST /history appends and GET /history returns the thread", async () => { - const post = await app.inject({ + it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => { + const created = await app.inject({ method: "POST", - url: "/api/admin/ai/history", + url: "/api/admin/ai/conversations", headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json", }, - payload: { - messages: [ - { role: "user", content: `${HIST_MARKER} dotaz` }, - { role: "assistant", content: `${HIST_MARKER} odpoved` }, - ], - }, + payload: { title: `${HIST_MARKER} t` }, }); - expect(post.statusCode).toBe(200); + expect(created.statusCode).toBe(200); + const id = created.json().data.conversation.id as number; - const get = await app.inject({ + const appended = await app.inject({ + method: "POST", + url: `/api/admin/ai/conversations/${id}/messages`, + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { messages: [{ role: "user", content: "ahoj" }] }, + }); + expect(appended.statusCode).toBe(200); + + const msgs = await app.inject({ method: "GET", - url: "/api/admin/ai/history", + url: `/api/admin/ai/conversations/${id}/messages`, headers: { Authorization: `Bearer ${adminToken}` }, }); - expect(get.statusCode).toBe(200); - const contents = get - .json() - .data.messages.map((m: { content: string }) => m.content); - expect(contents).toContain(`${HIST_MARKER} dotaz`); - expect(contents).toContain(`${HIST_MARKER} odpoved`); + expect( + msgs.json().data.messages.map((m: { content: string }) => m.content), + ).toContain("ahoj"); + + const renamed = await app.inject({ + method: "PATCH", + url: `/api/admin/ai/conversations/${id}`, + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { title: `${HIST_MARKER} renamed` }, + }); + expect(renamed.json().data.conversation.title).toBe( + `${HIST_MARKER} renamed`, + ); + + const del = await app.inject({ + method: "DELETE", + url: `/api/admin/ai/conversations/${id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(del.statusCode).toBe(200); }); - it("POST /history rejects an empty messages array", async () => { + it("GET messages of a non-existent conversation → 404", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/admin/ai/conversations/999999000/messages", + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(404); + }); + + it("POST /conversations requires ai.use", async () => { const res = await app.inject({ method: "POST", - url: "/api/admin/ai/history", + url: "/api/admin/ai/conversations", + headers: { + Authorization: `Bearer ${noPermToken}`, + "Content-Type": "application/json", + }, + payload: { title: "x" }, + }); + expect(res.statusCode).toBe(403); + }); + + it("GET /conversations lists the user's conversations", async () => { + const created = await app.inject({ + method: "POST", + url: "/api/admin/ai/conversations", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { title: `${HIST_MARKER} listed` }, + }); + const id = created.json().data.conversation.id as number; + const list = await app.inject({ + method: "GET", + url: "/api/admin/ai/conversations", + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(list.statusCode).toBe(200); + expect( + list.json().data.conversations.map((c: { id: number }) => c.id), + ).toContain(id); + }); + + it("rejects an empty title (PATCH) and empty messages (POST) with 400", async () => { + const patch = await app.inject({ + method: "PATCH", + url: "/api/admin/ai/conversations/999999000", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { title: "" }, + }); + expect(patch.statusCode).toBe(400); + + const append = await app.inject({ + method: "POST", + url: "/api/admin/ai/conversations/999999000/messages", headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json", }, payload: { messages: [] }, }); - expect(res.statusCode).toBe(400); - }); - - it("DELETE /history clears the thread", async () => { - await app.inject({ - method: "POST", - url: "/api/admin/ai/history", - headers: { - Authorization: `Bearer ${adminToken}`, - "Content-Type": "application/json", - }, - payload: { messages: [{ role: "user", content: `${HIST_MARKER} x` }] }, - }); - const del = await app.inject({ - method: "DELETE", - url: "/api/admin/ai/history", - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(del.statusCode).toBe(200); - const get = await app.inject({ - method: "GET", - url: "/api/admin/ai/history", - headers: { Authorization: `Bearer ${adminToken}` }, - }); - const contents = get - .json() - .data.messages.map((m: { content: string }) => m.content); - expect(contents).not.toContain(`${HIST_MARKER} x`); + expect(append.statusCode).toBe(400); }); });