import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import router from '@/router' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory' import { allMetaSections, bylines, callsTo, click, createMetaBackend, failWith, metaSection, mountMeta, patchCalls, pending, renewSites, saveButtonEl, textOf, unmountMeta, type MetaBackend, } from './metaTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) beforeEach(() => { localStorage.clear() sessionStorage.clear() }) afterEach(unmountMeta) const EDITOR = '/article/12/edit' function backendWith(sites: 'one' | 'several', authors: string[], siteId = 1): MetaBackend { const backend = createMetaBackend({ sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(), }) backend.article.authors = bylines(...authors) backend.article.site_id = siteId return backend } function summary() { return textOf(document.body.querySelector('[data-testid="meta-summary"]')) } function toggle() { return document.body.querySelector('[data-testid="meta-toggle"]')! } function listboxTriggers() { return Array.from( document.body.querySelectorAll('button[aria-haspopup="listbox"]'), ) } function bodyOpen() { return document.body.querySelector('[data-testid="meta-body"]') !== null } function toasts() { return Array.from(document.body.querySelectorAll('[data-slot="viewport"]')) .map(textOf) .join(' ') } describe('Editor section: collapsed by default with one site and an author', () => { it('shows the one-line summary in the details sidebar, above Created, and no Site select', async () => { await mountMeta('editor', EDITOR, { backend: backendWith('one', ['Inès Duarte']), }) const details = document.body.querySelector('[data-testid="article-details"]')! const section = metaSection()! expect(details.contains(section)).toBe(true) expect(textOf(section)).toContain('Site and authors') expect(summary()).toBe('Inès Duarte') expect(toggle().getAttribute('aria-expanded')).toBe('false') expect(bodyOpen()).toBe(false) expect(listboxTriggers()).toHaveLength(0) const text = textOf(details) expect(text.indexOf('Site and authors')).toBeLessThan(text.indexOf('Created')) expect(text.indexOf('Details')).toBeLessThan(text.indexOf('Site and authors')) }) }) describe('Editor section: loading and not found', () => { it('shows the header with a skeleton summary while the article is pending', async () => { const backend = backendWith('one', ['Inès Duarte']) const gate = pending() backend.responders['GET /v1/article/12'] = () => gate.promise await mountMeta('editor', EDITOR, { backend }) await new Promise((resolve) => setTimeout(resolve, 200)) await flushPromises() const skeleton = document.body.querySelector('[data-testid="skeleton-details"]')! expect(textOf(skeleton)).toContain('Site and authors') expect(skeleton.querySelector('[data-testid="skeleton-meta"]')).not.toBeNull() expect(metaSection()).toBeNull() }) it('shows no section in the Not found state', async () => { const backend = backendWith('one', ['Inès Duarte']) backend.responders['GET /v1/article/12'] = failWith(404) await mountMeta('editor', EDITOR, { backend }) expect(textOf(document.body)).toContain("This article doesn't exist") expect(metaSection()).toBeNull() expect(document.body.textContent).not.toContain('Site and authors') }) }) describe('Editor section: it opens by itself when it needs attention', () => { it.each([ { sites: 'one', authors: ['Inès Duarte'], state: 'collapsed' }, { sites: 'several', authors: ['Inès Duarte'], state: 'open' }, { sites: 'one', authors: [], state: 'open' }, { sites: 'several', authors: [], state: 'open' }, ] as const)( '$sites site(s) and $authors.length author(s): $state', async ({ sites, authors, state }) => { await mountMeta('editor', EDITOR, { backend: backendWith(sites, [...authors]), }) expect(bodyOpen()).toBe(state === 'open') expect(toggle().getAttribute('aria-expanded')).toBe(String(state === 'open')) }, ) it('stays open while the user adds the first author, instead of collapsing under them', async () => { await mountMeta('editor', EDITOR, { backend: backendWith('one', []) }) expect(bodyOpen()).toBe(true) document.body.querySelector('button[aria-haspopup="listbox"]')!.click() await flushPromises() const option = Array.from( document.body.querySelectorAll('[role="option"]'), ).find((item) => textOf(item).includes('Camille Roche'))! option.click() await flushPromises() expect(bodyOpen()).toBe(true) expect(textOf(metaSection())).toContain('Camille Roche') }) }) describe('Editor section: the choice is remembered per browser', () => { it('keeps an opening for the next article, and a collapse over every auto-open, without any request', async () => { const backend = backendWith('one', ['Inès Duarte']) await mountMeta('editor', EDITOR, { backend }) toggle().click() await flushPromises() expect(bodyOpen()).toBe(true) expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true') await unmountMeta() await mountMeta('editor', '/article/13/edit', { backend: backendWith('one', ['Inès Duarte']), }) expect(bodyOpen()).toBe(true) toggle().click() await flushPromises() expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('false') await unmountMeta() const crowded = backendWith('several', []) await mountMeta('editor', EDITOR, { backend: crowded }) expect(bodyOpen()).toBe(false) expect(summary()).toContain('No authors') expect( crowded.calls.filter( (call) => call.method !== 'GET' || call.path.startsWith('/v1/author'), ), ).toEqual([]) }) }) describe('Editor section: mobile, inside the Details sheet', () => { function openSheet() { return click('Details') } function sheet() { return document.body.querySelector('[role="dialog"]') } it('hides the desktop sidebar, starts closed in the sheet and shares the choice with it', async () => { await mountMeta('editor', EDITOR, { width: MOBILE_WIDTH, backend: backendWith('several', ['Inès Duarte'], 2), }) const sidebar = document.body.querySelector('[data-testid="article-details"]')! expect(sidebar.className).toContain('hidden') expect(sheet()).toBeNull() await openSheet() const inSheet = sheet()!.querySelector('[data-testid="meta-section"]')! expect(inSheet.querySelector('[data-testid="meta-body"]')).toBeNull() expect(textOf(inSheet)).toContain('Journal · Inès Duarte') inSheet.querySelector('[data-testid="meta-toggle"]')!.click() await flushPromises() expect(inSheet.querySelector('[data-testid="meta-body"]')).not.toBeNull() expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true') sheet()!.querySelector('button[aria-label="Close"]')!.click() await flushPromises() await new Promise((resolve) => setTimeout(resolve, 400)) await openSheet() const again = sheet()!.querySelector('[data-testid="meta-section"]')! expect(again.querySelector('[data-testid="meta-body"]')).not.toBeNull() expect(allMetaSections().length).toBeGreaterThanOrEqual(2) }) }) describe('Editor section: collapsed summary variants', () => { it.each([ { sites: 'one', authors: ['Inès Duarte'], siteId: 1, line: 'Inès Duarte', }, { sites: 'one', authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'], siteId: 1, line: 'Inès Duarte +2', }, { sites: 'one', authors: [], siteId: 1, line: 'No authors' }, { sites: 'several', authors: ['Inès Duarte'], siteId: 2, line: 'Journal · Inès Duarte', }, { sites: 'several', authors: [], siteId: 2, line: 'Journal · No authors', }, ] as const)( '$sites site(s), $authors.length author(s): "$line"', async ({ sites, authors, siteId, line }) => { localStorage.setItem(META_SECTION_OPEN_KEY, 'false') await mountMeta('editor', EDITOR, { backend: backendWith(sites, [...authors], siteId), }) expect(summary()).toBe(line) }, ) }) describe('Editor section: the Site select', () => { it('is absent with one site and the Authors list is the first control', async () => { localStorage.setItem(META_SECTION_OPEN_KEY, 'true') await mountMeta('editor', EDITOR, { backend: backendWith('one', ['Inès Duarte']), }) const body = document.body.querySelector('[data-testid="meta-body"]')! expect(textOf(body)).not.toContain('Canonical host') expect(textOf(body.firstElementChild)).toContain('Authors') expect(listboxTriggers().map(textOf)).toEqual(['Add an author']) }) it.each(['owner', 'admin', 'verified'] as const)( 'is above the authors with the canonical host as helper text for a %s', async (role) => { const backend = backendWith('several', ['Inès Duarte'], 2) backend.me.role = role === 'verified' ? undefined : role await mountMeta('editor', EDITOR, { backend }) const body = textOf(document.body.querySelector('[data-testid="meta-body"]')) expect(listboxTriggers().map(textOf)[0]).toBe('Journal · journal.renew.example') expect(body).toContain('Canonical host: journal.renew.example') expect(body.indexOf('Site')).toBeLessThan(body.indexOf('Authors')) }, ) it('lists every site with its host, the default first', async () => { await mountMeta('editor', EDITOR, { backend: backendWith('several', ['Inès Duarte'], 2), }) listboxTriggers()[0]!.click() await flushPromises() const options = Array.from( document.body.querySelectorAll('[role="option"]'), ).map(textOf) expect(options).toEqual([ 'Renew renew.example', 'Journal journal.renew.example', 'Projects projects.renew.example', 'Archive archive.renew.example', ]) }) it('treats a change as an edit: nothing is sent until Save, then one PATCH with only site_id', async () => { const backend = backendWith('several', ['Inès Duarte'], 1) await mountMeta('editor', EDITOR, { backend }) expect(textOf(document.body)).toContain('Saved') expect(saveButtonEl().disabled).toBe(true) listboxTriggers()[0]!.click() await flushPromises() Array.from(document.body.querySelectorAll('[role="option"]')) .find((item) => textOf(item).startsWith('Journal'))! .click() await flushPromises() expect(textOf(document.body)).toContain('Unsaved changes') expect(saveButtonEl().disabled).toBe(false) expect(patchCalls(backend)).toHaveLength(0) saveButtonEl().click() await flushPromises() expect(patchCalls(backend)).toHaveLength(1) expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 }) expect(textOf(document.body)).toContain('Saved') expect(textOf(document.body.querySelector('[data-testid="meta-body"]'))).toContain( 'Canonical host: journal.renew.example', ) }) it('still loads the editor when the sites cannot be loaded, with no select and no notice', async () => { const backend = backendWith('several', ['Inès Duarte']) backend.responders['GET /v1/site/'] = failWith(500) await mountMeta('editor', EDITOR, { backend }) expect(textOf(document.body)).toContain('How we price a bathroom renovation') expect(summary()).toBe('Inès Duarte') expect(listboxTriggers()).toHaveLength(0) expect(document.body.querySelector('[role="alert"]')).toBeNull() expect(toasts()).toBe('') }) }) describe('Editor section: a site or author that no longer exists', () => { it('keeps the changes, shows Save failed, an info toast, and reloads the sites', async () => { const backend = backendWith('several', ['Inès Duarte'], 1) backend.responders['PATCH /v1/article/12'] = failWith(404, 'site_not_found') await mountMeta('editor', EDITOR, { backend }) listboxTriggers()[0]!.click() await flushPromises() Array.from(document.body.querySelectorAll('[role="option"]')) .find((item) => textOf(item).startsWith('Journal'))! .click() await flushPromises() saveButtonEl().click() await flushPromises() expect(textOf(document.body)).toContain('Save failed') expect(toasts()).toContain('That site no longer exists') expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2) expect(listboxTriggers()[0]!.textContent).toContain('Journal') }) it('keeps the added author and shows the author toast on author_not_found', async () => { const backend = backendWith('one', ['Inès Duarte']) backend.responders['PATCH /v1/article/12'] = failWith(404, 'author_not_found') localStorage.setItem(META_SECTION_OPEN_KEY, 'true') await mountMeta('editor', EDITOR, { backend }) listboxTriggers()[0]!.click() await flushPromises() Array.from(document.body.querySelectorAll('[role="option"]')) .find((item) => textOf(item).includes('Camille Roche'))! .click() await flushPromises() saveButtonEl().click() await flushPromises() expect(textOf(document.body)).toContain('Save failed') expect(toasts()).toContain('That author no longer exists') expect(textOf(metaSection())).toContain('Camille Roche') }) }) describe('Editor section: the existing save behaviour treats the change like any other edit', () => { async function changeSite(backend: MetaBackend) { await mountMeta('editor', EDITOR, { backend }) listboxTriggers()[0]!.click() await flushPromises() Array.from(document.body.querySelectorAll('[role="option"]')) .find((item) => textOf(item).startsWith('Journal'))! .click() await flushPromises() } it('offers Save and publish and saves with Ctrl+S', async () => { const backend = backendWith('several', ['Inès Duarte']) await changeSite(backend) expect(textOf(document.body)).toContain('Save and publish') window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true })) await flushPromises() expect(patchCalls(backend)).toHaveLength(1) expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 }) }) it('asks before leaving with an unsaved site change', async () => { await changeSite(backendWith('several', ['Inès Duarte'])) const leaving = router.push('/article') await flushPromises() await new Promise((resolve) => setTimeout(resolve, 50)) await flushPromises() expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain( 'Leave without saving?', ) await click('Keep editing') await leaving expect(router.currentRoute.value.path).toBe(EDITOR) }) it('still loads the article when sessionStorage throws', async () => { const spies = ['getItem', 'setItem', 'removeItem'].map((method) => vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(() => { throw new Error('storage blocked') }), ) try { await mountMeta('editor', EDITOR, { backend: backendWith('several', ['Inès Duarte']), }) expect(metaSection()).not.toBeNull() expect(textOf(document.body)).not.toContain('Unsaved changes') } finally { spies.forEach((spy) => spy.mockRestore()) } }) it('keeps the chosen site in sessionStorage when the session expires and restores it', async () => { const backend = backendWith('several', ['Inès Duarte']) backend.responders['PATCH /v1/article/12'] = failWith(401) await changeSite(backend) saveButtonEl().click() await flushPromises() expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-meta:12')!)).toMatchObject( { site_id: 2, }, ) expect(sessionStorage.getItem('renewcms:article-draft:12')).toBeNull() await unmountMeta() await mountMeta('editor', EDITOR, { backend: backendWith('several', ['Inès Duarte']), }) expect(textOf(document.body)).toContain('Unsaved changes') expect(listboxTriggers()[0]!.textContent).toContain('Journal') }) })