553 lines
17 KiB
TypeScript
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
|