feat: add deterministic agent publishing contract
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# Agent publishing API
|
||||
|
||||
Base URL: `https://anwarpy.ai/api/agent/posts`
|
||||
|
||||
Authentication: send the current secret in `x-agent-key`. The key is stored only in the deployment environment and the local secret store; it must not appear in source, logs, prompts, or this document.
|
||||
|
||||
## Create
|
||||
|
||||
`POST /api/agent/posts`
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "دليل عربي",
|
||||
"slug": "arabic-ai-guide",
|
||||
"dek": "وصف عملي",
|
||||
"teaser": "مطلوب لمقال VIP فقط",
|
||||
"content": { "root": { "type": "root", "children": [] } },
|
||||
"category": "أدوات",
|
||||
"access": "vip",
|
||||
"readingMinutes": 7,
|
||||
"publish": true,
|
||||
"coverImage": {
|
||||
"filename": "arabic-ai-guide.jpg",
|
||||
"dataBase64": "<raw JPEG base64>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `slug`: lowercase ASCII kebab-case.
|
||||
- `category`: one of `وكلاء`, `نماذج`, `استرجاع`, `سلامة`, `أدوات`, `أعمال`.
|
||||
- `access`: `free` or `vip`.
|
||||
- VIP requires a non-empty teaser.
|
||||
- `coverImage` is optional. When provided it must be JPEG and no larger than 5MB. Otherwise the seeded category cover is used.
|
||||
- `publish` omitted or false creates `status=draft`; true creates `status=published` with a server timestamp.
|
||||
- Unknown fields, including direct `status`, `publishedAt`, and `level`, return 400.
|
||||
- An existing or racing duplicate slug returns 409.
|
||||
|
||||
Success: 201.
|
||||
|
||||
## Lookup
|
||||
|
||||
`GET /api/agent/posts/<slug>`
|
||||
|
||||
Returns 200 for a draft or published post, 404 when absent. Authentication is required. The response never includes article or teaser content.
|
||||
|
||||
## Promote or republish
|
||||
|
||||
`PUT /api/agent/posts/<slug>`
|
||||
|
||||
Draft promotion body:
|
||||
|
||||
```json
|
||||
{ "publish": true }
|
||||
```
|
||||
|
||||
A full republish uses the same body as create, requires `publish=true`, and requires the body slug to match the URL slug. It updates the existing post and never creates a second slug.
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
Canonical URLs have no `www` and no trailing slash.
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
||||
"build": "payload generate:types && next build",
|
||||
"start": "next start",
|
||||
"generate:types": "payload generate:types",
|
||||
|
||||
@@ -2,38 +2,82 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
import { decodeCoverImage, parsePostPatch, postResponse } from '../post-contract'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Agent update endpoint: PUT /api/agent/posts/:slug */
|
||||
export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
function validKey(req: NextRequest) {
|
||||
const key = process.env.AGENT_API_KEY
|
||||
if (!key || req.headers.get('x-agent-key') !== key) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return Boolean(key && req.headers.get('x-agent-key') === key)
|
||||
}
|
||||
|
||||
function badRequest(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'invalid request'
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
async function findPost(slug: string) {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { slug } = await ctx.params
|
||||
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
|
||||
|
||||
const existing = await payload.find({
|
||||
const result = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const post = existing.docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
return { payload, post: result.docs[0] }
|
||||
}
|
||||
|
||||
// Only writable fields; status is admin-only
|
||||
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const field of allowed) {
|
||||
if (field in body) data[field] = body[field]
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
|
||||
if (!validKey(req)) return unauthorized()
|
||||
const { slug } = await ctx.params
|
||||
const { post } = await findPost(slug)
|
||||
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
return NextResponse.json(postResponse(post))
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
|
||||
if (!validKey(req)) return unauthorized()
|
||||
const { slug } = await ctx.params
|
||||
|
||||
let patch
|
||||
try {
|
||||
patch = parsePostPatch(await req.json(), slug)
|
||||
} catch (error) {
|
||||
return badRequest(error)
|
||||
}
|
||||
|
||||
const doc = await payload.update({ collection: 'posts', id: post.id, data, depth: 0 })
|
||||
return NextResponse.json({
|
||||
id: doc.id, title: doc.title, slug: doc.slug,
|
||||
status: doc.status, access: doc.access, category: doc.category,
|
||||
const { payload, post } = await findPost(slug)
|
||||
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
const { coverImage, publish: _publish, ...fields } = patch
|
||||
const data: Record<string, unknown> = { ...fields }
|
||||
if (coverImage) {
|
||||
const file = decodeCoverImage(coverImage)
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
data: {},
|
||||
file,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
data.cover = media.id
|
||||
}
|
||||
if (patch.publish) {
|
||||
data.status = 'published'
|
||||
data.publishedAt = post.publishedAt || new Date().toISOString()
|
||||
}
|
||||
|
||||
const doc = await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: data as never,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return NextResponse.json(postResponse(doc))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export const CATEGORIES = ['وكلاء', 'نماذج', 'استرجاع', 'سلامة', 'أدوات', 'أعمال'] as const
|
||||
export const MAX_COVER_BYTES = 5 * 1024 * 1024
|
||||
|
||||
const ALLOWED_FIELDS = new Set([
|
||||
'title',
|
||||
'slug',
|
||||
'dek',
|
||||
'teaser',
|
||||
'content',
|
||||
'category',
|
||||
'access',
|
||||
'readingMinutes',
|
||||
'coverImage',
|
||||
'publish',
|
||||
])
|
||||
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/
|
||||
|
||||
type CoverImage = {
|
||||
dataBase64: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
export type PostBody = {
|
||||
title: string
|
||||
slug: string
|
||||
dek: string
|
||||
teaser?: string
|
||||
content: Record<string, unknown>
|
||||
category: (typeof CATEGORIES)[number]
|
||||
access: 'free' | 'vip'
|
||||
readingMinutes: number
|
||||
coverImage?: CoverImage
|
||||
publish: boolean
|
||||
}
|
||||
|
||||
export type UploadedCover = {
|
||||
data: Buffer
|
||||
mimetype: 'image/jpeg'
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${field} is required`)
|
||||
}
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
export function parsePostBody(input: unknown): PostBody {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new Error('JSON object body is required')
|
||||
}
|
||||
|
||||
const body = input as Record<string, unknown>
|
||||
for (const field of Object.keys(body)) {
|
||||
if (!ALLOWED_FIELDS.has(field)) throw new Error(`unsupported field: ${field}`)
|
||||
}
|
||||
|
||||
const slug = nonEmptyString(body.slug, 'slug')
|
||||
if (!SLUG_RE.test(slug)) throw new Error('slug must be lowercase ASCII kebab-case')
|
||||
|
||||
const access = body.access
|
||||
if (access !== 'free' && access !== 'vip') throw new Error('access must be free or vip')
|
||||
|
||||
const category = body.category
|
||||
if (typeof category !== 'string' || !CATEGORIES.includes(category as PostBody['category'])) {
|
||||
throw new Error('category is invalid')
|
||||
}
|
||||
|
||||
if (!body.content || typeof body.content !== 'object' || Array.isArray(body.content)) {
|
||||
throw new Error('content must be a Lexical object')
|
||||
}
|
||||
|
||||
const readingMinutes = body.readingMinutes
|
||||
if (!Number.isInteger(readingMinutes) || Number(readingMinutes) < 1 || Number(readingMinutes) > 120) {
|
||||
throw new Error('readingMinutes must be an integer from 1 to 120')
|
||||
}
|
||||
|
||||
const publish = body.publish ?? false
|
||||
if (typeof publish !== 'boolean') throw new Error('publish must be boolean')
|
||||
|
||||
const teaser = body.teaser
|
||||
if (teaser !== undefined && typeof teaser !== 'string') throw new Error('teaser must be a string')
|
||||
if (access === 'vip' && (typeof teaser !== 'string' || teaser.trim() === '')) {
|
||||
throw new Error('VIP teaser is required')
|
||||
}
|
||||
|
||||
if (body.coverImage !== undefined) {
|
||||
if (!body.coverImage || typeof body.coverImage !== 'object' || Array.isArray(body.coverImage)) {
|
||||
throw new Error('coverImage must be an object')
|
||||
}
|
||||
const cover = body.coverImage as Record<string, unknown>
|
||||
if (typeof cover.dataBase64 !== 'string') throw new Error('coverImage.dataBase64 is required')
|
||||
if (cover.filename !== undefined && typeof cover.filename !== 'string') {
|
||||
throw new Error('coverImage.filename must be a string')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: nonEmptyString(body.title, 'title'),
|
||||
slug,
|
||||
dek: nonEmptyString(body.dek, 'dek'),
|
||||
...(teaser === undefined ? {} : { teaser: teaser.trim() }),
|
||||
content: body.content as Record<string, unknown>,
|
||||
category: category as PostBody['category'],
|
||||
access,
|
||||
readingMinutes: Number(readingMinutes),
|
||||
...(body.coverImage === undefined ? {} : { coverImage: body.coverImage as CoverImage }),
|
||||
publish,
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePostPatch(input: unknown, expectedSlug: string): Partial<PostBody> {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new Error('JSON object body is required')
|
||||
}
|
||||
const body = input as Record<string, unknown>
|
||||
const fields = Object.keys(body)
|
||||
if (fields.length === 0) throw new Error('at least one field is required')
|
||||
for (const field of fields) {
|
||||
if (!ALLOWED_FIELDS.has(field)) throw new Error(`unsupported field: ${field}`)
|
||||
}
|
||||
if (fields.length === 1 && fields[0] === 'publish') {
|
||||
if (body.publish !== true) throw new Error('publish-only update must set publish to true')
|
||||
return { publish: true }
|
||||
}
|
||||
|
||||
const parsed = parsePostBody(body)
|
||||
if (!parsed.publish) throw new Error('republish update must set publish to true')
|
||||
if (parsed.slug !== expectedSlug) throw new Error('slug must match the URL path')
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function decodeCoverImage(cover: CoverImage): UploadedCover {
|
||||
const base64 = cover.dataBase64.trim()
|
||||
const maxBase64Length = Math.ceil(MAX_COVER_BYTES / 3) * 4
|
||||
if (base64.length > maxBase64Length) throw new Error('coverImage must be 5MB or smaller')
|
||||
if (!base64 || base64.length % 4 !== 0 || !BASE64_RE.test(base64)) {
|
||||
throw new Error('coverImage data is invalid base64')
|
||||
}
|
||||
|
||||
const data = Buffer.from(base64, 'base64')
|
||||
if (data.length > MAX_COVER_BYTES) throw new Error('coverImage must be 5MB or smaller')
|
||||
if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8 || data.at(-2) !== 0xff || data.at(-1) !== 0xd9) {
|
||||
throw new Error('coverImage must be JPEG')
|
||||
}
|
||||
|
||||
const requested = path.basename(cover.filename || 'cover.jpg')
|
||||
const stem = requested.replace(/\.jpe?g$/i, '').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'cover'
|
||||
const name = `${stem}.jpg`
|
||||
return { data, mimetype: 'image/jpeg', name, size: data.length }
|
||||
}
|
||||
|
||||
type PostDocument = {
|
||||
id: unknown
|
||||
title?: unknown
|
||||
slug?: unknown
|
||||
status?: unknown
|
||||
access?: unknown
|
||||
category?: unknown
|
||||
publishedAt?: unknown
|
||||
}
|
||||
|
||||
export function postResponse(doc: PostDocument) {
|
||||
const slug = String(doc.slug)
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
slug,
|
||||
status: doc.status,
|
||||
access: doc.access,
|
||||
category: doc.category,
|
||||
publishedAt: doc.publishedAt ?? null,
|
||||
url: `https://anwarpy.ai/posts/${slug}`,
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import { getPayload } from 'payload'
|
||||
import type { Payload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import { decodeCoverImage, parsePostBody, postResponse, type PostBody } from './post-contract'
|
||||
|
||||
type Body = Record<string, unknown>
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -13,19 +13,19 @@ function unauthorized() {
|
||||
|
||||
function validKey(req: NextRequest) {
|
||||
const key = process.env.AGENT_API_KEY
|
||||
if (!key) return false
|
||||
return req.headers.get('x-agent-key') === key
|
||||
return Boolean(key && req.headers.get('x-agent-key') === key)
|
||||
}
|
||||
|
||||
/** Fields the agent is allowed to write; status is NEVER included. */
|
||||
function pickFields(body: Body): Body {
|
||||
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
|
||||
const out: Body = {}
|
||||
for (const field of allowed) {
|
||||
if (field in body) out[field] = body[field]
|
||||
}
|
||||
// cover omitted → per-category default assigned below (seeded covers)
|
||||
return out
|
||||
function badRequest(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'invalid request'
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
function isUniqueSlugError(error: unknown) {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const value = error as { code?: unknown; message?: unknown; detail?: unknown }
|
||||
const text = `${String(value.message ?? '')} ${String(value.detail ?? '')}`.toLowerCase()
|
||||
return value.code === '23505' && text.includes('slug')
|
||||
}
|
||||
|
||||
const CATEGORY_COVER_SLUGS: Record<string, string> = {
|
||||
@@ -37,95 +37,74 @@ const CATEGORY_COVER_SLUGS: Record<string, string> = {
|
||||
'أعمال': 'cover-business',
|
||||
}
|
||||
|
||||
async function defaultCoverId(payload: Payload, category: unknown) {
|
||||
const filename = CATEGORY_COVER_SLUGS[String(category)] || 'cover-default'
|
||||
const res = await payload.find({
|
||||
async function defaultCoverId(payload: Payload, category: string) {
|
||||
const filename = CATEGORY_COVER_SLUGS[category] || 'cover-default'
|
||||
const result = await payload.find({
|
||||
collection: 'media',
|
||||
where: { filename: { equals: `${filename}.jpg` } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return res.docs[0]?.id
|
||||
const id = result.docs[0]?.id
|
||||
if (!id) throw new Error(`default cover is missing for category: ${category}`)
|
||||
return id
|
||||
}
|
||||
|
||||
async function coverId(payload: Payload, body: PostBody) {
|
||||
if (!body.coverImage) return defaultCoverId(payload, body.category)
|
||||
const file = decodeCoverImage(body.coverImage)
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
data: {},
|
||||
file,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return media.id
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!validKey(req)) return unauthorized()
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const body = (await req.json().catch(() => null)) as Body | null
|
||||
if (!body || typeof body.slug !== 'string' || !body.slug) {
|
||||
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
|
||||
|
||||
let body: PostBody
|
||||
try {
|
||||
body = parsePostBody(await req.json())
|
||||
} catch (error) {
|
||||
return badRequest(error)
|
||||
}
|
||||
|
||||
// Slug conflict → 409
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const existing = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: body.slug } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
if (existing.docs.length > 0) {
|
||||
return NextResponse.json({ error: 'slug already exists' }, { status: 409 })
|
||||
}
|
||||
|
||||
const data = pickFields(body)
|
||||
if (!data.cover) data.cover = await defaultCoverId(payload, data.category)
|
||||
if (!data.content || typeof data.content !== 'object') {
|
||||
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later
|
||||
data.content = {
|
||||
root: {
|
||||
type: 'root', format: '', indent: 0, version: 1, direction: 'rtl',
|
||||
children: [{ type: 'paragraph', version: 1, children: [{ type: 'text', version: 1, text: '…' }] }],
|
||||
},
|
||||
try {
|
||||
const { coverImage: _coverImage, publish, ...fields } = body
|
||||
const publishedAt = publish ? new Date().toISOString() : undefined
|
||||
const doc = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
...fields,
|
||||
cover: await coverId(payload, body),
|
||||
status: publish ? 'published' : 'draft',
|
||||
...(publishedAt ? { publishedAt } : {}),
|
||||
} as never,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return NextResponse.json(postResponse(doc), { status: 201 })
|
||||
} catch (error) {
|
||||
if (isUniqueSlugError(error)) {
|
||||
return NextResponse.json({ error: 'slug already exists' }, { status: 409 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const doc = await payload.create({
|
||||
collection: 'posts',
|
||||
data: { ...data, status: 'draft' },
|
||||
draft: true,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
status: doc.status,
|
||||
access: doc.access,
|
||||
category: doc.category,
|
||||
}, { status: 201 })
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!validKey(req)) return unauthorized()
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const slug = req.nextUrl.pathname.split('/').filter(Boolean).pop() as string
|
||||
const body = (await req.json().catch(() => null)) as Body | null
|
||||
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const post = existing.docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
const data = pickFields(body)
|
||||
delete (data as Body).status // agent can never publish/unpublish
|
||||
|
||||
const doc = await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
status: doc.status,
|
||||
access: doc.access,
|
||||
category: doc.category,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user