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() 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('button[aria-haspopup="listbox"]')! } async function openAdd(options: Parameters[2] = {}) { const backend = await mountDirectory('authors', '/authors', options) await click('Add author') return backend } async function openEdit(name: string, options: Parameters[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() 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('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('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('button[aria-haspopup="listbox"]')!.click() await flushPromises() expect(textOf(ask)).toContain('Marta Alves marta@renew.example') const option = Array.from(ask.querySelectorAll('[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('[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