import { afterEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import { useApi } from '@/composables/useApi' import { blur, button, callsTo, chooseMenuItem, click, createBackend, dialog, failWith, field, labelled, mountDirectory, networkFailure, openRowMenu, pending, rowTexts, textOf, typeInto, unmountDirectory, } from './directoryTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const BASE_URL_MESSAGES = { scheme: 'Use https. For example https://blog.example.com.', path: 'Remove the path. Use only the host, like https://blog.example.com.', invalid: 'Enter a full URL, like https://blog.example.com.', } const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment." afterEach(unmountDirectory) async function openAdd(options: Parameters[2] = {}) { const backend = await mountDirectory('sites', '/settings/sites', options) await click('Add site') return backend } async function openEdit(name: string) { const backend = await mountDirectory('sites', '/settings/sites') await openRowMenu(name) await chooseMenuItem('Edit') return backend } async function fillValid(name = 'Journal 2', url = 'https://journal2.example') { await typeInto(field('e.g. Journal'), name) await typeInto(field('https://blog.example.com'), url) } function box() { return dialog()! } function submit(label = 'Add site') { return click(label, box()) } describe('Sites form: add, empty', () => { it('opens a slide-over with every field, placeholder and help', async () => { await openAdd() const text = textOf(box()) for (const copy of [ 'Add site', 'Name', 'Key', 'Base URL', 'The public address of the frontend. Use https and no path.', 'Default social image (optional)', 'Choose image', 'Used when an article has no image of its own.', 'Default robots policy', 'Index', 'Noindex', "Search engines may list this site's pages.", 'Locale (optional)', 'A language tag. Leave empty if the site has no single language.', 'Make this the default site', 'New articles start here. The current default is Renew.', 'Canonical URL preview', 'Cancel', ]) { expect(text).toContain(copy) } expect(field('e.g. Journal')).toBeTruthy() expect(field('Generated from the name')).toBeTruthy() expect(field('https://blog.example.com')).toBeTruthy() expect(field('e.g. en or fr-CH')).toBeTruthy() expect(button('Add site', box())).toBeTruthy() }) it('focuses Name', async () => { await openAdd() await flushPromises() expect(document.activeElement).toBe(field('e.g. Journal')) }) }) describe('Sites form: key from the name', () => { it('fills the key with an Auto badge and unlocks it with the pencil', async () => { await openAdd() await typeInto(field('e.g. Journal'), 'Journal Online') const key = labelled('Key') as HTMLInputElement expect(key.value).toBe('journal-online') expect(textOf(box())).toContain('Auto') expect(textOf(box())).toContain( "Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.", ) await click('Edit key', box()) await typeInto(labelled('Key') as HTMLInputElement, 'custom-key') await typeInto(field('e.g. Journal'), 'Something else') expect((labelled('Key') as HTMLInputElement).value).toBe('custom-key') expect(textOf(box())).not.toContain('Auto') }) }) describe.each([ ['http://blog.example.com', BASE_URL_MESSAGES.scheme], ['https://blog.example.com/en', BASE_URL_MESSAGES.path], ['blog', BASE_URL_MESSAGES.invalid], ])('Sites form: base URL "%s" is not valid', (value, message) => { it('shows the message on leaving the field and sends nothing', async () => { const backend = await openAdd() await fillValid('Journal 2', value) await blur(field('https://blog.example.com')) expect(textOf(box())).toContain(message) await submit() expect(callsTo(backend, 'POST', '/v1/site/')).toHaveLength(0) expect(textOf(box())).toContain(message) }) }) describe('Sites form: base URL refused by the server', () => { it('shows the generic URL message on 400 invalid_site_url', async () => { const backend = createBackend() backend.responders['POST /v1/site/'] = failWith(400, 'invalid_site_url') await openAdd({ backend }) await fillValid() await submit() expect(textOf(box())).toContain(BASE_URL_MESSAGES.invalid) }) }) describe.each(['http://localhost:5173', 'http://127.0.0.1:8081', 'http://[::1]:5173'])( 'Sites form: loopback host %s accepts http', (value) => { it('shows no base URL error', async () => { await openAdd() await fillValid('Journal 2', value) await blur(field('https://blog.example.com')) const text = textOf(box()) expect(text).not.toContain(BASE_URL_MESSAGES.scheme) expect(text).not.toContain(BASE_URL_MESSAGES.invalid) expect(text).not.toContain(BASE_URL_MESSAGES.path) }) }, ) describe('Sites form: add succeeds', () => { it('sends exactly one POST, shows Saving… with disabled fields, then closes and lists the site', async () => { const backend = createBackend() const hold = pending() backend.responders['POST /v1/site/'] = () => hold.promise await openAdd({ backend }) await typeInto(field('e.g. Journal'), 'Notes') await typeInto(field('https://blog.example.com'), 'https://notes.example') box().querySelector('[role="radio"][value="noindex"]')!.click() await flushPromises() await submit() expect(button('Saving…', box())).toBeTruthy() expect(field('e.g. Journal').disabled).toBe(true) const created = { id: 5, name: 'Notes', key: 'notes', base_url: 'https://notes.example', default_social_image_id: null, default_robots: 'noindex' as const, locale: null, is_default: false, article_count: 0, } backend.sites.push(created) hold.resolve(created) await flushPromises() const posts = callsTo(backend, 'POST', '/v1/site/') expect(posts).toHaveLength(1) expect(posts[0]!.body).toEqual({ name: 'Notes', key: 'notes', base_url: 'https://notes.example', default_robots: 'noindex', is_default: false, }) expect(dialog()).toBeNull() expect(rowTexts().at(-1)).toContain('Notes') }) }) describe('Sites form: duplicate key', () => { it('flags the key on leaving the field and applies the suggestion', async () => { await openAdd() await typeInto(field('e.g. Journal'), 'Journal') await click('Edit key', box()) const key = labelled('Key') as HTMLInputElement await blur(key) expect(textOf(box())).toContain( 'A site with the key "journal" already exists. Choose another key.', ) await click('Use journal-2', box()) expect((labelled('Key') as HTMLInputElement).value).toBe('journal-2') expect(textOf(box())).not.toContain('already exists') }) it('flags the key when the server answers 409 site_key_taken', async () => { const backend = createBackend() backend.sites = backend.sites.filter((site) => site.key !== 'journal') backend.responders['POST /v1/site/'] = failWith(409, 'site_key_taken') await openAdd({ backend }) await fillValid('Journal', 'https://journal.example') await submit() expect(textOf(box())).toContain( 'A site with the key "journal" already exists. Choose another key.', ) expect(textOf(box())).toContain('Use journal-2') }) }) describe.each([ ['status 500', failWith(500)], ['a network error', networkFailure], ])('Sites form: save failed with %s', (_name, responder) => { it('shows the notice, keeps the values and enables the form again', async () => { const backend = createBackend() backend.responders['POST /v1/site/'] = responder await openAdd({ backend }) await fillValid() await submit() expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED) expect(field('e.g. Journal').value).toBe('Journal 2') expect(field('https://blog.example.com').value).toBe('https://journal2.example') expect(field('e.g. Journal').disabled).toBe(false) expect(button('Add site', box())).toBeTruthy() }) }) describe('Sites form: invalid locale from the server', () => { it('shows the locale message and keeps the panel open', async () => { const backend = createBackend() backend.responders['POST /v1/site/'] = failWith(400, 'invalid_locale') await openAdd({ backend }) await fillValid() await typeInto(field('e.g. en or fr-CH'), 'english') await submit() expect(textOf(box())).toContain('Use a language tag like en or fr-CH.') expect(dialog()).not.toBeNull() }) }) describe('Sites form: edit the default site', () => { it('fills the fields, locks the key and keeps the default switch on and locked', async () => { await openEdit('Renew') const text = textOf(box()) expect(text).toContain('Edit Renew') expect(field('e.g. Journal').value).toBe('Renew') const key = labelled('Key') as HTMLInputElement expect(key.value).toBe('renew') expect(key.readOnly || key.disabled).toBe(true) expect(box().querySelector('[data-testid="slug-lock"]')).not.toBeNull() expect(text).toContain("Used in API paths, so it can't change.") expect(text).toContain('Default site') expect(text).toContain( 'New articles start on the default site. To change it, make another site the default.', ) const toggle = box().querySelector('[role="switch"]')! expect(toggle.getAttribute('aria-checked')).toBe('true') expect(toggle.disabled || toggle.getAttribute('data-disabled') !== null).toBe(true) expect(button('Save changes', box())).toBeTruthy() }) }) describe('Sites form: edit another site, noindex', () => { it('shows Noindex selected with its help and sends a PATCH without the key', async () => { const backend = await openEdit('Projects') const text = textOf(box()) expect(text).toContain('Edit Projects') expect(text).toContain("Asks search engines not to list this site's pages.") expect(text).toContain('Make this the default site') const noindex = box().querySelector('[role="radio"][value="noindex"]')! expect(noindex.getAttribute('aria-checked')).toBe('true') await typeInto(field('e.g. Journal'), 'Projects 2') await submit('Save changes') const patches = callsTo(backend, 'PATCH', '/v1/site/3') expect(patches).toHaveLength(1) expect(patches[0]!.body).toEqual({ name: 'Projects 2' }) expect(patches[0]!.body).not.toHaveProperty('key') }) }) describe('Sites form: make another site the default from the form', () => { it('sends the changed fields, then the default route, and never is_default in the first PATCH', async () => { const backend = await openEdit('Projects') await typeInto(field('e.g. Journal'), 'Projects 2') box().querySelector('[role="switch"]')!.click() await flushPromises() await submit('Save changes') const order = backend.calls .filter((call) => call.method === 'PATCH') .map((call) => call.path) expect(order).toEqual(['/v1/site/3', '/v1/site/3/default']) expect(backend.calls.find((call) => call.path === '/v1/site/3')!.body).not.toHaveProperty( 'is_default', ) expect( textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]), ).toContain('Default') }) }) describe('Sites form: discard changes', () => { it('asks before closing a changed form with Escape and keeps it on Keep editing', async () => { await openAdd() await typeInto(field('e.g. Journal'), 'Notes') box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await flushPromises() const ask = document.body.querySelector('[role="alertdialog"]')! expect(textOf(ask)).toContain('Discard changes?') await click('Keep editing', ask) expect(field('e.g. Journal').value).toBe('Notes') }) it('discards on confirmation and closes an unchanged form with Cancel straight away', async () => { await openAdd() await typeInto(field('e.g. Journal'), 'Notes') await click('Cancel', box()) await click('Discard changes', document.body.querySelector('[role="alertdialog"]')!) expect(document.body.querySelector('[role="dialog"]')).toBeNull() await click('Add site') await click('Cancel', box()) expect(document.body.querySelector('[role="alertdialog"]')).toBeNull() expect(document.body.querySelector('[role="dialog"]')).toBeNull() }) }) describe('Sites form: deep link', () => { it('opens the edit slide-over for a known key', async () => { await mountDirectory('sites', '/settings/sites?edit=projects') expect(textOf(box())).toContain('Edit Projects') }) it('shows an info toast and opens nothing for an unknown key', async () => { await mountDirectory('sites', '/settings/sites?edit=nope') expect(dialog()).toBeNull() expect(document.body.textContent).toContain('That site no longer exists') }) }) describe('Slide-over accessibility', () => { it('moves focus into the panel and returns it to the opening control on close', async () => { await mountDirectory('sites', '/settings/sites') const opener = button('Add site')! opener.focus() opener.click() await flushPromises() await flushPromises() expect(box().contains(document.activeElement)).toBe(true) await click('Cancel', box()) await flushPromises() expect(document.activeElement).toBe(opener) }) it('ignores Escape while a request is running', async () => { const backend = createBackend() backend.responders['POST /v1/site/'] = () => pending().promise await openAdd({ backend }) await fillValid() await submit() box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await flushPromises() expect(dialog()).not.toBeNull() expect(document.body.querySelector('[role="alertdialog"]')).toBeNull() }) }) void useApi