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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user