新增内容判断引擎文章发布接口
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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<script>alert(1)</script>\n<b>文字</b>'),
|
||||
'<p>第一段</p>\n<p><script>alert(1)</script></p>\n<p><b>文字</b></p>',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user