Files
blog/editor-api/test/api-e2e.mjs
T

238 lines
11 KiB
JavaScript
Raw Normal View History

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