import assert from 'node:assert/strict'; import { after, before, test } from 'node:test'; import express from 'express'; import { ApiError, createContentIngestRouter, plainTextToParagraphHtml } from '../src/content-ingest.js'; const TOKEN = 'test-content-ingest-token'; const HASH_V1 = 'a'.repeat(64); const HASH_V2 = 'b'.repeat(64); let server; let baseUrl; let repository; class MemoryRepository { constructor() { this.articles = new Map(); this.requests = new Map(); } async upsert(input, publicSiteUrl) { const request = this.requests.get(input.idempotencyKey); if (request) { if (request.contentHash !== input.contentHash) { throw new ApiError(409, 'CONTENT_CONFLICT', '相同Idempotency-Key对应了不同正文哈希'); } return { created: false, result: { ...request.result } }; } const identity = `${input.sourceSystem}:${input.sourceTaskId}:${input.siteProfile}`; const existing = this.articles.get(identity); const article = existing || { id: `art-${this.articles.size + 1}`, slug: input.requestedSlug, revision: 0 }; article.revision += 1; article.title = input.title; article.bodyPlain = input.bodyPlain; article.contentHtml = input.contentHtml; article.status = input.publishMode === 'publish' ? 'published' : 'draft'; article.contentHash = input.contentHash; article.publishedAt = new Date().toISOString(); this.articles.set(identity, article); const result = { publication_id: article.id, status: article.status, url: `${publicSiteUrl}/insights/${article.slug}`, revision: article.revision, content_hash: article.contentHash, published_at: article.publishedAt, }; this.requests.set(input.idempotencyKey, { contentHash: input.contentHash, result }); return { created: !existing, result }; } } function payload(overrides = {}) { return { request_id: 'request-1', source_system: 'amaba-content-core', source_task_id: 'task-1', source_version_id: 'version-1', source_version_number: 1, content_hash: HASH_V1, site_profile: 'human', publish_mode: 'publish', article: { title: '测试文章', body_plain: '第一自然段。\n第二自然段。', summary: '测试摘要', slug: 'test-article', primary_keyword: '企业进化', keywords: ['企业进化'], content_type: 'industry', direction: '行业观察', target_audience: '企业经营者', author_name: '阿美巴', cover_image_url: null, canonical_url: null, }, quality: { human_confirmed: true, confirmed_at: '2026-09-11T10:00:00+08:00', remaining_issue_levels: ['P3'] }, provenance: { knowledge_refs: [] }, semantic: { entities: [], questions: [], facts: [], suggested_schema_type: 'Article' }, ...overrides, }; } async function request(path, { token = TOKEN, body, key = 'task-1:version-1:human', method = 'POST' } = {}) { const headers = {}; if (token !== null) headers.Authorization = `Bearer ${token}`; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (key !== null) headers['Idempotency-Key'] = key; return fetch(`${baseUrl}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); } before(async () => { repository = new MemoryRepository(); const app = express(); app.use(express.json({ limit: '5mb' })); app.use('/api/internal/content/v1', createContentIngestRouter({ repository, token: TOKEN, publicSiteUrl: 'https://www.ameiba.cn', rateLimitMax: 100 })); server = app.listen(0, '127.0.0.1'); await new Promise((resolve) => server.once('listening', resolve)); baseUrl = `http://127.0.0.1:${server.address().port}`; }); after(() => new Promise((resolve) => server.close(resolve))); test('健康检查需要正确Token并返回human站点身份', async () => { const unauthorized = await request('/api/internal/content/v1/health', { method: 'GET', token: null, key: null }); assert.equal(unauthorized.status, 401); const response = await request('/api/internal/content/v1/health', { method: 'GET', key: null }); assert.equal(response.status, 200); assert.deepEqual(await response.json(), { status: 'ok', service: 'amaba-human-website', site_profile: 'human', api_version: 'v1' }); }); test('未配置发布Token时接口返回503', async () => { const app = express(); app.use(express.json()); app.use('/api/internal/content/v1', createContentIngestRouter({ repository: new MemoryRepository(), token: '' })); const localServer = app.listen(0, '127.0.0.1'); await new Promise((resolve) => localServer.once('listening', resolve)); const response = await fetch(`http://127.0.0.1:${localServer.address().port}/api/internal/content/v1/health`); assert.equal(response.status, 503); await new Promise((resolve) => localServer.close(resolve)); }); test('错误Token返回401', async () => { const response = await request('/api/internal/content/v1/articles/upsert', { token: 'wrong', body: payload() }); assert.equal(response.status, 401); }); test('缺少标题返回400', async () => { const data = payload(); data.article.title = ''; assert.equal((await request('/api/internal/content/v1/articles/upsert', { body: data })).status, 400); }); test('缺少正文返回400', async () => { const data = payload(); data.article.body_plain = ''; assert.equal((await request('/api/internal/content/v1/articles/upsert', { body: data })).status, 400); }); test('未人工确认时拒绝发布', async () => { const data = payload(); data.quality.human_confirmed = false; assert.equal((await request('/api/internal/content/v1/articles/upsert', { body: data })).status, 403); }); test('错误site_profile被拒绝', async () => { const data = payload({ site_profile: 'ai' }); assert.equal((await request('/api/internal/content/v1/articles/upsert', { body: data })).status, 403); }); test('首次发布返回201并生成公开详情URL', async () => { const response = await request('/api/internal/content/v1/articles/upsert', { body: payload() }); const result = await response.json(); assert.equal(response.status, 201); assert.equal(result.success, true); assert.equal(result.revision, 1); assert.equal(result.url, 'https://www.ameiba.cn/insights/test-article'); assert.equal(repository.articles.size, 1); }); test('相同幂等键重复请求不创建重复文章', async () => { const response = await request('/api/internal/content/v1/articles/upsert', { body: payload() }); assert.equal(response.status, 200); assert.equal((await response.json()).publication_id, 'art-1'); assert.equal(repository.articles.size, 1); }); test('相同幂等键与不同哈希返回409', async () => { const response = await request('/api/internal/content/v1/articles/upsert', { body: payload({ content_hash: HASH_V2 }) }); assert.equal(response.status, 409); }); test('新版本更新同一文章、保留URL并增加revision', async () => { const data = payload({ source_version_id: 'version-2', source_version_number: 2, content_hash: HASH_V2 }); data.article.slug = 'should-not-replace-original-url'; data.article.body_plain = '新版本正文。'; const response = await request('/api/internal/content/v1/articles/upsert', { body: data, key: 'task-1:version-2:human' }); const result = await response.json(); assert.equal(response.status, 200); assert.equal(result.publication_id, 'art-1'); assert.equal(result.url, 'https://www.ameiba.cn/insights/test-article'); assert.equal(result.revision, 2); assert.equal(repository.articles.size, 1); }); test('纯文本按自然段转义,HTML和脚本不会执行', () => { assert.equal( plainTextToParagraphHtml('第一段\n\n\n文字'), '
第一段
\n<script>alert(1)</script>
\n<b>文字</b>
', ); });