feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user