feat: add deterministic agent publishing contract

This commit is contained in:
AHMED
2026-10-09 15:48:13 +03:00
parent 690c2deed7
commit 00f8e4aee7
6 changed files with 501 additions and 104 deletions
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
MAX_COVER_BYTES,
decodeCoverImage,
parsePostBody,
parsePostPatch,
postResponse,
} from '../src/app/api/agent/posts/post-contract.ts'
const content = {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'rtl',
children: [{ type: 'paragraph', version: 1, children: [{ type: 'text', version: 1, text: 'محتوى' }] }],
},
}
const validBody = {
title: 'دليل عربي',
slug: 'arabic-ai-guide',
dek: 'وصف عملي',
content,
category: 'أدوات',
access: 'free',
readingMinutes: 7,
publish: true,
}
describe('parsePostBody', () => {
it('accepts the exact publishing contract', () => {
const parsed = parsePostBody(validBody)
assert.equal(parsed.slug, 'arabic-ai-guide')
assert.equal(parsed.publish, true)
})
it('rejects direct status and level writes', () => {
assert.throws(() => parsePostBody({ ...validBody, status: 'published' }), /unsupported field: status/)
assert.throws(() => parsePostBody({ ...validBody, level: 'متقدم' }), /unsupported field: level/)
})
it('requires a non-empty VIP teaser', () => {
assert.throws(() => parsePostBody({ ...validBody, access: 'vip', teaser: ' ' }), /VIP teaser/)
})
it('rejects invalid access, category, slug, and publish values', () => {
assert.throws(() => parsePostBody({ ...validBody, access: 'paid' }), /access/)
assert.throws(() => parsePostBody({ ...validBody, category: 'عام' }), /category/)
assert.throws(() => parsePostBody({ ...validBody, slug: 'عنوان عربي' }), /slug/)
assert.throws(() => parsePostBody({ ...validBody, publish: 'true' }), /publish/)
})
})
describe('parsePostPatch', () => {
it('accepts publish-only draft promotion', () => {
assert.deepEqual(parsePostPatch({ publish: true }, 'arabic-ai-guide'), { publish: true })
})
it('accepts a full republish only for the path slug', () => {
assert.equal(parsePostPatch(validBody, 'arabic-ai-guide').slug, 'arabic-ai-guide')
assert.throws(() => parsePostPatch(validBody, 'different-slug'), /slug must match/)
})
it('rejects status writes and empty updates', () => {
assert.throws(() => parsePostPatch({ status: 'published' }, 'arabic-ai-guide'), /unsupported field: status/)
assert.throws(() => parsePostPatch({}, 'arabic-ai-guide'), /at least one field/)
})
})
describe('decodeCoverImage', () => {
it('accepts a small JPEG and normalizes its filename', () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0xff, 0xd9])
const file = decodeCoverImage({ dataBase64: jpeg.toString('base64'), filename: '../cover.jpg' })
assert.equal(file.mimetype, 'image/jpeg')
assert.equal(file.name, 'cover.jpg')
assert.deepEqual(file.data, jpeg)
})
it('rejects non-JPEG data and oversized files', () => {
assert.throws(() => decodeCoverImage({ dataBase64: Buffer.from('png').toString('base64') }), /JPEG/)
const oversized = Buffer.alloc(MAX_COVER_BYTES + 1, 0)
oversized[0] = 0xff
oversized[1] = 0xd8
oversized[oversized.length - 2] = 0xff
oversized[oversized.length - 1] = 0xd9
assert.throws(() => decodeCoverImage({ dataBase64: oversized.toString('base64') }), /5MB/)
})
})
describe('postResponse', () => {
it('returns the minimal deterministic response and canonical URL', () => {
assert.deepEqual(
postResponse({
id: 42,
title: 'دليل عربي',
slug: 'arabic-ai-guide',
status: 'published',
access: 'vip',
category: 'أدوات',
publishedAt: '2026-10-09T12:00:00.000Z',
}),
{
id: 42,
title: 'دليل عربي',
slug: 'arabic-ai-guide',
status: 'published',
access: 'vip',
category: 'أدوات',
publishedAt: '2026-10-09T12:00:00.000Z',
url: 'https://anwarpy.ai/posts/arabic-ai-guide',
},
)
})
})