feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

+2 -1
View File
@@ -79,12 +79,13 @@ describe('Navigation', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
'/settings',
])
const activity = links[4]!
const activity = links[5]!
expect(activity.getAttribute('aria-current')).toBe('page')
await expectIcon(activity, 'history')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
textOf,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
async function openDelete(name: string, backend = createBackend()) {
await mountDirectory('authors', '/authors', { backend })
await openRowMenu(name)
await chooseMenuItem('Delete…')
return backend
}
function box() {
return dialog()!
}
function trigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
async function openSelect() {
trigger().click()
await flushPromises()
}
function optionLabels() {
return Array.from(box().querySelectorAll('[role="option"]')).map((option) => textOf(option))
}
describe('Authors delete: reassign (recommended)', () => {
it('explains, preselects the first other author and lists the buttons', async () => {
const backend = await openDelete('Camille Roche')
const text = textOf(box())
expect(box().getAttribute('role')).toBe('alertdialog')
expect(text).toContain('Delete this author?')
expect(text).toContain(
'"Camille Roche" will be deleted and is credited on 3 articles. This can\'t be undone.',
)
expect(text).toContain('Credit these articles to')
expect(textOf(trigger())).toBe('Inès Duarte')
expect(text).toContain(
'Where an article has several authors, Camille Roche is removed and the others stay.',
)
expect(button('Cancel', box())).toBeTruthy()
expect(button('Delete and reassign 3 articles', box())).toBeTruthy()
expect(
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
).toHaveLength(1)
})
it('adds the linked-account line for a linked author', async () => {
await openDelete('Inès Duarte')
expect(textOf(box())).toContain(
"Linked to ines.duarte@renew.example. The user account isn't affected.",
)
})
})
describe('Authors delete: reassign succeeds', () => {
it('sends one DELETE with reassign_to, reads Reassigning… and toasts', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['DELETE /v1/author/7'] = () => hold.promise
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
expect(button('Reassigning…', box())).toBeTruthy()
hold.resolve(undefined)
await flushPromises()
const deletes = callsTo(backend, 'DELETE', '/v1/author/7')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.get('reassign_to')).toBe('2')
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
expect(document.body.textContent).toContain('Author deleted')
expect(document.body.textContent).toContain('3 articles now credit Inès Duarte.')
})
})
describe('Authors delete: choose who', () => {
it('does not list the deleted author and ends with Nobody', async () => {
await openDelete('Camille Roche')
await openSelect()
const labels = optionLabels()
expect(labels.some((label) => label.includes('Camille Roche'))).toBe(false)
expect(labels.at(-1)).toContain('Nobody')
expect(labels.at(-1)).toContain('Remove the byline')
expect(labels[0]).toContain('Inès Duarte')
})
})
describe('Authors delete: nobody', () => {
it('explains the byline and sends reassign_to=none', async () => {
const backend = await openDelete('Camille Roche')
await openSelect()
Array.from(box().querySelectorAll<HTMLElement>('[role="option"]'))
.find((option) => textOf(option).includes('Nobody'))!
.click()
await flushPromises()
expect(textOf(box())).toContain(
'The articles keep any other authors. Articles with only this author will have no byline.',
)
await click('Delete and reassign 3 articles', box())
expect(callsTo(backend, 'DELETE', '/v1/author/7')[0]!.query.get('reassign_to')).toBe('none')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors delete: failed with %s', (_name, responder) => {
it('keeps the dialog open with the notice and enables the buttons again', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = responder
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't delete the author")
expect(alert).toContain('Nothing was changed. Try again in a moment.')
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
expect(button('Delete and reassign 3 articles', box())!.hasAttribute('disabled')).toBe(
false,
)
})
})
describe('Authors delete: server fallback (409)', () => {
it('opens the fallback dialog and Reassign articles reopens the reassign dialog', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = failWith(409, 'author_has_articles')
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
await flushPromises()
const text = textOf(box())
expect(text).toContain("Can't delete this author")
expect(text).toContain(
'"Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first.',
)
expect(button('Close', box())).toBeTruthy()
expect(button('Reassign articles', box())).toBeTruthy()
await click('Reassign articles', box())
await flushPromises()
expect(textOf(box())).toContain('Delete this author?')
})
})
describe('Authors delete: no articles', () => {
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
const backend = await openDelete('Léa Fontaine')
const text = textOf(box())
expect(text).toContain('Delete this author?')
expect(text).toContain(
'"Léa Fontaine" will be deleted. No articles credit this author. This can\'t be undone.',
)
expect(trigger()).toBeNull()
expect(button('Delete author', box())).toBeTruthy()
await click('Delete author', box())
const deletes = callsTo(backend, 'DELETE', '/v1/author/10')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
expect(rowTexts().join(' ')).not.toContain('Léa Fontaine')
})
})
describe('Authors delete: author already gone', () => {
it('closes, removes the row and shows the info toast on 404', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = failWith(404, 'author_not_found')
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
expect(document.body.textContent).toContain('That author no longer exists')
})
})
+543
View File
@@ -0,0 +1,543 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
authorsFixture,
blur,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
labelled,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
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 URL_MESSAGE = 'Enter a full URL starting with https://.'
const SAVE_FAILED = "Couldn't save the author. Your changes are still here. Try again in a moment."
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function nameField() {
return field('e.g. Camille Roche')
}
function bioField() {
return field('A sentence or two about the author.')
}
function websiteField() {
return field('https://example.com')
}
function slugField() {
return labelled('Slug') as HTMLInputElement
}
function submit(label = 'Add author') {
return click(label, box())
}
function userTrigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('authors', '/authors', options)
await click('Add author')
return backend
}
async function openEdit(name: string, options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('authors', '/authors', options)
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
describe('Authors form: add, empty', () => {
it('opens a slide-over with every field, placeholder, counter and the byline preview', async () => {
await openAdd()
const text = textOf(box())
for (const copy of [
'Add author',
'Avatar (optional)',
'Choose image',
'Name',
'Slug',
'Bio (optional)',
'0 / 280',
'Website (optional)',
'Social links (optional)',
'Add link',
'0 of 5',
'Linked user (optional)',
'Link a CMS user if this author has an account. Guest writers need no link.',
'Byline preview',
'Author name',
'The bio appears here.',
'Cancel',
]) {
expect(text).toContain(copy)
}
expect(nameField()).toBeTruthy()
expect(field('Generated from the name')).toBeTruthy()
expect(bioField()).toBeTruthy()
expect(websiteField()).toBeTruthy()
expect(textOf(userTrigger())).toBe('No linked user')
expect(button('Add author', box())).toBeTruthy()
})
})
describe('Authors form: slug from the name', () => {
it('fills the slug with an Auto badge, an Edit slug pencil and follows the byline preview', async () => {
await openAdd()
await typeInto(nameField(), 'Camille Roche')
expect(slugField().value).toBe('camille-roche')
const text = textOf(box())
expect(text).toContain('Auto')
expect(button('Edit slug', box())).toBeTruthy()
expect(text).toContain(
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
)
expect(text).toContain('Byline preview Camille Roche')
})
})
describe('Authors form: add succeeds', () => {
it('sends exactly one POST with the exact body, reads Saving… and lists the author', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['POST /v1/author/'] = (call) => {
backend.authors.push({
id: 200,
is_linked: false,
article_count: 0,
...call.body,
} as never)
return hold.promise
}
await openAdd({ backend })
await typeInto(nameField(), 'Zoe Lambert')
await typeInto(bioField(), 'Writes about plaster.')
await typeInto(websiteField(), 'https://zoe.example')
await click('Add link', box())
await typeInto(
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
'https://x.com/zoe',
)
await submit()
expect(button('Saving…', box())).toBeTruthy()
hold.resolve({ id: 200 })
await flushPromises()
const posts = callsTo(backend, 'POST', '/v1/author/')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toEqual({
name: 'Zoe Lambert',
slug: 'zoe-lambert',
bio: 'Writes about plaster.',
avatar_id: null,
website: 'https://zoe.example',
social_links: [{ type: 'x', url: 'https://x.com/zoe' }],
user_id: null,
})
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).toContain('Zoe Lambert')
})
})
describe('Authors form: slug collision', () => {
it('shows the message, a suggestion and the reset link, and the suggestion keeps the other values', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
await openAdd({ backend })
await typeInto(nameField(), 'Camille Roche')
await typeInto(bioField(), 'A bio')
await submit()
const text = textOf(box())
expect(text).toContain('The slug "camille-roche" is already used by another author.')
expect(text).toContain('Use camille-roche-2')
expect(text).toContain('Reset to match the name')
await click('Use camille-roche-2', box())
expect(slugField().value).toBe('camille-roche-2')
expect(bioField().value).toBe('A bio')
expect(nameField().value).toBe('Camille Roche')
})
})
describe('Authors form: validation', () => {
it('asks for a name and sends nothing', async () => {
const backend = await openAdd()
await submit()
expect(textOf(box())).toContain('Enter a name.')
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a bio of 291 characters with the counter in the error colour', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await typeInto(bioField(), 'x'.repeat(291))
await submit()
expect(textOf(box())).toContain('Shorten the bio to 280 characters.')
const counter = Array.from(box().querySelectorAll('span')).find(
(span) => textOf(span) === '291 / 280' && !span.querySelector('span'),
)!
expect(counter.className).toContain('text-error')
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a website without https', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await typeInto(websiteField(), 'example.com')
await submit()
expect(textOf(box())).toContain(URL_MESSAGE)
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a social link with an http URL under its row', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await click('Add link', box())
await typeInto(
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
'http://x',
)
await submit()
const row = box()
.querySelector('[data-testid="social-link-row"]')!
.closest('[data-slot="root"]')!
expect(textOf(row)).toContain(URL_MESSAGE)
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
})
describe.each([
[422, 'bio_too_long', 'Shorten the bio to 280 characters.'],
[422, 'invalid_url', URL_MESSAGE],
[409, 'user_already_linked', 'That user already has an author. Choose another user.'],
])('Authors form: server validation %s %s', (status, code, message) => {
it('shows the message under the field', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(status, code)
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await typeInto(websiteField(), 'https://zoe.example')
await submit()
expect(textOf(box())).toContain(message)
})
})
describe('Authors form: social links', () => {
it('adds rows with a type select, a type-specific placeholder, and a limit of five', async () => {
await openAdd()
for (let index = 0; index < 5; index += 1) {
await click('Add link', box())
}
expect(box().querySelectorAll('[data-testid="social-link-row"]')).toHaveLength(5)
expect(button('Add link', box())!.hasAttribute('disabled')).toBe(true)
expect(textOf(box())).toContain('You can add up to 5 links.')
expect(textOf(box())).toContain('5 of 5')
expect(box().querySelectorAll('input[placeholder="https://x.com/username"]')).toHaveLength(
5,
)
})
})
describe('Authors form: linked user, choose', () => {
it('lists the users without an author under their group', async () => {
const backend = await openAdd()
userTrigger().click()
await flushPromises()
const text = textOf(box())
expect(text).toContain('Users without an author')
expect(text).toContain('Marta Alves marta@renew.example')
expect(text).toContain('Rui Santos rui@renew.example')
expect(text).not.toContain('Inès Duarte ines.duarte')
expect(callsTo(backend, 'GET', '/v1/user/')).toHaveLength(1)
expect(
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
).toHaveLength(1)
})
it('pages through the authors to find every linked user', async () => {
const backend = createBackend()
backend.authors = Array.from({ length: 120 }, (_, index) => ({
...authorsFixture()[1]!,
id: 600 + index,
name: `Writer ${index}`,
slug: `writer-${index}`,
user_id: index === 119 ? 1 : null,
}))
await openAdd({ backend })
userTrigger().click()
await flushPromises()
const paths = backend.calls
.filter((call) => call.path === '/v1/author/')
.map((call) => call.raw)
expect(paths).toContain('/v1/author/?page=1&size=100')
expect(paths).toContain('/v1/author/?page=2&size=100')
expect(textOf(box())).not.toContain('Marta Alves')
expect(textOf(box())).toContain('Rui Santos')
})
it('says so when every user already has an author', async () => {
const backend = createBackend()
backend.authors = authorsFixture().map((item, index) => ({ ...item, user_id: index + 1 }))
backend.users = backend.users.slice(0, 1)
await openAdd({ backend })
userTrigger().click()
await flushPromises()
expect(textOf(box())).toContain('Every user already has an author.')
})
})
describe('Authors form: edit a linked author', () => {
it('fills the fields, shows the linked user with Unlink and sends only the changed fields', async () => {
const backend = await openEdit('Inès Duarte')
const text = textOf(box())
expect(text).toContain('Edit Inès Duarte')
expect(nameField().value).toBe('Inès Duarte')
expect(slugField().value).toBe('ines-duarte')
expect(text).toContain('ines.duarte@renew.example')
expect(button('Unlink', box())).toBeTruthy()
expect(text).toContain('New articles by this user start with this author.')
await typeInto(bioField(), 'A new bio')
await submit('Save changes')
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ bio: 'A new bio' })
})
})
describe('Authors form: unlink', () => {
it('asks, sends one PATCH with user_id null, returns to No linked user and toasts', async () => {
const backend = await openEdit('Inès Duarte')
await click('Unlink', box())
const ask = document.body.querySelector('[role="alertdialog"]')!
const text = textOf(ask)
expect(text).toContain('Unlink Inès Duarte?')
expect(text).toContain(
'Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author.',
)
expect(button('Cancel', ask)).toBeTruthy()
await click('Unlink user', ask)
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ user_id: null })
expect(textOf(userTrigger())).toBe('No linked user')
expect(document.body.textContent).toContain('User unlinked')
expect(document.body.textContent).toContain('Inès Duarte is now a guest author.')
})
})
describe('Authors form: link a guest', () => {
it('links a user from the row menu and shows Linked on the row', async () => {
const backend = await mountDirectory('authors', '/authors')
await openRowMenu('Camille Roche')
await chooseMenuItem('Link user')
const ask = document.body.querySelector('[role="alertdialog"]')!
expect(textOf(ask)).toContain('Link Camille Roche to a user?')
expect(textOf(ask)).toContain('Only users without an author are listed.')
expect(button('Link user', ask)!.hasAttribute('disabled')).toBe(true)
ask.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
await flushPromises()
expect(textOf(ask)).toContain('Marta Alves marta@renew.example')
const option = Array.from(ask.querySelectorAll<HTMLElement>('[role="option"]')).find(
(item) => textOf(item).includes('Marta Alves'),
)!
option.click()
await flushPromises()
await click('Link user', ask)
const patches = callsTo(backend, 'PATCH', '/v1/author/7')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ user_id: 1 })
expect(rowTexts()[1]).toContain('Linked')
})
})
describe('Authors form: avatar picker', () => {
it('opens the media picker for the avatar, returns the id and sends avatar_id on save', async () => {
const backend = createBackend()
backend.responders['GET /v1/media/'] = () => ({
items: [
{
id: 55,
filename: 'face.jpg',
url: '/media/face.jpg',
size: 1000,
content_type: 'image/jpeg',
width: 400,
height: 400,
sha256: '0abc',
uploaded_by: 'Marta Alves',
created_at: '2026-09-12T12:00:00Z',
used_in: 0,
duplicate: false,
variant_count: 0,
variant_size: 0,
},
],
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 1, duplicates: 0 },
})
await openEdit('Camille Roche', { backend })
await click('Choose image', box())
await flushPromises()
const picker = Array.from(document.body.querySelectorAll('[role="dialog"]')).at(-1)!
const text = textOf(picker)
expect(text).toContain('Choose an image')
expect(text).toContain('The avatar for Camille Roche.')
expect(text).toContain(
'Shown as a square. Choose an image with the face or logo near the middle.',
)
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
await flushPromises()
await click('Use image', picker)
expect(textOf(box())).toContain('face.jpg')
await submit('Save changes')
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({ avatar_id: 55 })
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors form: save failed with %s', (_name, responder) => {
it('shows the notice and keeps the values', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = responder
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await typeInto(bioField(), 'Keep me')
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(nameField().value).toBe('Zoe')
expect(bioField().value).toBe('Keep me')
expect(nameField().disabled).toBe(false)
})
})
describe('Authors form: role changed while open', () => {
it('raises an error toast on 403 author_rights', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await submit()
expect(document.body.textContent).toContain('Only an owner or admin can change authors')
expect(document.body.textContent).toContain('You can still edit your own author profile.')
})
})
describe('Authors form: deep link', () => {
it('opens the edit slide-over for an owner or admin', async () => {
await mountDirectory('authors', '/authors?edit=camille-roche', { role: 'admin' })
expect(textOf(box())).toContain('Edit Camille Roche')
})
it('shows an info toast and opens nothing for an unknown slug', async () => {
await mountDirectory('authors', '/authors?edit=nobody-here')
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('That author no longer exists')
})
})
describe('Authors form: own author, any role, by deep link only', () => {
function memberBackend() {
const backend = createBackend()
backend.authors = authorsFixture(false)
return backend
}
const own = { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }
it('opens the form with the linked user locked, no Unlink, no Link user and no delete', async () => {
const backend = memberBackend()
await mountDirectory('authors', '/authors?edit=ines-duarte', {
backend,
role: 'member',
author: own,
email: 'ines.duarte@renew.example',
})
const text = textOf(box())
expect(text).toContain('Edit Inès Duarte')
expect(text).toContain('ines.duarte@renew.example')
expect(text).toContain(
'This is your author profile. Only an owner or admin can change the link.',
)
expect(button('Unlink', box())).toBeUndefined()
expect(button('Link user', box())).toBeUndefined()
expect(button(/Delete/, box())).toBeUndefined()
await typeInto(bioField(), 'Mine')
await submit('Save changes')
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({ bio: 'Mine' })
})
it('opens the read-only detail for another author', async () => {
await mountDirectory('authors', '/authors?edit=camille-roche', {
backend: memberBackend(),
role: 'member',
author: own,
})
expect(textOf(box())).toContain('Only an owner or admin can change this author.')
expect(box().querySelectorAll('input, textarea')).toHaveLength(0)
})
})
void blur
+324
View File
@@ -0,0 +1,324 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import type { Author } from '@/composables/useAuthors'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
authorsFixture,
button,
callsTo,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
rows,
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 SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const OWNER_DESCRIPTION =
"The names readers see on your articles. An author doesn't need an account."
const READ_ONLY_DESCRIPTION =
'The names readers see on your articles. Only an owner or admin can change authors.'
afterEach(unmountDirectory)
function listCalls(backend: ReturnType<typeof createBackend>) {
return backend.calls.filter((call) => call.method === 'GET' && call.path === '/v1/author/')
}
function manyAuthors(count: number): Author[] {
return Array.from({ length: count }, (_, index) => ({
...authorsFixture()[1]!,
id: 500 + index,
name: `Writer ${String(index).padStart(2, '0')}`,
slug: `writer-${index}`,
}))
}
describe.each(['owner', 'admin'])('Authors: default for %s', (role) => {
it('shows the header, the search field, the columns and the rows', async () => {
const backend = await mountDirectory('authors', '/authors', { role })
expect(document.body.querySelector('h1')?.textContent).toBe('Authors')
expect(document.body.textContent).toContain(OWNER_DESCRIPTION)
expect(button('Add author')).toBeTruthy()
expect(
document.body.querySelector('input[placeholder="Search by name or slug"]'),
).toBeTruthy()
const header = document.body.querySelector(
'[data-testid="directory-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Author Slug Account Articles')
expect(rowTexts()).toEqual([
'ID Inès Duarte ines-duarte Linked 9 articles',
'CR Camille Roche camille-roche Guest 3 articles',
'AK Dr. Alexandra Konstantinopoulou-Whitfield dr-alexandra-konstantinopoulou-whitfield Guest 1 article',
'LF Léa Fontaine lea-fontaine Guest No articles',
])
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?page=1&size=25')
})
it('puts Authors between Media and Users in the sidebar, current on /authors', async () => {
await mountDirectory('authors', '/authors', { role })
const links = Array.from(
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
)
const hrefs = links.map((link) => link.getAttribute('href'))
expect(hrefs.slice(2, 5)).toEqual(['/media', '/authors', '/users'])
expect(links[3]!.getAttribute('aria-current')).toBe('page')
})
it('shows a long name in full on hover', async () => {
await mountDirectory('authors', '/authors', { role })
const name = Array.from(document.body.querySelectorAll('span.truncate')).find((span) =>
textOf(span).startsWith('Dr. Alexandra'),
)!
expect(name.getAttribute('title')).toBe('Dr. Alexandra Konstantinopoulou-Whitfield')
})
})
describe('Authors: loading', () => {
it('draws skeleton rows after the delay and marks the list busy', async () => {
vi.useFakeTimers()
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['GET /v1/author/'] = () => hold.promise
await mountDirectory('authors', '/authors', { backend })
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(6)
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors: error on load with %s', (_name, responder) => {
it('shows the alert and requests the list again on Try again', async () => {
const backend = createBackend()
backend.responders['GET /v1/author/'] = responder
await mountDirectory('authors', '/authors', { backend })
const alert = textOf(document.body.querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't load authors")
expect(alert).toContain('Check your connection and try again.')
delete backend.responders['GET /v1/author/']
await click('Try again')
expect(listCalls(backend)).toHaveLength(2)
expect(rows()).toHaveLength(4)
})
})
describe.each([
['owner', true],
['admin', true],
['member', false],
])('Authors: empty, first run for %s', (role, canAdd) => {
it('hides the toolbar and shows the empty state', async () => {
const backend = createBackend()
backend.authors = []
await mountDirectory('authors', '/authors', { backend, role })
expect(
document.body.querySelector('input[placeholder="Search by name or slug"]'),
).toBeNull()
expect(document.body.textContent).toContain('No authors yet')
expect(document.body.textContent).toContain(
"Authors are the names readers see on articles. They don't need an account.",
)
const emptyButton = Array.from(document.body.querySelectorAll('button')).filter(
(element) => textOf(element) === 'Add author',
)
expect(emptyButton.length > 0).toBe(canAdd)
})
})
describe('Authors: no results', () => {
it('names the search and clears it from the URL', async () => {
const backend = await mountDirectory('authors', '/authors?q=zoe')
expect(document.body.textContent).toContain('No authors match "zoe"')
expect(document.body.textContent).toContain(
'Check the spelling, or search for a shorter part of the name.',
)
await click('Clear search')
await flushPromises()
expect(router.currentRoute.value.query.q).toBeUndefined()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
})
})
describe('Authors: search and pagination in the URL', () => {
it('debounces the search, requests it and keeps it in the URL', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors')
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by name or slug"]',
)!
await typeInto(input, 'cam')
expect(listCalls(backend)).toHaveLength(1)
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=cam&page=1&size=25')
expect(router.currentRoute.value.query.q).toBe('cam')
})
it('requests the page and size found in the URL', async () => {
const backend = createBackend()
backend.authors = manyAuthors(130)
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend })
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50')
expect(document.body.textContent).toContain('Showing 51–100 of 130')
})
it('shows the paginator only when the total exceeds 25', async () => {
await mountDirectory('authors', '/authors')
expect(document.body.textContent).not.toContain('per page')
await unmountDirectory()
const backend = createBackend()
backend.authors = manyAuthors(40)
await mountDirectory('authors', '/authors', { backend })
expect(document.body.textContent).toContain('Showing 1–25 of 40')
})
})
describe.each([['size=30'], ['page=abc'], ['page=0']])(
'Authors: invalid value in the URL (%s)',
(query) => {
it('replaces the value by its default with no notice and never reaches the API', async () => {
const backend = await mountDirectory('authors', `/authors?${query}`)
await flushPromises()
expect(router.currentRoute.value.query.size).toBeUndefined()
expect(router.currentRoute.value.query.page).toBeUndefined()
expect(listCalls(backend).map((call) => call.raw)).toEqual([
'/v1/author/?page=1&size=25',
])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
},
)
describe('Authors: row menu for an owner or admin', () => {
it('offers Edit, Link user and Delete… for a guest and Edit and Delete… for a linked author', async () => {
await mountDirectory('authors', '/authors')
await openRowMenu('Camille Roche')
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
['Edit', 'Link user', 'Delete…'],
)
})
it('lists Edit and Delete… only for a linked author', async () => {
await mountDirectory('authors', '/authors')
await openRowMenu('Inès Duarte')
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
['Edit', 'Delete…'],
)
})
it('opens a bottom sheet below md', async () => {
await mountDirectory('authors', '/authors', { width: MOBILE_WIDTH })
await openRowMenu('Camille Roche')
expect(textOf(dialog()!.querySelector('h2'))).toBe('Camille Roche')
expect(dialog()!.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
})
})
describe('Authors: read-only list for other roles', () => {
it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => {
await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH })
expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION)
expect(button('Add author')).toBeUndefined()
expect(
Array.from(document.body.querySelectorAll('button[aria-label^="Actions for"]')),
).toHaveLength(0)
expect(document.body.querySelectorAll('button[aria-label^="View "]')).toHaveLength(4)
})
it('keeps search working and shows the Authors sidebar entry on desktop', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors', { role: 'member' })
expect(
document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'),
).not.toBeNull()
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by name or slug"]',
)!
await typeInto(input, 'lea')
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=lea&page=1&size=25')
})
})
describe('Authors: read-only detail', () => {
async function openDetail() {
await mountDirectory('authors', '/authors', { role: 'member' })
await click('View Inès Duarte')
}
it('shows the author with Close as the only action and no email', async () => {
await openDetail()
const text = textOf(dialog())
for (const copy of [
'Inès Duarte',
'Linked',
'9 articles',
'Bio',
'Website',
'Only an owner or admin can change this author.',
]) {
expect(text).toContain(copy)
}
expect(text).not.toContain('ines.duarte@renew.example')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
const actions = Array.from(dialog()!.querySelectorAll('button')).map(textOf)
expect(actions.filter((label) => label !== '')).toEqual(['Close'])
})
it('opens the same panel from /authors?edit=ines-duarte', async () => {
await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' })
expect(textOf(dialog())).toContain('Only an owner or admin can change this author.')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
})
})
void callsTo
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale, setLocale } from '@/i18n'
import { ApiError } from '@/composables/useApi'
import type { Author } from '@/composables/useAuthors'
import {
authorsFixture,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
labelled,
mountDirectory,
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() }
})
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function manyAuthors(count: number): Author[] {
return Array.from({ length: count }, (_, index) => ({
...authorsFixture()[1]!,
id: 500 + index,
name: `Writer ${String(index).padStart(3, '0')}`,
slug: `writer-${index}`,
article_count: index === 0 ? 3 : 1,
}))
}
describe('Author slug suggestions are never taken', () => {
it('skips slugs that are already on the loaded page and ones the server just refused', async () => {
const backend = createBackend()
backend.authors.push({
...authorsFixture()[1]!,
id: 90,
name: 'Other',
slug: 'camille-roche-2',
})
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
await mountDirectory('authors', '/authors', { backend })
await click('Add author')
await typeInto(field('e.g. Camille Roche'), 'Camille Roche')
await click('Add author', box())
expect(textOf(box())).toContain('Use camille-roche-3')
await click('Use camille-roche-3', box())
await click('Add author', box())
expect(textOf(box())).toContain('Use camille-roche-4')
expect((labelled('Slug') as HTMLInputElement).value).toBe('camille-roche-3')
})
})
describe('Author reassign options', () => {
it('walks every page of authors so nobody past the first hundred is missing', async () => {
const backend = createBackend()
backend.authors = manyAuthors(130)
await mountDirectory('authors', '/authors', { backend })
await openRowMenu('Writer 000')
await chooseMenuItem('Delete…')
const paths = backend.calls
.filter((call) => call.path === '/v1/author/')
.map((call) => call.raw)
expect(paths).toContain('/v1/author/?page=1&size=100')
expect(paths).toContain('/v1/author/?page=2&size=100')
const trigger = box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
trigger.click()
await flushPromises()
const labels = Array.from(box().querySelectorAll('[role="option"]')).map(textOf)
expect(labels.some((label) => label.includes('Writer 129'))).toBe(true)
expect(labels.some((label) => label.includes('Writer 000'))).toBe(false)
})
it('says so when the options cannot be loaded and still offers Nobody', async () => {
const backend = createBackend()
await mountDirectory('authors', '/authors', { backend })
backend.responders['GET /v1/author/?page=1&size=100'] = failWith(500)
await openRowMenu('Camille Roche')
await chooseMenuItem('Delete…')
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't load authors")
expect(alert).toContain('Check your connection and try again.')
expect(textOf(box().querySelector('button[aria-haspopup="listbox"]'))).toBe('Nobody')
})
})
describe('Making another site the default from the form', () => {
async function openProjects(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Projects')
await chooseMenuItem('Edit')
return backend
}
it('keeps the list truthful when only the default step fails and retries just that step', async () => {
const backend = createBackend()
let defaultCalls = 0
backend.responders['PATCH /v1/site/3/default'] = () => {
defaultCalls += 1
if (defaultCalls === 1) {
throw new ApiError(500)
}
backend.sites.forEach((site) => (site.is_default = site.id === 3))
return backend.sites.find((site) => site.id === 3)
}
await openProjects(backend)
await typeInto(field('e.g. Journal'), 'Projects 2')
box().querySelector<HTMLElement>('[role="switch"]')!.click()
await flushPromises()
await click('Save changes', box())
expect(textOf(box().querySelector('[role="alert"]'))).toContain("Couldn't save the site")
expect(
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
).toContain('Projects 2')
await click('Save changes', box())
expect(callsTo(backend, 'PATCH', '/v1/site/3')).toHaveLength(1)
expect(callsTo(backend, 'PATCH', '/v1/site/3/default')).toHaveLength(2)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
})
describe('Focus returns to the row menu button', () => {
it('puts focus back on the ellipsis button after closing a panel opened from the menu', async () => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Projects')
await chooseMenuItem('Edit')
await click('Cancel', box())
await flushPromises()
expect(document.activeElement).toBe(button('Actions for Projects'))
})
})
describe('Failure copy passes through the dialog in the active language', () => {
beforeEach(() => setLocale('fr'))
afterEach(() => initializeLocale())
it('shows the French notice when setting the default fails', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
await mountDirectory('sites', '/settings/sites', { backend })
await click('Actions pour Journal')
await chooseMenuItem('Définir par défaut')
await click('Définir par défaut', box())
expect(textOf(box().querySelector('[role="alert"]'))).toContain(
'Impossible d’enregistrer le site.',
)
})
})
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale, setLocale } from '@/i18n'
import { messages } from '@/i18n/messages'
import { chooseMenuItem, click, mountDirectory, textOf, unmountDirectory } from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const MIN_LENGTH = 14
const translatable = Object.entries(messages).filter(
([english, french]) =>
english !== french && !english.includes('{') && english.length >= MIN_LENGTH,
)
function leftovers() {
const shown = textOf(document.body)
return translatable.filter(([english]) => shown.includes(english)).map(([english]) => english)
}
beforeEach(() => {
localStorage.clear()
setLocale('fr')
})
afterEach(async () => {
await unmountDirectory()
localStorage.clear()
initializeLocale()
})
describe('Language: every string of the sites screen has a French entry', () => {
it('translates the list, the row menu and the add form', async () => {
await mountDirectory('sites', '/settings/sites')
expect(document.body.textContent).toContain('Les frontends qui affichent votre contenu.')
expect(leftovers()).toEqual([])
await click('Actions pour Journal')
expect(document.body.textContent).toContain('Définir par défaut')
expect(leftovers()).toEqual([])
await chooseMenuItem('Définir par défaut')
expect(leftovers()).toEqual([])
})
it('translates the add form and the delete dialog', async () => {
await mountDirectory('sites', '/settings/sites')
await click('Ajouter un site')
expect(leftovers()).toEqual([])
})
it('translates the delete dialog', async () => {
await mountDirectory('sites', '/settings/sites')
await click('Actions pour Journal')
await chooseMenuItem('Supprimer…')
expect(document.body.textContent).toContain('Supprimer ce site ?')
expect(leftovers()).toEqual([])
})
it('translates the 403 page', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
expect(document.body.textContent).toContain('Vous n’avez pas accès aux sites')
expect(document.body.textContent).toContain('Retour aux paramètres')
expect(leftovers()).toEqual([])
})
})
describe('Language: every string of the authors screen has a French entry', () => {
it('translates the list, the add form and the delete dialog', async () => {
await mountDirectory('authors', '/authors')
expect(document.body.textContent).toContain('Auteurs')
expect(
document.body.querySelector('input[placeholder="Rechercher par nom ou identifiant"]'),
).not.toBeNull()
expect(leftovers()).toEqual([])
await click('Ajouter un auteur')
await flushPromises()
expect(document.body.textContent).toContain('Aperçu de la signature')
expect(leftovers()).toEqual([])
})
it('translates the delete dialog', async () => {
await mountDirectory('authors', '/authors')
await click('Actions pour Camille Roche')
await chooseMenuItem('Supprimer…')
expect(document.body.textContent).toContain('Supprimer cet auteur ?')
expect(leftovers()).toEqual([])
})
it('translates the read-only list', async () => {
await mountDirectory('authors', '/authors', { role: 'member' })
expect(document.body.textContent).toContain('Seul un propriétaire ou un administrateur')
expect(leftovers()).toEqual([])
})
})
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
let wrapper: VueWrapper | null = null
function reply(status: number, body = '') {
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } })
}
async function mountWith(
view: typeof AuthorsView | typeof SitesView,
path: string,
verified: boolean,
) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.status = 'ready'
await router.push(path)
wrapper = mount(defineComponent({ render: () => h(UApp, null, { default: () => h(view) }) }), {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.unstubAllGlobals()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Session problems use the app-wide handling', () => {
it.each([
['/authors', AuthorsView, '/v1/author/'],
['/settings/sites', SitesView, '/v1/site/'],
])('sends a 401 on %s to the login page with the expired reason', async (path, view, api) => {
const calls: string[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (input: string) => {
calls.push(input)
return reply(401)
}),
)
await mountWith(view, path, true)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(calls.some((call) => call.startsWith(api))).toBe(true)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toEqual({ redirect: path, reason: 'expired' })
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('sends an unverified 403 on /authors to /register/pending', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => reply(403)),
)
await mountWith(AuthorsView, '/authors', false)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
})
@@ -1505,6 +1505,7 @@ describe('Sidebar', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the seven navigation items as the current page', async () => {
it('marks none of the eight navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(7)
expect(links).toHaveLength(8)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
+34 -2
View File
@@ -30,10 +30,10 @@ function deferred<T>() {
return { promise, resolve }
}
async function mountPage(settings: () => Promise<unknown>) {
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1 }
session.session = { verified: true, username: 'marta', id: 1, ...(role ? { role } : {}) }
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === SETTINGS_PATH) {
@@ -185,3 +185,35 @@ describe('A 401 or an unverified 403 is handled globally, not by the page', () =
expect(tryAgainButton()).toBeUndefined()
})
})
describe('Publishing card: the owner manages sites from Settings', () => {
it('shows a Publishing card with a Manage sites row linking to /settings/sites', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
const card = document.body.querySelector('[data-testid="publishing-links-card"]')!
expect(card.textContent).toContain('Publishing')
expect(card.textContent).toContain('Where your content is shown.')
const row = card.querySelector('a')!
expect(row.getAttribute('href')).toBe('/settings/sites')
expect(row.textContent).toContain('Manage sites')
expect(row.textContent).toContain('Base URLs, default image and robots policy')
})
it('shows the sidebar Publishing group with a Sites entry linking to /settings/sites', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(nav.textContent).toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')?.textContent).toContain('Sites')
})
it('hides the Publishing card, the Manage sites row and the Sites entry from an admin', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'admin')
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
expect(document.body.textContent).not.toContain('Manage sites')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(nav.textContent).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
})
})
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
textOf,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
async function openDelete(name: string, backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu(name)
await chooseMenuItem('Delete…')
return backend
}
function box() {
return dialog()!
}
function trigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
describe('Sites delete: site with articles', () => {
it('explains, defaults the destination to the default site and shows the move summary', async () => {
await openDelete('Journal')
const text = textOf(box())
expect(box().getAttribute('role')).toBe('alertdialog')
expect(text).toContain('Delete this site?')
expect(text).toContain(
'"Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can\'t be undone.',
)
expect(text).toContain('Move articles to')
expect(textOf(trigger())).toBe('Renew · renew.example')
expect(text).toContain(
'38 articles will move to Renew. Their canonical host changes to renew.example.',
)
expect(button('Cancel', box())).toBeTruthy()
expect(button('Move 38 articles and delete', box())).toBeTruthy()
})
})
describe('Sites delete: choose destination', () => {
it('does not offer the deleted site and shows the name and the host on each option', async () => {
await openDelete('Journal')
trigger().click()
await flushPromises()
const options = Array.from(box().querySelectorAll('[role="option"]')).map((option) =>
textOf(option),
)
expect(options).toEqual([
'Renew renew.example',
'Projects projects.example',
'Archive archive.example',
])
})
})
describe('Sites delete: with articles succeeds', () => {
it('sends one DELETE with reassign_to, reads Moving articles… and toasts', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['DELETE /v1/site/2'] = () => hold.promise
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(button('Moving articles…', box())).toBeTruthy()
hold.resolve(undefined)
await flushPromises()
const deletes = callsTo(backend, 'DELETE', '/v1/site/2')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.get('reassign_to')).toBe('1')
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('Site deleted')
expect(document.body.textContent).toContain('38 articles moved to Renew.')
})
})
describe('Sites delete: no articles', () => {
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
const backend = await openDelete('Archive')
const text = textOf(box())
expect(text).toContain('Delete this site?')
expect(text).toContain(
'"Archive" will be deleted. No articles belong to it. This can\'t be undone.',
)
expect(trigger()).toBeNull()
expect(button('Delete site', box())).toBeTruthy()
await click('Delete site', box())
const deletes = callsTo(backend, 'DELETE', '/v1/site/4')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
expect(rowTexts().join(' ')).not.toContain('Archive')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites delete: failure with %s', (_name, responder) => {
it('keeps the dialog open with the notice and enables the buttons again', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = responder
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't delete the site")
expect(alert).toContain('Nothing was changed. Try again in a moment.')
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
expect(button('Move 38 articles and delete', box())!.hasAttribute('disabled')).toBe(false)
})
})
describe('Sites delete: server refusals', () => {
it('keeps the dialog open and asks for a destination on 409 site_has_articles', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(409, 'site_has_articles')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).not.toBeNull()
expect(textOf(box())).toContain('Choose where the articles go. This site has 38 articles.')
})
it('closes and toasts on 403 default_site', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(403, 'default_site')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain(
"The default site can't be deleted. Make another site the default first.",
)
})
it('closes, removes the row and toasts on 404', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(404, 'site_not_found')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('That site no longer exists')
})
})
+417
View File
@@ -0,0 +1,417 @@
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,
labelled,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
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 BASE_URL_MESSAGES = {
scheme: 'Use https. For example https://blog.example.com.',
path: 'Remove the path. Use only the host, like https://blog.example.com.',
invalid: 'Enter a full URL, like https://blog.example.com.',
}
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
afterEach(unmountDirectory)
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('sites', '/settings/sites', options)
await click('Add site')
return backend
}
async function openEdit(name: string) {
const backend = await mountDirectory('sites', '/settings/sites')
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
async function fillValid(name = 'Journal 2', url = 'https://journal2.example') {
await typeInto(field('e.g. Journal'), name)
await typeInto(field('https://blog.example.com'), url)
}
function box() {
return dialog()!
}
function submit(label = 'Add site') {
return click(label, box())
}
describe('Sites form: add, empty', () => {
it('opens a slide-over with every field, placeholder and help', async () => {
await openAdd()
const text = textOf(box())
for (const copy of [
'Add site',
'Name',
'Key',
'Base URL',
'The public address of the frontend. Use https and no path.',
'Default social image (optional)',
'Choose image',
'Used when an article has no image of its own.',
'Default robots policy',
'Index',
'Noindex',
"Search engines may list this site's pages.",
'Locale (optional)',
'A language tag. Leave empty if the site has no single language.',
'Make this the default site',
'New articles start here. The current default is Renew.',
'Canonical URL preview',
'Cancel',
]) {
expect(text).toContain(copy)
}
expect(field('e.g. Journal')).toBeTruthy()
expect(field('Generated from the name')).toBeTruthy()
expect(field('https://blog.example.com')).toBeTruthy()
expect(field('e.g. en or fr-CH')).toBeTruthy()
expect(button('Add site', box())).toBeTruthy()
})
it('focuses Name', async () => {
await openAdd()
await flushPromises()
expect(document.activeElement).toBe(field('e.g. Journal'))
})
})
describe('Sites form: key from the name', () => {
it('fills the key with an Auto badge and unlocks it with the pencil', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Journal Online')
const key = labelled('Key') as HTMLInputElement
expect(key.value).toBe('journal-online')
expect(textOf(box())).toContain('Auto')
expect(textOf(box())).toContain(
"Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.",
)
await click('Edit key', box())
await typeInto(labelled('Key') as HTMLInputElement, 'custom-key')
await typeInto(field('e.g. Journal'), 'Something else')
expect((labelled('Key') as HTMLInputElement).value).toBe('custom-key')
expect(textOf(box())).not.toContain('Auto')
})
})
describe.each([
['http://blog.example.com', BASE_URL_MESSAGES.scheme],
['https://blog.example.com/en', BASE_URL_MESSAGES.path],
['blog', BASE_URL_MESSAGES.invalid],
])('Sites form: base URL "%s" is not valid', (value, message) => {
it('shows the message on leaving the field and sends nothing', async () => {
const backend = await openAdd()
await fillValid('Journal 2', value)
await blur(field('https://blog.example.com'))
expect(textOf(box())).toContain(message)
await submit()
expect(callsTo(backend, 'POST', '/v1/site/')).toHaveLength(0)
expect(textOf(box())).toContain(message)
})
})
describe('Sites form: base URL refused by the server', () => {
it('shows the generic URL message on 400 invalid_site_url', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_site_url')
await openAdd({ backend })
await fillValid()
await submit()
expect(textOf(box())).toContain(BASE_URL_MESSAGES.invalid)
})
})
describe.each(['http://localhost:5173', 'http://127.0.0.1:8081', 'http://[::1]:5173'])(
'Sites form: loopback host %s accepts http',
(value) => {
it('shows no base URL error', async () => {
await openAdd()
await fillValid('Journal 2', value)
await blur(field('https://blog.example.com'))
const text = textOf(box())
expect(text).not.toContain(BASE_URL_MESSAGES.scheme)
expect(text).not.toContain(BASE_URL_MESSAGES.invalid)
expect(text).not.toContain(BASE_URL_MESSAGES.path)
})
},
)
describe('Sites form: add succeeds', () => {
it('sends exactly one POST, shows Saving… with disabled fields, then closes and lists the site', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['POST /v1/site/'] = () => hold.promise
await openAdd({ backend })
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
await flushPromises()
await submit()
expect(button('Saving…', box())).toBeTruthy()
expect(field('e.g. Journal').disabled).toBe(true)
hold.resolve({
id: 5,
name: 'Notes',
key: 'notes',
base_url: 'https://notes.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 0,
})
await flushPromises()
const posts = callsTo(backend, 'POST', '/v1/site/')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toEqual({
name: 'Notes',
key: 'notes',
base_url: 'https://notes.example',
default_robots: 'noindex',
is_default: false,
})
expect(dialog()).toBeNull()
expect(rowTexts().at(-1)).toContain('Notes')
})
})
describe('Sites form: duplicate key', () => {
it('flags the key on leaving the field and applies the suggestion', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Journal')
await click('Edit key', box())
const key = labelled('Key') as HTMLInputElement
await blur(key)
expect(textOf(box())).toContain(
'A site with the key "journal" already exists. Choose another key.',
)
await click('Use journal-2', box())
expect((labelled('Key') as HTMLInputElement).value).toBe('journal-2')
expect(textOf(box())).not.toContain('already exists')
})
it('flags the key when the server answers 409 site_key_taken', async () => {
const backend = createBackend()
backend.sites = backend.sites.filter((site) => site.key !== 'journal')
backend.responders['POST /v1/site/'] = failWith(409, 'site_key_taken')
await openAdd({ backend })
await fillValid('Journal', 'https://journal.example')
await submit()
expect(textOf(box())).toContain(
'A site with the key "journal" already exists. Choose another key.',
)
expect(textOf(box())).toContain('Use journal-2')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites form: save failed with %s', (_name, responder) => {
it('shows the notice, keeps the values and enables the form again', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = responder
await openAdd({ backend })
await fillValid()
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(field('e.g. Journal').value).toBe('Journal 2')
expect(field('https://blog.example.com').value).toBe('https://journal2.example')
expect(field('e.g. Journal').disabled).toBe(false)
expect(button('Add site', box())).toBeTruthy()
})
})
describe('Sites form: invalid locale from the server', () => {
it('shows the locale message and keeps the panel open', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_locale')
await openAdd({ backend })
await fillValid()
await typeInto(field('e.g. en or fr-CH'), 'english')
await submit()
expect(textOf(box())).toContain('Use a language tag like en or fr-CH.')
expect(dialog()).not.toBeNull()
})
})
describe('Sites form: edit the default site', () => {
it('fills the fields, locks the key and keeps the default switch on and locked', async () => {
await openEdit('Renew')
const text = textOf(box())
expect(text).toContain('Edit Renew')
expect(field('e.g. Journal').value).toBe('Renew')
const key = labelled('Key') as HTMLInputElement
expect(key.value).toBe('renew')
expect(key.readOnly || key.disabled).toBe(true)
expect(box().querySelector('[data-testid="slug-lock"]')).not.toBeNull()
expect(text).toContain("Used in API paths, so it can't change.")
expect(text).toContain('Default site')
expect(text).toContain(
'New articles start on the default site. To change it, make another site the default.',
)
const toggle = box().querySelector<HTMLButtonElement>('[role="switch"]')!
expect(toggle.getAttribute('aria-checked')).toBe('true')
expect(toggle.disabled || toggle.getAttribute('data-disabled') !== null).toBe(true)
expect(button('Save changes', box())).toBeTruthy()
})
})
describe('Sites form: edit another site, noindex', () => {
it('shows Noindex selected with its help and sends a PATCH without the key', async () => {
const backend = await openEdit('Projects')
const text = textOf(box())
expect(text).toContain('Edit Projects')
expect(text).toContain("Asks search engines not to list this site's pages.")
expect(text).toContain('Make this the default site')
const noindex = box().querySelector('[role="radio"][value="noindex"]')!
expect(noindex.getAttribute('aria-checked')).toBe('true')
await typeInto(field('e.g. Journal'), 'Projects 2')
await submit('Save changes')
const patches = callsTo(backend, 'PATCH', '/v1/site/3')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ name: 'Projects 2' })
expect(patches[0]!.body).not.toHaveProperty('key')
})
})
describe('Sites form: make another site the default from the form', () => {
it('sends the changed fields, then the default route, and never is_default in the first PATCH', async () => {
const backend = await openEdit('Projects')
await typeInto(field('e.g. Journal'), 'Projects 2')
box().querySelector<HTMLElement>('[role="switch"]')!.click()
await flushPromises()
await submit('Save changes')
const order = backend.calls
.filter((call) => call.method === 'PATCH')
.map((call) => call.path)
expect(order).toEqual(['/v1/site/3', '/v1/site/3/default'])
expect(backend.calls.find((call) => call.path === '/v1/site/3')!.body).not.toHaveProperty(
'is_default',
)
expect(
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
).toContain('Default')
})
})
describe('Sites form: discard changes', () => {
it('asks before closing a changed form with Escape and keeps it on Keep editing', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
const ask = document.body.querySelector('[role="alertdialog"]')!
expect(textOf(ask)).toContain('Discard changes?')
await click('Keep editing', ask)
expect(field('e.g. Journal').value).toBe('Notes')
})
it('discards on confirmation and closes an unchanged form with Cancel straight away', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await click('Cancel', box())
await click('Discard changes', document.body.querySelector('[role="alertdialog"]')!)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await click('Add site')
await click('Cancel', box())
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
})
describe('Sites form: deep link', () => {
it('opens the edit slide-over for a known key', async () => {
await mountDirectory('sites', '/settings/sites?edit=projects')
expect(textOf(box())).toContain('Edit Projects')
})
it('shows an info toast and opens nothing for an unknown key', async () => {
await mountDirectory('sites', '/settings/sites?edit=nope')
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('That site no longer exists')
})
})
describe('Slide-over accessibility', () => {
it('moves focus into the panel and returns it to the opening control on close', async () => {
await mountDirectory('sites', '/settings/sites')
const opener = button('Add site')!
opener.focus()
opener.click()
await flushPromises()
await flushPromises()
expect(box().contains(document.activeElement)).toBe(true)
await click('Cancel', box())
await flushPromises()
expect(document.activeElement).toBe(opener)
})
it('ignores Escape while a request is running', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = () => pending<unknown>().promise
await openAdd({ backend })
await fillValid()
await submit()
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(dialog()).not.toBeNull()
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
})
})
void useApi
+323
View File
@@ -0,0 +1,323 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
rows,
textOf,
unmountDirectory,
button,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SKELETON_DELAY_MS = 150
const DEFAULT_SITE_TIP = "The default site can't be deleted. Make another site the default first."
afterEach(unmountDirectory)
describe('Sites: default (owner)', () => {
it('shows the header, the columns, the rows, the counts and the badges', async () => {
await mountDirectory('sites', '/settings/sites')
expect(document.body.querySelector('h1')?.textContent).toBe('Sites')
expect(document.body.textContent).toContain('The frontends that display your content.')
expect(button('Add site')).toBeTruthy()
const header = document.body.querySelector(
'[data-testid="directory-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Site Base URL Articles')
expect(rowTexts()).toEqual([
'Renew Default renew https://renew.example 268 articles',
'Journal journal https://journal.example 38 articles',
'Projects Noindex projects https://projects.example 6 articles',
'Archive archive https://archive.example No articles',
])
})
it('draws cards below md with a full-width Add site button pinned to the bottom', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
expect(document.body.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(4)
expect(document.body.querySelector('[data-testid="directory-row"]')).toBeNull()
const pinned = button('Add site')!
expect(pinned.className).toContain('fixed')
expect(pinned.className).toContain('inset-x-4')
})
})
describe('Sites: only the default site', () => {
it('lists the row with the hint and draws no empty-state wall', async () => {
const backend = createBackend()
backend.sites = backend.sites.slice(0, 1)
await mountDirectory('sites', '/settings/sites', { backend })
expect(rows()).toHaveLength(1)
expect(document.body.textContent).toContain(
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.',
)
expect(document.body.textContent).not.toContain('No sites yet')
})
})
describe('Sites: loading', () => {
it('draws nothing before 150 ms, then skeleton rows with aria-busy', async () => {
vi.useFakeTimers()
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['GET /v1/site/'] = () => hold.promise
await mountDirectory('sites', '/settings/sites', { backend })
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(0)
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(3)
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites: error on load with %s', (_name, responder) => {
it('shows the alert, no rows, and Try again requests the list again', async () => {
const backend = createBackend()
backend.responders['GET /v1/site/'] = responder
await mountDirectory('sites', '/settings/sites', { backend })
const alert = document.body.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load sites")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(rows()).toHaveLength(0)
expect(textOf(alert)).not.toContain('500')
delete backend.responders['GET /v1/site/']
await click('Try again')
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(4)
})
})
describe('Sites: row menu', () => {
it('offers Edit, Set as default and Delete… for a non-default site', async () => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Journal')
const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
expect(labels).toEqual(['Edit', 'Set as default', 'Delete…'])
})
it('opens a bottom sheet titled with the site name below md', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await openRowMenu('Journal')
const sheet = dialog()!
expect(textOf(sheet.querySelector('h2'))).toBe('Journal')
expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
})
})
describe('Sites: delete unavailable on the default site', () => {
it('keeps Delete… focusable with aria-disabled and sends nothing when activated', async () => {
const backend = await mountDirectory('sites', '/settings/sites')
await openRowMenu('Renew')
const item = button(/^Delete…/)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.hasAttribute('disabled')).toBe(false)
expect(item.getAttribute('data-disabled')).toBeNull()
expect(textOf(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
DEFAULT_SITE_TIP,
)
item.focus()
expect(document.activeElement).toBe(item)
item.click()
await flushPromises()
expect(dialog()?.getAttribute('role')).not.toBe('alertdialog')
expect(backend.calls.filter((call) => call.method === 'DELETE')).toHaveLength(0)
})
it('writes the explanation in the sheet below md', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await openRowMenu('Renew')
expect(textOf(dialog())).toContain(DEFAULT_SITE_TIP)
expect(button('Delete…')!.getAttribute('aria-disabled')).toBe('true')
})
})
describe('Sites: set as default', () => {
async function openDialog(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Journal')
await chooseMenuItem('Set as default')
return backend
}
it('asks, sends one PATCH without a body, shows Saving… and moves the badge with a toast', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['PATCH /v1/site/2/default'] = () => hold.promise
await openDialog(backend)
const box = dialog()!
expect(textOf(box)).toContain('Make Journal the default site?')
expect(textOf(box)).toContain(
'New articles start on this site. Articles that are already on other sites stay where they are.',
)
expect(button('Cancel', box)).toBeTruthy()
await click('Make default', box)
expect(button('Saving…', box)).toBeTruthy()
hold.resolve({})
await flushPromises()
const patches = callsTo(backend, 'PATCH', '/v1/site/2/default')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toBeUndefined()
expect(textOf(rows()[1])).toContain('Default')
expect(textOf(rows()[0])).not.toContain('Default')
expect(document.body.textContent).toContain('Default site changed')
expect(document.body.textContent).toContain('"Journal" is now the default site.')
})
it('keeps the dialog open with a notice on a 500', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).not.toBeNull()
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toContain(
"Couldn't save the site. Your changes are still here. Try again in a moment.",
)
})
it('closes, removes the row and toasts on a 404', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(404, 'site_not_found')
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('That site no longer exists')
expect(document.body.textContent).toContain(
'It was deleted somewhere else, so we removed it from the list.',
)
})
it('closes and shows the owner-only toast on a 403', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(
403,
'Only the owner can do this.',
)
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
})
describe.each([
['admin', '/settings/sites'],
['member', '/settings/sites'],
['admin', '/settings/sites?edit=journal'],
])('Sites: forbidden (403 page) for %s on %s', (role, path) => {
it('explains instead of redirecting, opens no panel and Back to Settings goes to /settings', async () => {
const backend = await mountDirectory('sites', path, { role })
const text = document.body.textContent ?? ''
expect(text).toContain("You don't have access to Sites")
expect(text).toContain(
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
)
expect(router.currentRoute.value.fullPath).toBe(path)
expect(dialog()).toBeNull()
expect(
document.body.querySelector('nav[aria-label="Main"] a[href="/settings/sites"]'),
).toBeNull()
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(0)
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
await click('Back to Settings')
expect(push).toHaveBeenCalledWith('/settings')
push.mockRestore()
})
})
describe('Sites: the API answers 403 where the SPA expected the owner', () => {
it('shows the 403 page when the answer is for the page load', async () => {
const backend = createBackend()
backend.responders['GET /v1/site/'] = failWith(403, 'Only the owner can do this.')
await mountDirectory('sites', '/settings/sites', { backend })
expect(document.body.textContent).toContain("You don't have access to Sites")
expect(rows()).toHaveLength(0)
})
it('shows an error toast when the answer is for an action', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/4'] = failWith(403, 'Only the owner can do this.')
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Archive')
await chooseMenuItem('Delete…')
await click('Delete site', dialog()!)
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
})
describe('Sites: the sidebar for the owner and for an admin', () => {
it('shows a Publishing group with a Sites entry linking to /settings/sites for the owner', async () => {
await mountDirectory('sites', '/settings/sites')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).toContain('Publishing')
const link = nav.querySelector<HTMLAnchorElement>('a[href="/settings/sites"]')!
expect(textOf(link)).toBe('Sites')
expect(link.getAttribute('aria-current')).toBe('page')
})
it('has no Publishing group and no Sites entry for an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
})
})
void useApi
void DESKTOP_WIDTH
+445
View File
@@ -0,0 +1,445 @@
import { vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi, type MeAuthor } from '@/composables/useApi'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
export interface Call {
method: string
path: string
raw: string
query: URLSearchParams
body: Record<string, unknown> | undefined
}
export type Responder = (call: Call) => unknown | Promise<unknown>
export interface Backend {
sites: Site[]
authors: Author[]
users: unknown[]
calls: Call[]
responders: Record<string, Responder>
}
export function sitesFixture(): Site[] {
const base = {
default_social_image_id: null,
default_robots: 'index' as const,
locale: null,
}
return [
{
...base,
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
is_default: true,
article_count: 268,
},
{
...base,
id: 2,
name: 'Journal',
key: 'journal',
base_url: 'https://journal.example',
is_default: false,
article_count: 38,
},
{
...base,
id: 3,
name: 'Projects',
key: 'projects',
base_url: 'https://projects.example',
default_robots: 'noindex',
is_default: false,
article_count: 6,
},
{
...base,
id: 4,
name: 'Archive',
key: 'archive',
base_url: 'https://archive.example',
is_default: false,
article_count: 0,
},
]
}
function author(overrides: Partial<Author> & Pick<Author, 'id' | 'name' | 'slug'>): Author {
return {
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 0,
...overrides,
}
}
export function authorsFixture(withEmails = true): Author[] {
const list = [
author({
id: 2,
name: 'Inès Duarte',
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter.',
website: 'https://ines-duarte.example',
social_links: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
user_id: 3,
is_linked: true,
article_count: 9,
user_email: 'ines.duarte@renew.example',
}),
author({
id: 7,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
article_count: 3,
}),
author({
id: 9,
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
slug: 'dr-alexandra-konstantinopoulou-whitfield',
article_count: 1,
}),
author({ id: 10, name: 'Léa Fontaine', slug: 'lea-fontaine' }),
]
return withEmails ? list : list.map((item) => ({ ...item, user_email: undefined }))
}
export function usersFixture() {
const user = (id: number, username: string, display: string, email: string, role: string) => ({
kind: 'user',
id,
username,
display_name: display,
email,
role,
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
})
return [
user(3, 'ines', 'Inès Duarte', 'ines.duarte@renew.example', 'admin'),
user(1, 'marta', 'Marta Alves', 'marta@renew.example', 'owner'),
user(4, 'rui', 'Rui Santos', 'rui@renew.example', 'admin'),
]
}
function fail(status: number, code?: string): never {
throw new ApiError(status, undefined, code)
}
function parseBody(options: RequestInit | undefined) {
return typeof options?.body === 'string'
? (JSON.parse(options.body) as Record<string, unknown>)
: undefined
}
function pageOf(backend: Backend, query: URLSearchParams) {
const q = (query.get('q') ?? '').toLowerCase()
const size = Number(query.get('size') ?? 25)
const page = Number(query.get('page') ?? 1)
const matching = backend.authors.filter((item) =>
`${item.name} ${item.slug}`.toLowerCase().includes(q),
)
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
}
}
function defaultRoute(backend: Backend, call: Call): unknown {
const { method, path, query, body } = call
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
if (method === 'GET' && path === '/v1/site/') {
return structuredClone(backend.sites)
}
if (method === 'POST' && path === '/v1/site/') {
if (backend.sites.some((site) => site.key === body?.key)) {
fail(409, 'site_key_taken')
}
const created = {
id: 100,
default_social_image_id: null,
locale: null,
article_count: 0,
...body,
} as unknown as Site
backend.sites.push(created)
return created
}
if (siteMatch && method === 'PATCH') {
const site = backend.sites.find((candidate) => candidate.id === Number(siteMatch[1]))
if (!site) {
fail(404, 'site_not_found')
}
if (siteMatch[2]) {
backend.sites.forEach((candidate) => (candidate.is_default = candidate === site))
} else {
Object.assign(site, body)
}
return structuredClone(site)
}
if (siteMatch && method === 'DELETE') {
const id = Number(siteMatch[1])
const site = backend.sites.find((candidate) => candidate.id === id)
if (!site) {
fail(404, 'site_not_found')
}
if (site.is_default) {
fail(403, 'default_site')
}
const reassign = query.get('reassign_to')
if (site.article_count > 0 && reassign === null) {
fail(409, 'site_has_articles')
}
backend.sites = backend.sites.filter((candidate) => candidate.id !== id)
return undefined
}
if (method === 'GET' && path === '/v1/author/') {
return pageOf(backend, query)
}
if (method === 'POST' && path === '/v1/author/') {
const created = { id: 200, is_linked: body?.user_id != null, article_count: 0, ...body }
backend.authors.push(created as unknown as Author)
return created
}
if (authorMatch && method === 'PATCH') {
const target = backend.authors.find((candidate) => candidate.id === Number(authorMatch[1]))
if (!target) {
fail(404, 'author_not_found')
}
Object.assign(target, body)
if (body && 'user_id' in body) {
target.is_linked = body.user_id !== null
}
return structuredClone(target)
}
if (authorMatch && method === 'DELETE') {
const id = Number(authorMatch[1])
backend.authors = backend.authors.filter((candidate) => candidate.id !== id)
return undefined
}
if (method === 'GET' && path === '/v1/user/') {
return backend.users
}
if (method === 'GET' && path === '/v1/media/') {
return {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
}
throw new Error(`Unexpected request: ${method} ${path}`)
}
export function createBackend(): Backend {
return {
sites: sitesFixture(),
authors: authorsFixture(),
users: usersFixture(),
calls: [],
responders: {},
}
}
export function callsTo(backend: Backend, method: string, path: string) {
return backend.calls.filter((call) => call.method === method && call.path === path)
}
export function failWith(status: number, code?: string): Responder {
return () => fail(status, code)
}
export const networkFailure: Responder = () => {
throw new NetworkError()
}
export function pending<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
export interface MountOptions {
role?: string
width?: number
backend?: Backend
author?: MeAuthor
email?: string
}
export const kit: { wrapper: VueWrapper | null; backend: Backend } = {
wrapper: null,
backend: createBackend(),
}
export async function mountDirectory(
view: 'sites' | 'authors',
url: string,
options: MountOptions = {},
) {
setViewportWidth(options.width ?? DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
setActivePinia(createPinia())
const backend = options.backend ?? createBackend()
kit.backend = backend
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
email: options.email ?? 'marta@renew.example',
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
...(options.author ? { author: options.author } : {}),
}
session.status = 'ready'
const request = vi.fn(async (path: string, init?: RequestInit) => {
const [pathname = '', search = ''] = path.split('?')
const call: Call = {
method: init?.method ?? 'GET',
path: pathname,
raw: path,
query: new URLSearchParams(search),
body: parseBody(init),
}
backend.calls.push(call)
const responder =
backend.responders[`${call.method} ${path}`] ??
backend.responders[`${call.method} ${pathname}`]
return responder ? responder(call) : defaultRoute(backend, call)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(url)
const component = view === 'sites' ? SitesView : AuthorsView
kit.wrapper = mount(
defineComponent({ render: () => h(UApp, null, { default: () => h(component) }) }),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
return backend
}
export async function unmountDirectory() {
kit.wrapper?.unmount()
kit.wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
if (!element) {
return ''
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
export function button(label: string | RegExp, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('button, [role="menuitem"], a')).find(
(element) => {
const text = element.getAttribute('aria-label') || textOf(element)
return typeof label === 'string' ? text === label : label.test(text)
},
)
}
export async function click(label: string | RegExp, root: ParentNode = document.body) {
const target = button(label, root)
if (!target) {
throw new Error(`No control named ${String(label)}`)
}
target.click()
await flushPromises()
}
export function dialog() {
return document.body.querySelector<HTMLElement>('[role="dialog"], [role="alertdialog"]')
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[data-testid="directory-row"], [data-testid="directory-card"]',
),
)
}
export function rowTexts() {
return rows().map((row) => textOf(row))
}
export async function openRowMenu(name: string) {
await click(`Actions for ${name}`)
}
export async function chooseMenuItem(label: string) {
await click(label)
}
export async function typeInto(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
element.value = value
element.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function blur(element: HTMLElement) {
element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
element.dispatchEvent(new Event('blur'))
await flushPromises()
}
export function field(placeholder: string) {
return document.body.querySelector<HTMLInputElement>(`[placeholder="${placeholder}"]`)!
}
export function labelled(label: string) {
const labelElement = Array.from(document.body.querySelectorAll('label')).find((element) =>
textOf(element).startsWith(label),
)
const id = labelElement?.getAttribute('for')
return id ? document.getElementById(id)! : null
}
export function toasts() {
return textOf(
document.body.querySelector(
'[data-slot="viewport"], [role="region"][aria-label*="otification"]',
),
)
}