feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user