Files
Alfred/tests/server/routes/chat.test.ts

701 lines
25 KiB
TypeScript

import type Database from "bun:sqlite";
import { describe, expect, 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";
import "../mocks/ai";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
async function createConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateConversation: h } = await import("../../../src/server/routes/chat/create");
return h(req, db, MODE, LOG);
}
async function deleteConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteConversation: h } = await import("../../../src/server/routes/chat/delete");
return h(req, db, MODE, LOG);
}
async function getConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetConversation: h } = await import("../../../src/server/routes/chat/get");
return h(req, db, MODE, LOG);
}
async function listConversationsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListConversations: h } = await import("../../../src/server/routes/chat/list");
return h(req, db, MODE, LOG);
}
async function listMessagesViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListMessages: h } = await import("../../../src/server/routes/chat/messages");
return h(req, db, MODE, LOG);
}
async function patchConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateConversation: h } = await import("../../../src/server/routes/chat/update");
return h(req, db, MODE, LOG);
}
function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string {
const result = createModel(
db,
{
capabilities: ["text"],
modelId,
name: modelName,
providerId,
},
LOG,
);
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 }, LOG);
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",
},
LOG,
);
if ("error" in result) throw new Error(result.error);
return result.provider.id;
}
async function sendChatViaHandler(req: Request, db: Database): Promise<Response> {
const { handleSendChat: h } = await import("../../../src/server/routes/chat/send");
return h(req, db, MODE, LOG);
}
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(2);
expect(msgBody.items.some((m) => m.role === "user")).toBe(true);
expect(msgBody.items.some((m) => m.role === "assistant")).toBe(true);
handle.close();
} finally {
handle.cleanup();
}
});
test("首次发送消息时触发标题生成", async () => {
const handle = createMigratedMemoryTestDatabase("chat-send-title");
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;
expect(created.title).toBe("新会话");
await sendChatViaHandler(
new Request(`http://localhost/api/projects/${projectId}/chat`, {
body: JSON.stringify({
conversationId: created.id,
messages: [{ parts: [{ text: "请帮我分析一下这个项目的性能瓶颈", type: "text" }], role: "user" }],
modelDbId: modelId,
}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
await new Promise((resolve) => setTimeout(resolve, 50));
const getRes = await getConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`),
db,
);
const body = (await getRes.json()) as { conversation: Conversation };
expect(body.conversation.title).not.toBe("新会话");
expect(body.conversation.title).toBe("AI总结标题");
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();
}
});
});
});