Files
RenewCMS/web/src/views/__tests__/AuthorsForm.spec.ts
T

553 lines
17 KiB
TypeScript

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