/** * 校验规则的单元自测(不依赖服务器与数据库)。 * * 重点验证容易出错的地方: * - PATCH 空对象必须被拒绝(`.partial()` 不会移除 `.default()`) * - slug 格式、标签去重、日期转换 * - 列表查询参数的类型强制与边界 * * 用法:node scripts/test-validation.mjs */ import { createPostSchema, updatePostSchema, listPostsQuerySchema, estimateReadingMinutes, } from "../lib/validation/post.ts"; let pass = 0; let fail = 0; function check(name, condition, detail = "") { if (condition) { pass++; console.log(` [PASS] ${name}`); } else { fail++; console.log(` [FAIL] ${name} ${detail}`); } } console.log("── createPostSchema ─────────────────"); const validCreate = createPostSchema.safeParse({ slug: "hello-world", title: "标题", summary: "摘要", content: "正文", }); check("合法输入通过", validCreate.success); check("status 默认为 draft", validCreate.data?.status === "draft"); check("tags 默认为空数组", Array.isArray(validCreate.data?.tags) && validCreate.data.tags.length === 0); const badSlug = createPostSchema.safeParse({ slug: "Hello_World", title: "t", summary: "s", content: "c", }); check("非法 slug 被拒绝", !badSlug.success); const emptyTitle = createPostSchema.safeParse({ slug: "ok", title: " ", summary: "s", content: "c", }); check("纯空白标题被拒绝", !emptyTitle.success); const dupTags = createPostSchema.safeParse({ slug: "ok", title: "t", summary: "s", content: "c", tags: ["a", "a", "b"], }); check("标签自动去重", dupTags.success && dupTags.data.tags.length === 2, JSON.stringify(dupTags.data?.tags)); console.log("\n── updatePostSchema(关键回归点)──"); const emptyUpdate = updatePostSchema.safeParse({}); check("空对象被拒绝(回归:不得因 default 而通过)", !emptyUpdate.success, JSON.stringify(emptyUpdate.data)); const titleOnly = updatePostSchema.safeParse({ title: "新标题" }); check("只传 title 通过", titleOnly.success); check("未传的字段不出现在结果中", !("summary" in (titleOnly.data ?? {})), JSON.stringify(titleOnly.data)); const tagsOnly = updatePostSchema.safeParse({ tags: ["x"] }); check("只传 tags 通过", tagsOnly.success); const invalidStatus = updatePostSchema.safeParse({ status: "archived" }); check("非法 status 被拒绝", !invalidStatus.success); const slugUpdate = updatePostSchema.safeParse({ slug: "new-slug" }); check("可单独更新 slug", slugUpdate.success); console.log("\n── listPostsQuerySchema ─────────────"); const defaults = listPostsQuerySchema.safeParse({}); check("默认 limit=20", defaults.data?.limit === 20); check("默认 offset=0", defaults.data?.offset === 0); check("默认 includeDrafts=false", defaults.data?.includeDrafts === false); const coerced = listPostsQuerySchema.safeParse({ limit: "5", offset: "10" }); check("字符串数字被强制转换", coerced.data?.limit === 5 && coerced.data?.offset === 10); const tooBig = listPostsQuerySchema.safeParse({ limit: "101" }); check("limit 超上限被拒绝", !tooBig.success); const zero = listPostsQuerySchema.safeParse({ limit: "0" }); check("limit=0 被拒绝", !zero.success); const negative = listPostsQuerySchema.safeParse({ offset: "-5" }); check("负 offset 被拒绝", !negative.success); const drafts = listPostsQuerySchema.safeParse({ includeDrafts: "true" }); check("includeDrafts 字符串转布尔", drafts.data?.includeDrafts === true); const badStatus = listPostsQuerySchema.safeParse({ status: "nope" }); check("非法 status 被拒绝", !badStatus.success); console.log("\n── estimateReadingMinutes ───────────"); check("空内容至少 1 分钟", estimateReadingMinutes("") === 1); check("短内容为 1 分钟", estimateReadingMinutes("hello world") === 1); check( "长中文内容时长增加", estimateReadingMinutes("中".repeat(1400)) >= 3, `got ${estimateReadingMinutes("中".repeat(1400))}`, ); check( "长英文内容时长增加", estimateReadingMinutes("word ".repeat(1000)) >= 4, `got ${estimateReadingMinutes("word ".repeat(1000))}`, ); console.log("\n================================"); console.log(` 通过 ${pass} / ${pass + fail}`); console.log("================================"); process.exitCode = fail > 0 ? 1 : 0;