Files
ameiba_cn_new/cms/server/test/article-admin.test.js
T
ReiandClaude Opus 5.5 b3d2dada81 后台“认知观察”文章管理改版:搜索筛选、上下线、批量删除,修改正文网站立即生效
- 文章列表:按标题/路径搜索,按状态和来源(内容系统/手动添加)筛选,显示发布时间、字数和推荐权重;一键上线/下线、查看前台页面、单篇和批量删除,操作结果有提示
- 编辑改为右侧抽屉,正文按段落纯文本编辑;发布时间可改;来自内容系统的文章提示再次发布会覆盖修改
- 修复:后台改正文网站不变(网站显示 body_plain,后台只改了 content_html),现在两者一起保存
- 修复:修改文章时没传的字段会被清空,现在只改提交的字段
- 删除文章时同时删除内容系统的发布记录,内容系统可以重新发布;新增批量删除接口
- 上线时没有发布时间就用当前时间;路径标识重复时给出可读提示

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:58:02 +08:00

98 lines
4.4 KiB
JavaScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { ArticleAdminError, articleChanges, createArticle, deleteArticles, htmlToPlainText, updateArticle } from '../src/article-admin.js';
const NOW = '2026-10-19T08:00:00.000Z';
class FakeTx {
constructor(rows = {}) {
this.rows = rows;
this.statements = [];
}
async one(sql, params) {
this.statements.push([sql, params]);
return this.rows[params[0]] || null;
}
async execute(sql, params) {
this.statements.push([sql, params]);
return { affectedRows: params.length };
}
}
test('a status toggle changes only the status', () => {
const changes = articleChanges({ status: 'draft' }, { status: 'published', publish_time: NOW }, NOW);
assert.deepEqual(changes, { status: 'draft' });
});
test('editing the plain body keeps the HTML body in step', () => {
const changes = articleChanges({ body_plain: '第一段\r\n\r\n<第二段> & 结尾' }, { status: 'draft' }, NOW);
assert.equal(changes.body_plain, '第一段\n\n<第二段> & 结尾');
assert.equal(changes.content_html, '<p>第一段</p>\n<p>&lt;第二段&gt; &amp; 结尾</p>');
});
test('an HTML-only edit refreshes the plain body the website shows', () => {
const changes = articleChanges({ content_html: '<p>新的一段</p><p>A&amp;B<br>换行</p>' }, { status: 'draft' }, NOW);
assert.equal(changes.body_plain, '新的一段\nA&B\n换行');
});
test('going live without a publish time uses now; an existing time is kept', () => {
assert.equal(articleChanges({ status: 'published' }, { status: 'draft', publish_time: null }, NOW).publish_time, NOW);
assert.equal(articleChanges({ status: 'published' }, { status: 'draft', publish_time: '2026-01-01T00:00:00.000Z' }, NOW).publish_time, undefined);
assert.equal(articleChanges({ status: 'published', publish_time: '' }, { status: 'draft', publish_time: '2026-01-01' }, NOW).publish_time, NOW);
});
test('an empty title or slug is refused', () => {
assert.throws(() => articleChanges({ title: ' ' }, {}, NOW), ArticleAdminError);
assert.throws(() => articleChanges({ slug: '' }, {}, NOW), ArticleAdminError);
});
test('htmlToPlainText handles empty values', () => {
assert.equal(htmlToPlainText(null), '');
assert.equal(htmlToPlainText('<div>一</div><div>二</div>'), '一\n二');
});
test('updating writes only the changed columns', async () => {
const tx = new FakeTx({ a1: { id: 'a1', status: 'published', publish_time: NOW } });
await updateArticle(tx, 'a1', { title: '新标题' }, NOW);
const [sql, params] = tx.statements.find(([statement]) => statement.startsWith('UPDATE'));
assert.equal(sql, 'UPDATE articles SET title=?, updated_at=? WHERE id=?');
assert.deepEqual(params, ['新标题', NOW, 'a1']);
});
test('updating a missing article is a 404', async () => {
await assert.rejects(updateArticle(new FakeTx(), 'gone', { title: 'x' }, NOW), (error) => error.status === 404);
});
test('a duplicate slug becomes a readable 409', async () => {
const tx = new FakeTx({ a1: { id: 'a1', status: 'draft' } });
tx.execute = async () => { throw Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' }); };
await assert.rejects(updateArticle(tx, 'a1', { slug: 'taken' }, NOW), (error) => error.status === 409);
});
test('creating saves plain and HTML bodies and defaults to published now', async () => {
const tx = new FakeTx();
await createArticle(tx, 'a2', { title: '标题', body_plain: '正文' }, NOW);
const [sql, params] = tx.statements.find(([statement]) => statement.startsWith('INSERT'));
const columns = sql.match(/\(([^)]+)\) VALUES/)[1].split(', ');
const row = Object.fromEntries(columns.map((column, index) => [column, params[index]]));
assert.equal(row.status, 'published');
assert.equal(row.publish_time, NOW);
assert.equal(row.body_plain, '正文');
assert.equal(row.content_html, '<p>正文</p>');
assert.match(row.slug, /^art-\d+$/);
});
test('deleting also removes the content-system publication records', async () => {
const tx = new FakeTx();
const deleted = await deleteArticles(tx, ['a1', 'a2', 'a1', 3]);
assert.equal(deleted, 2);
assert.deepEqual(tx.statements.map(([sql]) => sql), [
'DELETE FROM article_ingest_requests WHERE article_id IN (?,?)',
'DELETE FROM articles WHERE id IN (?,?)',
]);
await assert.rejects(deleteArticles(tx, []), (error) => error.status === 400);
await assert.rejects(deleteArticles(tx, Array.from({ length: 201 }, (_, i) => `a${i}`)), (error) => error.status === 400);
});