342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
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 no owner-only toast of its own when the PATCH answers 403 missing_permission', async () => {
|
||
const backend = createBackend()
|
||
backend.responders['PATCH /v1/site/1'] = failWith(403, 'missing_permission')
|
||
await openEdit('Renew', backend)
|
||
await typeInto(templateField(), '%title% – %site%')
|
||
await submit()
|
||
|
||
expect(document.body.textContent).not.toContain('Only the owner can manage sites')
|
||
})
|
||
|
||
it('shows the 403 page to a writer', async () => {
|
||
await mountDirectory('sites', '/settings/sites', { role: 'member' })
|
||
|
||
expect(document.body.textContent).toContain("You don't have access to this page")
|
||
expect(document.body.textContent).not.toContain('Title template')
|
||
expect(vi.mocked(useApi)).toHaveBeenCalled()
|
||
})
|
||
})
|