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() 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('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('[role="option"]')) } function option(name: string) { return options().find((item) => textOf(item).includes(name))! } function searchInput() { return document.body.querySelector('input[placeholder="Search authors"]')! } function createRow() { return document.body.querySelector('[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('input[placeholder="e.g. Camille Roche"]')! } function slugField() { return dialog()!.querySelector( '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('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('[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('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) }) })