494 lines
20 KiB
TypeScript
494 lines
20 KiB
TypeScript
import type Database from "bun:sqlite";
|
||
|
||
import { describe, expect, test } from "bun:test";
|
||
|
||
import type { Material, RuntimeMode } from "../../../src/shared/api";
|
||
|
||
import { archiveProject, createProject } from "../../../src/server/db/projects";
|
||
import { createNoopLogger } from "../../../src/server/logger";
|
||
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
||
|
||
const MODE: RuntimeMode = "test";
|
||
const LOG = createNoopLogger();
|
||
|
||
async function createMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||
const { handleCreateMaterial: h } = await import("../../../src/server/routes/materials/create");
|
||
return h(req, db, MODE, LOG);
|
||
}
|
||
|
||
function createTestProject(db: Database, name = "测试项目") {
|
||
const result = createProject(db, { name }, LOG);
|
||
if ("error" in result) throw new Error(result.error);
|
||
return result.project;
|
||
}
|
||
|
||
async function deleteMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||
const { handleDeleteMaterial: h } = await import("../../../src/server/routes/materials/delete");
|
||
return h(req, db, MODE, LOG);
|
||
}
|
||
|
||
async function approveMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||
const { handleApproveMaterial: h } = await import("../../../src/server/routes/materials/approve");
|
||
return h(req, db, MODE, LOG);
|
||
}
|
||
|
||
async function discardMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||
const { handleDiscardMaterial: h } = await import("../../../src/server/routes/materials/discard");
|
||
return h(req, db, MODE, LOG);
|
||
}
|
||
|
||
async function retryMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||
const { handleRetryMaterial: h } = await import("../../../src/server/routes/materials/retry");
|
||
return h(req, db, MODE, LOG);
|
||
}
|
||
|
||
async function getMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||
const { handleGetMaterial: h } = await import("../../../src/server/routes/materials/get");
|
||
return h(req, db, MODE, LOG);
|
||
}
|
||
|
||
async function listMaterialsViaHandler(req: Request, db: Database): Promise<Response> {
|
||
const { handleListMaterials: h } = await import("../../../src/server/routes/materials/list");
|
||
return h(req, db, MODE, LOG);
|
||
}
|
||
|
||
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
|
||
const handle = createMigratedMemoryTestDatabase("material-route-test");
|
||
try {
|
||
await callback(handle.db);
|
||
handle.close();
|
||
} finally {
|
||
handle.cleanup();
|
||
}
|
||
}
|
||
|
||
describe("素材 API 路由", () => {
|
||
describe("POST /api/projects/:id/materials", () => {
|
||
test("正常创建素材", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const res = await createMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(201);
|
||
const body = (await res.json()) as { material: Material };
|
||
expect(body.material.description).toBe("测试素材");
|
||
expect(body.material.associatedDate).toBe("2024-01-15");
|
||
expect(body.material.projectId).toBe(project.id);
|
||
expect(body.material.status).toBe("pending");
|
||
});
|
||
});
|
||
|
||
test("缺少 description 返回 400", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const res = await createMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(400);
|
||
});
|
||
});
|
||
|
||
test("缺少 associatedDate 返回 400", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ description: "测试素材" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const res = await createMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(400);
|
||
});
|
||
});
|
||
|
||
test("associatedDate 格式错误返回 400", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024/01/15", description: "测试素材" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const res = await createMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(400);
|
||
});
|
||
});
|
||
|
||
test("项目不存在返回 404", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const req = new Request("http://localhost/api/projects/nonexistent/materials", {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const res = await createMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
test("已归档项目返回 409", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
archiveProject(db, project.id, LOG);
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const res = await createMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(409);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("GET /api/projects/:id/materials", () => {
|
||
test("正常列表查询", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const req1 = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "素材1" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
await createMaterialViaHandler(req1, db);
|
||
const req2 = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-16", description: "素材2" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
await createMaterialViaHandler(req2, db);
|
||
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials?page=1&pageSize=20`);
|
||
const res = await listMaterialsViaHandler(req, db);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { items: Material[]; total: number };
|
||
expect(body.total).toBe(2);
|
||
expect(body.items.length).toBe(2);
|
||
});
|
||
});
|
||
|
||
test("空项目返回空列表", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials?page=1&pageSize=20`);
|
||
const res = await listMaterialsViaHandler(req, db);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { items: Material[]; total: number };
|
||
expect(body.total).toBe(0);
|
||
expect(body.items.length).toBe(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("GET /api/projects/:id/materials/:mid", () => {
|
||
test("正常获取详情", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "详情测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const createRes = await createMaterialViaHandler(createReq, db);
|
||
const createBody = (await createRes.json()) as { material: Material };
|
||
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`);
|
||
const res = await getMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { material: Material };
|
||
expect(body.material.description).toBe("详情测试");
|
||
});
|
||
});
|
||
|
||
test("归属错误返回 403", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const projectA = createTestProject(db, "项目A");
|
||
const projectB = createProject(db, { name: "项目B" }, LOG);
|
||
if ("error" in projectB) throw new Error(projectB.error);
|
||
const createReq = new Request(`http://localhost/api/projects/${projectA.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const createRes = await createMaterialViaHandler(createReq, db);
|
||
const createBody = (await createRes.json()) as { material: Material };
|
||
|
||
const req = new Request(
|
||
`http://localhost/api/projects/${projectB.project.id}/materials/${createBody.material.id}`,
|
||
);
|
||
const res = await getMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(403);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("DELETE /api/projects/:id/materials/:mid", () => {
|
||
test("正常删除", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "待删除" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const createRes = await createMaterialViaHandler(createReq, db);
|
||
const createBody = (await createRes.json()) as { material: Material };
|
||
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`, {
|
||
method: "DELETE",
|
||
});
|
||
const res = await deleteMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(204);
|
||
});
|
||
});
|
||
|
||
test("删除后再查返回 404", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "待删除" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const createRes = await createMaterialViaHandler(createReq, db);
|
||
const createBody = (await createRes.json()) as { material: Material };
|
||
|
||
const delReq = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`, {
|
||
method: "DELETE",
|
||
});
|
||
await deleteMaterialViaHandler(delReq, db);
|
||
|
||
const getReq = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`);
|
||
const res = await getMaterialViaHandler(getReq, db);
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
test("归属错误返回 403", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const projectA = createTestProject(db, "项目A");
|
||
const projectB = createProject(db, { name: "项目B" }, LOG);
|
||
if ("error" in projectB) throw new Error(projectB.error);
|
||
const createReq = new Request(`http://localhost/api/projects/${projectA.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
});
|
||
const createRes = await createMaterialViaHandler(createReq, db);
|
||
const createBody = (await createRes.json()) as { material: Material };
|
||
|
||
const req = new Request(
|
||
`http://localhost/api/projects/${projectB.project.id}/materials/${createBody.material.id}`,
|
||
{
|
||
method: "DELETE",
|
||
},
|
||
);
|
||
const res = await deleteMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(403);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("POST /api/projects/:id/materials/:mid/approve", () => {
|
||
test("review 状态通过返回 201", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createRes = await createMaterialViaHandler(
|
||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
}),
|
||
db,
|
||
);
|
||
const created = (await createRes.json()) as { material: Material };
|
||
setMaterialStatusRaw(db, created.material.id, "review");
|
||
|
||
const req = new Request(
|
||
`http://localhost/api/projects/${project.id}/materials/${created.material.id}/approve`,
|
||
{ method: "POST" },
|
||
);
|
||
const res = await approveMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(201);
|
||
const body = (await res.json()) as { material: Material };
|
||
expect(body.material.status).toBe("approved");
|
||
});
|
||
});
|
||
|
||
test("非 review 状态返回 409", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createRes = await createMaterialViaHandler(
|
||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
}),
|
||
db,
|
||
);
|
||
const created = (await createRes.json()) as { material: Material };
|
||
|
||
const req = new Request(
|
||
`http://localhost/api/projects/${project.id}/materials/${created.material.id}/approve`,
|
||
{ method: "POST" },
|
||
);
|
||
const res = await approveMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(409);
|
||
});
|
||
});
|
||
|
||
test("素材不存在返回 404", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/nonexistent/approve`, {
|
||
method: "POST",
|
||
});
|
||
const res = await approveMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("POST /api/projects/:id/materials/:mid/discard", () => {
|
||
test("review 状态放弃返回 201", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createRes = await createMaterialViaHandler(
|
||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
}),
|
||
db,
|
||
);
|
||
const created = (await createRes.json()) as { material: Material };
|
||
setMaterialStatusRaw(db, created.material.id, "review");
|
||
|
||
const req = new Request(
|
||
`http://localhost/api/projects/${project.id}/materials/${created.material.id}/discard`,
|
||
{ method: "POST" },
|
||
);
|
||
const res = await discardMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(201);
|
||
const body = (await res.json()) as { material: Material };
|
||
expect(body.material.status).toBe("discarded");
|
||
});
|
||
});
|
||
|
||
test("非 review 状态返回 409", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createRes = await createMaterialViaHandler(
|
||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
}),
|
||
db,
|
||
);
|
||
const created = (await createRes.json()) as { material: Material };
|
||
|
||
const req = new Request(
|
||
`http://localhost/api/projects/${project.id}/materials/${created.material.id}/discard`,
|
||
{ method: "POST" },
|
||
);
|
||
const res = await discardMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(409);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("POST /api/projects/:id/materials/:mid/retry", () => {
|
||
test("failed 状态重试返回 201,processedContent 已清空", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createRes = await createMaterialViaHandler(
|
||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
}),
|
||
db,
|
||
);
|
||
const created = (await createRes.json()) as { material: Material };
|
||
setMaterialStatusRaw(db, created.material.id, "failed", "之前的内容");
|
||
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${created.material.id}/retry`, {
|
||
method: "POST",
|
||
});
|
||
const res = await retryMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(201);
|
||
const body = (await res.json()) as { material: Material };
|
||
expect(body.material.status).toBe("pending");
|
||
expect(body.material.processedContent).toBeNull();
|
||
});
|
||
});
|
||
|
||
test("非 failed 状态返回 409", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createRes = await createMaterialViaHandler(
|
||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
}),
|
||
db,
|
||
);
|
||
const created = (await createRes.json()) as { material: Material };
|
||
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${created.material.id}/retry`, {
|
||
method: "POST",
|
||
});
|
||
const res = await retryMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(409);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("DELETE 状态校验扩展", () => {
|
||
test("processing 状态返回 409", async () => {
|
||
await withRouteDb(async (db) => {
|
||
const project = createTestProject(db);
|
||
const createRes = await createMaterialViaHandler(
|
||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||
headers: { "Content-Type": "application/json" },
|
||
method: "POST",
|
||
}),
|
||
db,
|
||
);
|
||
const created = (await createRes.json()) as { material: Material };
|
||
setMaterialStatusRaw(db, created.material.id, "processing");
|
||
|
||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${created.material.id}`, {
|
||
method: "DELETE",
|
||
});
|
||
const res = await deleteMaterialViaHandler(req, db);
|
||
expect(res.status).toBe(409);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
function setMaterialStatusRaw(
|
||
db: Database,
|
||
materialId: string,
|
||
status: "approved" | "discarded" | "failed" | "pending" | "processing" | "review",
|
||
processedContent: string | null = null,
|
||
): void {
|
||
db.prepare("UPDATE materials SET status = ?, processed_content = ?, updated_at = ? WHERE id = ?").run(
|
||
status,
|
||
processedContent,
|
||
new Date().toISOString(),
|
||
materialId,
|
||
);
|
||
}
|