feat: SEO backend data (1/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 19:30:29 +02:00
1 parent cb1410f0b0
commit 0eb46b0d74
90 files changed
+6094 -350

No files matched your search

+93 -3
View File
@@ -93,6 +93,32 @@ const EXPECTED_PUBLIC = [
'GET /healthz',
]
const ARTICLE_LIST_KEYS = [
'id',
'title',
'body',
'body_format',
'is_online',
'site_id',
'slug',
'excerpt',
'created_at',
'updated_at',
]
const ARTICLE_SEO_KEYS = [
'slug',
'excerpt',
'seo_title',
'seo_description',
'cover_image_id',
'cover_image_alt',
'social_title',
'social_description',
'social_image_id',
'canonical_url',
'noindex',
'seo_dismissed',
]
const ARTICLE_KEYS = [
'id',
'title',
@@ -100,6 +126,7 @@ const ARTICLE_KEYS = [
'body_format',
'is_online',
'site_id',
...ARTICLE_SEO_KEYS,
'created_at',
'updated_at',
]
@@ -113,6 +140,9 @@ const SITE_KEYS = [
'locale',
'is_default',
'article_count',
'site_name',
'title_template',
'default_description',
]
const MEDIA_ITEM_KEYS = [
'id',
@@ -300,11 +330,26 @@ describe('Response samples use the real domain JSON keys', () => {
expect(single).not.toHaveProperty('images')
expect(single).not.toHaveProperty('status')
const list = parsed(find('GET /v1/article/'))
expectOnlyKeys(list.items[0], ARTICLE_KEYS)
for (const item of list.items) {
expect(keysOf(item)).toEqual([...ARTICLE_LIST_KEYS].sort())
}
expect(list.items[0]).not.toHaveProperty('images')
expect(list.items[0]).not.toHaveProperty('status')
})
it('carries the twelve SEO fields on single reads and only excerpt and slug in the list', () => {
const single = parsed(find('GET /v1/article/{id}'))
for (const key of ARTICLE_SEO_KEYS) {
expect(single, key).toHaveProperty(key)
}
const item = parsed(find('GET /v1/article/')).items[0]
const absent = ARTICLE_SEO_KEYS.filter((key) => !['slug', 'excerpt'].includes(key))
expect(absent).toHaveLength(10)
for (const key of absent) {
expect(item, key).not.toHaveProperty(key)
}
})
it('returns articles from create, update, publish and unpublish', () => {
for (const route of [
'POST /v1/article/',
@@ -358,9 +403,16 @@ describe('Response samples use the real domain JSON keys', () => {
expect(list[0].is_default).toBe(true)
})
it('uses id, title and published for usage items', () => {
it('keeps id, title and published on usage items and adds type and fields', () => {
const usage = parsed(find('GET /v1/media/{id}/usage'))
expect(keysOf(usage[0])).toEqual(['id', 'published', 'title'])
expect(keysOf(usage[0])).toEqual(['fields', 'id', 'published', 'title', 'type'])
expect(usage.map((item: { type: string }) => item.type)).toEqual([
'article',
'author',
'site',
])
expect(keysOf(usage[1])).toEqual(['fields', 'id', 'title', 'type'])
expect(keysOf(usage[2])).toEqual(['fields', 'id', 'title', 'type'])
})
it('uses user and invite keys for the user list and invites', () => {
@@ -421,6 +473,18 @@ describe('Request bodies and query parameters', () => {
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([
['body', 'string', false],
['site_id', 'number', false],
['slug', 'string', false],
['excerpt', 'string', false],
['seo_title', 'string', false],
['seo_description', 'string', false],
['cover_image_id', 'number | null', false],
['cover_image_alt', 'string', false],
['social_title', 'string', false],
['social_description', 'string', false],
['social_image_id', 'number | null', false],
['canonical_url', 'string', false],
['noindex', 'boolean', false],
['seo_dismissed', 'string[]', false],
])
expect(find('PATCH /v1/article/{id}').body[0]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/media/{id}')).toEqual([['filename', 'string', true]])
@@ -433,6 +497,32 @@ describe('Request bodies and query parameters', () => {
expect(bodyOf('POST /v1/user/invite')).toEqual([['email', 'string', true]])
})
it('limits the SEO request fields like the handler validate tags', () => {
const rule = (name: string) =>
find('PATCH /v1/article/{id}').body.find((field) => field.name === name)!.rule
expect(rule('excerpt')).toContain('300')
expect(rule('seo_title')).toContain('120')
expect(rule('seo_description')).toContain('320')
expect(rule('cover_image_alt')).toContain('200')
expect(rule('social_title')).toContain('120')
expect(rule('social_description')).toContain('320')
expect(rule('canonical_url')).toContain('2048')
expect(rule('slug')).toContain('100')
expect(rule('cover_image_id')).toContain('null removes')
expect(rule('social_image_id')).toContain('null removes')
expect(rule('seo_dismissed')).toContain('no_au')
expect(find('PATCH /v1/article/{id}').bodySample).toMatchObject({ cover_image_id: 4 })
})
it('lists the slug conflict and the new error tokens on the article update', () => {
const captions = find('PATCH /v1/article/{id}').errors.map((error) => error.caption)
expect(captions).toContain('invalid_slug')
expect(captions).toContain('invalid_url')
expect(captions).toContain('image_not_found')
expect(captions.some((caption) => caption.startsWith('slug_taken'))).toBe(true)
expect(captions.some((caption) => caption.includes('X-Slug-Suggestion'))).toBe(true)
})
it('uses the multipart field "file" for the upload', () => {
const upload = find('POST /v1/media/')
expect(upload.bodyKind).toBe('multipart')
+140 -7
View File
@@ -180,11 +180,41 @@ const article = (overrides: Record<string, unknown> = {}) => ({
body_format: 'markdown',
is_online: true,
site_id: 1,
slug: 'how-we-price-a-bathroom-renovation',
excerpt: 'What drives the price of a bathroom renovation.',
seo_title: '',
seo_description: '',
cover_image_id: 4,
cover_image_alt: 'A tiled bathroom wall',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
seo_dismissed: [],
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
...overrides,
})
const LIST_ITEM_OMITTED_KEYS = [
'seo_title',
'seo_description',
'cover_image_id',
'cover_image_alt',
'social_title',
'social_description',
'social_image_id',
'canonical_url',
'noindex',
'seo_dismissed',
]
const articleListItem = (overrides: Record<string, unknown> = {}) =>
Object.fromEntries(
Object.entries(article(overrides)).filter(([key]) => !LIST_ITEM_OMITTED_KEYS.includes(key)),
)
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
id: 4,
filename: 'north-wall.jpg',
@@ -211,6 +241,9 @@ const site = (overrides: Record<string, unknown> = {}) => ({
locale: 'en',
is_default: true,
article_count: 30,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: 'Notes from a renovation site.',
...overrides,
})
@@ -263,12 +296,14 @@ const ownerOrAdminErrors = (...own: ApiError[]) => [
const articlePage = () => ({
items: [
article(),
article({
articleListItem(),
articleListItem({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
slug: 'choosing-tiles-for-a-small-bathroom',
excerpt: '',
updated_at: '2026-09-09T07:00:00Z',
}),
],
@@ -417,13 +452,20 @@ export const API_GROUPS: ApiGroup[] = [
'art-update',
'PATCH',
'/v1/article/{id}',
'Update the body of an article, or move it to another site. Send body, site_id or both.',
'Update the body of an article, move it to another site, or edit its SEO fields. Send at least one field; absent fields are untouched.',
'Verified user',
verified(
articleErrors(
e(400, BODY_MISMATCH),
e(400, 'invalid_slug'),
e(400, 'image_not_found'),
...articleIdErrors,
e(404, 'site_not_found'),
e(
409,
'slug_taken (the X-Slug-Suggestion header holds the first free slug)',
),
e(422, 'invalid_url'),
),
),
json(article({ body: 'Updated text.', is_online: false })),
@@ -441,10 +483,88 @@ export const API_GROUPS: ApiGroup[] = [
name: 'site_id',
type: 'number',
required: false,
rule: 'The id of an existing site, 1 or more. null is refused. At least one of body and site_id is required.',
rule: 'The id of an existing site, 1 or more. null is refused.',
},
{
name: 'slug',
type: 'string',
required: false,
rule: 'Lowercase letters, digits and single dashes, 1 to 100 characters, unique on the site. An empty string regenerates it from the title. null is refused.',
},
{
name: 'excerpt',
type: 'string',
required: false,
rule: 'Up to 300 characters. An empty string clears it; null is refused.',
},
{
name: 'seo_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. An empty string clears it; null is refused.',
},
{
name: 'seo_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. An empty string clears it; null is refused.',
},
{
name: 'cover_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image, 1 or more. null removes the cover.',
},
{
name: 'cover_image_alt',
type: 'string',
required: false,
rule: 'Up to 200 characters. An empty string clears it; null is refused.',
},
{
name: 'social_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. An empty string clears it; null is refused.',
},
{
name: 'social_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. An empty string clears it; null is refused.',
},
{
name: 'social_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image, 1 or more. null removes the social image.',
},
{
name: 'canonical_url',
type: 'string',
required: false,
rule: 'An absolute https address with a host, up to 2048 characters. An empty string clears it; null is refused.',
},
{
name: 'noindex',
type: 'boolean',
required: false,
rule: 'Only a flag: the article stays online. null is refused.',
},
{
name: 'seo_dismissed',
type: 'string[]',
required: false,
rule: 'Dismissed suggestion codes: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Duplicates are collapsed; null is refused.',
},
],
bodySample: { body: 'Updated text.', site_id: 2 },
bodySample: {
body: 'Updated text.',
site_id: 2,
slug: 'bathroom-pricing',
excerpt: 'What drives the price.',
cover_image_id: 4,
},
},
),
endpoint(
@@ -602,10 +722,20 @@ export const API_GROUPS: ApiGroup[] = [
'med-use',
'GET',
'/v1/media/{id}/usage',
'List the articles that use an image.',
'List the articles, authors and sites that use an image, with the places where each one uses it.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json([{ id: 12, title: ARTICLE_TITLE, published: true }]),
json([
{
type: 'article',
id: 12,
title: ARTICLE_TITLE,
published: true,
fields: ['body', 'cover'],
},
{ type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] },
{ type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] },
]),
{ pathId: 4 },
),
endpoint(
@@ -868,6 +998,9 @@ export const API_GROUPS: ApiGroup[] = [
locale: null,
is_default: false,
article_count: 4,
site_name: '',
title_template: '%title% - %site%',
default_description: '',
}),
]),
),