494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
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)
|
|
})
|
|
})
|