feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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: '',
|
||||
}),
|
||||
]),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user