This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+306
View File
@@ -0,0 +1,306 @@
/**
* API 端到端自测。
*
* 覆盖完整 CRUD 链路 + 边界与错误分支:
* - 列表/分页/标签筛选/草稿隔离
* - 详情读取(含 404)
* - 创建(含校验失败、slug 冲突)
* - 更新(部分更新、slug 改名、草稿↔发布流转)
* - 删除(含重复删除 404)
*
* 所有测试数据以 `zz-` 前缀创建,结束后清理,不影响真实内容。
*
* 用法:node scripts/test-api.mjs [baseUrl]
* 默认 baseUrl = http://localhost:3000
*/
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
const PREFIX = "zz-test";
let pass = 0;
let fail = 0;
const failures = [];
function check(name, condition, detail = "") {
if (condition) {
pass++;
console.log(` [PASS] ${name}`);
} else {
fail++;
failures.push(`${name} ${detail}`);
console.log(` [FAIL] ${name} ${detail}`);
}
}
/** 发起请求并返回 { status, body } */
async function call(method, path, body) {
const response = await fetch(`${BASE}${path}`, {
method,
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { status: response.status, body: parsed };
}
console.log(`目标: ${BASE}\n`);
// ─────────────────────────────────────────────
console.log("── 0. 健康检查 ──────────────────────");
const health = await call("GET", "/api/health");
check("GET /api/health 返回 200", health.status === 200, `got ${health.status}`);
check("健康状态为 ok", health.body?.data?.status === "ok", JSON.stringify(health.body?.data));
check(
"返回数据库延迟",
typeof health.body?.data?.database?.latencyMs === "number",
);
// ─────────────────────────────────────────────
console.log("\n── 1. 列表与分页 ────────────────────");
const list = await call("GET", "/api/posts");
check("GET /api/posts 返回 200", list.status === 200, `got ${list.status}`);
check("响应含 data 数组", Array.isArray(list.body?.data));
check("响应含 meta.total", typeof list.body?.meta?.total === "number");
check("响应含 meta.hasMore", typeof list.body?.meta?.hasMore === "boolean");
check(
"默认只返回已发布文章",
list.body.data.every((p) => p.draft === false),
JSON.stringify(list.body.data?.map((p) => [p.slug, p.draft])),
);
check(
"按日期倒序",
list.body.data.every(
(post, index, array) =>
index === 0 || new Date(array[index - 1].date) >= new Date(post.date),
),
JSON.stringify(list.body.data?.map((p) => p.date)),
);
const limited = await call("GET", "/api/posts?limit=1");
check("limit=1 只返回 1 条", limited.body?.data?.length === 1);
check("limit 反映在 meta", limited.body?.meta?.limit === 1);
const badLimit = await call("GET", "/api/posts?limit=999");
check("limit 超限返回 400", badLimit.status === 400, `got ${badLimit.status}`);
check(
"校验错误含 code",
badLimit.body?.error?.code === "VALIDATION_ERROR",
JSON.stringify(badLimit.body?.error),
);
const badOffset = await call("GET", "/api/posts?offset=-1");
check("offset 负数返回 400", badOffset.status === 400, `got ${badOffset.status}`);
// ─────────────────────────────────────────────
console.log("\n── 2. 标签筛选与聚合 ────────────────");
const tags = await call("GET", "/api/tags");
check("GET /api/tags 返回 200", tags.status === 200);
check("标签为数组", Array.isArray(tags.body?.data));
check(
"标签含 count 字段",
tags.body.data.every((t) => typeof t.tag === "string" && typeof t.count === "number"),
JSON.stringify(tags.body?.data?.[0]),
);
if (tags.body.data.length > 0) {
const firstTag = tags.body.data[0].tag;
const filtered = await call("GET", `/api/posts?tag=${encodeURIComponent(firstTag)}`);
check(`按标签 "${firstTag}" 筛选返回 200`, filtered.status === 200);
check(
"筛选结果都含该标签",
filtered.body.data.every((p) => p.tags.includes(firstTag)),
JSON.stringify(filtered.body.data?.map((p) => p.tags)),
);
check("筛选结果非空", filtered.body.data.length > 0);
}
// ─────────────────────────────────────────────
console.log("\n── 3. 创建文章 ──────────────────────");
const createPayload = {
slug: `${PREFIX}-create`,
title: "测试文章:创建",
summary: "这是一篇用于接口自测的文章摘要。",
content: "## 标题\n\n正文内容,包含 `行内代码`。\n\n```ts\nconst x: number = 1;\n```\n",
tags: ["测试", "API"],
status: "published",
};
// 先清理可能残留的同名数据
await call("DELETE", `/api/posts/${PREFIX}-create`);
const createRes = await call("POST", "/api/posts", createPayload);
check("POST /api/posts 返回 201", createRes.status === 201, `got ${createRes.status}`);
check("返回含 slug", createRes.body?.data?.slug === createPayload.slug);
check("返回含正文", typeof createRes.body?.data?.content === "string");
check("阅读时长已计算", createRes.body?.data?.readingMinutes >= 1);
check("标签已保存", Array.isArray(createRes.body?.data?.tags) && createRes.body.data.tags.length === 2);
// 重复 slug → 409
const dupRes = await call("POST", "/api/posts", createPayload);
check("重复 slug 返回 409", dupRes.status === 409, `got ${dupRes.status}`);
check("冲突错误码正确", dupRes.body?.error?.code === "CONFLICT", JSON.stringify(dupRes.body?.error));
// 校验失败 → 400
const invalidRes = await call("POST", "/api/posts", {
slug: "Invalid_Slug",
title: "",
summary: "",
content: "",
});
check("非法输入返回 400", invalidRes.status === 400, `got ${invalidRes.status}`);
check("含字段级 details", Array.isArray(invalidRes.body?.error?.details));
check(
"details 指出 slug 问题",
invalidRes.body.error.details.some((d) => d.field === "slug"),
JSON.stringify(invalidRes.body?.error?.details),
);
// 非 JSON 请求体 → 400
const badJson = await fetch(`${BASE}/api/posts`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{ not json",
});
check("非法 JSON 返回 400", badJson.status === 400, `got ${badJson.status}`);
// ─────────────────────────────────────────────
console.log("\n── 4. 详情读取 ──────────────────────");
const detail = await call("GET", `/api/posts/${PREFIX}-create`);
check("GET 详情返回 200", detail.status === 200, `got ${detail.status}`);
check("详情含正文", typeof detail.body?.data?.content === "string");
check("详情标题正确", detail.body?.data?.title === createPayload.title);
const missing = await call("GET", "/api/posts/zz-does-not-exist");
check("不存在的 slug 返回 404", missing.status === 404, `got ${missing.status}`);
check("404 错误码正确", missing.body?.error?.code === "NOT_FOUND");
// ─────────────────────────────────────────────
console.log("\n── 5. 更新文章 ──────────────────────");
const patchRes = await call("PATCH", `/api/posts/${PREFIX}-create`, {
title: "测试文章:已更新标题",
});
check("PATCH 返回 200", patchRes.status === 200, `got ${patchRes.status}`);
check("标题已更新", patchRes.body?.data?.title === "测试文章:已更新标题");
check(
"未提供的字段保持不变",
patchRes.body?.data?.summary === createPayload.summary,
);
check("slug 未被意外修改", patchRes.body?.data?.slug === PREFIX + "-create");
// 部分更新正文 → 阅读时长应重算
const longContent = "中".repeat(1400);
const contentPatch = await call("PATCH", `/api/posts/${PREFIX}-create`, {
content: longContent,
});
check(
"更新正文后重算阅读时长",
contentPatch.body?.data?.readingMinutes >= 3,
`got ${contentPatch.body?.data?.readingMinutes}`,
);
// 空 body → 400
const emptyPatch = await call("PATCH", `/api/posts/${PREFIX}-create`, {});
check("空更新返回 400", emptyPatch.status === 400, `got ${emptyPatch.status}`);
// 更新不存在的资源 → 404
const patchMissing = await call("PATCH", "/api/posts/zz-nope", { title: "x" });
check("更新不存在的文章返回 404", patchMissing.status === 404, `got ${patchMissing.status}`);
// slug 改名
const renamed = await call("PATCH", `/api/posts/${PREFIX}-create`, {
slug: `${PREFIX}-renamed`,
});
check("可以修改 slug", renamed.status === 200 && renamed.body?.data?.slug === `${PREFIX}-renamed`);
const oldGone = await call("GET", `/api/posts/${PREFIX}-create`);
check("旧 slug 已不可访问", oldGone.status === 404, `got ${oldGone.status}`);
// ─────────────────────────────────────────────
console.log("\n── 6. 草稿流转与隔离 ────────────────");
const draftSlug = `${PREFIX}-draft`;
await call("DELETE", `/api/posts/${draftSlug}`);
const draftCreate = await call("POST", "/api/posts", {
slug: draftSlug,
title: "测试草稿",
summary: "草稿摘要",
content: "草稿正文",
status: "draft",
});
check("创建草稿返回 201", draftCreate.status === 201, `got ${draftCreate.status}`);
check("草稿 draft=true", draftCreate.body?.data?.draft === true);
const draftInList = await call("GET", "/api/posts");
check(
"草稿不出现在默认列表中",
!draftInList.body.data.some((p) => p.slug === draftSlug),
);
const draftDetail = await call("GET", `/api/posts/${draftSlug}`);
check("草稿详情返回 404", draftDetail.status === 404, `got ${draftDetail.status}`);
const draftIncluded = await call("GET", "/api/posts?includeDrafts=true");
check(
"includeDrafts=true 时可见草稿",
draftIncluded.body.data.some((p) => p.slug === draftSlug),
);
const draftOnly = await call("GET", "/api/posts?status=draft");
check(
"status=draft 只返回草稿",
draftOnly.body.data.length > 0 && draftOnly.body.data.every((p) => p.draft === true),
);
// 草稿 → 发布
const published = await call("PATCH", `/api/posts/${draftSlug}`, {
status: "published",
});
check("草稿可发布", published.status === 200 && published.body?.data?.draft === false);
check("发布后有日期", Boolean(published.body?.data?.date));
const nowVisible = await call("GET", `/api/posts/${draftSlug}`);
check("发布后可正常访问", nowVisible.status === 200, `got ${nowVisible.status}`);
// 已发布 → 草稿
const reDraft = await call("PATCH", `/api/posts/${draftSlug}`, { status: "draft" });
check("可退回草稿", reDraft.body?.data?.draft === true);
const hiddenAgain = await call("GET", `/api/posts/${draftSlug}`);
check("退回草稿后不可访问", hiddenAgain.status === 404, `got ${hiddenAgain.status}`);
// ─────────────────────────────────────────────
console.log("\n── 7. 删除 ──────────────────────────");
const del = await call("DELETE", `/api/posts/${PREFIX}-renamed`);
check("DELETE 返回 204", del.status === 204, `got ${del.status}`);
const delAgain = await call("DELETE", `/api/posts/${PREFIX}-renamed`);
check("重复删除返回 404", delAgain.status === 404, `got ${delAgain.status}`);
const afterDelete = await call("GET", `/api/posts/${PREFIX}-renamed`);
check("删除后不可访问", afterDelete.status === 404, `got ${afterDelete.status}`);
// 清理草稿
await call("DELETE", `/api/posts/${draftSlug}`);
// ─────────────────────────────────────────────
console.log("\n── 8. 清理确认 ──────────────────────");
const finalList = await call("GET", "/api/posts?includeDrafts=true&limit=100");
const leftovers = finalList.body.data.filter((p) => p.slug.startsWith(PREFIX));
check("测试数据已清理干净", leftovers.length === 0, JSON.stringify(leftovers.map((p) => p.slug)));
check("真实文章仍在", finalList.body.data.length >= 3, `剩余 ${finalList.body.data.length} 篇`);
console.log("\n================================");
console.log(` 通过 ${pass} / ${pass + fail}`);
if (failures.length > 0) {
console.log(" 失败项:");
for (const f of failures) console.log(` - ${f}`);
}
console.log("================================");
process.exitCode = fail > 0 ? 1 : 0;