307 lines
13 KiB
JavaScript
307 lines
13 KiB
JavaScript
/**
|
||
* 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;
|