feat: SEO backend output (2/4)
This commit is contained in:
1 parent
0eb46b0d74
commit
eb9876fde3
32 files changed
+3859
-65
No files matched your search
@@ -21,11 +21,13 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
Articles: [
|
||||
'GET /v1/article/',
|
||||
'GET /v1/article/{id}',
|
||||
'GET /v1/article/{id}/seo',
|
||||
'POST /v1/article/',
|
||||
'PATCH /v1/article/{id}',
|
||||
'PATCH /v1/article/{id}/publish',
|
||||
'PATCH /v1/article/{id}/unpublish',
|
||||
'DELETE /v1/article/{id}',
|
||||
'POST /v1/article/{id}/seo/check',
|
||||
],
|
||||
Media: [
|
||||
'GET /v1/media/',
|
||||
@@ -48,7 +50,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'GET /v1/auth/me',
|
||||
],
|
||||
Settings: ['GET /v1/settings'],
|
||||
Sites: ['GET /v1/site/'],
|
||||
Sites: ['GET /v1/site/', 'GET /v1/site/{key}/sitemap'],
|
||||
Authors: ['GET /v1/author/'],
|
||||
}
|
||||
|
||||
@@ -102,6 +104,8 @@ const ARTICLE_LIST_KEYS = [
|
||||
'site_id',
|
||||
'slug',
|
||||
'excerpt',
|
||||
'seo_status',
|
||||
'seo_suggestions',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
@@ -203,13 +207,13 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
||||
}
|
||||
|
||||
describe('Catalogue routes', () => {
|
||||
it('holds exactly the 26 key-callable routes in 6 groups, in order', () => {
|
||||
it('holds exactly the 29 key-callable routes in 6 groups, in order', () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||
),
|
||||
).toEqual(EXPECTED_GROUPS)
|
||||
expect(allEndpoints()).toHaveLength(26)
|
||||
expect(allEndpoints()).toHaveLength(29)
|
||||
})
|
||||
|
||||
it('holds the 25 session-only rows in 7 sub-groups', () => {
|
||||
@@ -238,7 +242,7 @@ describe('Catalogue routes', () => {
|
||||
),
|
||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||
]
|
||||
expect(described).toHaveLength(6 + 26 + 25 + 6)
|
||||
expect(described).toHaveLength(6 + 29 + 25 + 6)
|
||||
const blank = described.filter(([, description]) => description.trim() === '')
|
||||
expect(blank.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
@@ -266,7 +270,7 @@ describe('Catalogue completeness', () => {
|
||||
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
|
||||
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
|
||||
expect(examples.curl).toContain(
|
||||
`"${ORIGIN}${endpoint.path.replace('{id}', String(endpoint.pathId))}"`,
|
||||
`"${ORIGIN}${endpoint.path.replace('{key}', 'renew-blog').replace('{id}', String(endpoint.pathId))}"`,
|
||||
)
|
||||
expect(examples.response).toBe(endpoint.response)
|
||||
expect(examples.response.trim()).not.toBe('')
|
||||
@@ -281,12 +285,17 @@ describe('Catalogue completeness', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('gives Public to the two article reads and Owner only to the invite routes', () => {
|
||||
it('gives Public to the article reads, the resolved SEO and the sitemap, and Owner only to the invite routes', () => {
|
||||
expect(
|
||||
allEndpoints()
|
||||
.filter((endpoint) => endpoint.access === 'Public')
|
||||
.map(label),
|
||||
).toEqual(['GET /v1/article/', 'GET /v1/article/{id}'])
|
||||
).toEqual([
|
||||
'GET /v1/article/',
|
||||
'GET /v1/article/{id}',
|
||||
'GET /v1/article/{id}/seo',
|
||||
'GET /v1/site/{key}/sitemap',
|
||||
])
|
||||
expect(
|
||||
allEndpoints()
|
||||
.filter((endpoint) => endpoint.access === 'Owner only')
|
||||
@@ -299,6 +308,53 @@ describe('Catalogue completeness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SEO output routes', () => {
|
||||
it('documents the seo list parameter and the two list fields', () => {
|
||||
expect(find('GET /v1/article/').query.map((field) => field.name)).toContain('seo')
|
||||
for (const item of parsed(find('GET /v1/article/')).items) {
|
||||
expect(item).toHaveProperty('seo_status')
|
||||
expect(item).toHaveProperty('seo_suggestions')
|
||||
}
|
||||
})
|
||||
|
||||
it('answers the resolved object with its twelve keys and no body', () => {
|
||||
const resolved = parsed(find('GET /v1/article/{id}/seo'))
|
||||
expect(keysOf(resolved)).toEqual(
|
||||
[
|
||||
'title',
|
||||
'description',
|
||||
'canonical_url',
|
||||
'robots',
|
||||
'og',
|
||||
'twitter',
|
||||
'image',
|
||||
'authors',
|
||||
'published_at',
|
||||
'modified_at',
|
||||
'fallbacks',
|
||||
'json_ld',
|
||||
].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it('answers the check with status, suggestions and slug and takes a JSON body', () => {
|
||||
const checked = parsed(find('POST /v1/article/{id}/seo/check'))
|
||||
expect(keysOf(checked)).toEqual(['slug', 'status', 'suggestions'])
|
||||
const names = find('POST /v1/article/{id}/seo/check').body.map((field) => field.name)
|
||||
expect(names).toContain('body')
|
||||
expect(names).not.toContain('site_id')
|
||||
expect(names).not.toContain('author_ids')
|
||||
})
|
||||
|
||||
it('answers the sitemap as an array of url, modified_at and noindex with a page and a size', () => {
|
||||
const sitemap = find('GET /v1/site/{key}/sitemap')
|
||||
expect(sitemap.query.map((field) => field.name)).toEqual(['page', 'size'])
|
||||
for (const item of parsed(sitemap)) {
|
||||
expect(keysOf(item)).toEqual(['modified_at', 'noindex', 'url'])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Owner or admin purge routes', () => {
|
||||
it('gives Owner or admin to the four purge routes only', () => {
|
||||
expect(
|
||||
@@ -542,13 +598,15 @@ describe('Request bodies and query parameters', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('lists format on every article route', () => {
|
||||
for (const endpoint of API_GROUPS[0]!.endpoints) {
|
||||
it('lists format on every article route except the SEO routes', () => {
|
||||
for (const endpoint of API_GROUPS[0]!.endpoints.filter(
|
||||
(item) => !item.path.includes('/seo'),
|
||||
)) {
|
||||
expect(endpoint.query.map((param) => param.name)).toContain('format')
|
||||
}
|
||||
})
|
||||
|
||||
it('lists page, size, q, status, sort and site on the article list only', () => {
|
||||
it('lists page, size, q, status, sort, site and seo on the article list only', () => {
|
||||
expect(find('GET /v1/article/').query.map((param) => param.name)).toEqual([
|
||||
'format',
|
||||
'page',
|
||||
@@ -557,6 +615,7 @@ describe('Request bodies and query parameters', () => {
|
||||
'status',
|
||||
'sort',
|
||||
'site',
|
||||
'seo',
|
||||
])
|
||||
expect(find('GET /v1/article/{id}').query.map((param) => param.name)).toEqual(['format'])
|
||||
})
|
||||
@@ -652,8 +711,10 @@ describe('Per-endpoint error captions use the real strings', () => {
|
||||
expect(find(route).errors).toContainEqual({ status, caption })
|
||||
})
|
||||
|
||||
it('adds the format error to every article card', () => {
|
||||
for (const endpoint of API_GROUPS[0]!.endpoints) {
|
||||
it('adds the format error to every article card except the SEO routes', () => {
|
||||
for (const endpoint of API_GROUPS[0]!.endpoints.filter(
|
||||
(item) => !item.path.includes('/seo'),
|
||||
)) {
|
||||
expect(endpoint.errors, label(endpoint)).toContainEqual({
|
||||
status: 400,
|
||||
caption: 'The format must be one of: markdown, html, json.',
|
||||
|
||||
@@ -137,6 +137,108 @@ const ARTICLE_LIST_QUERY: ApiField[] = [
|
||||
required: false,
|
||||
rule: 'The key of a site. Only its articles are listed. An unknown key answers an empty page.',
|
||||
},
|
||||
{
|
||||
name: 'seo',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'ready, suggestions or hidden. Only the articles with that SEO status are listed, before paging; counts stay unfiltered. Any other value is refused.',
|
||||
},
|
||||
]
|
||||
|
||||
const SITEMAP_QUERY: ApiField[] = [
|
||||
{
|
||||
name: 'page',
|
||||
type: 'number',
|
||||
required: false,
|
||||
rule: 'A whole number from 1. Default 1.',
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
type: 'number',
|
||||
required: false,
|
||||
rule: '25, 50, 100 or 500. Default 100.',
|
||||
},
|
||||
]
|
||||
|
||||
const CHECK_BODY: ApiField[] = [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Replaces the saved body for this check only. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Same rules as PATCH. An empty string regenerates it from the title. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Up to 300 characters. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'seo_title',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Up to 120 characters. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'seo_description',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Up to 320 characters. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'cover_image_id',
|
||||
type: 'number | null',
|
||||
required: false,
|
||||
rule: 'The id of an existing image. null means no cover.',
|
||||
},
|
||||
{
|
||||
name: 'cover_image_alt',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Up to 200 characters. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'social_title',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Up to 120 characters. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'social_description',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Up to 320 characters. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'social_image_id',
|
||||
type: 'number | null',
|
||||
required: false,
|
||||
rule: 'The id of an existing image. null means no social image.',
|
||||
},
|
||||
{
|
||||
name: 'canonical_url',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'An absolute https address with a host, up to 2048 characters. null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'noindex',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
rule: 'null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'seo_dismissed',
|
||||
type: 'string[]',
|
||||
required: false,
|
||||
rule: 'Dismissed suggestion codes, replacing the saved list for this check only. null is refused.',
|
||||
},
|
||||
]
|
||||
|
||||
const MEDIA_QUERY: ApiField[] = [
|
||||
@@ -197,6 +299,90 @@ const article = (overrides: Record<string, unknown> = {}) => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const SAMPLE_SITE_KEY = 'renew-blog'
|
||||
|
||||
const resolvedSeo = () => ({
|
||||
title: `${ARTICLE_TITLE} | Renew`,
|
||||
description: 'What drives the price of a bathroom renovation.',
|
||||
canonical_url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||
robots: 'index, follow',
|
||||
og: {
|
||||
type: 'article',
|
||||
title: `${ARTICLE_TITLE} | Renew`,
|
||||
description: 'What drives the price of a bathroom renovation.',
|
||||
image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
|
||||
image_alt: 'A tiled bathroom wall',
|
||||
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||
site_name: 'Renew',
|
||||
locale: 'en',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${ARTICLE_TITLE} | Renew`,
|
||||
description: 'What drives the price of a bathroom renovation.',
|
||||
image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
|
||||
image_alt: 'A tiled bathroom wall',
|
||||
},
|
||||
image: {
|
||||
url: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
|
||||
alt: 'A tiled bathroom wall',
|
||||
source: 'cover',
|
||||
},
|
||||
authors: [
|
||||
{
|
||||
name: 'Inès Duarte',
|
||||
url: 'https://ines-duarte.example',
|
||||
same_as: ['https://github.com/ines-duarte'],
|
||||
},
|
||||
],
|
||||
published_at: '2026-03-12T09:14:00Z',
|
||||
modified_at: '2026-09-10T07:00:00Z',
|
||||
fallbacks: { title: 'title', description: 'excerpt', image: 'cover', canonical: 'site' },
|
||||
json_ld: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: ARTICLE_TITLE,
|
||||
description: 'What drives the price of a bathroom renovation.',
|
||||
image: ['https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg'],
|
||||
author: [
|
||||
{
|
||||
'@type': 'Person',
|
||||
name: 'Inès Duarte',
|
||||
url: 'https://ines-duarte.example',
|
||||
sameAs: ['https://github.com/ines-duarte'],
|
||||
},
|
||||
],
|
||||
datePublished: '2026-03-12T09:14:00Z',
|
||||
dateModified: '2026-09-10T07:00:00Z',
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const seoCheck = () => ({
|
||||
status: 'suggestions',
|
||||
suggestions: [
|
||||
{ code: 'desc_short', field: 'seo_description', params: { length: 49, min: 70 } },
|
||||
{ code: 'no_au', field: 'author_ids', params: {} },
|
||||
],
|
||||
slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' },
|
||||
})
|
||||
|
||||
const sitemapPage = () => [
|
||||
{
|
||||
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||
modified_at: '2026-09-10T07:00:00Z',
|
||||
noindex: false,
|
||||
},
|
||||
{
|
||||
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
|
||||
modified_at: '2026-09-09T07:00:00Z',
|
||||
noindex: true,
|
||||
},
|
||||
]
|
||||
|
||||
const LIST_ITEM_OMITTED_KEYS = [
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
@@ -210,10 +396,13 @@ const LIST_ITEM_OMITTED_KEYS = [
|
||||
'seo_dismissed',
|
||||
]
|
||||
|
||||
const articleListItem = (overrides: Record<string, unknown> = {}) =>
|
||||
Object.fromEntries(
|
||||
const articleListItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
...Object.fromEntries(
|
||||
Object.entries(article(overrides)).filter(([key]) => !LIST_ITEM_OMITTED_KEYS.includes(key)),
|
||||
)
|
||||
),
|
||||
seo_status: 'suggestions',
|
||||
seo_suggestions: 2,
|
||||
})
|
||||
|
||||
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 4,
|
||||
@@ -305,6 +494,8 @@ const articlePage = () => ({
|
||||
slug: 'choosing-tiles-for-a-small-bathroom',
|
||||
excerpt: '',
|
||||
updated_at: '2026-09-09T07:00:00Z',
|
||||
seo_status: 'ready',
|
||||
seo_suggestions: 0,
|
||||
}),
|
||||
],
|
||||
total: 30,
|
||||
@@ -405,6 +596,15 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
json(article()),
|
||||
{ query: [FORMAT_QUERY] },
|
||||
),
|
||||
endpoint(
|
||||
'art-seo',
|
||||
'GET',
|
||||
'/v1/article/{id}/seo',
|
||||
'Get the resolved SEO object of an article: title, description, canonical address, robots, Open Graph, Twitter, image, authors, dates and ready JSON-LD. Drafts answer 404 unless the caller is verified.',
|
||||
'Public',
|
||||
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
|
||||
json(resolvedSeo()),
|
||||
),
|
||||
endpoint(
|
||||
'art-create',
|
||||
'POST',
|
||||
@@ -597,6 +797,28 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
NO_CONTENT_RESPONSE,
|
||||
{ query: [FORMAT_QUERY] },
|
||||
),
|
||||
endpoint(
|
||||
'art-check',
|
||||
'POST',
|
||||
'/v1/article/{id}/seo/check',
|
||||
'Check the SEO of an article without saving anything. Send {} for the saved state, or fields that replace the saved values for this check only.',
|
||||
'Verified user',
|
||||
verified([
|
||||
e(400, BODY_MISMATCH),
|
||||
e(400, 'invalid_slug'),
|
||||
e(400, 'image_not_found'),
|
||||
e(400, ID_FORMAT),
|
||||
e(401, UNAUTHORIZED),
|
||||
e(404, 'Article not found'),
|
||||
e(422, 'invalid_url'),
|
||||
]),
|
||||
json(seoCheck()),
|
||||
{
|
||||
bodyKind: 'json',
|
||||
body: CHECK_BODY,
|
||||
bodySample: { seo_title: 'Bathroom costs, explained', noindex: false },
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -977,7 +1199,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
key: 'sites',
|
||||
title: 'Sites',
|
||||
description:
|
||||
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
|
||||
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.',
|
||||
endpoints: [
|
||||
endpoint(
|
||||
'site-list',
|
||||
@@ -1004,6 +1226,16 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
}),
|
||||
]),
|
||||
),
|
||||
endpoint(
|
||||
'site-sitemap',
|
||||
'GET',
|
||||
'/v1/site/{key}/sitemap',
|
||||
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.',
|
||||
'Public',
|
||||
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
|
||||
json(sitemapPage()),
|
||||
{ query: SITEMAP_QUERY },
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1206,7 +1438,8 @@ export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\
|
||||
export const AUTH_EXAMPLE_PATH = '/v1/auth/me'
|
||||
|
||||
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
|
||||
return item.pathId === null ? item.path : item.path.replace('{id}', String(item.pathId))
|
||||
const keyed = item.path.replace('{key}', SAMPLE_SITE_KEY)
|
||||
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
|
||||
}
|
||||
|
||||
export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExamples {
|
||||
|
||||
@@ -1967,8 +1967,14 @@ export const messages = {
|
||||
'Lié à votre compte. Les nouveaux articles commencent avec cet auteur.',
|
||||
"You don't have an author profile yet. Create one to be credited on your own articles.":
|
||||
'Vous n’avez pas encore de profil d’auteur. Créez-en un pour être crédité sur vos propres articles.',
|
||||
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.':
|
||||
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par le propriétaire.',
|
||||
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.':
|
||||
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire et tout le monde peut lire un sitemap ; les modifications se font dans le navigateur par le propriétaire.',
|
||||
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.':
|
||||
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau. Le total est dans l’en-tête X-Total-Count.',
|
||||
'The request cannot be processed due to a mismatch in the format of the body.':
|
||||
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
|
||||
site_not_found: 'Site introuvable',
|
||||
'25, 50, 100 or 500. Default 100.': '25, 50, 100 ou 500. Par défaut 100.',
|
||||
'List the sites, the default first and then by name, each with its article count.':
|
||||
'Liste les sites, le site par défaut d’abord puis par nom, chacun avec son nombre d’articles.',
|
||||
'You must be logged in to access this resource.':
|
||||
|
||||
@@ -170,15 +170,15 @@ describe('Default, every card collapsed', () => {
|
||||
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
|
||||
})
|
||||
|
||||
it('groups the 24 cards as 7, 10, 6 and 1', async () => {
|
||||
it('groups the cards as 9, 10, 6 and 1', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(
|
||||
['articles', 'media', 'users', 'settings'].map(
|
||||
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
||||
),
|
||||
).toEqual([7, 10, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('7 routes')
|
||||
).toEqual([9, 10, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('9 routes')
|
||||
expect(section(wrapper, 'settings').text()).toContain('1 route')
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user