feat: Sites feature backend
This commit is contained in:
1 parent
45fcbd890d
commit
a11338094e
98 files changed
+5365
-214
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user