import { afterEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import router from '@/router' import { useApi } from '@/composables/useApi' import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport' import { callsTo, chooseMenuItem, click, createBackend, dialog, failWith, mountDirectory, networkFailure, openRowMenu, pending, rowTexts, rows, textOf, unmountDirectory, button, } from './directoryTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const SKELETON_DELAY_MS = 150 const DEFAULT_SITE_TIP = "The default site can't be deleted. Make another site the default first." afterEach(unmountDirectory) describe('Sites: default (owner)', () => { it('shows the header, the columns, the rows, the counts and the badges', async () => { await mountDirectory('sites', '/settings/sites') expect(document.body.querySelector('h1')?.textContent).toBe('Sites') expect(document.body.textContent).toContain('The frontends that display your content.') expect(button('Add site')).toBeTruthy() const header = document.body.querySelector( '[data-testid="directory-row"]', )!.previousElementSibling! expect(textOf(header)).toBe('Site Base URL Articles') expect(rowTexts()).toEqual([ 'Renew Default renew https://renew.example 268 articles', 'Journal journal https://journal.example 38 articles', 'Projects Noindex projects https://projects.example 6 articles', 'Archive archive https://archive.example No articles', ]) }) it('draws cards below md with a full-width Add site button pinned to the bottom', async () => { await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH, }) expect(document.body.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(4) expect(document.body.querySelector('[data-testid="directory-row"]')).toBeNull() const pinned = button('Add site')! expect(pinned.className).toContain('fixed') expect(pinned.className).toContain('inset-x-4') }) }) describe('Sites: only the default site', () => { it('lists the row with the hint and draws no empty-state wall', async () => { const backend = createBackend() backend.sites = backend.sites.slice(0, 1) await mountDirectory('sites', '/settings/sites', { backend }) expect(rows()).toHaveLength(1) expect(document.body.textContent).toContain( 'Add another site if your content is shown on more than one frontend. Each article belongs to one site.', ) expect(document.body.textContent).not.toContain('No sites yet') }) }) describe('Sites: loading', () => { it('draws nothing before 150 ms, then skeleton rows with aria-busy', async () => { vi.useFakeTimers() const backend = createBackend() const hold = pending() backend.responders['GET /v1/site/'] = () => hold.promise await mountDirectory('sites', '/settings/sites', { backend }) expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect( document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'), ).toHaveLength(0) await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS) await flushPromises() expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull() expect( document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'), ).toHaveLength(3) }) }) describe.each([ ['status 500', failWith(500)], ['a network error', networkFailure], ])('Sites: error on load with %s', (_name, responder) => { it('shows the alert, no rows, and Try again requests the list again', async () => { const backend = createBackend() backend.responders['GET /v1/site/'] = responder await mountDirectory('sites', '/settings/sites', { backend }) const alert = document.body.querySelector('[role="alert"]')! expect(textOf(alert)).toContain("Couldn't load sites") expect(textOf(alert)).toContain('Check your connection and try again.') expect(rows()).toHaveLength(0) expect(textOf(alert)).not.toContain('500') delete backend.responders['GET /v1/site/'] await click('Try again') expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2) expect(document.body.querySelector('[role="alert"]')).toBeNull() expect(rows()).toHaveLength(4) }) }) describe('Sites: row menu', () => { it('offers Edit, Set as default and Delete… for a non-default site', async () => { await mountDirectory('sites', '/settings/sites') await openRowMenu('Journal') const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf) expect(labels).toEqual(['Edit', 'Set as default', 'Delete…']) }) it('opens a bottom sheet titled with the site name below md', async () => { await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH, }) await openRowMenu('Journal') const sheet = dialog()! expect(textOf(sheet.querySelector('h2'))).toBe('Journal') expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(3) }) }) describe('Sites: delete unavailable on the default site', () => { it('keeps Delete… focusable with aria-disabled and sends nothing when activated', async () => { const backend = await mountDirectory('sites', '/settings/sites') await openRowMenu('Renew') const item = button(/^Delete…/)! expect(item.getAttribute('aria-disabled')).toBe('true') expect(item.hasAttribute('disabled')).toBe(false) expect(item.getAttribute('data-disabled')).toBeNull() expect(textOf(document.getElementById(item.getAttribute('aria-describedby')!))).toBe( DEFAULT_SITE_TIP, ) item.focus() expect(document.activeElement).toBe(item) item.click() await flushPromises() expect(dialog()?.getAttribute('role')).not.toBe('alertdialog') expect(backend.calls.filter((call) => call.method === 'DELETE')).toHaveLength(0) }) it('writes the explanation in the sheet below md', async () => { await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH, }) await openRowMenu('Renew') expect(textOf(dialog())).toContain(DEFAULT_SITE_TIP) expect(button('Delete…')!.getAttribute('aria-disabled')).toBe('true') }) }) describe('Sites: set as default', () => { async function openDialog(backend = createBackend()) { await mountDirectory('sites', '/settings/sites', { backend }) await openRowMenu('Journal') await chooseMenuItem('Set as default') return backend } it('asks, sends one PATCH without a body, shows Saving… and moves the badge with a toast', async () => { const backend = createBackend() const hold = pending() backend.responders['PATCH /v1/site/2/default'] = () => hold.promise await openDialog(backend) const box = dialog()! expect(textOf(box)).toContain('Make Journal the default site?') expect(textOf(box)).toContain( 'New articles start on this site. Articles that are already on other sites stay where they are.', ) expect(button('Cancel', box)).toBeTruthy() await click('Make default', box) expect(button('Saving…', box)).toBeTruthy() hold.resolve({}) await flushPromises() const patches = callsTo(backend, 'PATCH', '/v1/site/2/default') expect(patches).toHaveLength(1) expect(patches[0]!.body).toBeUndefined() expect(textOf(rows()[1])).toContain('Default') expect(textOf(rows()[0])).not.toContain('Default') expect(document.body.textContent).toContain('Default site changed') expect(document.body.textContent).toContain('"Journal" is now the default site.') }) it('keeps the dialog open with a notice on a 500', async () => { const backend = createBackend() backend.responders['PATCH /v1/site/2/default'] = failWith(500) await openDialog(backend) await click('Make default', dialog()!) expect(dialog()).not.toBeNull() expect(textOf(dialog()!.querySelector('[role="alert"]'))).toContain( "Couldn't save the site. Your changes are still here. Try again in a moment.", ) }) it('closes, removes the row and toasts on a 404', async () => { const backend = createBackend() backend.responders['PATCH /v1/site/2/default'] = failWith(404, 'site_not_found') await openDialog(backend) await click('Make default', dialog()!) expect(dialog()).toBeNull() expect(rowTexts().join(' ')).not.toContain('Journal') expect(document.body.textContent).toContain('That site no longer exists') expect(document.body.textContent).toContain( 'It was deleted somewhere else, so we removed it from the list.', ) }) it('closes and shows the owner-only toast on a 403', async () => { const backend = createBackend() backend.responders['PATCH /v1/site/2/default'] = failWith( 403, 'Only the owner can do this.', ) await openDialog(backend) await click('Make default', dialog()!) expect(dialog()).toBeNull() expect(document.body.textContent).toContain('Only the owner can manage sites') expect(document.body.textContent).toContain('Ask the owner of this site.') }) }) describe.each([ ['admin', '/settings/sites'], ['member', '/settings/sites'], ['admin', '/settings/sites?edit=journal'], ])('Sites: forbidden (403 page) for %s on %s', (role, path) => { it('explains instead of redirecting, opens no panel and Back to Settings goes to /settings', async () => { const backend = await mountDirectory('sites', path, { role }) const text = document.body.textContent ?? '' expect(text).toContain("You don't have access to Sites") expect(text).toContain( 'Only the owner can manage sites. Ask the owner of this site if you need a change.', ) expect(router.currentRoute.value.fullPath).toBe(path) expect(dialog()).toBeNull() expect( document.body.querySelector('nav[aria-label="Main"] a[href="/settings/sites"]'), ).toBeNull() expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(0) const push = vi.spyOn(router, 'push').mockResolvedValue(undefined) await click('Back to Settings') expect(push).toHaveBeenCalledWith('/settings') push.mockRestore() }) }) describe('Sites: the API answers 403 where the SPA expected the owner', () => { it('shows the 403 page when the answer is for the page load', async () => { const backend = createBackend() backend.responders['GET /v1/site/'] = failWith(403, 'Only the owner can do this.') await mountDirectory('sites', '/settings/sites', { backend }) expect(document.body.textContent).toContain("You don't have access to Sites") expect(rows()).toHaveLength(0) }) it('shows an error toast when the answer is for an action', async () => { const backend = createBackend() backend.responders['DELETE /v1/site/4'] = failWith(403, 'Only the owner can do this.') await mountDirectory('sites', '/settings/sites', { backend }) await openRowMenu('Archive') await chooseMenuItem('Delete…') await click('Delete site', dialog()!) expect(document.body.textContent).toContain('Only the owner can manage sites') expect(document.body.textContent).toContain('Ask the owner of this site.') }) }) describe('Sites: the sidebar for the owner and for an admin', () => { it('shows a Publishing group with a Sites entry linking to /settings/sites for the owner', async () => { await mountDirectory('sites', '/settings/sites') const nav = document.body.querySelector('nav[aria-label="Main"]')! expect(textOf(nav)).toContain('Publishing') const link = nav.querySelector('a[href="/settings/sites"]')! expect(textOf(link)).toBe('Sites') expect(link.getAttribute('aria-current')).toBe('page') }) it('has no Publishing group and no Sites entry for an admin', async () => { await mountDirectory('sites', '/settings/sites', { role: 'admin' }) const nav = document.body.querySelector('nav[aria-label="Main"]')! expect(textOf(nav)).not.toContain('Publishing') expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull() }) }) void useApi void DESKTOP_WIDTH