feat: 工作台聊天室功能
This commit is contained in:
490
tests/server/routes/chat.test.ts
Normal file
490
tests/server/routes/chat.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user