test(odin): conversation CRUD, isolation, auto-title, cascade

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 19:23:30 +02:00
parent 6664e3bc4d
commit 92594f89c3

View File

@@ -14,27 +14,29 @@ import {
getBudgetUsd, getBudgetUsd,
assertBudgetAvailable, assertBudgetAvailable,
isConfigured, isConfigured,
getChatHistory, listConversations,
appendChatMessages, createConversation,
clearChatHistory, getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
} from "../services/ai.service"; } from "../services/ai.service";
const KIND = "test_ai"; // marker so we only clean our own rows 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 CONV_UID_A = 999999991; // synthetic, FK-free owner ids
const HIST_UID_A = 999999991; const CONV_UID_B = 999999992;
const HIST_UID_B = 999999992; const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
const HIST_MARKER = "「test-hist」"; // marker for HTTP-test rows on real users
beforeEach(async () => { beforeEach(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } }); await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
}); });
afterAll(async () => { afterAll(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } }); await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
await prisma.ai_chat_messages.deleteMany({ await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [HIST_UID_A, HIST_UID_B] } }, where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
}); });
await prisma.ai_chat_messages.deleteMany({ await prisma.ai_conversations.deleteMany({
where: { content: { contains: HIST_MARKER } }, 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 () => { beforeEach(async () => {
await prisma.ai_chat_messages.deleteMany({ await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [HIST_UID_A, HIST_UID_B] } }, where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
}); });
}); });
it("appends and returns a user's thread oldest → newest", async () => { // Narrow a service result to its error branch with a clear failure message.
await appendChatMessages(HIST_UID_A, [ const expectError = (
{ role: "user", content: "ahoj" }, r: { error: string; status: number } | { data: unknown },
{ role: "assistant", content: "Dobrý den" }, 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, [ const list = await listConversations(CONV_UID_A);
{ role: "user", content: "jak se máš" }, 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 () => { it("does not overwrite a renamed title on later appends", async () => {
await appendChatMessages(HIST_UID_A, [{ role: "user", content: "A1" }]); const conv = await createConversation(CONV_UID_A);
await appendChatMessages(HIST_UID_B, [{ role: "user", content: "B1" }]); await renameConversation(CONV_UID_A, conv.id, "Moje téma");
const a = await getChatHistory(HIST_UID_A); await appendConversationMessages(CONV_UID_A, conv.id, [
const b = await getChatHistory(HIST_UID_B); { role: "user", content: "tohle by nemělo přepsat název" },
expect(a.map((m) => m.content)).toEqual(["A1"]); ]);
expect(b.map((m) => m.content)).toEqual(["B1"]); const list = await listConversations(CONV_UID_A);
expect(list[0].title).toBe("Moje téma");
}); });
it("clears a user's thread", async () => { it("bumps updated_at on append", async () => {
await appendChatMessages(HIST_UID_A, [{ role: "user", content: "x" }]); const conv = await createConversation(CONV_UID_A);
await clearChatHistory(HIST_UID_A); // Force an old timestamp, then prove append refreshes it (TIMESTAMP(0) is
expect(await getChatHistory(HIST_UID_A)).toEqual([]); // 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; (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({ const res = await app.inject({
method: "GET", method: "GET",
url: "/api/admin/ai/history", url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${noPermToken}` }, headers: { Authorization: `Bearer ${noPermToken}` },
}); });
expect(res.statusCode).toBe(403); expect(res.statusCode).toBe(403);
}); });
it("POST /history appends and GET /history returns the thread", async () => { it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
const post = await app.inject({ const created = await app.inject({
method: "POST", method: "POST",
url: "/api/admin/ai/history", url: "/api/admin/ai/conversations",
headers: { headers: {
Authorization: `Bearer ${adminToken}`, Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
payload: { payload: { title: `${HIST_MARKER} t` },
messages: [
{ role: "user", content: `${HIST_MARKER} dotaz` },
{ role: "assistant", content: `${HIST_MARKER} odpoved` },
],
},
}); });
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", method: "GET",
url: "/api/admin/ai/history", url: `/api/admin/ai/conversations/${id}/messages`,
headers: { Authorization: `Bearer ${adminToken}` }, headers: { Authorization: `Bearer ${adminToken}` },
}); });
expect(get.statusCode).toBe(200); expect(
const contents = get msgs.json().data.messages.map((m: { content: string }) => m.content),
.json() ).toContain("ahoj");
.data.messages.map((m: { content: string }) => m.content);
expect(contents).toContain(`${HIST_MARKER} dotaz`); const renamed = await app.inject({
expect(contents).toContain(`${HIST_MARKER} odpoved`); 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({ const res = await app.inject({
method: "POST", 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: { headers: {
Authorization: `Bearer ${adminToken}`, Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
payload: { messages: [] }, payload: { messages: [] },
}); });
expect(res.statusCode).toBe(400); expect(append.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`);
}); });
}); });