import type Database from "bun:sqlite"; import { describe, expect, mock, test } from "bun:test"; import type { Conversation, Message, RuntimeMode } from "../../../src/shared/api"; import { createModel } from "../../../src/server/db/models"; import { createProject } from "../../../src/server/db/projects"; import { createProvider } from "../../../src/server/db/providers"; import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedMemoryTestDatabase } from "../../helpers"; const MODE: RuntimeMode = "test"; void mock.module("ai", () => ({ createAgentUIStreamResponse: () => Promise.resolve( new Response( 'data: {"type":"start-step"}\n\ndata: {"type":"text-start","id":"txt-1"}\n\ndata: {"type":"text-delta","id":"txt-1","delta":"test reply from AI"}\n\ndata: {"type":"text-end","id":"txt-1"}\n\ndata: {"type":"finish-step"}\n\ndata: {"type":"finish"}\n\n', { headers: { "Content-Type": "text/event-stream" }, }, ), ), createProviderRegistry: () => ({ languageModel: () => ({}), }), generateText: () => Promise.resolve({ text: "", usage: {} }), stepCountIs: () => () => true, tool: () => ({ execute: async () => await Promise.resolve({}) }), ToolLoopAgent: function M() { // no-op: createAgentUIStreamResponse handles streaming }, })); async function createConversationViaHandler(req: Request, db: Database): Promise { const { handleCreateConversation: h } = await import("../../../src/server/routes/chat/create"); return h(req, db, MODE); } async function deleteConversationViaHandler(req: Request, db: Database): Promise { const { handleDeleteConversation: h } = await import("../../../src/server/routes/chat/delete"); return h(req, db, MODE); } async function getConversationViaHandler(req: Request, db: Database): Promise { const { handleGetConversation: h } = await import("../../../src/server/routes/chat/get"); return h(req, db, MODE); } async function listConversationsViaHandler(req: Request, db: Database): Promise { const { handleListConversations: h } = await import("../../../src/server/routes/chat/list"); return h(req, db, MODE); } async function listMessagesViaHandler(req: Request, db: Database): Promise { const { handleListMessages: h } = await import("../../../src/server/routes/chat/messages"); return h(req, db, MODE); } async function patchConversationViaHandler(req: Request, db: Database): Promise { const { handleUpdateConversation: h } = await import("../../../src/server/routes/chat/update"); return h(req, db, MODE); } function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string { const result = createModel(db, { capabilities: ["text"], modelId, name: modelName, providerId, }); if ("error" in result) throw new Error(result.error); return result.model.id; } function seedProject(db: Database, name = "测试项目"): string { const result = createProject(db, { description: "测试", name }); if ("error" in result) throw new Error(result.error); return result.project.id; } function seedProvider(db: Database, name = "测试供应商"): string { const result = createProvider(db, { apiKey: "sk-test", baseUrl: "https://api.test.com/v1", name, type: "openai", }); if ("error" in result) throw new Error(result.error); return result.provider.id; } async function sendChatViaHandler(req: Request, db: Database): Promise { const { handleSendChat: h } = await import("../../../src/server/routes/chat/send"); return h(req, db, MODE, createNoopLogger()); } describe("聊天 API 路由", () => { describe("POST /api/projects/:id/conversations", () => { test("创建会话成功", async () => { const handle = createMigratedMemoryTestDatabase("chat-create"); try { const db = handle.db; const projectId = seedProject(db); const providerId = seedProvider(db); seedModel(db, providerId); const req = new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }); const res = await createConversationViaHandler(req, db); expect(res.status).toBe(201); const body = (await res.json()) as { conversation: Conversation }; expect(body.conversation.title).toBe("新会话"); expect(body.conversation.projectId).toBe(projectId); handle.close(); } finally { handle.cleanup(); } }); test("无可用模型时返回 400", async () => { const handle = createMigratedMemoryTestDatabase("chat-create-no-model"); try { const db = handle.db; const projectId = seedProject(db); const req = new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }); const res = await createConversationViaHandler(req, db); expect(res.status).toBe(400); const body = (await res.json()) as { error: string }; expect(body.error).toContain("模型"); handle.close(); } finally { handle.cleanup(); } }); }); describe("GET /api/projects/:id/conversations", () => { test("列出项目会话", async () => { const handle = createMigratedMemoryTestDatabase("chat-list"); try { const db = handle.db; const projectId = seedProject(db); const providerId = seedProvider(db); seedModel(db, providerId); const req1 = new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }); await createConversationViaHandler(req1, db); const req2 = new Request(`http://localhost/api/projects/${projectId}/conversations?page=1&pageSize=10`); const res = await listConversationsViaHandler(req2, db); expect(res.status).toBe(200); const body = (await res.json()) as { items: Conversation[]; total: number }; expect(body.total).toBe(1); expect(body.items[0]?.title).toBe("新会话"); handle.close(); } finally { handle.cleanup(); } }); test("不同项目会话隔离", async () => { const handle = createMigratedMemoryTestDatabase("chat-isolation"); try { const db = handle.db; const projectA = seedProject(db, "项目A"); const projectB = seedProject(db, "项目B"); const providerId = seedProvider(db); seedModel(db, providerId); await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectA}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const res = await listConversationsViaHandler( new Request(`http://localhost/api/projects/${projectB}/conversations?page=1&pageSize=10`), db, ); const body = (await res.json()) as { items: Conversation[]; total: number }; expect(body.total).toBe(0); handle.close(); } finally { handle.cleanup(); } }); }); describe("GET /api/projects/:id/conversations/:cid", () => { test("获取会话详情成功", async () => { const handle = createMigratedMemoryTestDatabase("chat-get"); try { const db = handle.db; const projectId = seedProject(db); const providerId = seedProvider(db); seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await getConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`), db, ); expect(res.status).toBe(200); const body = (await res.json()) as { conversation: Conversation }; expect(body.conversation.title).toBe("新会话"); handle.close(); } finally { handle.cleanup(); } }); test("不存在的会话返回 404", async () => { const handle = createMigratedMemoryTestDatabase("chat-get-404"); try { const db = handle.db; const projectId = seedProject(db); const res = await getConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations/nonexistent`), db, ); expect(res.status).toBe(404); handle.close(); } finally { handle.cleanup(); } }); test("跨项目获取会话返回 403", async () => { const handle = createMigratedMemoryTestDatabase("chat-get-403"); try { const db = handle.db; const projectA = seedProject(db, "项目A"); const projectB = seedProject(db, "项目B"); const providerId = seedProvider(db); seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectA}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await getConversationViaHandler( new Request(`http://localhost/api/projects/${projectB}/conversations/${created.id}`), db, ); expect(res.status).toBe(403); const body = (await res.json()) as { error: string }; expect(body.error).toContain("不属于该项目"); handle.close(); } finally { handle.cleanup(); } }); }); describe("DELETE /api/projects/:id/conversations/:cid", () => { test("删除会话成功", async () => { const handle = createMigratedMemoryTestDatabase("chat-delete"); try { const db = handle.db; const projectId = seedProject(db); const providerId = seedProvider(db); seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await deleteConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, { method: "DELETE" }), db, ); expect(res.status).toBe(200); const getRes = await getConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`), db, ); expect(getRes.status).toBe(404); handle.close(); } finally { handle.cleanup(); } }); test("跨项目删除会话返回 403", async () => { const handle = createMigratedMemoryTestDatabase("chat-delete-403"); try { const db = handle.db; const projectA = seedProject(db, "项目A"); const projectB = seedProject(db, "项目B"); const providerId = seedProvider(db); seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectA}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await deleteConversationViaHandler( new Request(`http://localhost/api/projects/${projectB}/conversations/${created.id}`, { method: "DELETE" }), db, ); expect(res.status).toBe(403); const getRes = await getConversationViaHandler( new Request(`http://localhost/api/projects/${projectA}/conversations/${created.id}`), db, ); expect(getRes.status).toBe(200); handle.close(); } finally { handle.cleanup(); } }); }); describe("PATCH /api/projects/:id/conversations/:cid", () => { test("更新会话 modelId 成功", async () => { const handle = createMigratedMemoryTestDatabase("chat-patch-model"); try { const db = handle.db; const projectId = seedProject(db); const providerId = seedProvider(db); const modelId1 = seedModel(db, providerId, "GPT-4o", "gpt-4o"); const modelId2 = seedModel(db, providerId, "GPT-4o-mini", "gpt-4o-mini"); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({ modelId: modelId1 }), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await patchConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, { body: JSON.stringify({ modelId: modelId2 }), headers: { "Content-Type": "application/json" }, method: "PATCH", }), db, ); expect(res.status).toBe(200); const body = (await res.json()) as { conversation: Conversation }; expect(body.conversation.modelId).toBe(modelId2); handle.close(); } finally { handle.cleanup(); } }); test("更新会话 title 成功", async () => { const handle = createMigratedMemoryTestDatabase("chat-patch-title"); try { const db = handle.db; const projectId = seedProject(db); const providerId = seedProvider(db); seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await patchConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, { body: JSON.stringify({ title: "新标题" }), headers: { "Content-Type": "application/json" }, method: "PATCH", }), db, ); expect(res.status).toBe(200); const body = (await res.json()) as { conversation: Conversation }; expect(body.conversation.title).toBe("新标题"); handle.close(); } finally { handle.cleanup(); } }); test("跨项目更新会话返回 403", async () => { const handle = createMigratedMemoryTestDatabase("chat-patch-403"); try { const db = handle.db; const projectA = seedProject(db, "项目A"); const projectB = seedProject(db, "项目B"); const providerId = seedProvider(db); seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectA}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await patchConversationViaHandler( new Request(`http://localhost/api/projects/${projectB}/conversations/${created.id}`, { body: JSON.stringify({ title: "探测" }), headers: { "Content-Type": "application/json" }, method: "PATCH", }), db, ); expect(res.status).toBe(403); handle.close(); } finally { handle.cleanup(); } }); test("不存在的会话返回 404", async () => { const handle = createMigratedMemoryTestDatabase("chat-patch-404"); try { const db = handle.db; const projectId = seedProject(db); const res = await patchConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations/nonexistent`, { body: JSON.stringify({ title: "探测" }), headers: { "Content-Type": "application/json" }, method: "PATCH", }), db, ); expect(res.status).toBe(404); handle.close(); } finally { handle.cleanup(); } }); test("无效 modelId 返回 400", async () => { const handle = createMigratedMemoryTestDatabase("chat-patch-bad-model"); try { const db = handle.db; const projectId = seedProject(db); const providerId = seedProvider(db); seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await patchConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, { body: JSON.stringify({ modelId: "invalid-model-id" }), headers: { "Content-Type": "application/json" }, method: "PATCH", }), db, ); expect(res.status).toBe(400); handle.close(); } finally { handle.cleanup(); } }); }); describe("GET /api/projects/:id/conversations/:cid/messages", () => { test("跨项目获取消息返回 403", async () => { const handle = createMigratedMemoryTestDatabase("chat-msg-403"); try { const db = handle.db; const projectA = seedProject(db, "项目A"); const projectB = seedProject(db, "项目B"); const providerId = seedProvider(db); seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectA}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await listMessagesViaHandler( new Request( `http://localhost/api/projects/${projectB}/conversations/${created.id}/messages?page=1&pageSize=10`, ), db, ); expect(res.status).toBe(403); handle.close(); } finally { handle.cleanup(); } }); }); describe("POST /api/projects/:id/chat", () => { test("发送消息成功", async () => { const handle = createMigratedMemoryTestDatabase("chat-send"); try { const db = handle.db; const projectId = seedProject(db); const providerId = seedProvider(db); const modelId = seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectId}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await sendChatViaHandler( new Request(`http://localhost/api/projects/${projectId}/chat`, { body: JSON.stringify({ conversationId: created.id, messages: [{ content: "你好", role: "user" }], modelDbId: modelId, }), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); expect(res.status).toBe(200); const msgRes = await listMessagesViaHandler( new Request( `http://localhost/api/projects/${projectId}/conversations/${created.id}/messages?page=1&pageSize=10`, ), db, ); const msgBody = (await msgRes.json()) as { items: Message[] }; expect(msgBody.items.length).toBeGreaterThanOrEqual(1); expect(msgBody.items.some((m) => m.role === "user")).toBe(true); handle.close(); } finally { handle.cleanup(); } }); test("缺少 conversationId 返回 400", async () => { const handle = createMigratedMemoryTestDatabase("chat-send-400"); try { const db = handle.db; const projectId = seedProject(db); const res = await sendChatViaHandler( new Request(`http://localhost/api/projects/${projectId}/chat`, { body: JSON.stringify({ messages: [{ content: "hi", role: "user" }] }), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); expect(res.status).toBe(400); handle.close(); } finally { handle.cleanup(); } }); test("跨项目发送消息返回 403", async () => { const handle = createMigratedMemoryTestDatabase("chat-send-403"); try { const db = handle.db; const projectA = seedProject(db, "项目A"); const projectB = seedProject(db, "项目B"); const providerId = seedProvider(db); const modelId = seedModel(db, providerId); const createRes = await createConversationViaHandler( new Request(`http://localhost/api/projects/${projectA}/conversations`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); const created = ((await createRes.json()) as { conversation: Conversation }).conversation; const res = await sendChatViaHandler( new Request(`http://localhost/api/projects/${projectB}/chat`, { body: JSON.stringify({ conversationId: created.id, messages: [{ content: "探测", role: "user" }], modelDbId: modelId, }), headers: { "Content-Type": "application/json" }, method: "POST", }), db, ); expect(res.status).toBe(403); handle.close(); } finally { handle.cleanup(); } }); }); });