Files
app-261004/scripts/test-api.mjs
T
2026-10-08 11:09:37 +08:00

307 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;