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, '

第一段

\n

<第二段> & 结尾

'); }); test('an HTML-only edit refreshes the plain body the website shows', () => { const changes = articleChanges({ content_html: '

新的一段

A&B
换行

' }, { 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('
一
二
'), '一\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, '

正文

'); 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); });