import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import { click, failWith, metaKit, networkFailure, pending, textOf, unmountMeta, } from './metaTestKit' import { typeInto } from './directoryTestKit' import { FOUR_SUGGESTIONS, answerChecks, checkAnswer, checkBodies, checkCalls, fieldByLabel, mountSeo, openDisclosure, openPanel, panelRoot, patchBodies, saveChangesButton, seoBackend, } from './seoTestKit' import ArticleEditor from '@/components/ArticleEditor.vue' import router from '@/router' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) beforeEach(() => { localStorage.clear() sessionStorage.clear() }) afterEach(async () => { vi.restoreAllMocks() await unmountMeta() }) const NOTICE = "Couldn't save search and sharing. Your changes are still here. Try again in a moment." function toasts() { return textOf(document.body.querySelector('[data-slot="viewport"]')) } function saveButton() { return document.body.querySelector('[data-testid="save-button"]')! } async function changeTitle(panel: HTMLElement, value = 'A new meta title') { await typeInto(fieldByLabel(panel, 'Meta title')!, value) } async function submit(panel: HTMLElement) { saveChangesButton(panel).click() await flushPromises() } async function editBody(html: string) { metaKit.wrapper!.findComponent(ArticleEditor).vm.$emit('update:modelValue', html) await flushPromises() } async function failedPanelSave(failure = failWith(500)) { const backend = await mountSeo(seoBackend()) const panel = await openPanel() await changeTitle(panel) backend.responders['PATCH /v1/article/12'] = failure await submit(panel) return { backend, panel } } describe('Save changes commits the panel to the draft and saves', () => { it('sends exactly one PATCH holding seo_title only', async () => { const backend = await mountSeo(seoBackend()) const panel = await openPanel() await changeTitle(panel) await submit(panel) expect(patchBodies(backend)).toEqual([{ seo_title: 'A new meta title' }]) }) it('sends the fields that changed and no unchanged one', async () => { const backend = await mountSeo(seoBackend()) const panel = await openPanel() await openDisclosure('Social sharing') await openDisclosure('Advanced') await typeInto(fieldByLabel(panel, 'Excerpt')!, 'A short summary') await typeInto(fieldByLabel(panel, 'Social title')!, 'Shared title') await typeInto(fieldByLabel(panel, 'Canonical URL')!, 'https://renew.example/costs') panel.querySelector('button[role="switch"]')!.click() await flushPromises() await submit(panel) expect(patchBodies(backend)).toEqual([ { excerpt: 'A short summary', social_title: 'Shared title', canonical_url: 'https://renew.example/costs', noindex: true, }, ]) }) it('sends the pending body edit in the same PATCH', async () => { const backend = await mountSeo(seoBackend()) await editBody('

Edited on screen

') const panel = await openPanel() await changeTitle(panel) await submit(panel) expect(patchBodies(backend)).toEqual([ { seo_title: 'A new meta title', body: '

Edited on screen

' }, ]) }) it('closes without a request when nothing changed', async () => { const backend = await mountSeo(seoBackend()) const panel = await openPanel() await submit(panel) expect(patchBodies(backend)).toEqual([]) expect(panelRoot()).toBeNull() }) }) describe('Saving state', () => { it('disables every field of the panel and reads Saving… while the request runs', async () => { const backend = await mountSeo(seoBackend()) const panel = await openPanel() await changeTitle(panel) const gate = pending() backend.responders['PATCH /v1/article/12'] = () => gate.promise await submit(panel) expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(true) expect(fieldByLabel(panel, 'Meta description')!.disabled).toBe(true) expect(fieldByLabel(panel, 'Excerpt')!.disabled).toBe(true) expect(textOf(panel)).toContain('Saving…') expect(fieldByLabel(panel, 'Meta title')!.className).toContain('disabled:opacity-100') expect(fieldByLabel(panel, 'Meta title')!.className).not.toContain('opacity-40') expect( textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement), ).toContain('Save') expect(document.body.textContent).toContain('Saving…') gate.resolve({}) await flushPromises() }) }) describe('Saved', () => { it('closes the panel, reads Saved, refreshes the check and toasts', async () => { const backend = await mountSeo(seoBackend()) const panel = await openPanel() await changeTitle(panel) const before = checkCalls(backend).length await submit(panel) await new Promise((resolve) => setTimeout(resolve, 50)) await flushPromises() expect(panelRoot()).toBeNull() expect(document.body.textContent).toContain('Saved') expect(document.body.textContent).not.toContain('Unsaved changes') expect(checkCalls(backend)).toHaveLength(before + 1) expect(checkBodies(backend).at(-1)).toEqual({}) expect(toasts()).toContain('Search and sharing saved') expect(toasts()).toContain('Your search and sharing settings are up to date.') }) }) describe('Saved refreshes the check', () => { it('shows the answer of the new check, not the cached one, after the panel closes', async () => { const backend = await mountSeo(seoBackend()) const panel = await openPanel() await changeTitle(panel) answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS)) await submit(panel) await new Promise((resolve) => setTimeout(resolve, 50)) await flushPromises() expect(panelRoot()).toBeNull() expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain( 'The meta title is 74 characters.', ) }) }) describe('Save failed', () => { it.each([ ['status 500', failWith(500)], ['a network error', networkFailure], ])('keeps the panel open with the notice and the values on %s', async (_name, failure) => { const { panel } = await failedPanelSave(failure) expect(panelRoot()).not.toBeNull() expect(textOf(panel)).toContain(NOTICE) expect(fieldByLabel(panel, 'Meta title')!.value).toBe('A new meta title') expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(false) expect(saveChangesButton(panel).disabled).toBe(false) expect(document.body.textContent).toContain('Save failed') expect(document.body.textContent).toContain('Retry') }) }) describe('The editor Save button treats SEO edits like any other edit', () => { async function closeAfterFailure() { const { backend, panel } = await failedPanelSave() await click('Cancel', panel) await new Promise((resolve) => setTimeout(resolve, 50)) await flushPromises() backend.responders['PATCH /v1/article/12'] = () => backend.article backend.responders['PATCH /v1/article/12/publish'] = () => ({ ...backend.article, is_online: true, }) return backend } it('reads Unsaved changes, enables Save and offers Save and publish', async () => { await closeAfterFailure() expect(document.body.textContent).toContain('Unsaved changes') expect(saveButton().disabled).toBe(false) expect(document.body.textContent).toContain('Save and publish') }) it('sends seo_title with the Save button', async () => { const backend = await closeAfterFailure() saveButton().click() await flushPromises() expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' }) }) it('sends seo_title with Ctrl+S', async () => { const backend = await closeAfterFailure() window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true })) await flushPromises() expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' }) }) it('sends seo_title with Cmd+S', async () => { const backend = await closeAfterFailure() window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true })) await flushPromises() expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' }) }) it('saves with Save and publish and then sends the publish request', async () => { const backend = await closeAfterFailure() const before = backend.calls.length await click('Save and publish') const after = backend.calls.slice(before).filter((call) => call.method === 'PATCH') expect(after.map((call) => call.path)).toEqual(['/v1/article/12', '/v1/article/12/publish']) expect(after[0]!.body).toEqual({ seo_title: 'A new meta title' }) }) it('does not publish when the save in front of it fails', async () => { const backend = await closeAfterFailure() backend.responders['PATCH /v1/article/12'] = failWith(500) await click('Save and publish') expect(backend.calls.filter((call) => call.path === '/v1/article/12/publish')).toHaveLength( 0, ) }) }) describe('Publish with a clean draft', () => { it('only sends the publish request', async () => { const backend = await mountSeo(seoBackend()) backend.responders['PATCH /v1/article/12/publish'] = () => ({ ...backend.article, is_online: true, }) const before = backend.calls.length await click('Publish') const patches = backend.calls.slice(before).filter((call) => call.method === 'PATCH') expect(patches.map((call) => call.path)).toEqual(['/v1/article/12/publish']) }) }) describe('Leaving with unsaved SEO edits', () => { it('asks Leave without saving? when the draft holds an unsaved SEO value', async () => { const { panel } = await failedPanelSave() await click('Cancel', panel) await new Promise((resolve) => setTimeout(resolve, 50)) 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 }) }) describe('A storage that throws never loses the editor', () => { it('saves, and asks before leaving, with the values in memory when sessionStorage throws', async () => { ;['getItem', 'setItem', 'removeItem'].forEach((method) => vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(function ( this: Storage, ) { if (this === sessionStorage) { throw new DOMException('blocked', 'SecurityError') } return null }), ) const backend = await mountSeo(seoBackend()) const panel = await openPanel() await changeTitle(panel) backend.responders['PATCH /v1/article/12'] = failWith(500) await submit(panel) await click('Cancel', panel) await new Promise((resolve) => setTimeout(resolve, 50)) await flushPromises() 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 backend.responders['PATCH /v1/article/12'] = () => backend.article saveButton().click() await flushPromises() expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' }) }) }) describe('Suggestions never block', () => { it.each([ ['suggestions', checkAnswer(FOUR_SUGGESTIONS)], ['hidden', checkAnswer(FOUR_SUGGESTIONS, 'hidden')], ])( 'keeps Save, Save and publish and Ctrl+S working when the check says %s', async (_name, answer) => { const backend = seoBackend() answerChecks(backend, answer) await mountSeo(backend) await editBody('

Edited

') expect(saveButton().disabled).toBe(false) expect(document.body.textContent).toContain('Save and publish') const publish = Array.from( document.body.querySelectorAll('button'), ).find((button) => textOf(button) === 'Save and publish')! expect(publish.disabled).toBe(false) window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true })) await flushPromises() expect(patchBodies(backend)).toEqual([{ body: '

Edited

' }]) }, ) }) describe('Cancel', () => { it('discards the typing without a request and without changing the status', async () => { const backend = await mountSeo(seoBackend()) const panel = await openPanel() await changeTitle(panel) const calls = backend.calls.length await click('Cancel', panel) await new Promise((resolve) => setTimeout(resolve, 50)) await flushPromises() expect(panelRoot()).toBeNull() expect(document.body.textContent).not.toContain('Unsaved changes') expect(backend.calls.slice(calls).filter((call) => call.method !== 'GET')).toEqual([]) }) }) describe('Offline', () => { it('shows the failed check, fails the dialog and lets the writing continue', async () => { const backend = seoBackend() backend.responders['POST /v1/article/12/seo/check'] = networkFailure backend.responders['GET /v1/article/12/seo'] = networkFailure await mountSeo(backend) document.body.querySelector('[data-testid="seo-toggle"]')!.click() await flushPromises() expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain( "Couldn't check this article.", ) await click('What the frontend gets') await flushPromises() expect(document.body.textContent).toContain("Couldn't load the SEO data") await editBody('

Still writing

') expect(document.body.textContent).toContain('Unsaved changes') }) }) describe('Roles', () => { it.each(['owner', 'admin', 'verified'] as const)( 'makes the section, the panel and the dialog available to a %s', async (role) => { const backend = seoBackend({ role }) backend.responders['GET /v1/article/12/seo'] = () => ({ title: 'T', description: 'D', canonical_url: 'https://renew.example/x', robots: 'index, follow', og: { type: 'article', image: '', image_alt: '' }, twitter: { card: 'summary' }, image: { url: '', alt: '', source: 'none' }, authors: [], published_at: null, modified_at: '2026-09-26T11:02:00Z', fallbacks: { title: 'title', description: 'body', image: 'none', canonical: 'site', }, json_ld: { '@type': 'Article' }, }) await mountSeo(backend) const panel = await openPanel() expect(textOf(panel)).toContain('Search and sharing') await click('Cancel', panel) await new Promise((resolve) => setTimeout(resolve, 50)) await flushPromises() await click('What the frontend gets') await flushPromises() expect(document.body.querySelector('[data-testid="api-preview"]')).not.toBeNull() }, ) })