491 lines
17 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|
|
});
|