Files
RenewCMS/web/src/views/__tests__/SitesView.spec.ts
T

357 lines
13 KiB
TypeScript

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 { customAccess } from '@/test/access'
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<typeof import('@/composables/useApi')>()
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<unknown>()
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<unknown>()
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 leaves the generic missing-permission toast to the API client on a 403', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(403, 'missing_permission')
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).toBeNull()
expect(document.body.textContent).not.toContain('Only the owner can manage sites')
})
})
describe.each([
['member', '/settings/sites'],
['member', '/settings/sites?edit=journal'],
])('Sites: forbidden (403 page) for %s on %s', (role, path) => {
it('explains instead of redirecting, opens no panel and offers Go to Articles', async () => {
const backend = await mountDirectory('sites', path, { role })
const text = document.body.textContent ?? ''
expect(text).toContain("You don't have access to this page")
expect(text).toContain(
"Your role, Writer, doesn't include it. Ask an owner or admin if you need it.",
)
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('Go to Articles')
expect(push).toHaveBeenCalledWith('/article')
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 this page")
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).not.toContain('Only the owner can manage sites')
})
})
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<HTMLAnchorElement>('a[href="/settings/sites"]')!
expect(textOf(link)).toBe('Sites')
expect(link.getAttribute('aria-current')).toBe('page')
})
it('shows the Publishing group and the 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)).toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).not.toBeNull()
})
it('has no Publishing group and no Sites entry for a writer', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'member' })
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
})
it('disables Delete site with the Owner only tag for an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
await openRowMenu('Archive')
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(candidate) => candidate.textContent?.includes('Delete…'),
)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.textContent).toContain('Owner only')
})
it('enables Delete site with settings.delete_site', async () => {
await mountDirectory('sites', '/settings/sites', {
access: customAccess('settings.view, settings.manage_sites, settings.delete_site'),
})
await openRowMenu('Archive')
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(candidate) => candidate.textContent?.includes('Delete…'),
)!
expect(item.getAttribute('aria-disabled')).not.toBe('true')
expect(item.textContent).not.toContain('Owner only')
})
})
void useApi
void DESKTOP_WIDTH