/** * editor-api 端到端回归测试(对真实仓库跑,测完自动还原)。 * * node test/api-e2e.mjs [baseUrl] [token] * * 重点验证「不写坏老文章」这条底线: * ① 只改正文 → front matter 区块必须**逐字节不变**(含 pid / ai_comment 等冷门键) * ② 原样回存 → 整个文件必须**逐字节不变** * ③ 改 front matter 字段 → 其余字段必须原样保留 */ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; const BASE = process.argv[2] || 'http://127.0.0.1:8017'; const TOKEN = process.argv[3] || 'devtoken'; const ROOT = path.resolve(import.meta.dirname, '../..'); const TARGET_SLUG = '20251121'; const TARGET_FILE = path.join(ROOT, 'content/posts/2025/2025-11-21-除草日记/index.md'); // ★ API 的定位键是**目录名**(全仓唯一),不是 slug —— 仓库里有 5 组 slug 撞名, // 按 slug 取会静默地操作到另一篇文章上。这里刻意用带中文的目录名走一遍, // 顺带验证 URL 编码链路。 const TARGET_ID = path.basename(path.dirname(TARGET_FILE)); const P = (id) => '/posts/' + encodeURIComponent(id); const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); const read = (p) => fs.readFileSync(p, 'utf8'); let pass = 0; const fails = []; function check(name, cond, extra = '') { if (cond) { pass++; console.log(' ✅ ' + name); } else { fails.push(name + (extra ? ' → ' + extra : '')); console.log(' ❌ ' + name + (extra ? ' → ' + extra : '')); } } async function call(method, p, body, raw) { const res = await fetch(BASE + p, { method, headers: { 'X-Editor-Token': TOKEN, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined, }); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } return { status: res.status, data }; } const original = read(TARGET_FILE); const originalSha = sha(TARGET_FILE); console.log('\n== ① 鉴权 =='); { const noToken = await fetch(BASE + '/posts'); check('无令牌 → 401', noToken.status === 401, '实际 ' + noToken.status); const health = await fetch(BASE + '/health'); check('/health 免鉴权', health.status === 200, '实际 ' + health.status); const bad = await call('GET', '/posts'); check('带令牌 → 200', bad.status === 200, '实际 ' + bad.status); } console.log('\n== ② 读取 =='); let post; { const r = await call('GET', P(TARGET_ID)); post = r.data; check('能用目录名(含中文)定位', r.status === 200 && post.slug === TARGET_SLUG, r.status + ' / ' + post?.slug); check('返回 id = 目录名', post.id === TARGET_ID, String(post.id)); check('front matter 解析成功', post.frontMatter && !post.frontMatterError, String(post.frontMatterError || '')); check('冷门键 pid 已解析', post.frontMatter && post.frontMatter.pid === 133, JSON.stringify(post.frontMatter?.pid)); check('title 正确', post.title === '除草日记', String(post.title)); } console.log('\n== ③ 原样回存 → 必须逐字节不变 =='); { const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content }); check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120)); check('文件 sha256 未变', sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16) + ' vs ' + originalSha.slice(0, 16)); } console.log('\n== ④ 只改正文 → front matter 区块必须逐字节不变 =='); { const body = post.content + '\n\n(测试追加的一行,稍后还原)\n'; const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: body }); check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120)); const now = read(TARGET_FILE); const fmOf = (t) => t.replace(/\r\n/g, '\n').split('\n').slice(0, t.split('\n').findIndex((l, i) => i > 0 && l.trim() === '---') + 1).join('\n'); check('front matter 区块逐字节不变', fmOf(now) === fmOf(original)); check('正文确实写进去了', now.includes('测试追加的一行')); check('冷门键 ai_comment 仍在', now.includes('ai_comment:')); check('pid 仍在', now.includes('pid: 133')); // 还原 const back = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content }); check('还原成功', back.status === 200 && sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16)); } console.log('\n== ⑤ 改 front matter 字段 → 其余字段保留 =='); { const fm = { ...post.frontMatter, title: '除草日记(测试改名)' }; const r = await call('PUT', P(TARGET_ID), { frontMatter: fm, content: post.content }); check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120)); const after = (await call('GET', P(TARGET_ID))).data; check('title 已更新', after.title === '除草日记(测试改名)', String(after.title)); check('pid 未丢', after.frontMatter.pid === 133, JSON.stringify(after.frontMatter.pid)); check('ai_comment 未丢', 'ai_comment' in after.frontMatter); check('ai_comment 值未变(含结尾换行)', after.frontMatter.ai_comment === post.frontMatter.ai_comment); check('categories 未丢', Array.isArray(after.frontMatter.categories) && after.frontMatter.categories.length === 1); // 还原。注意:一旦真的改过 front matter,整块会被按规范重排 // (例如 >- 折叠块变成 | 字面块),所以这里校验的是**值**而不是字节。 // 「字节不变」的保证只适用于「没碰 front matter」的场景,见 ③ / ④。 await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content }); const back = (await call('GET', P(TARGET_ID))).data; check('还原后 front matter 值与原值一致', JSON.stringify(back.frontMatter) === JSON.stringify(post.frontMatter), JSON.stringify(back.frontMatter).slice(0, 160)); check('还原后正文与原文一致', back.content === post.content); check('还原后换行风格未变(CRLF/LF 保持)', (read(TARGET_FILE).includes('\r\n')) === original.includes('\r\n')); } console.log('\n== ⑥ 新建 → 删除(走回收站)=='); { const slug = 'e2e-tmp-' + Date.now().toString(36); const created = await call('POST', '/posts', { frontMatter: { title: 'E2E 临时文章', slug, date: '2026-10-04', categories: ['测试'], tags: ['a', 'b'], draft: true }, content: '# 标题\n\n正文。\n', }); check('新建成功', created.status === 200, JSON.stringify(created.data).slice(0, 160)); const dir = created.data?.dirPath; check('目录已创建', !!dir && fs.existsSync(path.join(dir, 'index.md')), String(dir)); const got = await call('GET', '/posts/' + slug); check('能读回', got.status === 200 && got.data.title === 'E2E 临时文章'); const dup = await call('POST', '/posts', { frontMatter: { title: '重名', slug }, content: '' }); check('重复 slug 被拒绝(400/409)', dup.status === 400 || dup.status === 409, '实际 ' + dup.status); const del = await call('DELETE', '/posts/' + slug); check('删除成功', del.status === 200, JSON.stringify(del.data).slice(0, 120)); check('目录已移走', !!dir && !fs.existsSync(dir)); if (del.data?.movedTo && fs.existsSync(del.data.movedTo)) fs.rmSync(del.data.movedTo, { recursive: true, force: true }); } console.log('\n== ⑦ 图片上传 =='); { const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); const res = await fetch(BASE + '/upload?name=t.png&key=' + encodeURIComponent(TARGET_ID), { method: 'POST', headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'image/png' }, body: png, }); const data = await res.json(); check('上传成功', res.status === 200 && data.ok, JSON.stringify(data).slice(0, 140)); if (data.fileName) { const f = path.join(path.dirname(TARGET_FILE), data.fileName); check('文件落盘', fs.existsSync(f)); check('返回裸文件名(仓库现行写法)', data.url === data.fileName, String(data.url)); if (fs.existsSync(f)) fs.unlinkSync(f); } const bad = await fetch(BASE + '/upload?name=x.txt', { method: 'POST', headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'text/plain' }, body: 'hi', }); check('非图片被拒绝', bad.status === 400, String(bad.status)); } console.log('\n== ⑧ git 状态 =='); { const r = await call('GET', '/git/status'); check('能取到状态', r.status === 200 && typeof r.data.branch === 'string', JSON.stringify(r.data).slice(0, 140)); console.log(' 分支 ' + r.data.branch + ' | 改动 ' + r.data.changed + ' 个文件 | 最近提交 ' + String(r.data.lastCommit).slice(0, 60)); } console.log('\n== ⑨ slug 撞名 → 必须拒绝,不能猜 =='); { // 仓库真实存在:2021-09-01-Twitter… 与 2021-08-31-无悔 都用 slug 20210901 const r = await call('GET', '/posts/20210901'); check('撞名 slug → 409(而不是随便挑一篇)', r.status === 409, '实际 ' + r.status); check('错误信息里点出候选篇目', String(r.data?.error || '').includes('无悔'), String(r.data?.error || '').slice(0, 120)); check('单篇 slug 仍可定位', (await call('GET', '/posts/20251121')).status === 200); check('不存在的 id → 404', (await call('GET', '/posts/__不存在__')).status === 404); } console.log('\n== ⑩ front matter 后没有空行的文章 → 不能平白多插空行 =='); { // 语料里 111 篇有空行、19 篇没有;这类写法差异最容易在回存时产生脏 diff const list = (await call('GET', '/posts?perPage=200')).data.posts; let tested = 0; for (const item of list) { const r = await call('GET', P(item.id)); if (r.status !== 200 || !r.data?.filePath) continue; const file = r.data.filePath; const before = read(file); // 只挑「--- 之后紧接正文」的那种 if (/\n---\r?\n[^\r\n]/.test(before) === false) continue; await call('PUT', P(item.id), { frontMatter: r.data.frontMatter, content: r.data.content }); check('无空行文章原样回存字节不变:' + item.id, sha(file) === crypto.createHash('sha256').update(before).digest('hex')); fs.writeFileSync(file, before); if (++tested >= 3) break; } if (!tested) console.log(' (语料里没找到这种写法,跳过)'); } // 收尾:确保目标文件恢复原样 if (sha(TARGET_FILE) !== originalSha) { fs.writeFileSync(TARGET_FILE, original); check('测试后强制还原目标文件', true); } console.log('\n──────────────────────────────'); console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项'); if (fails.length) { console.log('失败明细:'); for (const f of fails) console.log(' · ' + f); } process.exit(fails.length ? 1 : 0);