feat: 素材处理管线——自动处理、审核流程、6状态机
This commit is contained in:
277
tests/server/db/materials.test.ts
Normal file
277
tests/server/db/materials.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
approveMaterial,
|
||||
createMaterial,
|
||||
deleteMaterial,
|
||||
discardMaterial,
|
||||
getMaterial,
|
||||
retryMaterial,
|
||||
} from "../../../src/server/db/materials";
|
||||
import { createProject } from "../../../src/server/db/projects";
|
||||
import { createNoopLogger } from "../../../src/server/logger";
|
||||
import { createMigratedTestDatabase } from "../../helpers";
|
||||
|
||||
const LOG = createNoopLogger();
|
||||
|
||||
function withMaterialsDb(callback: (db: Database) => void): void {
|
||||
const handle = createMigratedTestDatabase("materials-dao-test");
|
||||
try {
|
||||
callback(handle.db);
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function setupProject(db: Database, name = "测试项目"): string {
|
||||
const result = createProject(db, { name }, LOG);
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.project.id;
|
||||
}
|
||||
|
||||
function setupMaterial(
|
||||
db: Database,
|
||||
projectId: string,
|
||||
overrides: Partial<{
|
||||
associatedDate: string;
|
||||
description: string;
|
||||
materialType: "general" | "meeting";
|
||||
}> = {},
|
||||
): string {
|
||||
const result = createMaterial(
|
||||
db,
|
||||
projectId,
|
||||
{
|
||||
associatedDate: overrides.associatedDate ?? "2024-01-15",
|
||||
description: overrides.description ?? "测试素材",
|
||||
materialType: overrides.materialType,
|
||||
},
|
||||
LOG,
|
||||
);
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.material.id;
|
||||
}
|
||||
|
||||
function setMaterialStatus(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
describe("素材数据访问层", () => {
|
||||
describe("createMaterial", () => {
|
||||
test("默认 materialType 为 general", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const result = createMaterial(db, projectId, { associatedDate: "2024-01-15", description: "测试" }, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { materialType: string } }).material;
|
||||
expect(material.materialType).toBe("general");
|
||||
});
|
||||
});
|
||||
|
||||
test("显式指定 materialType 为 meeting", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const result = createMaterial(
|
||||
db,
|
||||
projectId,
|
||||
{ associatedDate: "2024-01-15", description: "会议素材", materialType: "meeting" },
|
||||
LOG,
|
||||
);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { materialType: string } }).material;
|
||||
expect(material.materialType).toBe("meeting");
|
||||
});
|
||||
});
|
||||
|
||||
test("非法 materialType 返回 400", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const result = createMaterial(
|
||||
db,
|
||||
projectId,
|
||||
{
|
||||
associatedDate: "2024-01-15",
|
||||
description: "测试",
|
||||
materialType: "invalid" as "general",
|
||||
},
|
||||
LOG,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMaterial", () => {
|
||||
test("返回包含 materialType 和 processedContent 字段", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
const result = getMaterial(db, projectId, materialId);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { materialType: string; processedContent: null | string } }).material;
|
||||
expect(material.materialType).toBe("general");
|
||||
expect(material.processedContent).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("approveMaterial", () => {
|
||||
test("review 状态通过成功", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
const result = approveMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { status: string } }).material;
|
||||
expect(material.status).toBe("approved");
|
||||
});
|
||||
});
|
||||
|
||||
test("非 review 状态拒绝(pending)", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
|
||||
const result = approveMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
test("素材不存在返回 404", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const result = approveMaterial(db, projectId, "nonexistent", LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("discardMaterial", () => {
|
||||
test("review 状态放弃成功", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
const result = discardMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { status: string } }).material;
|
||||
expect(material.status).toBe("discarded");
|
||||
});
|
||||
});
|
||||
|
||||
test("非 review 状态拒绝", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
|
||||
const result = discardMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryMaterial", () => {
|
||||
test("failed 状态重试成功并清空 processedContent", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "failed", "之前的内容");
|
||||
|
||||
const result = retryMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { processedContent: null | string; status: string } }).material;
|
||||
expect(material.status).toBe("pending");
|
||||
expect(material.processedContent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("非 failed 状态拒绝", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
const result = retryMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMaterial", () => {
|
||||
test("processing 状态禁止删除返回 409", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "processing");
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
test("pending 状态可正常删除", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("review 状态可正常删除", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("failed 状态可正常删除", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "failed");
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("approved 状态可正常删除", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "approved");
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
247
tests/server/processing/processor.test.ts
Normal file
247
tests/server/processing/processor.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import { notDeleted, wrap } from "../../../src/server/db/connection";
|
||||
import { createMaterial } from "../../../src/server/db/materials";
|
||||
import { createProject } from "../../../src/server/db/projects";
|
||||
import { materials } from "../../../src/server/db/schema";
|
||||
import { createNoopLogger } from "../../../src/server/logger";
|
||||
import type { ProcessableMaterial } from "../../../src/server/processing/processor";
|
||||
import { MaterialProcessor } from "../../../src/server/processing/processor";
|
||||
|
||||
import { createMigratedTestDatabase } from "../../helpers";
|
||||
|
||||
const LOG = createNoopLogger();
|
||||
|
||||
function withProcessorDb(callback: (db: Database) => void): void {
|
||||
const handle = createMigratedTestDatabase("processor-test");
|
||||
try {
|
||||
callback(handle.db);
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function setupProject(db: Database, name = "测试项目"): string {
|
||||
const result = createProject(db, { name }, LOG);
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.project.id;
|
||||
}
|
||||
|
||||
function setupMaterial(
|
||||
db: Database,
|
||||
projectId: string,
|
||||
overrides: Partial<{
|
||||
associatedDate: string;
|
||||
description: string;
|
||||
materialType: "general" | "meeting";
|
||||
}> = {},
|
||||
): string {
|
||||
const result = createMaterial(
|
||||
db,
|
||||
projectId,
|
||||
{
|
||||
associatedDate: overrides.associatedDate ?? "2024-01-15",
|
||||
description: overrides.description ?? "测试素材",
|
||||
materialType: overrides.materialType,
|
||||
},
|
||||
LOG,
|
||||
);
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.material.id;
|
||||
}
|
||||
|
||||
function getMaterialRow(db: Database, materialId: string) {
|
||||
return wrap(db)
|
||||
.select()
|
||||
.from(materials)
|
||||
.where(and(eq(materials.id, materialId), notDeleted(materials)))
|
||||
.get();
|
||||
}
|
||||
|
||||
function setMaterialStatus(
|
||||
db: Database,
|
||||
materialId: string,
|
||||
status: "approved" | "discarded" | "failed" | "pending" | "processing" | "review",
|
||||
): void {
|
||||
db.prepare("UPDATE materials SET status = ?, updated_at = ? WHERE id = ?").run(
|
||||
status,
|
||||
new Date().toISOString(),
|
||||
materialId,
|
||||
);
|
||||
}
|
||||
|
||||
class FailingProcessor extends MaterialProcessor {
|
||||
public attempts = 0;
|
||||
public failUntilAttempt = Number.POSITIVE_INFINITY;
|
||||
|
||||
protected override async processOne(material: ProcessableMaterial): Promise<string> {
|
||||
this.attempts += 1;
|
||||
if (this.attempts <= this.failUntilAttempt) {
|
||||
throw new Error(`mock failure ${this.attempts}`);
|
||||
}
|
||||
return super.processOne(material);
|
||||
}
|
||||
}
|
||||
|
||||
describe("素材处理器", () => {
|
||||
test("recoverStuckMaterials 将 processing 状态恢复为 pending", () => {
|
||||
withProcessorDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id1 = setupMaterial(db, projectId);
|
||||
const id2 = setupMaterial(db, projectId);
|
||||
|
||||
setMaterialStatus(db, id1, "processing");
|
||||
setMaterialStatus(db, id2, "processing");
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const recovered = processor.recoverStuckMaterials();
|
||||
|
||||
expect(recovered).toBe(2);
|
||||
expect(getMaterialRow(db, id1)?.status).toBe("pending");
|
||||
expect(getMaterialRow(db, id2)?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
test("recoverStuckMaterials 无 processing 素材时返回 0", () => {
|
||||
withProcessorDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
setupMaterial(db, projectId);
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const recovered = processor.recoverStuckMaterials();
|
||||
|
||||
expect(recovered).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("processNext 将 pending 素材处理为 review 并写入 processedContent", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId, { description: "测试内容" });
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).toBe("测试内容");
|
||||
});
|
||||
});
|
||||
|
||||
test("processNext 根据 materialType 选择模板", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId, {
|
||||
description: "会议内容",
|
||||
materialType: "meeting",
|
||||
});
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).toBe("会议内容");
|
||||
});
|
||||
});
|
||||
|
||||
test("processNext 重试机制:前 2 次失败,第 3 次成功", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId);
|
||||
|
||||
const processor = new FailingProcessor(db, LOG);
|
||||
processor.failUntilAttempt = 2;
|
||||
|
||||
await processor.processNext();
|
||||
|
||||
expect(processor.attempts).toBe(3);
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("processNext 3 次都失败后标记为 failed", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId);
|
||||
|
||||
const processor = new FailingProcessor(db, LOG);
|
||||
processor.failUntilAttempt = Number.POSITIVE_INFINITY;
|
||||
|
||||
await processor.processNext();
|
||||
|
||||
expect(processor.attempts).toBe(3);
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("failed");
|
||||
expect(row?.processedContent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("空队列时不报错", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
setupProject(db);
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
});
|
||||
});
|
||||
|
||||
test("FIFO 顺序:先创建的先处理", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
|
||||
const id1 = setupMaterial(db, projectId, { description: "第一个" });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const id2 = setupMaterial(db, projectId, { description: "第二个" });
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
|
||||
expect(getMaterialRow(db, id1)?.status).toBe("review");
|
||||
expect(getMaterialRow(db, id2)?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
test("start 启动后能正常 stop", () => {
|
||||
withProcessorDb((db) => {
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
processor.start(100);
|
||||
processor.stop();
|
||||
});
|
||||
});
|
||||
|
||||
test("start 后定时器会推进 pending 素材到 review", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId, { description: "定时扫描" });
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
processor.start(50);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
processor.stop();
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).toBe("定时扫描");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withProcessorDbAsync(callback: (db: Database) => Promise<void>): Promise<void> {
|
||||
const handle = createMigratedTestDatabase("processor-test-async");
|
||||
try {
|
||||
await callback(handle.db);
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,21 @@ async function deleteMaterialViaHandler(req: Request, db: Database): Promise<Res
|
||||
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);
|
||||
@@ -280,4 +295,199 @@ describe("素材 API 路由", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ const MOCK_CREATED: Material = {
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "测试描述",
|
||||
id: "new-id",
|
||||
materialType: "general",
|
||||
processedContent: null,
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
|
||||
@@ -12,6 +12,8 @@ const MOCK_MATERIAL: Material = {
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "测试素材描述",
|
||||
id: "test-id",
|
||||
materialType: "general",
|
||||
processedContent: null,
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
@@ -28,7 +30,7 @@ describe("MaterialCard", () => {
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("测试素材描述")).not.toBeNull();
|
||||
expect(screen.getByText("待审核")).not.toBeNull();
|
||||
expect(screen.getByText("待处理")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("点击卡片触发 onSelect", () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ const MOCK_MATERIALS: Material[] = [
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "素材一",
|
||||
id: "id-1",
|
||||
materialType: "general",
|
||||
processedContent: null,
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
@@ -22,6 +24,8 @@ const MOCK_MATERIALS: Material[] = [
|
||||
createdAt: "2026-06-02T00:00:00.000Z",
|
||||
description: "素材二",
|
||||
id: "id-2",
|
||||
materialType: "meeting",
|
||||
processedContent: null,
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||
|
||||
Reference in New Issue
Block a user