Files
GEOAgentArticleOptimizer/src/app/api/__tests__/cases.test.ts
T

448 lines
14 KiB
TypeScript

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const llmMocks = vi.hoisted(() => ({
generateValidatedJson: vi.fn(),
}));
vi.mock("../../../lib/llm/client", async () => {
const actual = await vi.importActual<typeof import("../../../lib/llm/client")>(
"../../../lib/llm/client",
);
return {
...actual,
generateValidatedJson: llmMocks.generateValidatedJson,
};
});
import { createSqliteRepository } from "../../../lib/db/sqlite-repository";
import {
HUMAN_COPY_RUBRIC_V1,
scoreHumanCopyResult,
} from "../../../lib/calibration/scoring";
import { GET as listCases } from "../cases/route";
import { GET as getCase, PATCH as patchCase } from "../cases/[caseId]/route";
import { POST as archiveCase } from "../cases/[caseId]/archive/route";
import { POST as restoreCase } from "../cases/[caseId]/restore/route";
import { POST as rerunCase } from "../cases/[caseId]/rerun/route";
import {
GET as listVersionPublications,
POST as createVersionPublication,
} from "../cases/[caseId]/versions/[versionId]/publications/route";
import { POST as recordPerformance } from "../publications/[publicationId]/performance/route";
describe("case APIs", () => {
let tempDir: string;
const originalDataDir = process.env.APP_DATA_DIR;
const originalApiKey = process.env.API_ACCESS_KEY;
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "geo-cases-api-"));
process.env.APP_DATA_DIR = tempDir;
process.env.API_ACCESS_KEY = "test-key";
process.env.API_AUTH_DISABLED = "false";
});
afterEach(() => {
process.env.APP_DATA_DIR = originalDataDir;
process.env.API_ACCESS_KEY = originalApiKey;
process.env.API_AUTH_DISABLED = originalAuthDisabled;
llmMocks.generateValidatedJson.mockReset();
rmSync(tempDir, { recursive: true, force: true });
});
it("lists, reads, patches, archives, and restores cases", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原文",
publish_target: "朋友圈",
source_excerpt: "原文",
});
const patchResponse = await patchCase(
request({
customer_name: "客户A",
brand_name: "品牌B",
project_tags: ["朋友圈"],
notes: "保留口语。",
}),
params({ caseId: created.id }),
);
expect(patchResponse.status).toBe(200);
const archiveResponse = await archiveCase(
request({}),
params({ caseId: created.id }),
);
expect(archiveResponse.status).toBe(200);
const listResponse = await listCases(request({}));
const listBody = (await listResponse.json()) as { cases: unknown[] };
expect(listBody.cases).toHaveLength(0);
const restoreResponse = await restoreCase(
request({}),
params({ caseId: created.id }),
);
expect(restoreResponse.status).toBe(200);
const detailResponse = await getCase(
request({}),
params({ caseId: created.id }),
);
const detailBody = (await detailResponse.json()) as {
case: { customer_name: string; project_tags: string[] };
};
expect(detailBody.case.customer_name).toBe("客户A");
expect(detailBody.case.project_tags).toEqual(["朋友圈"]);
});
it("creates publication records for result versions", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:私域",
summary: "原文",
publish_target: "私域",
source_excerpt: "原文",
});
const version = await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "优化后文案",
payload: {
optimized_text: "优化后文案",
change_notes: [],
ai_taste_checks: [],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
const createResponse = await createVersionPublication(
request({
publish_target: "私域",
url: "https://example.com/private",
published_at: "2026-07-08T12:00:00.000Z",
notes: "客户发布",
}),
params({ caseId: created.id, versionId: version.id }),
);
expect(createResponse.status).toBe(201);
const listResponse = await listVersionPublications(
request({}),
params({ caseId: created.id, versionId: version.id }),
);
const listBody = (await listResponse.json()) as {
publications: Array<{ publish_target: string }>;
};
expect(listBody.publications).toEqual([
expect.objectContaining({ publish_target: "私域" }),
]);
});
it("records manual performance for a result-version publication without scoring", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:私域",
summary: "原文",
publish_target: "私域",
source_excerpt: "原文",
});
const version = await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "优化后文案",
payload: {
optimized_text: "优化后文案",
change_notes: [],
ai_taste_checks: [],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
const publication = await repository.createPublicationRecord({
result_version_id: version.id,
job_id: null,
revision: null,
publish_target: "私域",
url: "https://example.com/private",
published_at: "2026-07-08T12:00:00.000Z",
status: "published",
notes: "",
});
const response = await recordPerformance(
request({
window_label: "T+7d",
views: "1200",
feedback_summary: "客户反馈更自然",
}),
params({ publicationId: publication.id }),
);
const body = (await response.json()) as {
snapshot?: { metrics: { views: number } };
calibrationEvent: null;
};
expect(response.status).toBe(201);
expect(body.snapshot?.metrics.views).toBe(1200);
expect(body.calibrationEvent).toBeNull();
});
it("creates calibration events for scored human-copy result-version performance", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:私域",
summary: "原文",
publish_target: "私域",
source_excerpt: "原文",
});
const result = {
optimized_text: "优化后文案",
change_notes: [],
ai_taste_checks: [],
warnings: [],
};
const version = await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "优化后文案",
payload: result,
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1);
await repository.saveScoringRun(
scoreHumanCopyResult({
resultVersionId: version.id,
result,
}),
);
const publication = await repository.createPublicationRecord({
result_version_id: version.id,
job_id: null,
revision: null,
publish_target: "私域",
url: "https://example.com/private",
published_at: "2026-07-08T12:00:00.000Z",
status: "published",
notes: "",
});
const response = await recordPerformance(
request({
window_label: "T+7d",
views: "1200",
feedback_summary: "客户反馈更自然",
}),
params({ publicationId: publication.id }),
);
const body = (await response.json()) as {
calibrationEvent: { observations: string[] } | null;
};
expect(response.status).toBe(201);
expect(body.calibrationEvent?.observations.join(" ")).toContain("综合评分");
});
it("reruns an article case into a new result version and article job", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "article",
title: "GEO 指南",
summary: "原文摘要",
publish_target: "official_site",
source_excerpt: "原文摘要",
});
const sourceJob = await repository.createArticleJob({
case_id: created.id,
source_title: "GEO 指南",
source_body: "Example Technology Co., Ltd. has 8 years of GEO experience.",
image_inputs: [{ type: "description", content: "dashboard" }],
publish_platform: "official_site",
user_instructions: "保持事实准确",
});
await repository.saveFactCard(sourceJob.id, confirmedFactCard);
await repository.saveCaseInput({
case_id: created.id,
case_type: "article",
article_job_id: sourceJob.id,
payload: {
source_title: "GEO 指南",
source_body: "Example Technology Co., Ltd. has 8 years of GEO experience.",
image_inputs: [{ type: "description", content: "dashboard" }],
publish_platform: "official_site",
user_instructions: "保持事实准确",
},
});
await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "article",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "第一版",
payload: null,
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
llmMocks.generateValidatedJson
.mockResolvedValueOnce({
title: "第二版 GEO 指南",
summary: "第二版 official site GEO optimization 摘要",
body_markdown:
"## 第二版\nExample Technology Co., Ltd. has 8 years of GEO optimization experience.",
image_suggestions: [],
changed_sections: ["标题"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({
overall_status: "pass",
checks: [],
});
const response = await rerunCase(request({}), params({ caseId: created.id }));
const body = (await response.json()) as {
result_version: { version: number };
optimizedArticle: { title: string; job_id: string };
qaReport: { overall_status: string };
exportPaths: Record<string, string>;
};
expect(response.status).toBe(201);
expect(body.result_version.version).toBe(2);
expect(body.optimizedArticle.title).toBe("第二版 GEO 指南");
expect(body.qaReport.overall_status).toBe("pass");
expect(body.exportPaths.markdown).toContain(body.optimizedArticle.job_id);
await expect(repository.getArticleJob(body.optimizedArticle.job_id)).resolves.toMatchObject({
case_id: created.id,
status: "optimized",
});
});
it("reruns a human-copy case into a new result version", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原文",
publish_target: "朋友圈",
source_excerpt: "原文",
});
await repository.saveCaseInput({
case_id: created.id,
case_type: "human_copy",
article_job_id: null,
payload: {
source_text: "原文",
goal: "自然一点",
intensity: "light",
user_instructions: "",
publish_target: "朋友圈",
},
});
await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "第一版",
payload: {
optimized_text: "第一版",
change_notes: [],
ai_taste_checks: [],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
llmMocks.generateValidatedJson.mockResolvedValueOnce({
optimized_text: "第二版",
change_notes: [],
ai_taste_checks: [],
warnings: [],
});
const response = await rerunCase(request({}), params({ caseId: created.id }));
const body = (await response.json()) as {
result_version: { id: string; version: number };
result: { optimized_text: string };
};
expect(response.status).toBe(201);
expect(body.result_version.version).toBe(2);
expect(body.result.optimized_text).toBe("第二版");
await expect(
repository.getLatestScoringRunForResultVersion(body.result_version.id),
).resolves.toMatchObject({
case_type: "human_copy",
rubric_version_id: HUMAN_COPY_RUBRIC_V1.id,
});
});
});
const confirmedFactCard = {
company_full_name: "Example Technology Co., Ltd.",
company_short_names: ["Example Tech"],
brand_names: ["Example"],
product_names: ["Example GEO"],
target_industry: "GEO optimization",
target_audience: "Marketing teams",
experience_years: 8,
core_claims: ["8 years of GEO optimization experience"],
forbidden_claims: ["industry first"],
image_topics: ["dashboard"],
uncertain_items: [],
is_ready_for_optimization: true,
confirmed_by_user: true,
} as const;
function request(body: unknown, options: { apiKey?: string | null } = {}) {
const headers: Record<string, string> = { "content-type": "application/json" };
const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey;
if (apiKey) headers["x-api-key"] = apiKey;
return new Request("http://localhost/api/cases", {
method: "POST",
body: JSON.stringify(body),
headers,
});
}
function params<T extends Record<string, string>>(values: T) {
return { params: Promise.resolve(values) };
}