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

491 lines
17 KiB
TypeScript

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 { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
void mock.module("ai", () => ({
createProviderRegistry: () => ({
languageModel: () => ({}),
}),
generateText: () => Promise.resolve({ text: "mock", usage: {} }),
stepCountIs: () => () => true,
streamText: () => ({
text: Promise.resolve("test reply from AI"),
toUIMessageStreamResponse: () =>
new Response('data: {"type":"text","text":"test reply from AI"}\n\n', {
headers: { "Content-Type": "text/event-stream" },
}),
}),
}));
async function createConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateConversation: h } = await import("../../../src/server/routes/chat/create");
return h(req, db, MODE);
}
async function deleteConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteConversation: h } = await import("../../../src/server/routes/chat/delete");
return h(req, db, MODE);
}
async function getConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetConversation: h } = await import("../../../src/server/routes/chat/get");
return h(req, db, MODE);
}
async function listConversationsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListConversations: h } = await import("../../../src/server/routes/chat/list");
return h(req, db, MODE);
}
async function listMessagesViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListMessages: h } = await import("../../../src/server/routes/chat/messages");
return h(req, db, MODE);
}
function seedModel(db: Database, providerId: string, modelName = "GPT-4o"): string {
const result = createModel(db, {
capabilities: ["text"],
modelId: "gpt-4o",
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<Response> {
const { handleSendChat: h } = await import("../../../src/server/routes/chat/send");
return h(req, db, MODE);
}
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("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();
}
});
});
});