feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)

This commit is contained in:
Florian Sylvain committed 2026-10-15 05:25:44 +02:00
1 parent 3d81d2c717
commit 6b8990c55e
239 files changed
+9194 -1354

No files matched your search

@@ -0,0 +1,493 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { META_SECTION_OPEN_KEY, hintDismissedKey } from '@/lib/metaSectionMemory'
import {
MARTA_EMAIL,
authorRows,
bylines,
callsTo,
click,
createMetaBackend,
dialog,
failWith,
mountMeta,
networkFailure,
patchCalls,
renewSites,
rowNames,
textOf,
toasts,
typeInto,
unmountMeta,
type MetaBackend,
type MetaRole,
} from './metaTestKit'
import { useSessionStore } from '@/stores/session'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const EDITOR = '/article/12/edit'
const WAIT_FOR_SEARCH_MS = 320
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
})
afterEach(unmountMeta)
function backendWith(role: MetaRole, ...names: string[]): MetaBackend {
const backend = createMetaBackend({ sites: renewSites().slice(0, 1), role })
backend.article.authors = bylines(...names)
return backend
}
function trigger() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
).find((element) => textOf(element) === 'Add an author')!
}
async function openCombobox() {
trigger().click()
await flushPromises()
}
function options() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
}
function option(name: string) {
return options().find((item) => textOf(item).includes(name))!
}
function searchInput() {
return document.body.querySelector<HTMLInputElement>('input[placeholder="Search authors"]')!
}
function createRow() {
return document.body.querySelector<HTMLElement>('[data-testid="create-author-row"]')
}
async function search(term: string) {
await typeInto(searchInput(), term)
await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_SEARCH_MS))
await flushPromises()
}
function authorListCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/author/')
}
describe('Add author: the combobox', () => {
it('opens with a Search authors field, requests the first 100 authors and dims the ones already on the article', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
expect(searchInput()).not.toBeNull()
expect(authorListCalls(backend)).toHaveLength(1)
expect(authorListCalls(backend)[0]!.query.get('size')).toBe('100')
expect(option('Inès Duarte').className).toContain('opacity-60')
expect(option('Camille Roche').className).not.toContain('opacity-60')
})
it('does nothing when a checked author is activated, and appends a new author on pick', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
option('Inès Duarte').click()
await flushPromises()
expect(rowNames()).toEqual(['Inès Duarte'])
expect(textOf(document.body)).not.toContain('Unsaved changes')
option('Camille Roche').click()
await flushPromises()
expect(rowNames()).toEqual(['Inès Duarte', 'Camille Roche'])
expect(textOf(document.body)).toContain('Unsaved changes')
expect(patchCalls(backend)).toHaveLength(0)
})
it('keeps the section open when the first author is added to an article that had none', async () => {
localStorage.clear()
const backend = backendWith('owner')
await mountMeta('editor', EDITOR, { backend })
expect(document.body.querySelector('[data-testid="meta-summary"]')).toBeNull()
await openCombobox()
option('Camille Roche').click()
await flushPromises()
expect(rowNames()).toEqual(['Camille Roche'])
expect(document.body.querySelector('[data-testid="meta-summary"]')).toBeNull()
expect(trigger()).toBeDefined()
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBeNull()
})
it('searches the server with q when the install has more than 100 authors', async () => {
const backend = backendWith('owner', 'Inès Duarte')
const crowd = Array.from({ length: 120 }, (_, index) => ({
...backend.authors[0]!,
id: 1000 + index,
name: `Writer ${String(index).padStart(3, '0')}`,
slug: `writer-${index}`,
}))
backend.authors = [
...crowd,
{
...backend.authors[0]!,
id: 2000,
name: 'Zoe Adams',
slug: 'zoe-adams',
},
]
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
expect(options()).toHaveLength(100)
expect(textOf(document.body)).not.toContain('Zoe Adams')
await search('Zoe')
const last = authorListCalls(backend).at(-1)!
expect(last.query.get('q')).toBe('Zoe')
expect(last.query.get('size')).toBe('100')
expect(options().map(textOf)).toEqual(['ZA Zoe Adams zoe-adams'])
})
})
describe('Add author: "Create author…" only for roles with author rights', () => {
it.each([
{ role: 'owner', shown: true },
{ role: 'admin', shown: true },
{ role: 'verified', shown: false },
] as const)('the $role sees the row: $shown', async ({ role, shown }) => {
await mountMeta('editor', EDITOR, {
backend: backendWith(role, 'Inès Duarte'),
})
await openCombobox()
expect(createRow() !== null).toBe(shown)
if (shown) {
expect(textOf(createRow())).toBe('Create author…')
}
})
})
describe('Add author: no match', () => {
it.each([
{ role: 'owner', create: true },
{ role: 'verified', create: false },
] as const)(
'shows the message for a $role, with the create row: $create',
async ({ role, create }) => {
await mountMeta('editor', EDITOR, {
backend: backendWith(role, 'Inès Duarte'),
})
await openCombobox()
await search('Zoe Lambert')
expect(textOf(document.body)).toContain('No author matches "Zoe Lambert".')
expect(createRow() !== null).toBe(create)
if (create) {
expect(textOf(createRow())).toBe('Create author "Zoe Lambert"…')
}
},
)
})
describe('Add author: authors could not be loaded', () => {
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('shows the error and retries when the load fails with $failure', async ({ responder }) => {
const backend = backendWith('owner', 'Inès Duarte')
backend.responders['GET /v1/author/'] = responder
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
const text = textOf(document.body)
expect(text).toContain("Couldn't load authors")
expect(text).toContain('Check your connection and try again.')
expect(createRow()).toBeNull()
expect(rowNames()).toEqual(['Inès Duarte'])
delete backend.responders['GET /v1/author/']
await click('Try again')
expect(authorListCalls(backend)).toHaveLength(2)
expect(options().length).toBeGreaterThan(0)
expect(rowNames()).toEqual(['Inès Duarte'])
})
})
describe('Create author dialog', () => {
async function openDialog(backend: MetaBackend) {
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
await search('Zoe Lambert')
createRow()!.click()
await flushPromises()
}
function nameField() {
return dialog()!.querySelector<HTMLInputElement>('input[placeholder="e.g. Camille Roche"]')!
}
function slugField() {
return dialog()!.querySelector<HTMLInputElement>(
'input[placeholder="Generated from the name"]',
)!
}
it('opens over the editor with the name, the auto slug and both actions', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await openDialog(backend)
const text = textOf(dialog())
expect(text).toContain('Create author')
expect(text).toContain(
'Adds a byline for this article. You can add a bio and avatar later in Authors.',
)
expect(nameField().value).toBe('Zoe Lambert')
expect(slugField().value).toBe('zoe-lambert')
expect(text).toContain('Auto')
expect(text).toContain('Cancel')
expect(patchCalls(backend)).toHaveLength(0)
})
it('creates with name and slug, appends the author and waits for Save to send the authors', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await openDialog(backend)
await click('Create author', dialog()!)
const posts = callsTo(backend, 'POST', '/v1/author/')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toEqual({
name: 'Zoe Lambert',
slug: 'zoe-lambert',
})
expect(dialog()).toBeNull()
expect(rowNames()).toEqual(['Inès Duarte', 'Zoe Lambert'])
expect(textOf(document.body)).toContain('Unsaved changes')
expect(patchCalls(backend)).toHaveLength(0)
})
it('offers "This is me" only without an author profile, unchecked, and sends user_id when checked', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await openDialog(backend)
const checkbox = dialog()!.querySelector<HTMLElement>('button[role="checkbox"]')!
expect(textOf(dialog())).toContain('This is me')
expect(textOf(dialog())).toContain(`Links the author to ${MARTA_EMAIL}.`)
expect(checkbox.getAttribute('aria-checked')).toBe('false')
checkbox.click()
await flushPromises()
await click('Create author', dialog()!)
expect(callsTo(backend, 'POST', '/v1/author/')[0]!.body).toEqual({
name: 'Zoe Lambert',
slug: 'zoe-lambert',
user_id: 1,
})
expect(useSessionStore().session?.author).toMatchObject({
name: 'Zoe Lambert',
})
})
it('has no "This is me" and never sends a user_id when the user already has an author', async () => {
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
author: { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' },
})
await openDialog(backend)
expect(textOf(dialog())).not.toContain('This is me')
await click('Create author', dialog()!)
expect(callsTo(backend, 'POST', '/v1/author/')[0]!.body).not.toHaveProperty('user_id')
})
it('shows the taken slug with a suggestion that keeps the name', async () => {
const backend = backendWith('owner', 'Inès Duarte')
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
await openDialog(backend)
await click('Create author', dialog()!)
expect(textOf(dialog())).toContain(
'The slug "zoe-lambert" is already used by another author.',
)
await click('Use zoe-lambert-2', dialog()!)
expect(slugField().value).toBe('zoe-lambert-2')
expect(nameField().value).toBe('Zoe Lambert')
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])(
'keeps the dialog and the values open when creating fails with $failure',
async ({ responder }) => {
const backend = backendWith('owner', 'Inès Duarte')
backend.responders['POST /v1/author/'] = responder
await openDialog(backend)
await click('Create author', dialog()!)
expect(textOf(dialog())).toContain("Couldn't create the author")
expect(textOf(dialog())).toContain('The article is unchanged. Try again in a moment.')
expect(nameField().value).toBe('Zoe Lambert')
expect(rowNames()).toEqual(['Inès Duarte'])
expect(patchCalls(backend)).toHaveLength(0)
},
)
it('shows the rights toast and closes when the role changed while the dialog was open', async () => {
const backend = backendWith('owner', 'Inès Duarte')
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
await openDialog(backend)
await click('Create author', dialog()!)
expect(toasts()).toContain('Only an owner or admin can change authors')
expect(dialog()).toBeNull()
})
})
describe('Create my author profile hint', () => {
function hint() {
return document.body.querySelector<HTMLElement>('[data-testid="author-hint"]') ?? undefined
}
it.each(['owner', 'admin', 'verified'] as const)(
'appears for a %s with no profile, no authors on the article and no dismissal',
async (role) => {
await mountMeta('editor', EDITOR, { backend: backendWith(role) })
expect(textOf(hint())).toContain('Create one to be credited on your articles.')
expect(textOf(hint())).toContain('Create my author profile')
expect(textOf(document.body)).toContain('No authors. This article will have no byline.')
},
)
it('stays away when the user already has an author profile', async () => {
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
author: { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' },
})
backend.article.authors = []
await mountMeta('editor', EDITOR, { backend })
expect(hint()).toBeUndefined()
})
it('stays away when the article has an author', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('owner', 'Inès Duarte'),
})
expect(hint()).toBeUndefined()
})
it('stays away once this user dismissed it', async () => {
localStorage.setItem(hintDismissedKey(1), 'true')
await mountMeta('editor', EDITOR, { backend: backendWith('owner') })
expect(hint()).toBeUndefined()
})
it('creates the profile directly: one POST with no body, no dialog, the author first and a toast', async () => {
const backend = backendWith('verified', 'Inès Duarte')
backend.article.authors = []
await mountMeta('editor', EDITOR, { backend })
await click('Create my author profile')
const posts = callsTo(backend, 'POST', '/v1/author/me')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toBeUndefined()
expect(dialog()).toBeNull()
expect(rowNames()).toEqual(['Marta Alves'])
expect(textOf(document.body)).toContain('Unsaved changes')
expect(hint()).toBeUndefined()
expect(useSessionStore().session?.author).toMatchObject({
name: 'Marta Alves',
})
expect(toasts()).toContain('Author profile created')
expect(toasts()).toContain('Marta Alves is now your byline.')
expect(patchCalls(backend)).toHaveLength(0)
})
it('puts the new author before the others as the primary byline', async () => {
const backend = backendWith('owner')
await mountMeta('editor', EDITOR, { backend })
await click('Create my author profile')
await openCombobox()
option('Camille Roche').click()
await flushPromises()
expect(rowNames()).toEqual(['Marta Alves', 'Camille Roche'])
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])(
'keeps the hint and shows an error toast when it fails with $failure',
async ({ responder }) => {
const backend = backendWith('owner')
backend.responders['POST /v1/author/me'] = responder
await mountMeta('editor', EDITOR, { backend })
await click('Create my author profile')
expect(textOf(hint())).toContain('Create my author profile')
expect(toasts()).toContain("Couldn't create your author profile")
expect(toasts()).toContain('Try again in a moment.')
expect(rowNames()).toEqual([])
expect(textOf(document.body)).not.toContain('Unsaved changes')
},
)
it('reloads the session and drops the hint when the profile already exists (409)', async () => {
const backend = backendWith('owner')
const conflict = failWith(409, 'user_already_linked')
backend.responders['POST /v1/author/me'] = (call) => {
backend.me.author = {
id: 300,
name: 'Marta Alves',
slug: 'marta-alves',
}
return conflict(call)
}
await mountMeta('editor', EDITOR, { backend })
await click('Create my author profile')
expect(callsTo(backend, 'GET', '/v1/auth/me')).toHaveLength(1)
expect(hint()).toBeUndefined()
expect(useSessionStore().session?.author).toMatchObject({ id: 300 })
expect(rowNames()).toEqual([])
})
it('is dismissed without a request, kept per user in localStorage, and does not return', async () => {
const backend = backendWith('owner')
await mountMeta('editor', EDITOR, { backend })
const before = backend.calls.length
document.body.querySelector<HTMLElement>('button[aria-label="Dismiss"]')!.click()
await flushPromises()
expect(hint()).toBeUndefined()
expect(localStorage.getItem(hintDismissedKey(1))).toBe('true')
expect(backend.calls).toHaveLength(before)
await unmountMeta()
const other = backendWith('owner')
await mountMeta('editor', '/article/13/edit', { backend: other })
expect(hint()).toBeUndefined()
expect(authorRows()).toHaveLength(0)
})
})