This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 检查「不存在的文章 / 草稿」是否返回真正的 HTTP 404。
|
||||
*
|
||||
* 背景:在动态路由(无 generateStaticParams 预生成)中,
|
||||
* `notFound()` 的行为依赖渲染模式。若页面被当作静态/缓存内容渲染,
|
||||
* 可能只渲染 404 内容但状态码仍是 200——这对 SEO 有害,
|
||||
* 搜索引擎会把不存在的 URL 当成有效页面收录。
|
||||
*
|
||||
* 用法:node scripts/check-404.mjs [baseUrl]
|
||||
*/
|
||||
|
||||
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
|
||||
const cases = [
|
||||
{ path: "/zh/posts/definitely-not-exist", expect: 404, label: "不存在的文章" },
|
||||
{ path: "/zh/posts/web-performance-priority", expect: 404, label: "草稿文章" },
|
||||
{ path: "/zh/no-such-page", expect: 404, label: "不存在的页面" },
|
||||
{ path: "/zh/posts/building-a-blog-with-nextjs", expect: 200, label: "已发布文章" },
|
||||
{ path: "/api/posts/definitely-not-exist", expect: 404, label: "API:不存在的文章" },
|
||||
{ path: "/api/posts/web-performance-priority", expect: 404, label: "API:草稿" },
|
||||
];
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const response = await fetch(`${BASE}${testCase.path}`, { redirect: "manual" });
|
||||
const okStatus = response.status === testCase.expect;
|
||||
|
||||
if (okStatus) {
|
||||
pass++;
|
||||
console.log(` [PASS] ${testCase.label}: ${response.status}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(
|
||||
` [FAIL] ${testCase.label}: 期望 ${testCase.expect},实际 ${response.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 通过 ${pass} / ${pass + fail}`);
|
||||
process.exitCode = fail > 0 ? 1 : 0;
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 检查文章页代码块在窄屏下的滚动行为。
|
||||
*
|
||||
* 期望:代码块自身横向滚动(overflow-x: auto),
|
||||
* 而不是把整个页面撑出横向滚动条。
|
||||
*
|
||||
* 用法:CDP_WS=<ws地址> node scripts/check-code-blocks.mjs <url> <width>
|
||||
*/
|
||||
|
||||
const [, , targetUrl, widthArg = "390"] = process.argv;
|
||||
const width = Number(widthArg);
|
||||
|
||||
const wsUrl = process.env.CDP_WS;
|
||||
if (!wsUrl) {
|
||||
console.error("缺少 CDP_WS 环境变量");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = new WebSocket(wsUrl);
|
||||
let messageId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
function send(method, params = {}) {
|
||||
const id = ++messageId;
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`CDP 超时: ${method}`));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id && pending.has(message.id)) {
|
||||
const { resolve } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
resolve(message.result);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("open", async () => {
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
width,
|
||||
height: 900,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
});
|
||||
await send("Page.navigate", { url: targetUrl });
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
const result = await send("Runtime.evaluate", {
|
||||
expression: `(() => {
|
||||
const pres = [...document.querySelectorAll('.prose-blog pre')];
|
||||
const doc = document.documentElement;
|
||||
|
||||
return {
|
||||
documentOverflow: doc.scrollWidth - doc.clientWidth,
|
||||
preCount: pres.length,
|
||||
blocks: pres.map((pre, index) => {
|
||||
const figure = pre.closest('figure');
|
||||
return {
|
||||
index,
|
||||
overflowX: getComputedStyle(pre).overflowX,
|
||||
scrollable: pre.scrollWidth > pre.clientWidth,
|
||||
scrollWidth: pre.scrollWidth,
|
||||
clientWidth: pre.clientWidth,
|
||||
figureOverflow: figure ? getComputedStyle(figure).overflow : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(result.result.value, null, 2));
|
||||
socket.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 数据库连通性探测(node-postgres 版)。
|
||||
*
|
||||
* 用法:node scripts/db-check.mjs
|
||||
*
|
||||
* 与之前的版本不同,这里直接用 pg 驱动走 TCP 协议,
|
||||
* 因此能连接本地/自建的普通 Postgres。
|
||||
*/
|
||||
|
||||
import pg from "pg";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("缺少 DATABASE_URL(检查 .env.local)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const masked = url.replace(/:\/\/([^:]+):[^@]+@/, "://$1:***@");
|
||||
console.log(`连接目标: ${masked}\n`);
|
||||
|
||||
const parsed = new URL(url);
|
||||
const sslmode = parsed.searchParams.get("sslmode");
|
||||
const isLocal = /^(localhost|127\.0\.0\.1|::1)$/.test(parsed.hostname);
|
||||
const useSsl = sslmode === "disable" ? false : sslmode ? true : !isLocal;
|
||||
|
||||
console.log(`主机: ${parsed.hostname}:${parsed.port || 5432}`);
|
||||
console.log(`sslmode: ${sslmode ?? "(未指定)"} → 启用 SSL: ${useSsl}\n`);
|
||||
|
||||
const client = new pg.Client({
|
||||
connectionString: url,
|
||||
ssl: useSsl ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const info = await client.query(
|
||||
"select version() as version, current_database() as db, current_user as usr",
|
||||
);
|
||||
console.log("✓ 连接成功");
|
||||
console.log(` 数据库: ${info.rows[0].db}`);
|
||||
console.log(` 用户: ${info.rows[0].usr}`);
|
||||
console.log(` 版本: ${String(info.rows[0].version).split(",")[0]}\n`);
|
||||
|
||||
const tables = await client.query(
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = 'public' order by table_name`,
|
||||
);
|
||||
|
||||
console.log(`public schema 下的表 (${tables.rowCount}):`);
|
||||
if (tables.rowCount === 0) {
|
||||
console.log(" (空 —— 尚未执行迁移)");
|
||||
} else {
|
||||
for (const row of tables.rows) {
|
||||
const count = await client.query(
|
||||
`select count(*)::int as n from "${row.table_name}"`,
|
||||
);
|
||||
console.log(` - ${row.table_name} (${count.rows[0].n} 行)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("✗ 连接失败:", error.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 诊断 DATABASE_URL 与驱动/SSL 配置是否匹配。
|
||||
*
|
||||
* 覆盖两类常见问题:
|
||||
* 1. **驱动不匹配**:@neondatabase/serverless 的 neon-http 走 Neon 的 HTTP 代理,
|
||||
* 无法连接普通 Postgres(需改用 pg)。
|
||||
* 2. **SSL 不匹配**:本地 Postgres 默认不开 SSL,若连接串带 sslmode=require
|
||||
* 会报 "The server does not support SSL connections"。
|
||||
*
|
||||
* 用法:node scripts/db-diagnose.mjs
|
||||
*/
|
||||
|
||||
import { config } from "dotenv";
|
||||
import net from "node:net";
|
||||
import pg from "pg";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("缺少 DATABASE_URL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
console.error(`DATABASE_URL 不是合法 URL: ${url}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const host = parsed.hostname;
|
||||
const port = Number(parsed.port || 5432);
|
||||
const database = parsed.pathname.replace(/^\//, "");
|
||||
const sslmode = parsed.searchParams.get("sslmode");
|
||||
const isNeonHost = /\.neon\.tech$/i.test(host);
|
||||
const isLocal = /^(localhost|127\.0\.0\.1|::1)$/.test(host);
|
||||
|
||||
console.log("── DATABASE_URL 解析 ─────────────────");
|
||||
console.log(` host: ${host}`);
|
||||
console.log(` port: ${port}`);
|
||||
console.log(` database: ${database}`);
|
||||
console.log(` sslmode: ${sslmode ?? "(未指定)"}`);
|
||||
console.log(` Neon 托管域名: ${isNeonHost ? "是" : "否"}`);
|
||||
console.log(` 本地地址: ${isLocal ? "是" : "否"}\n`);
|
||||
|
||||
/** 探测 TCP 端口是否可连接 */
|
||||
function probeTcp(hostname, tcpPort, timeoutMs = 3000) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: hostname, port: tcpPort });
|
||||
const done = (result) => {
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("timeout", () => done(false));
|
||||
socket.once("error", () => done(false));
|
||||
});
|
||||
}
|
||||
|
||||
const tcpReachable = await probeTcp(host, port);
|
||||
|
||||
console.log("── 网络连通性 ────────────────────────");
|
||||
console.log(` TCP ${host}:${port} 可达: ${tcpReachable ? "是" : "否"}\n`);
|
||||
|
||||
// 依次尝试「按 sslmode」与「不启用 SSL」,判断是否为 SSL 配置问题
|
||||
async function tryConnect(useSsl) {
|
||||
const client = new pg.Client({
|
||||
connectionString: url,
|
||||
ssl: useSsl ? { rejectUnauthorized: false } : undefined,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.query("select 1");
|
||||
await client.end();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
await client.end().catch(() => {});
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
console.log("── 实际连接测试 ──────────────────────");
|
||||
const withConfiguredSsl = await tryConnect(
|
||||
sslmode === "disable" ? false : sslmode ? true : !isLocal,
|
||||
);
|
||||
console.log(
|
||||
` 按当前 sslmode 连接: ${withConfiguredSsl.ok ? "✓ 成功" : `✗ ${withConfiguredSsl.error}`}`,
|
||||
);
|
||||
|
||||
let oppositeResult = null;
|
||||
if (!withConfiguredSsl.ok) {
|
||||
oppositeResult = await tryConnect(!(sslmode === "disable"));
|
||||
console.log(
|
||||
` 改用相反 SSL 设置: ${oppositeResult.ok ? "✓ 成功" : `✗ ${oppositeResult.error}`}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
console.log("── 结论 ──────────────────────────────");
|
||||
if (withConfiguredSsl.ok) {
|
||||
console.log(" ✓ 配置正确,数据库可用。");
|
||||
} else if (oppositeResult?.ok) {
|
||||
console.log(" ✗ SSL 配置与服务器不匹配。");
|
||||
console.log(
|
||||
isLocal
|
||||
? " 本地 Postgres 默认不启用 SSL,请把连接串改为 ?sslmode=disable"
|
||||
: " 该服务器要求 SSL,请在连接串中加上 ?sslmode=require",
|
||||
);
|
||||
} else if (!tcpReachable) {
|
||||
console.log(" ✗ 端口不可达:请确认数据库已启动、主机与端口正确。");
|
||||
} else {
|
||||
console.log(" ✗ 连接失败,且与 SSL 无关,请检查用户名/密码/数据库名。");
|
||||
console.log(" → 若使用 neon-http 驱动连接普通 Postgres,需改用 pg 驱动。");
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 验证 posts 表的约束与索引是否按预期生效。
|
||||
*
|
||||
* 这里直接对数据库做"坏写入"测试,确认约束真的能拦住非法数据——
|
||||
* 只检查迁移文件容易漏掉约束静默失效的情况。
|
||||
*
|
||||
* 用法:node scripts/db-verify-schema.mjs
|
||||
*/
|
||||
|
||||
import pg from "pg";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
const client = new pg.Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: /localhost|127\.0\.0\.1/.test(process.env.DATABASE_URL ?? "")
|
||||
? undefined
|
||||
: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 期望插入失败(被约束拦截),返回错误信息 */
|
||||
async function expectReject(label, sql, params) {
|
||||
try {
|
||||
await client.query(sql, params);
|
||||
check(label, false, "← 竟然插入成功了,约束未生效");
|
||||
// 清理掉误插入的数据
|
||||
await client.query("delete from posts where slug like 'zz-test-%'");
|
||||
} catch (error) {
|
||||
check(label, true);
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
await client.connect();
|
||||
|
||||
console.log("── 列结构 ────────────────────────────");
|
||||
const cols = await client.query(
|
||||
`select column_name, data_type, is_nullable, column_default
|
||||
from information_schema.columns where table_name='posts' order by ordinal_position`,
|
||||
);
|
||||
const colNames = cols.rows.map((r) => r.column_name);
|
||||
console.log(` 列 (${colNames.length}): ${colNames.join(", ")}`);
|
||||
|
||||
check("包含 slug 列", colNames.includes("slug"));
|
||||
check("包含 status 列", colNames.includes("status"));
|
||||
check("包含 tags 列", colNames.includes("tags"));
|
||||
check("包含 published_at 列", colNames.includes("published_at"));
|
||||
check("包含 reading_minutes 列", colNames.includes("reading_minutes"));
|
||||
|
||||
const tagsCol = cols.rows.find((r) => r.column_name === "tags");
|
||||
check("tags 为数组类型", tagsCol?.data_type === "ARRAY", `got ${tagsCol?.data_type}`);
|
||||
|
||||
console.log("\n── 索引 ──────────────────────────────");
|
||||
const idx = await client.query(
|
||||
`select indexname, indexdef from pg_indexes where tablename='posts' order by indexname`,
|
||||
);
|
||||
for (const row of idx.rows) {
|
||||
console.log(` - ${row.indexname}`);
|
||||
}
|
||||
const idxNames = idx.rows.map((r) => r.indexname);
|
||||
check("slug 唯一索引存在", idxNames.includes("posts_slug_unique_idx"));
|
||||
check("状态+发布时间索引存在", idxNames.includes("posts_status_published_at_idx"));
|
||||
check("标签 GIN 索引存在", idxNames.includes("posts_tags_gin_idx"));
|
||||
|
||||
const ginDef = idx.rows.find((r) => r.indexname === "posts_tags_gin_idx")?.indexdef ?? "";
|
||||
check("标签索引使用 gin", /using gin/i.test(ginDef), ginDef);
|
||||
|
||||
console.log("\n── 约束 ──────────────────────────────");
|
||||
const cons = await client.query(
|
||||
`select conname, pg_get_constraintdef(oid) as def
|
||||
from pg_constraint where conrelid = 'posts'::regclass order by conname`,
|
||||
);
|
||||
for (const row of cons.rows) {
|
||||
console.log(` - ${row.conname}: ${row.def}`);
|
||||
}
|
||||
const conNames = cons.rows.map((r) => r.conname);
|
||||
check("status CHECK 约束存在", conNames.includes("posts_status_check"));
|
||||
check("slug 格式 CHECK 约束存在", conNames.includes("posts_slug_format_check"));
|
||||
|
||||
console.log("\n── 约束实际拦截能力(坏写入测试)──");
|
||||
await expectReject(
|
||||
"拒绝非法 status",
|
||||
`insert into posts (slug,title,summary,content,status) values ($1,$2,$3,$4,$5)`,
|
||||
["zz-test-badstatus", "t", "s", "c", "archived"],
|
||||
);
|
||||
await expectReject(
|
||||
"拒绝非法 slug(大写/下划线)",
|
||||
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
|
||||
["ZZ_Test_Slug", "t", "s", "c"],
|
||||
);
|
||||
|
||||
// 唯一约束需要先插入一行,再插入同样的 slug 才能验证
|
||||
await client.query(
|
||||
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
|
||||
["zz-test-dup", "t", "s", "c"],
|
||||
);
|
||||
await expectReject(
|
||||
"拒绝重复 slug",
|
||||
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
|
||||
["zz-test-dup", "t", "s", "c"],
|
||||
);
|
||||
|
||||
console.log("\n── 正常写入与数组读写 ───────────────");
|
||||
try {
|
||||
const inserted = await client.query(
|
||||
`insert into posts (slug,title,summary,content,tags,status,published_at,reading_minutes)
|
||||
values ($1,$2,$3,$4,$5,$6,now(),$7) returning id, tags, status`,
|
||||
[
|
||||
"zz-test-ok",
|
||||
"正常文章",
|
||||
"摘要",
|
||||
"正文",
|
||||
["Next.js", "数据库"],
|
||||
"published",
|
||||
3,
|
||||
],
|
||||
);
|
||||
check("合法数据可写入", inserted.rowCount === 1);
|
||||
check(
|
||||
"tags 数组正确回读",
|
||||
Array.isArray(inserted.rows[0].tags) && inserted.rows[0].tags.length === 2,
|
||||
JSON.stringify(inserted.rows[0].tags),
|
||||
);
|
||||
|
||||
// 验证 GIN 索引可用的标签查询语法
|
||||
const byTag = await client.query(
|
||||
`select count(*)::int as n from posts where tags @> ARRAY[$1]::text[]`,
|
||||
["Next.js"],
|
||||
);
|
||||
check("按标签包含查询可用", byTag.rows[0].n >= 1);
|
||||
|
||||
const anyTag = await client.query(
|
||||
`select count(*)::int as n from posts where $1 = any(tags)`,
|
||||
["数据库"],
|
||||
);
|
||||
check("按标签 ANY 查询可用", anyTag.rows[0].n >= 1);
|
||||
} finally {
|
||||
// 清理测试数据
|
||||
await client.query("delete from posts where slug like 'zz-test-%'");
|
||||
}
|
||||
|
||||
const remaining = await client.query("select count(*)::int as n from posts");
|
||||
console.log(`\n清理后剩余行数: ${remaining.rows[0].n}`);
|
||||
|
||||
console.log("\n================================");
|
||||
console.log(` 通过 ${pass} / ${pass + fail}`);
|
||||
console.log("================================");
|
||||
|
||||
await client.end();
|
||||
process.exitCode = fail > 0 ? 1 : 0;
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 隔离测试:确认 notFound() 返回 200 的根因。
|
||||
*
|
||||
* 依次访问三种路径,对比状态码:
|
||||
* 1. 未匹配任何路由的路径(由 Next.js 自身处理)
|
||||
* 2. 匹配动态路由但数据不存在(由页面内 notFound() 处理)
|
||||
* 3. 正常存在的数据
|
||||
*
|
||||
* 用法:node scripts/diagnose-404.mjs [baseUrl]
|
||||
*/
|
||||
|
||||
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
|
||||
const cases = [
|
||||
["/zh/this-route-does-not-exist", "未匹配路由"],
|
||||
["/zh/posts/zzz-absent", "动态路由 + notFound()"],
|
||||
["/zh/posts/building-a-blog-with-nextjs", "正常存在的文章"],
|
||||
["/api/posts/zzz-absent", "API 路由 404"],
|
||||
];
|
||||
|
||||
for (const [path, label] of cases) {
|
||||
const response = await fetch(`${BASE}${path}`, { redirect: "manual" });
|
||||
|
||||
// 检查响应头,判断是否为流式响应
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
console.log(
|
||||
`${String(response.status).padStart(3)} ${label.padEnd(24)} ${path}`,
|
||||
);
|
||||
console.log(
|
||||
` content-type=${contentType.split(";")[0]} ` +
|
||||
`x-nextjs-cache=${response.headers.get("x-nextjs-cache") ?? "-"} ` +
|
||||
`cache-control=${(response.headers.get("cache-control") ?? "-").slice(0, 40)}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 通过 Chrome DevTools Protocol 测量页面在移动端视口下的横向溢出。
|
||||
*
|
||||
* 用法:node scripts/measure-overflow.mjs <url> <width> <height>
|
||||
*
|
||||
* 之所以单独写这个脚本:横向溢出是纯视觉问题,单看截图难以定位到
|
||||
* 具体是哪个元素撑破了布局。这里直接问浏览器要每个元素的 boundingRect。
|
||||
*/
|
||||
|
||||
const [, , targetUrl, widthArg = "390", heightArg = "900"] = process.argv;
|
||||
const width = Number(widthArg);
|
||||
const height = Number(heightArg);
|
||||
|
||||
const wsUrl = process.env.CDP_WS;
|
||||
if (!wsUrl) {
|
||||
console.error("缺少 CDP_WS 环境变量(WebSocket 调试地址)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = new WebSocket(wsUrl);
|
||||
let messageId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
/** 发送 CDP 命令并等待响应 */
|
||||
function send(method, params = {}) {
|
||||
const id = ++messageId;
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`CDP 超时: ${method}`));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
/** 在页面上下文中执行表达式并返回其值 */
|
||||
async function evaluate(expression) {
|
||||
const result = await send("Runtime.evaluate", {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error(
|
||||
`页面执行异常: ${result.exceptionDetails.text} ${
|
||||
result.exceptionDetails.exception?.description ?? ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result.result.value;
|
||||
}
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id && pending.has(message.id)) {
|
||||
const { resolve, reject } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) {
|
||||
reject(new Error(message.error.message));
|
||||
} else {
|
||||
resolve(message.result);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("error", (error) => {
|
||||
console.error("WebSocket 错误:", error.message ?? error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
socket.addEventListener("open", async () => {
|
||||
try {
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
});
|
||||
|
||||
await send("Page.navigate", { url: targetUrl });
|
||||
// 等待网络空闲 + 首屏渲染完成
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
const report = await evaluate(`(() => {
|
||||
const doc = document.documentElement;
|
||||
const clientWidth = doc.clientWidth;
|
||||
const offenders = [];
|
||||
|
||||
for (const el of document.querySelectorAll("body *")) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) continue;
|
||||
|
||||
const overflowRight = rect.right - clientWidth;
|
||||
const overflowLeft = -rect.left;
|
||||
|
||||
if (overflowRight > 1 || overflowLeft > 1) {
|
||||
offenders.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: String(el.className || "").slice(0, 90),
|
||||
text: (el.textContent || "").trim().slice(0, 34),
|
||||
left: Math.round(rect.left),
|
||||
right: Math.round(rect.right),
|
||||
width: Math.round(rect.width),
|
||||
overflowRight: Math.round(overflowRight),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 只保留"最外层"的溢出元素,避免父子的重复报告
|
||||
const minimal = offenders.filter((candidate) => {
|
||||
return !offenders.some(
|
||||
(other) =>
|
||||
other !== candidate &&
|
||||
other.left <= candidate.left &&
|
||||
other.right >= candidate.right &&
|
||||
other.width > candidate.width,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
clientWidth,
|
||||
scrollWidth: doc.scrollWidth,
|
||||
documentOverflow: doc.scrollWidth - clientWidth,
|
||||
culpritCount: minimal.length,
|
||||
culprits: minimal.slice(0, 12),
|
||||
};
|
||||
})()`);
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
socket.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("测量失败:", error.message);
|
||||
socket.close();
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 通过 CDP 在精确的视口尺寸下截图(含整页截图)。
|
||||
*
|
||||
* 为什么不直接用 `chrome --headless --screenshot --window-size`:
|
||||
* 该模式下截图位图宽度与 CSS 视口宽度可能不一致,导致移动端截图
|
||||
* 看起来"内容被截断",实际测量(document.scrollWidth)却是正常的。
|
||||
* 这里用 Emulation.setDeviceMetricsOverride 明确指定视口,
|
||||
* 保证截图与真实设备表现一致。
|
||||
*
|
||||
* 用法:CDP_WS=<ws地址> node scripts/screenshot.mjs <url> <width> <height> <输出路径> [--full]
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const [, , targetUrl, widthArg, heightArg, outputPath, ...flags] = process.argv;
|
||||
const width = Number(widthArg ?? 1440);
|
||||
const height = Number(heightArg ?? 900);
|
||||
const fullPage = flags.includes("--full");
|
||||
|
||||
const wsUrl = process.env.CDP_WS;
|
||||
if (!wsUrl || !targetUrl || !outputPath) {
|
||||
console.error(
|
||||
"用法:CDP_WS=<ws地址> node scripts/screenshot.mjs <url> <width> <height> <输出路径> [--full]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = new WebSocket(wsUrl);
|
||||
let messageId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
function send(method, params = {}) {
|
||||
const id = ++messageId;
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`CDP 超时: ${method}`));
|
||||
}
|
||||
}, 40000);
|
||||
});
|
||||
}
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id && pending.has(message.id)) {
|
||||
const { resolve } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
resolve(message.result);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("open", async () => {
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: width < 768,
|
||||
});
|
||||
|
||||
await send("Page.navigate", { url: targetUrl });
|
||||
// 等待字体加载与首屏动画稳定
|
||||
await new Promise((resolve) => setTimeout(resolve, 4500));
|
||||
|
||||
// 让所有延迟出现的元素(滚动动画)进入最终状态
|
||||
await send("Runtime.evaluate", {
|
||||
expression:
|
||||
"window.scrollTo(0, document.body.scrollHeight); window.scrollTo(0, 0);",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
|
||||
const screenshot = await send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
captureBeyondViewport: fullPage,
|
||||
optimizeForSpeed: false,
|
||||
});
|
||||
|
||||
writeFileSync(outputPath, Buffer.from(screenshot.data, "base64"));
|
||||
console.log(`已保存 ${outputPath} (${width}x${height}${fullPage ? ", 整页" : ""})`);
|
||||
socket.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 把 content/posts/ 下的 Markdown 文件导入数据库。
|
||||
*
|
||||
* 用途:一次性把现有的文件式内容迁移进 posts 表,
|
||||
* 让数据库成为唯一内容源。
|
||||
*
|
||||
* 行为:
|
||||
* - 幂等:按 slug upsert(存在则更新,不存在则插入)
|
||||
* - 解析 YAML frontmatter 并做与 API 相同的校验
|
||||
* - `draft: true` 映射为 status='draft'
|
||||
* - 打印每条记录的处理结果
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/seed-posts.mjs # 导入 / 更新
|
||||
* node scripts/seed-posts.mjs --dry-run # 只解析并打印,不写库
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
import pg from "pg";
|
||||
import matter from "gray-matter";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
const DRY_RUN = process.argv.includes("--dry-run");
|
||||
const POSTS_DIR = join(process.cwd(), "content", "posts");
|
||||
|
||||
if (!existsSync(POSTS_DIR)) {
|
||||
console.error(`内容目录不存在: ${POSTS_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** 估算阅读时长(与 lib/validation/post.ts 保持一致) */
|
||||
function estimateReadingMinutes(content) {
|
||||
const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0;
|
||||
const latinWords =
|
||||
content.replace(/[\u4e00-\u9fa5]/g, " ").match(/[A-Za-z0-9]+/g)?.length ??
|
||||
0;
|
||||
return Math.max(1, Math.round(chineseCharacters / 350 + latinWords / 200));
|
||||
}
|
||||
|
||||
/** 从 frontmatter 解析出可写入数据库的记录 */
|
||||
function parseFile(fileName) {
|
||||
const slug = fileName.replace(/\.(mdx|md)$/i, "");
|
||||
const raw = readFileSync(join(POSTS_DIR, fileName), "utf8");
|
||||
const { data, content } = matter(raw);
|
||||
|
||||
const problems = [];
|
||||
const title = typeof data.title === "string" ? data.title.trim() : "";
|
||||
if (!title) problems.push("缺少 title");
|
||||
|
||||
let date = "";
|
||||
if (data.date instanceof Date && !Number.isNaN(data.date.getTime())) {
|
||||
date = data.date.toISOString().slice(0, 10);
|
||||
} else if (typeof data.date === "string" && data.date.trim()) {
|
||||
date = data.date.trim();
|
||||
}
|
||||
if (!date || Number.isNaN(new Date(date).getTime())) {
|
||||
problems.push("缺少或非法的 date");
|
||||
}
|
||||
|
||||
const summary = typeof data.summary === "string" ? data.summary.trim() : "";
|
||||
if (!summary) problems.push("缺少 summary");
|
||||
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
||||
problems.push(`slug "${slug}" 不符合 ^[a-z0-9]+(-[a-z0-9]+)*$(数据库 CHECK 约束会拒绝)`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
return { slug, error: problems.join("; ") };
|
||||
}
|
||||
|
||||
return {
|
||||
slug,
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
|
||||
status: data.draft === true ? "draft" : "published",
|
||||
publishedAt: new Date(`${date}T00:00:00Z`),
|
||||
cover: typeof data.cover === "string" ? data.cover : null,
|
||||
description:
|
||||
typeof data.description === "string" ? data.description : null,
|
||||
readingMinutes: estimateReadingMinutes(content),
|
||||
};
|
||||
}
|
||||
|
||||
const files = readdirSync(POSTS_DIR).filter((f) =>
|
||||
[".md", ".mdx"].includes(extname(f).toLowerCase()),
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("content/posts/ 下没有 Markdown 文件");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`发现 ${files.length} 个内容文件${DRY_RUN ? "(dry-run,不写库)" : ""}\n`);
|
||||
|
||||
const records = [];
|
||||
for (const file of files) {
|
||||
const parsed = parseFile(file);
|
||||
if (parsed.error) {
|
||||
console.error(`✗ ${file}: ${parsed.error}`);
|
||||
process.exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
records.push(parsed);
|
||||
}
|
||||
|
||||
if (process.exitCode === 1) {
|
||||
console.error("\n存在解析失败的文件,已中止导入。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
for (const r of records) {
|
||||
console.log(
|
||||
` ${r.status === "draft" ? "[草稿]" : "[已发布]"} ${r.slug} 日期=${r.publishedAt
|
||||
.toISOString()
|
||||
.slice(0, 10)} 标签=${r.tags.join(",") || "-"} ${r.readingMinutes}分钟`,
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const client = new pg.Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: /localhost|127\.0\.0\.1/.test(process.env.DATABASE_URL ?? "")
|
||||
? undefined
|
||||
: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
||||
try {
|
||||
for (const r of records) {
|
||||
// upsert:以 slug 为冲突键
|
||||
const result = await client.query(
|
||||
`insert into posts
|
||||
(slug, title, summary, content, tags, status, published_at,
|
||||
cover, description, reading_minutes, created_at, updated_at)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, now(), now())
|
||||
on conflict (slug) do update set
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
content = excluded.content,
|
||||
tags = excluded.tags,
|
||||
status = excluded.status,
|
||||
published_at = excluded.published_at,
|
||||
cover = excluded.cover,
|
||||
description = excluded.description,
|
||||
reading_minutes = excluded.reading_minutes,
|
||||
updated_at = now()
|
||||
returning (xmax = 0) as is_insert`,
|
||||
[
|
||||
r.slug,
|
||||
r.title,
|
||||
r.summary,
|
||||
r.content,
|
||||
r.tags,
|
||||
r.status,
|
||||
r.status === "published" ? r.publishedAt : null,
|
||||
r.cover,
|
||||
r.description,
|
||||
r.readingMinutes,
|
||||
],
|
||||
);
|
||||
|
||||
const isInsert = result.rows[0].is_insert;
|
||||
if (isInsert) inserted++;
|
||||
else updated++;
|
||||
|
||||
console.log(
|
||||
` ${isInsert ? "+ 新增" : "~ 更新"} ${r.slug} (${r.status}, ${r.readingMinutes} 分钟)`,
|
||||
);
|
||||
}
|
||||
|
||||
const total = await client.query("select count(*)::int as n from posts");
|
||||
console.log(`\n完成:新增 ${inserted},更新 ${updated},表内共 ${total.rows[0].n} 行`);
|
||||
} catch (error) {
|
||||
console.error("\n导入失败:", error.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 校验规则的单元自测(不依赖服务器与数据库)。
|
||||
*
|
||||
* 重点验证容易出错的地方:
|
||||
* - 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;
|
||||
Reference in new issue
Block a user