357 lines
13 KiB
TypeScript
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
|