feat: Sites feature backend

This commit is contained in:
Florian Sylvain committed 2026-10-14 03:56:50 +02:00
1 parent 45fcbd890d
commit a11338094e
98 files changed
+5365 -214

No files matched your search

+70 -11
View File
@@ -48,6 +48,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'GET /v1/auth/me',
],
Settings: ['GET /v1/settings'],
Sites: ['GET /v1/site/'],
}
const EXPECTED_SESSION: Record<string, string[]> = {
@@ -65,6 +66,12 @@ const EXPECTED_SESSION: Record<string, string[]> = {
'DELETE /v1/auth/register',
'POST /v1/auth/password-reset',
],
'Sites · owner only': [
'POST /v1/site/',
'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}',
],
'Settings · owner only': ['PATCH /v1/settings'],
'Activity log': ['GET /v1/activity', 'GET /v1/activity/summary', 'GET /v1/activity/export'],
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
@@ -79,7 +86,27 @@ const EXPECTED_PUBLIC = [
'GET /healthz',
]
const ARTICLE_KEYS = ['id', 'title', 'body', 'body_format', 'is_online', 'created_at', 'updated_at']
const ARTICLE_KEYS = [
'id',
'title',
'body',
'body_format',
'is_online',
'site_id',
'created_at',
'updated_at',
]
const SITE_KEYS = [
'id',
'name',
'key',
'base_url',
'default_social_image_id',
'default_robots',
'locale',
'is_default',
'article_count',
]
const MEDIA_ITEM_KEYS = [
'id',
'filename',
@@ -139,20 +166,20 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
}
describe('Catalogue routes', () => {
it('holds exactly the 24 key-callable routes in 4 groups, in order', () => {
it('holds exactly the 25 key-callable routes in 5 groups, in order', () => {
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(24)
expect(allEndpoints()).toHaveLength(25)
})
it('holds the 17 session-only rows in 5 sub-groups', () => {
it('holds the 21 session-only rows in 6 sub-groups', () => {
expect(
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
).toEqual(EXPECTED_SESSION)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(17)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(21)
})
it('holds the 6 public auth flow rows', () => {
@@ -174,7 +201,7 @@ describe('Catalogue routes', () => {
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(4 + 24 + 17 + 6)
expect(described).toHaveLength(5 + 25 + 21 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
@@ -296,8 +323,32 @@ describe('Response samples use the real domain JSON keys', () => {
expectOnlyKeys(parsed(find('PATCH /v1/media/{id}')), MEDIA_ITEM_KEYS)
})
it('uses articles_updated and merged for the merge', () => {
expect(keysOf(parsed(find('POST /v1/media/merge')))).toEqual(['articles_updated', 'merged'])
it('uses articles_updated, sites_updated and merged for the merge', () => {
expect(keysOf(parsed(find('POST /v1/media/merge')))).toEqual([
'articles_updated',
'merged',
'sites_updated',
])
})
it('adds sites_updated to the purge result', () => {
expect(keysOf(parsed(find('POST /v1/media/purge')))).toEqual([
'articles_updated',
'failed_groups',
'groups_purged',
'groups_total',
'merged',
'sites_updated',
])
})
it('uses the site keys for the site list', () => {
const list = parsed(find('GET /v1/site/'))
expect(Array.isArray(list)).toBe(true)
for (const item of list) {
expect(keysOf(item)).toEqual([...SITE_KEYS].sort())
}
expect(list[0].is_default).toBe(true)
})
it('uses id, title and published for usage items', () => {
@@ -360,7 +411,10 @@ describe('Request bodies and query parameters', () => {
])
expect(find('POST /v1/article/').body[0]!.rule).toContain('3 to 200')
expect(find('POST /v1/article/').body[1]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([['body', 'string', true]])
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([
['body', 'string', false],
['site_id', 'number', false],
])
expect(find('PATCH /v1/article/{id}').body[0]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/media/{id}')).toEqual([['filename', 'string', true]])
expect(bodyOf('POST /v1/media/merge')).toEqual([
@@ -397,7 +451,7 @@ describe('Request bodies and query parameters', () => {
}
})
it('lists page, size, q, status and sort on the article list only', () => {
it('lists page, size, q, status, sort and site on the article list only', () => {
expect(find('GET /v1/article/').query.map((param) => param.name)).toEqual([
'format',
'page',
@@ -405,6 +459,7 @@ describe('Request bodies and query parameters', () => {
'q',
'status',
'sort',
'site',
])
expect(find('GET /v1/article/{id}').query.map((param) => param.name)).toEqual(['format'])
})
@@ -516,7 +571,7 @@ describe('Per-endpoint error captions use the real strings', () => {
describe('Session only rows', () => {
const rows = SESSION_GROUPS.flatMap((group) => group.rows)
it('claims 403 only on verify, DELETE register, settings, activity, api-key and system rows', () => {
it('claims 403 only on verify, DELETE register, sites, settings, activity, api-key and system rows', () => {
expect(rows.filter((row) => row.answers403).map(label)).toEqual([
'GET /v1/system/update',
'POST /v1/system/update/check',
@@ -524,6 +579,10 @@ describe('Session only rows', () => {
'POST /v1/system/update/restart',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'POST /v1/site/',
'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}',
'PATCH /v1/settings',
'GET /v1/activity',
'GET /v1/activity/summary',
+78 -6
View File
@@ -126,6 +126,12 @@ const ARTICLE_LIST_QUERY: ApiField[] = [
required: false,
rule: 'field:direction, with field title, status, created_at or updated_at and direction asc or desc. Default updated_at:desc.',
},
{
name: 'site',
type: 'string',
required: false,
rule: 'The key of a site. Only its articles are listed. An unknown key answers an empty page.',
},
]
const MEDIA_QUERY: ApiField[] = [
@@ -168,6 +174,7 @@ const article = (overrides: Record<string, unknown> = {}) => ({
body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.',
body_format: 'markdown',
is_online: true,
site_id: 1,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
...overrides,
@@ -189,6 +196,19 @@ const mediaItem = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
const site = (overrides: Record<string, unknown> = {}) => ({
id: 1,
name: 'Renew blog',
key: 'renew-blog',
base_url: 'https://blog.renew.example',
default_social_image_id: 4,
default_robots: 'index',
locale: 'en',
is_default: true,
article_count: 30,
...overrides,
})
const me = {
id: 1,
username: 'marta',
@@ -374,9 +394,15 @@ export const API_GROUPS: ApiGroup[] = [
'art-update',
'PATCH',
'/v1/article/{id}',
'Update the body of an article.',
'Update the body of an article, or move it to another site. Send body, site_id or both.',
'Verified user',
verified(articleErrors(e(400, BODY_MISMATCH), ...articleIdErrors)),
verified(
articleErrors(
e(400, BODY_MISMATCH),
...articleIdErrors,
e(404, 'site_not_found'),
),
),
json(article({ body: 'Updated text.', is_online: false })),
{
query: [FORMAT_QUERY],
@@ -385,11 +411,17 @@ export const API_GROUPS: ApiGroup[] = [
{
name: 'body',
type: 'string',
required: true,
rule: 'Up to 10 000 characters.',
required: false,
rule: 'Up to 10 000 characters. An empty string empties the body; null is refused.',
},
{
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.',
},
],
bodySample: { body: 'Updated text.' },
bodySample: { body: 'Updated text.', site_id: 2 },
},
),
endpoint(
@@ -523,7 +555,7 @@ export const API_GROUPS: ApiGroup[] = [
e(401, UNAUTHORIZED),
e(409, 'Some of the images to merge changed. Refresh and try again.'),
]),
json({ articles_updated: 5, merged: 2 }),
json({ articles_updated: 5, sites_updated: 1, merged: 2 }),
{
bodyKind: 'json',
body: [
@@ -629,6 +661,7 @@ export const API_GROUPS: ApiGroup[] = [
groups_purged: 1,
merged: 2,
articles_updated: 5,
sites_updated: 1,
failed_groups: 0,
}),
{
@@ -784,6 +817,36 @@ 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.',
endpoints: [
endpoint(
'site-list',
'GET',
'/v1/site/',
'List the sites, the default first and then by name, each with its article count.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json([
site(),
site({
id: 2,
name: 'Renew shop',
key: 'renew-shop',
base_url: 'https://shop.renew.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 4,
}),
]),
),
],
},
]
const row = (
@@ -819,6 +882,15 @@ export const SESSION_GROUPS: SessionGroup[] = [
row('POST', '/v1/auth/password-reset', 'Reset password', false),
],
},
{
title: 'Sites · owner only',
rows: [
row('POST', '/v1/site/', 'Create a site'),
row('PATCH', '/v1/site/{id}', 'Edit a site'),
row('PATCH', '/v1/site/{id}/default', 'Make a site the default'),
row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'),
],
},
{
title: 'Settings · owner only',
rows: [row('PATCH', '/v1/settings', 'Change the activity log retention')],
@@ -22,6 +22,7 @@ const SECTION_LABELS = [
'Media',
'Users and invites',
'Settings',
'Sites',
'Images and variants',
'Session only',
'Errors',
@@ -365,16 +366,16 @@ describe('Authentication', () => {
})
describe('Session only', () => {
it('renders the banner, 17 rows without expand controls and the 403 chips', async () => {
it('renders the banner, 21 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.text()).toContain('17 routes')
expect(session.text()).toContain('21 routes')
expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(17)
expect(rows).toHaveLength(21)
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
@@ -386,6 +387,10 @@ describe('Session only', () => {
'POST /v1/system/update/restart',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'POST /v1/site/',
'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}',
'PATCH /v1/settings',
'GET /v1/activity',
'GET /v1/activity/summary',