465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
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<typeof import('@/composables/useApi')>()
|
|
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<HTMLButtonElement>('[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<HTMLElement>('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('<p>Edited on screen</p>')
|
|
const panel = await openPanel()
|
|
|
|
await changeTitle(panel)
|
|
await submit(panel)
|
|
|
|
expect(patchBodies(backend)).toEqual([
|
|
{ seo_title: 'A new meta title', body: '<p>Edited on screen</p>' },
|
|
])
|
|
})
|
|
|
|
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<unknown>()
|
|
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('<p>Edited</p>')
|
|
|
|
expect(saveButton().disabled).toBe(false)
|
|
expect(document.body.textContent).toContain('Save and publish')
|
|
const publish = Array.from(
|
|
document.body.querySelectorAll<HTMLButtonElement>('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: '<p>Edited</p>' }])
|
|
},
|
|
)
|
|
})
|
|
|
|
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<HTMLElement>('[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('<p>Still writing</p>')
|
|
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()
|
|
},
|
|
)
|
|
})
|