feat: SEO SPA Sites, Articles and Integration (4/4)

This commit is contained in:
Florian Sylvain committed 2026-10-18 15:51:28 +02:00
1 parent cb737b7ae3
commit 02fb8751fd
47 files changed
+3218 -227

No files matched your search

@@ -0,0 +1,342 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useApi } from '@/composables/useApi'
import {
blur,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
mountDirectory,
networkFailure,
openRowMenu,
textOf,
typeInto,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const TEMPLATE_ERROR = 'The template must contain %title%.'
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
const SITE_NAME_HELP = 'Shown in titles through the template, and sent as the Open Graph site name.'
const DESCRIPTION_HELP = 'Used when an article has no description, excerpt or body text.'
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function templateField() {
return box().querySelector<HTMLInputElement>('input[maxlength="200"]')!
}
function siteNameField() {
return field('e.g. Renew')
}
function descriptionField() {
return field('One or two sentences about the site.') as unknown as HTMLTextAreaElement
}
async function openEdit(name = 'Renew', backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
async function openAdd(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await click('Add site')
return backend
}
function submit(label = 'Save changes') {
return click(label, box())
}
describe('Sites SEO defaults: field order and copy', () => {
it('lists the fields in the board order, then the preview and the defaults block', async () => {
await openEdit()
const text = textOf(box())
const order = [
'Name',
'Key',
'Base URL',
'Default social image',
'Site name',
'Title template',
'Example',
'Default description',
'Default robots policy',
'Locale',
'Default site',
'Canonical URL preview',
'How articles use these defaults',
]
const positions = order.map((label) => text.indexOf(label))
expect(positions.every((position) => position >= 0)).toBe(true)
expect([...positions].sort((a, b) => a - b)).toEqual(positions)
})
it('edit: shows the saved values, helpers, example, counter and the defaults block', async () => {
await openEdit()
const text = textOf(box())
expect(siteNameField().value).toBe('Renew')
expect(text).toContain(SITE_NAME_HELP)
expect(text).not.toContain('Site name (optional)')
expect(templateField().value).toBe('%title% | %site%')
expect(text).toContain('Use %title% for the article title and %site% for the site name.')
expect(text).toContain('Example How we price a bathroom renovation | Renew')
expect(text).toContain('Default description (optional)')
expect(text).toContain('0 / 320')
expect(text).toContain(DESCRIPTION_HELP)
for (const copy of [
'Title Article title, then the template.',
"Description Used last, after the article's description, excerpt and body.",
'Image Used when an article has no cover image.',
'Robots Used unless an article is hidden from search engines.',
]) {
expect(text).toContain(copy)
}
expect(templateField().className).toContain('font-mono')
})
it('add: starts empty with the default template', async () => {
await openAdd()
expect(siteNameField().value).toBe('')
expect(templateField().value).toBe('%title% | %site%')
expect(descriptionField().value).toBe('')
expect(textOf(box())).toContain('How articles use these defaults')
})
})
describe.each([
['Journal', 'Journal', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
['Journal', '', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
['Journal', 'Le Journal', '%site%: %title%', 'Le Journal: How we price a bathroom renovation'],
['Journal', 'Journal', '%title%', 'How we price a bathroom renovation'],
['Journal', 'Journal', '%site% / %site%', 'Journal / Journal'],
['', '', '%title% | %site%', 'How we price a bathroom renovation | Renew'],
])(
'Sites SEO defaults: example for name "%s", site name "%s", template "%s"',
(name, siteName, template, example) => {
it('follows the typing and sends nothing', async () => {
const backend = await openAdd()
const before = backend.calls.length
await typeInto(field('e.g. Journal'), name)
await typeInto(siteNameField(), siteName)
await typeInto(templateField(), template)
expect(textOf(box())).toContain(`Example ${example}`)
expect(backend.calls).toHaveLength(before)
})
},
)
describe.each(['just text', '%site%', '%TITLE%', ''])(
'Sites SEO defaults: template "%s"',
(template) => {
it('is refused on blur and on submit, with nothing sent', async () => {
const backend = await openEdit()
await typeInto(templateField(), template)
await blur(templateField())
expect(textOf(box())).toContain(TEMPLATE_ERROR)
expect(textOf(box())).not.toContain('Example')
await typeInto(templateField(), template)
await submit()
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
expect(dialog()).not.toBeNull()
expect(document.activeElement).toBe(templateField())
})
},
)
describe('Sites SEO defaults: template errors', () => {
it('clears the error and shows the example again on a valid value', async () => {
await openEdit()
await typeInto(templateField(), 'nope')
await blur(templateField())
await typeInto(templateField(), '%title%')
expect(textOf(box())).not.toContain(TEMPLATE_ERROR)
expect(textOf(box())).toContain('Example')
})
it('shows the same message when the API answers 400 invalid_title_template', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = failWith(400, 'invalid_title_template')
await openEdit('Renew', backend)
await typeInto(templateField(), '%title% – %site%')
await submit()
expect(textOf(box())).toContain(TEMPLATE_ERROR)
expect(templateField().value).toBe('%title% – %site%')
})
})
describe('Sites SEO defaults: limits and counter', () => {
it('stops the inputs at 100, 200 and 320 characters', async () => {
await openAdd()
expect(siteNameField().getAttribute('maxlength')).toBe('100')
expect(templateField().getAttribute('maxlength')).toBe('200')
expect(descriptionField().getAttribute('maxlength')).toBe('320')
})
it.each([0, 45, 320])('counts %i characters without blocking', async (count) => {
await openAdd()
await typeInto(descriptionField(), 'a'.repeat(count))
expect(textOf(box())).toContain(`${count} / 320`)
})
})
describe('Sites SEO defaults: save payloads', () => {
it('sends only the changed template', async () => {
const backend = await openEdit()
await typeInto(templateField(), '%title% – %site%')
await submit()
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ title_template: '%title% – %site%' })
expect(dialog()).toBeNull()
})
it('sends site name, description and robots together, trimmed', async () => {
const backend = await openEdit()
await typeInto(siteNameField(), ' Renew Journal ')
await typeInto(descriptionField(), 'Notes on renewal work. ')
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
await flushPromises()
await submit()
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({
site_name: 'Renew Journal',
default_description: 'Notes on renewal work.',
default_robots: 'noindex',
})
})
it('creates a site without the three fields when untouched', async () => {
const backend = await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
await submit('Add site')
const body = callsTo(backend, 'POST', '/v1/site/')[0]!.body!
expect(body).not.toHaveProperty('site_name')
expect(body).not.toHaveProperty('title_template')
expect(body).not.toHaveProperty('default_description')
})
it('creates a site with the SEO values', async () => {
const backend = await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
await typeInto(siteNameField(), 'Renew Shop')
await typeInto(templateField(), '%title% - %site%')
await typeInto(descriptionField(), 'A shop about renewal.')
await submit('Add site')
expect(callsTo(backend, 'POST', '/v1/site/')[0]!.body).toMatchObject({
site_name: 'Renew Shop',
title_template: '%title% - %site%',
default_description: 'A shop about renewal.',
})
})
it('opens a site without the new fields on the defaults and sends nothing on save', async () => {
const backend = createBackend()
for (const site of backend.sites) {
delete site.site_name
delete site.title_template
delete site.default_description
}
await openEdit('Renew', backend)
expect(siteNameField().value).toBe('')
expect(descriptionField().value).toBe('')
expect(templateField().value).toBe('%title% | %site%')
await submit()
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
})
it.each(['site name', 'template', 'description'])(
'asks to discard a changed %s',
async (which) => {
await openEdit()
const target =
which === 'site name'
? siteNameField()
: which === 'template'
? templateField()
: descriptionField()
await typeInto(target, 'changed %title%')
await click('Cancel', box())
expect(textOf(document.body.querySelector('[role="alertdialog"]'))).toContain(
'Discard changes?',
)
},
)
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites SEO defaults: save failed with %s', (_name, responder) => {
it('keeps the panel, the notice and the three values', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = responder
await openEdit('Renew', backend)
await typeInto(siteNameField(), 'Renew Journal')
await typeInto(templateField(), '%title% – %site%')
await typeInto(descriptionField(), 'Notes.')
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(siteNameField().value).toBe('Renew Journal')
expect(templateField().value).toBe('%title% – %site%')
expect(descriptionField().value).toBe('Notes.')
expect(button('Save changes', box())?.disabled).toBe(false)
})
})
describe('Sites SEO defaults: sessions', () => {
it('shows the owner-only toast when the PATCH answers 403', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = failWith(403, 'owner_only')
await openEdit('Renew', backend)
await typeInto(templateField(), '%title% – %site%')
await submit()
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
it('shows the 403 page to an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
expect(document.body.textContent).toContain("You don't have access to Sites")
expect(document.body.textContent).not.toContain('Title template')
expect(vi.mocked(useApi)).toHaveBeenCalled()
})
})