/** * 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;