119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
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',
|
|
},
|
|
)
|
|
})
|
|
})
|