feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
1 parent
cb737b7ae3
commit
02fb8751fd
47 files changed
+3218
-227
No files matched your search
@@ -7,7 +7,12 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
||||
import {
|
||||
NEEDS_ATTENTION_ROUTE,
|
||||
articleEnvelope,
|
||||
articleListRoute,
|
||||
needsAttentionCountPage,
|
||||
} from './articleEnvelope'
|
||||
|
||||
const NOW = new Date('2026-08-26T10:00:00Z')
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
@@ -139,7 +144,10 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
global.fetch = fetchRouter({
|
||||
[NEEDS_ATTENTION_ROUTE]: () => mockResponse(200, needsAttentionCountPage()),
|
||||
...handlers,
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/article')
|
||||
const wrapper = mount(ArticleView, {
|
||||
attachTo: document.body,
|
||||
|
||||
@@ -8,7 +8,13 @@ import type { Article } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import {
|
||||
articleEnvelope,
|
||||
isNeedsAttentionCount,
|
||||
needsAttentionCountPage,
|
||||
parseListUrl,
|
||||
type EnvelopeParams,
|
||||
} from './articleEnvelope'
|
||||
|
||||
const KEY = 'renewcms-page-size-articles'
|
||||
const TOTAL = 312
|
||||
@@ -45,6 +51,9 @@ function installFetch() {
|
||||
json: async () => oneSite,
|
||||
} as unknown as Response)
|
||||
}
|
||||
if (method === 'GET' && isNeedsAttentionCount(url)) {
|
||||
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
|
||||
@@ -9,7 +9,13 @@ import type { Article, ArticlePage } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import {
|
||||
articleEnvelope,
|
||||
isNeedsAttentionCount,
|
||||
needsAttentionCountPage,
|
||||
parseListUrl,
|
||||
type EnvelopeParams,
|
||||
} from './articleEnvelope'
|
||||
|
||||
const TOTAL = 312
|
||||
const PLASTER_EVERY = 20
|
||||
@@ -85,6 +91,9 @@ function installFetch() {
|
||||
if (method === 'GET' && url === '/v1/site/') {
|
||||
return Promise.resolve(mockResponse(200, oneSite))
|
||||
}
|
||||
if (method === 'GET' && isNeedsAttentionCount(url)) {
|
||||
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
|
||||
@@ -64,7 +64,7 @@ function siteSelect() {
|
||||
}
|
||||
|
||||
function listCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/')
|
||||
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
|
||||
}
|
||||
|
||||
async function chooseSite(label: string) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const STUB_ORIGIN = 'https://cms.renew.example'
|
||||
const SECTION_LABELS = [
|
||||
'Authentication',
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
@@ -170,15 +171,17 @@ describe('Default, every card collapsed', () => {
|
||||
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
|
||||
})
|
||||
|
||||
it('groups the cards as 9, 10, 6 and 1', async () => {
|
||||
it('groups the cards as 8, 2, 10, 6 and 1', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(
|
||||
['articles', 'media', 'users', 'settings'].map(
|
||||
['articles', 'seo', 'media', 'users', 'settings'].map(
|
||||
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
||||
),
|
||||
).toEqual([9, 10, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('9 routes')
|
||||
).toEqual([8, 2, 10, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('8 routes')
|
||||
expect(section(wrapper, 'seo').text()).toContain('2 routes')
|
||||
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||
expect(section(wrapper, 'settings').text()).toContain('1 route')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import {
|
||||
articleOf,
|
||||
callsTo,
|
||||
createMetaBackend,
|
||||
failWith,
|
||||
mountMeta,
|
||||
networkFailure,
|
||||
renewSites,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SEARCH_WAIT_MS = 320
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
setLocale('en')
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
function withSeo(
|
||||
id: number,
|
||||
status: string | undefined,
|
||||
count: number | undefined,
|
||||
overrides: Partial<Article> = {},
|
||||
) {
|
||||
return articleOf(id, `Article ${id}`, {
|
||||
is_online: id % 2 === 0,
|
||||
...(status === undefined ? {} : { seo_status: status }),
|
||||
...(count === undefined ? {} : { seo_suggestions: count }),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function boardArticles() {
|
||||
return [
|
||||
withSeo(1, 'ready', 0),
|
||||
withSeo(2, 'suggestions', 2),
|
||||
withSeo(3, 'hidden', 0),
|
||||
withSeo(4, 'suggestions', 3),
|
||||
withSeo(5, 'suggestions', 1),
|
||||
]
|
||||
}
|
||||
|
||||
function backendWith(articles: Article[], sites: 'one' | 'several' = 'one'): MetaBackend {
|
||||
const backend = createMetaBackend({
|
||||
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
|
||||
})
|
||||
backend.articles = articles
|
||||
return backend
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="needs-attention-toggle"]')!
|
||||
}
|
||||
|
||||
function chips(root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="seo-status-chip"]'))
|
||||
}
|
||||
|
||||
function bodyRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
|
||||
}
|
||||
|
||||
function mainCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
|
||||
}
|
||||
|
||||
function countCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/').filter((call) => !call.query.has('format'))
|
||||
}
|
||||
|
||||
function currentQuery() {
|
||||
return { ...router.currentRoute.value.query }
|
||||
}
|
||||
|
||||
async function clickToggle() {
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function search(value: string) {
|
||||
await typeInto(document.body.querySelector<HTMLInputElement>('input[type="search"]')!, value)
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function clickTab(label: string) {
|
||||
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||
(item) => textOf(item).startsWith(label),
|
||||
)!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('SeoStatusChip: status table', () => {
|
||||
it.each([
|
||||
{ status: 'ready', count: 0, text: '', label: '' },
|
||||
{ status: 'suggestions', count: 2, text: '2', label: '2 suggestions' },
|
||||
{ status: 'suggestions', count: 1, text: '1', label: '1 suggestion' },
|
||||
{ status: 'suggestions', count: 3, text: '3', label: '3 suggestions' },
|
||||
{ status: 'hidden', count: 0, text: 'Hidden', label: 'Hidden from search engines' },
|
||||
{ status: 'hidden', count: 2, text: 'Hidden', label: 'Hidden from search engines' },
|
||||
])('$status with $count shows "$text"', async ({ status, count, text, label }) => {
|
||||
const backend = backendWith([withSeo(1, status, count)])
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
const found = chips(bodyRows()[0])
|
||||
if (!label) {
|
||||
expect(found).toHaveLength(0)
|
||||
return
|
||||
}
|
||||
expect(found).toHaveLength(1)
|
||||
const chip = found[0]!
|
||||
expect(textOf(chip)).toBe(text)
|
||||
expect(chip.getAttribute('title')).toBe(label)
|
||||
expect(chip.getAttribute('aria-label')).toBe(label)
|
||||
expect(chip.getAttribute('role')).toBe('img')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ item: 'without seo_status', article: withSeo(1, undefined, undefined) },
|
||||
{ item: 'with suggestions and a count of 0', article: withSeo(1, 'suggestions', 0) },
|
||||
{ item: 'with an unknown status', article: withSeo(1, 'surprise', 4) },
|
||||
])('shows nothing for an item $item', async ({ article }) => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith([article]) })
|
||||
|
||||
expect(chips()).toHaveLength(0)
|
||||
expect(bodyRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sits in the Status cell next to the badge on desktop and in the meta row on mobile', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
const statusCell = bodyRows()[1]!.children[2] as HTMLElement
|
||||
expect(chips(statusCell)).toHaveLength(1)
|
||||
expect(textOf(statusCell)).toContain('Online')
|
||||
expect(chips(document.body.querySelector('tbody')!)).toHaveLength(4)
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith(boardArticles()),
|
||||
})
|
||||
const cards = Array.from(document.body.querySelectorAll<HTMLElement>('li'))
|
||||
const second = cards.find((card) => textOf(card).includes('Article 2'))!
|
||||
expect(chips(second)).toHaveLength(1)
|
||||
expect(chips(second)[0]!.className).toContain('h-5')
|
||||
})
|
||||
|
||||
it('reads the chip in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
|
||||
const labels = chips().map((chip) => chip.getAttribute('aria-label'))
|
||||
expect(labels).toContain('2 suggestions')
|
||||
expect(labels).toContain('1 suggestion')
|
||||
expect(labels).toContain('Masqué des moteurs de recherche')
|
||||
expect(textOf(toggle())).toContain('À traiter')
|
||||
})
|
||||
|
||||
it('keeps the chip when a row is published', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
document.body
|
||||
.querySelector<HTMLElement>('button[aria-label="Actions for Article 3"]')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((item) => textOf(item) === 'Publish')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(chips(bodyRows()[2])).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention toggle', () => {
|
||||
it('is a switch with the board count, left of the search on desktop', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
|
||||
expect(toggle().getAttribute('role')).toBe('switch')
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('false')
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
expect(toggle().className).toContain('md:-order-1')
|
||||
expect(toggle().className).toContain('md:h-8')
|
||||
})
|
||||
|
||||
it('is 44 px high on mobile', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith(boardArticles()),
|
||||
})
|
||||
|
||||
expect(toggle().className).toContain('h-11')
|
||||
})
|
||||
|
||||
it('uses the sand fill when on', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
await clickToggle()
|
||||
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('true')
|
||||
expect(toggle().className).toContain('bg-(--rc-sand)')
|
||||
expect(toggle().className).toContain('text-(--rc-on-sand)')
|
||||
})
|
||||
|
||||
it('turns on keeping q, status, sort, site and size, and resets the page', async () => {
|
||||
const backend = backendWith(boardArticles(), 'several')
|
||||
await mountMeta(
|
||||
'articles',
|
||||
'/article?page=1&size=50&q=Article&status=online&sort=title:asc&site=renew',
|
||||
{ backend },
|
||||
)
|
||||
await clickToggle()
|
||||
|
||||
expect(currentQuery()).toEqual({
|
||||
size: '50',
|
||||
q: 'Article',
|
||||
status: 'online',
|
||||
sort: 'title:asc',
|
||||
site: 'renew',
|
||||
seo: 'suggestions',
|
||||
})
|
||||
const last = mainCalls(backend).at(-1)!
|
||||
expect(last.query.get('seo')).toBe('suggestions')
|
||||
expect(last.query.get('page')).toBe('1')
|
||||
expect(last.query.get('size')).toBe('50')
|
||||
expect(last.query.get('q')).toBe('Article')
|
||||
expect(last.query.get('sort')).toBe('title:asc')
|
||||
})
|
||||
|
||||
it('turns off, drops seo, resets the page and requests the list without seo', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
|
||||
await clickToggle()
|
||||
|
||||
expect(currentQuery()).toEqual({ size: '5' })
|
||||
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
|
||||
expect(mainCalls(backend).at(-1)!.query.get('page')).toBe('1')
|
||||
})
|
||||
|
||||
it('opens a URL with the filter already on', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=1', { backend })
|
||||
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('true')
|
||||
expect(mainCalls(backend)[0]!.query.get('seo')).toBe('suggestions')
|
||||
expect(bodyRows()).toHaveLength(3)
|
||||
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All 5')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'?seo=maybe',
|
||||
'?seo=',
|
||||
'?seo=READY',
|
||||
'?seo=ready',
|
||||
'?seo=hidden',
|
||||
'?seo=suggestions,x',
|
||||
])('drops %s silently', async (query) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', `/article${query}`, { backend })
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('false')
|
||||
expect(mainCalls(backend).every((call) => !call.query.has('seo'))).toBe(true)
|
||||
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ action: 'searches', act: () => search('Article') },
|
||||
{ action: 'changes the tab', act: () => clickTab('Drafts') },
|
||||
])('keeps ?seo=suggestions and resets the page when the user $action', async ({ act }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
|
||||
|
||||
await act()
|
||||
|
||||
expect(currentQuery().seo).toBe('suggestions')
|
||||
expect(currentQuery().page).toBeUndefined()
|
||||
expect(mainCalls(backend).at(-1)!.query.get('seo')).toBe('suggestions')
|
||||
})
|
||||
|
||||
it('behaves the same on a single-site install', async () => {
|
||||
const backend = backendWith(boardArticles(), 'one')
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(document.body.querySelector('button[aria-label="Site"]')).toBeNull()
|
||||
await clickToggle()
|
||||
expect(currentQuery()).toEqual({ seo: 'suggestions' })
|
||||
expect(bodyRows()).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention count', () => {
|
||||
it('reads the total of the extra request with the current q, status and site', async () => {
|
||||
const backend = backendWith(boardArticles(), 'several')
|
||||
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
|
||||
|
||||
const calls = countCalls(backend)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.raw).toBe(
|
||||
'/v1/article/?seo=suggestions&page=1&size=5&q=Article&status=online&site=renew',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ total: 3, label: 'Needs attention 3' },
|
||||
{ total: 0, label: 'Needs attention 0' },
|
||||
])('shows $label', async ({ total, label }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
backend.responders['GET /v1/article/'] = (call) =>
|
||||
call.query.has('format')
|
||||
? {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, online: 0, draft: 0 },
|
||||
}
|
||||
: { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe(label)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('shows no count and no notice on $failure', async ({ responder }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
const defaults = backend.responders
|
||||
backend.responders['GET /v1/article/'] = (call) => {
|
||||
if (!call.query.has('format')) {
|
||||
return responder(call)
|
||||
}
|
||||
return defaults
|
||||
? {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 5, online: 2, draft: 3 },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention')
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(currentQuery()).toEqual({})
|
||||
})
|
||||
|
||||
it('refreshes when q, tab or site change and never for page, size or sort', async () => {
|
||||
const backend = backendWith(
|
||||
Array.from({ length: 30 }, (_, index) => withSeo(index + 1, 'suggestions', 1)),
|
||||
'several',
|
||||
)
|
||||
await mountMeta('articles', '/article?size=5', { backend })
|
||||
expect(countCalls(backend)).toHaveLength(1)
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
|
||||
.find((item) => textOf(item).startsWith('Title'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect(countCalls(backend)).toHaveLength(1)
|
||||
|
||||
await clickTab('Drafts')
|
||||
expect(countCalls(backend)).toHaveLength(2)
|
||||
await search('Article 1')
|
||||
expect(countCalls(backend)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('uses the main response total while the filter is on, with no extra request', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions', { backend })
|
||||
|
||||
expect(countCalls(backend)).toHaveLength(0)
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention: empty states', () => {
|
||||
it('shows Nothing needs attention and returns to the list with Show all articles', async () => {
|
||||
const backend = backendWith([withSeo(1, 'ready', 0), withSeo(2, 'ready', 0)])
|
||||
await mountMeta('articles', '/article?seo=suggestions&q=', { backend })
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain('Nothing needs attention')
|
||||
expect(text).toContain(
|
||||
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
|
||||
)
|
||||
expect(document.body.querySelector('[data-testid="paginator-summary"]')).toBeNull()
|
||||
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
|
||||
.find((item) => textOf(item) === 'Show all articles')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
|
||||
expect(bodyRows()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ query: 'q=tiles', title: 'No articles match "tiles"' },
|
||||
{ query: 'status=draft', title: 'No drafts' },
|
||||
{ query: 'status=online', title: 'Nothing is online yet' },
|
||||
])('keeps the existing state for $query with the filter on', async ({ query, title }) => {
|
||||
const backend = backendWith([withSeo(1, 'ready', 0)])
|
||||
await mountMeta('articles', `/article?seo=suggestions&${query}`, { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain(title)
|
||||
expect(currentQuery().seo).toBe('suggestions')
|
||||
})
|
||||
|
||||
it('prefers Nothing needs attention to the site state', async () => {
|
||||
const backend = backendWith([withSeo(1, 'ready', 0)], 'several')
|
||||
await mountMeta('articles', '/article?seo=suggestions&site=archive', { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain('Nothing needs attention')
|
||||
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
import { failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
|
||||
import {
|
||||
checkAnswer,
|
||||
checkCalls,
|
||||
mountSeo,
|
||||
openPanel,
|
||||
openSection,
|
||||
patchBodies,
|
||||
sectionBody,
|
||||
sectionSummary,
|
||||
seoBackend,
|
||||
suggestion,
|
||||
} from './seoTestKit'
|
||||
|
||||
const emptyRectList = () => Object.assign([], { item: () => null }) as unknown as DOMRectList
|
||||
|
||||
Range.prototype.getClientRects = emptyRectList
|
||||
Range.prototype.getBoundingClientRect = () => new DOMRect()
|
||||
|
||||
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(unmountMeta)
|
||||
|
||||
const TITLE_MESSAGE =
|
||||
'The meta title is 74 characters. Search engines may cut it off after about 60.'
|
||||
|
||||
const ALL = [
|
||||
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||
suggestion('cover_alt', 'cover_image_alt'),
|
||||
suggestion('no_h', 'body'),
|
||||
]
|
||||
|
||||
function backendWithDismissals(saved: SuggestionCode[] = [], all = ALL) {
|
||||
const backend = seoBackend({ article: { seo_dismissed: saved } })
|
||||
const state = { saved }
|
||||
backend.responders['POST /v1/article/12/seo/check'] = () =>
|
||||
checkAnswer(all.filter((item) => !state.saved.includes(item.code)))
|
||||
backend.responders['PATCH /v1/article/12'] = (call) => {
|
||||
state.saved = [...new Set(call.body!.seo_dismissed as SuggestionCode[])]
|
||||
return { ...backend.article, seo_dismissed: state.saved }
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
function dismissButtons(root: ParentNode = document.body) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-testid="suggestion-dismiss"]'),
|
||||
)
|
||||
}
|
||||
|
||||
function panelRows(root: ParentNode) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
|
||||
}
|
||||
|
||||
async function dismiss(index: number) {
|
||||
dismissButtons()[index]!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function toastText() {
|
||||
return textOf(document.body.querySelector('[data-slot="viewport"]'))
|
||||
}
|
||||
|
||||
describe('Dismiss x: rendering', () => {
|
||||
it('draws one button per panel row and a footnote, and none in the compact list', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const compact = await openSection()
|
||||
expect(dismissButtons(compact)).toHaveLength(0)
|
||||
expect(textOf(compact)).not.toContain('Dismiss one and it stays hidden')
|
||||
|
||||
const panel = await openPanel()
|
||||
expect(dismissButtons(panel)).toHaveLength(3)
|
||||
expect(textOf(panel)).toContain(
|
||||
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
|
||||
)
|
||||
})
|
||||
|
||||
it('names the button with the message and orders it after the Go to link', async () => {
|
||||
await mountSeo(backendWithDismissals())
|
||||
const panel = await openPanel()
|
||||
|
||||
const first = dismissButtons(panel)[0]!
|
||||
expect(first.getAttribute('aria-label')).toBe(`Dismiss suggestion: ${TITLE_MESSAGE}`)
|
||||
expect(first.className).toContain('focus-visible:outline-2')
|
||||
const row = panelRows(panel)[0]!
|
||||
const link = Array.from(row.querySelectorAll('button')).find((button) =>
|
||||
textOf(button).startsWith('Go to'),
|
||||
)!
|
||||
expect(link.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
)
|
||||
})
|
||||
|
||||
it('is 44 px on mobile and 24 px on desktop', async () => {
|
||||
await mountSeo(backendWithDismissals(), { width: MOBILE_WIDTH })
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)[0]!.className).toContain('size-11')
|
||||
expect(dismissButtons(panel)[0]!.className).toContain('md:size-6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismiss x: behaviour', () => {
|
||||
it('sends one PATCH with the saved list plus the code and nothing else', async () => {
|
||||
const backend = backendWithDismissals(['no_au'])
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
await dismiss(0)
|
||||
|
||||
expect(patchBodies(backend)).toEqual([{ seo_dismissed: ['no_au', 'title_long'] }])
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
})
|
||||
|
||||
it('keeps the row and shows a busy button until the PATCH answers', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
const hold = pending<unknown>()
|
||||
const answer = backend.responders['PATCH /v1/article/12']!
|
||||
backend.responders['PATCH /v1/article/12'] = async (call) => {
|
||||
await hold.promise
|
||||
return answer(call)
|
||||
}
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(dismissButtons(panel)[0]!.disabled).toBe(true)
|
||||
expect(dismissButtons(panel)[0]!.getAttribute('aria-busy')).toBe('true')
|
||||
hold.resolve(undefined)
|
||||
await flushPromises()
|
||||
expect(panelRows(panel)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removes the row, keeps the order and updates the summary', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
const rows = panelRows(panel).map(textOf)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toContain('The cover image has no alt text')
|
||||
expect(rows[1]).toContain('The body has no headings')
|
||||
})
|
||||
|
||||
it('shows All good with the dismissed line when the last one goes, and focuses the link', async () => {
|
||||
const backend = backendWithDismissals([], [ALL[0]!])
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
const text = textOf(panel)
|
||||
expect(text).toContain('All good')
|
||||
expect(text).toContain('1 dismissed suggestion is hidden.')
|
||||
const restore = panel.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
|
||||
expect(textOf(restore)).toBe('Show dismissed')
|
||||
expect(document.activeElement).toBe(restore)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ position: 'first', index: 0, target: 0 },
|
||||
{ position: 'second', index: 1, target: 1 },
|
||||
{ position: 'last', index: 2, target: 1 },
|
||||
])('moves focus after dismissing the $position row', async ({ index, target }) => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(index)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(dismissButtons(panel)[target])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('keeps the row and toasts on $failure', async ({ responder }) => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = responder
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(dismissButtons(panel)[0]!.disabled).toBe(false)
|
||||
expect(toastText()).toContain("Couldn't dismiss the suggestion")
|
||||
expect(toastText()).toContain('Try again in a moment.')
|
||||
expect(checkCalls(backend)).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('shows the Not found state on a 404', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(404)
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(textOf(document.body)).toContain("This article doesn't exist.")
|
||||
})
|
||||
|
||||
it('shows nothing and sends no check on a 401', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(401)
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
const toastsBefore = toastText()
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(toastText()).toBe(toastsBefore)
|
||||
expect(checkCalls(backend)).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('restores every code with Show dismissed and checks again', async () => {
|
||||
const backend = backendWithDismissals(['title_long', 'short'], [ALL[0]!])
|
||||
await mountSeo(backend)
|
||||
const compact = await openSection()
|
||||
expect(textOf(compact)).toContain('2 dismissed suggestions are hidden.')
|
||||
const link = compact.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
|
||||
link.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(backend)).toEqual([{ seo_dismissed: [] }])
|
||||
expect(sectionBody()!.querySelectorAll('[data-testid="suggestion"]')).toHaveLength(1)
|
||||
expect(sectionSummary()).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)('is available to the %s', async (role) => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps Save enabled and does not touch the draft', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(patchBodies(backend)).toHaveLength(1)
|
||||
const save = document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
|
||||
expect(save?.getAttribute('data-state') ?? 'saved').not.toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismiss x: French', () => {
|
||||
it('reads the name, footnote and plural in French', async () => {
|
||||
const { setLocale } = await import('@/i18n')
|
||||
setLocale('fr')
|
||||
try {
|
||||
await mountSeo(backendWithDismissals(['no_au', 'short'], ALL))
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)[0]!.getAttribute('aria-label')).toMatch(
|
||||
/^Ignorer la suggestion : /,
|
||||
)
|
||||
expect(textOf(panel)).toContain(
|
||||
'Ignorez-en une et elle reste masquée pour cet article.',
|
||||
)
|
||||
expect(textOf(panel)).toContain('2 suggestions ignorées sont masquées.')
|
||||
} finally {
|
||||
setLocale('en')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import { API_GROUPS, ERROR_ROWS } from '@/data/apiCatalogue'
|
||||
import IntegrationView from '../IntegrationView.vue'
|
||||
|
||||
const ORIGIN = 'https://cms.renew.example'
|
||||
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
|
||||
const NOTE =
|
||||
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.'
|
||||
const GROUP_DESCRIPTION =
|
||||
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
|
||||
|
||||
let currentWrapper: VueWrapper | null = null
|
||||
let fetchSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
async function mountIntegration(path = '/integration') {
|
||||
vi.stubGlobal('location', { ...window.location, origin: ORIGIN })
|
||||
fetchSpy = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
await router.push(path)
|
||||
currentWrapper = mount(IntegrationView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return currentWrapper
|
||||
}
|
||||
|
||||
function section(wrapper: VueWrapper, id: string) {
|
||||
return wrapper.get(`[data-anchor="${id}"]`)
|
||||
}
|
||||
|
||||
function cardOf(wrapper: VueWrapper, route: string) {
|
||||
const [method, path] = route.split(' ')
|
||||
return wrapper
|
||||
.findAllComponents(EndpointCard)
|
||||
.find(
|
||||
(item) =>
|
||||
item.props('endpoint').method === method && item.props('endpoint').path === path,
|
||||
)!
|
||||
}
|
||||
|
||||
async function expand(wrapper: VueWrapper, route: string) {
|
||||
const found = wrapper
|
||||
.findAll('button[aria-expanded]')
|
||||
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))!
|
||||
await found.trigger('click')
|
||||
await flushPromises()
|
||||
return cardOf(wrapper, route)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
vi.unstubAllGlobals()
|
||||
setLocale('en')
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Search and sharing group', () => {
|
||||
it('sits directly after Articles with its description and 2 routes', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const keys = API_GROUPS.map((group) => group.key)
|
||||
expect(keys.indexOf('seo')).toBe(keys.indexOf('articles') + 1)
|
||||
const group = section(wrapper, 'seo')
|
||||
expect(group.text()).toContain('Search and sharing')
|
||||
expect(group.text()).toContain(GROUP_DESCRIPTION)
|
||||
expect(group.text()).toContain('2 routes')
|
||||
})
|
||||
|
||||
it('holds both cards, Public and API key OK, collapsed, with no duplicate route', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const group = section(wrapper, 'seo')
|
||||
const cards = group.findAllComponents(EndpointCard)
|
||||
expect(
|
||||
cards.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`),
|
||||
).toEqual(['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'])
|
||||
for (const item of cards) {
|
||||
expect(item.text()).toContain('Public')
|
||||
expect(item.text()).toContain('API key OK')
|
||||
expect(item.find('button[aria-expanded]').attributes('aria-expanded')).toBe('false')
|
||||
}
|
||||
const all = wrapper
|
||||
.findAllComponents(EndpointCard)
|
||||
.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`)
|
||||
expect(all.filter((route) => route.endsWith('/seo'))).toHaveLength(1)
|
||||
expect(all.filter((route) => route.endsWith('/sitemap'))).toHaveLength(1)
|
||||
expect(
|
||||
section(wrapper, 'articles')
|
||||
.findAllComponents(EndpointCard)
|
||||
.map((item) => item.props('endpoint').path),
|
||||
).toContain('/v1/article/{id}/seo/check')
|
||||
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
|
||||
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||
})
|
||||
|
||||
it('describes the resolved SEO object and shows the real error rows', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const seo = await expand(wrapper, 'GET /v1/article/{id}/seo')
|
||||
|
||||
const text = seo.text()
|
||||
expect(text).toContain('Drafts answer 404 to anonymous callers.')
|
||||
for (const name of [
|
||||
'canonical_url',
|
||||
'robots',
|
||||
'og',
|
||||
'twitter',
|
||||
'image',
|
||||
'authors',
|
||||
'published_at',
|
||||
'modified_at',
|
||||
'fallbacks',
|
||||
'json_ld',
|
||||
]) {
|
||||
expect(text).toContain(name)
|
||||
}
|
||||
expect(text).toContain('No request body.')
|
||||
for (const tab of ['fetch', 'curl', 'Response']) {
|
||||
expect(seo.findAll('[role="tab"]').map((item) => item.text())).toContain(tab)
|
||||
}
|
||||
expect(text).toContain('Copy')
|
||||
expect(text).toContain('400')
|
||||
expect(text).toContain('401')
|
||||
expect(text).toContain('404')
|
||||
})
|
||||
|
||||
it('shows the sitemap query rows, item shape, total header and real error strings', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const sitemap = await expand(wrapper, 'GET /v1/site/{key}/sitemap')
|
||||
|
||||
const text = sitemap.text()
|
||||
expect(text).toContain('A whole number from 1. Default 1.')
|
||||
expect(text).toContain('25, 50, 100 or 500. Default 100.')
|
||||
expect(text).toContain('{ url, modified_at, noindex }')
|
||||
expect(text).toContain('X-Total-Count')
|
||||
expect(text).toContain('/v1/site/renew-blog/sitemap')
|
||||
expect(text).toContain(BODY_MISMATCH)
|
||||
expect(text).toContain('site_not_found')
|
||||
expect(text).not.toContain('Invalid page or size.')
|
||||
})
|
||||
|
||||
it('lists the index in order and scrolls to the group with the deep link #seo', async () => {
|
||||
const wrapper = await mountIntegration('/integration#seo')
|
||||
|
||||
const labels = wrapper
|
||||
.get('nav[aria-label="On this page"]')
|
||||
.findAll('button')
|
||||
.map((button) => button.text())
|
||||
expect(labels).toEqual([
|
||||
'Authentication',
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Sites',
|
||||
'Authors',
|
||||
'Images and variants',
|
||||
'Session only',
|
||||
'Errors',
|
||||
'Notes',
|
||||
])
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
|
||||
const current = wrapper
|
||||
.get('nav[aria-label="On this page"]')
|
||||
.findAll('button[aria-current]')
|
||||
.map((button) => button.text())
|
||||
expect(current).toEqual(['Search and sharing'])
|
||||
})
|
||||
|
||||
it('adds the 403 note under the messages and leaves them unchanged', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
|
||||
expect(forbidden.messages).toHaveLength(3)
|
||||
expect(forbidden.note).toBe(NOTE)
|
||||
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
|
||||
expect(notes).toEqual([NOTE, NOTE])
|
||||
expect(ERROR_ROWS.filter((row) => row.note)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('makes no /v1 request when opening and expanding the new cards', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
await expand(wrapper, 'GET /v1/article/{id}/seo')
|
||||
await expand(wrapper, 'GET /v1/site/{key}/sitemap')
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the group, the cards and the note in French', async () => {
|
||||
setLocale('fr')
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const group = section(wrapper, 'seo')
|
||||
expect(group.text()).toContain('Recherche et partage')
|
||||
expect(group.text()).toContain('RenewCMS n’affiche pas de pages')
|
||||
expect(group.text()).toContain('2 routes')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire.',
|
||||
)
|
||||
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
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,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const TEMPLATE_ERROR = 'The template must contain %title%.'
|
||||
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
|
||||
const SITE_NAME_HELP = 'Shown in titles through the template, and sent as the Open Graph site name.'
|
||||
const DESCRIPTION_HELP = 'Used when an article has no description, excerpt or body text.'
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function templateField() {
|
||||
return box().querySelector<HTMLInputElement>('input[maxlength="200"]')!
|
||||
}
|
||||
|
||||
function siteNameField() {
|
||||
return field('e.g. Renew')
|
||||
}
|
||||
|
||||
function descriptionField() {
|
||||
return field('One or two sentences about the site.') as unknown as HTMLTextAreaElement
|
||||
}
|
||||
|
||||
async function openEdit(name = 'Renew', backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Edit')
|
||||
return backend
|
||||
}
|
||||
|
||||
async function openAdd(backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await click('Add site')
|
||||
return backend
|
||||
}
|
||||
|
||||
function submit(label = 'Save changes') {
|
||||
return click(label, box())
|
||||
}
|
||||
|
||||
describe('Sites SEO defaults: field order and copy', () => {
|
||||
it('lists the fields in the board order, then the preview and the defaults block', async () => {
|
||||
await openEdit()
|
||||
|
||||
const text = textOf(box())
|
||||
const order = [
|
||||
'Name',
|
||||
'Key',
|
||||
'Base URL',
|
||||
'Default social image',
|
||||
'Site name',
|
||||
'Title template',
|
||||
'Example',
|
||||
'Default description',
|
||||
'Default robots policy',
|
||||
'Locale',
|
||||
'Default site',
|
||||
'Canonical URL preview',
|
||||
'How articles use these defaults',
|
||||
]
|
||||
const positions = order.map((label) => text.indexOf(label))
|
||||
expect(positions.every((position) => position >= 0)).toBe(true)
|
||||
expect([...positions].sort((a, b) => a - b)).toEqual(positions)
|
||||
})
|
||||
|
||||
it('edit: shows the saved values, helpers, example, counter and the defaults block', async () => {
|
||||
await openEdit()
|
||||
|
||||
const text = textOf(box())
|
||||
expect(siteNameField().value).toBe('Renew')
|
||||
expect(text).toContain(SITE_NAME_HELP)
|
||||
expect(text).not.toContain('Site name (optional)')
|
||||
expect(templateField().value).toBe('%title% | %site%')
|
||||
expect(text).toContain('Use %title% for the article title and %site% for the site name.')
|
||||
expect(text).toContain('Example How we price a bathroom renovation | Renew')
|
||||
expect(text).toContain('Default description (optional)')
|
||||
expect(text).toContain('0 / 320')
|
||||
expect(text).toContain(DESCRIPTION_HELP)
|
||||
for (const copy of [
|
||||
'Title Article title, then the template.',
|
||||
"Description Used last, after the article's description, excerpt and body.",
|
||||
'Image Used when an article has no cover image.',
|
||||
'Robots Used unless an article is hidden from search engines.',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(templateField().className).toContain('font-mono')
|
||||
})
|
||||
|
||||
it('add: starts empty with the default template', async () => {
|
||||
await openAdd()
|
||||
|
||||
expect(siteNameField().value).toBe('')
|
||||
expect(templateField().value).toBe('%title% | %site%')
|
||||
expect(descriptionField().value).toBe('')
|
||||
expect(textOf(box())).toContain('How articles use these defaults')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['Journal', 'Journal', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
|
||||
['Journal', '', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
|
||||
['Journal', 'Le Journal', '%site%: %title%', 'Le Journal: How we price a bathroom renovation'],
|
||||
['Journal', 'Journal', '%title%', 'How we price a bathroom renovation'],
|
||||
['Journal', 'Journal', '%site% / %site%', 'Journal / Journal'],
|
||||
['', '', '%title% | %site%', 'How we price a bathroom renovation | Renew'],
|
||||
])(
|
||||
'Sites SEO defaults: example for name "%s", site name "%s", template "%s"',
|
||||
(name, siteName, template, example) => {
|
||||
it('follows the typing and sends nothing', async () => {
|
||||
const backend = await openAdd()
|
||||
const before = backend.calls.length
|
||||
await typeInto(field('e.g. Journal'), name)
|
||||
await typeInto(siteNameField(), siteName)
|
||||
await typeInto(templateField(), template)
|
||||
|
||||
expect(textOf(box())).toContain(`Example ${example}`)
|
||||
expect(backend.calls).toHaveLength(before)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe.each(['just text', '%site%', '%TITLE%', ''])(
|
||||
'Sites SEO defaults: template "%s"',
|
||||
(template) => {
|
||||
it('is refused on blur and on submit, with nothing sent', async () => {
|
||||
const backend = await openEdit()
|
||||
await typeInto(templateField(), template)
|
||||
await blur(templateField())
|
||||
|
||||
expect(textOf(box())).toContain(TEMPLATE_ERROR)
|
||||
expect(textOf(box())).not.toContain('Example')
|
||||
|
||||
await typeInto(templateField(), template)
|
||||
await submit()
|
||||
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(document.activeElement).toBe(templateField())
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe('Sites SEO defaults: template errors', () => {
|
||||
it('clears the error and shows the example again on a valid value', async () => {
|
||||
await openEdit()
|
||||
await typeInto(templateField(), 'nope')
|
||||
await blur(templateField())
|
||||
await typeInto(templateField(), '%title%')
|
||||
|
||||
expect(textOf(box())).not.toContain(TEMPLATE_ERROR)
|
||||
expect(textOf(box())).toContain('Example')
|
||||
})
|
||||
|
||||
it('shows the same message when the API answers 400 invalid_title_template', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/1'] = failWith(400, 'invalid_title_template')
|
||||
await openEdit('Renew', backend)
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(TEMPLATE_ERROR)
|
||||
expect(templateField().value).toBe('%title% – %site%')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites SEO defaults: limits and counter', () => {
|
||||
it('stops the inputs at 100, 200 and 320 characters', async () => {
|
||||
await openAdd()
|
||||
|
||||
expect(siteNameField().getAttribute('maxlength')).toBe('100')
|
||||
expect(templateField().getAttribute('maxlength')).toBe('200')
|
||||
expect(descriptionField().getAttribute('maxlength')).toBe('320')
|
||||
})
|
||||
|
||||
it.each([0, 45, 320])('counts %i characters without blocking', async (count) => {
|
||||
await openAdd()
|
||||
await typeInto(descriptionField(), 'a'.repeat(count))
|
||||
|
||||
expect(textOf(box())).toContain(`${count} / 320`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites SEO defaults: save payloads', () => {
|
||||
it('sends only the changed template', async () => {
|
||||
const backend = await openEdit()
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await submit()
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ title_template: '%title% – %site%' })
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('sends site name, description and robots together, trimmed', async () => {
|
||||
const backend = await openEdit()
|
||||
await typeInto(siteNameField(), ' Renew Journal ')
|
||||
await typeInto(descriptionField(), 'Notes on renewal work. ')
|
||||
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
|
||||
await flushPromises()
|
||||
await submit()
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({
|
||||
site_name: 'Renew Journal',
|
||||
default_description: 'Notes on renewal work.',
|
||||
default_robots: 'noindex',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a site without the three fields when untouched', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
await typeInto(field('https://blog.example.com'), 'https://notes.example')
|
||||
await submit('Add site')
|
||||
|
||||
const body = callsTo(backend, 'POST', '/v1/site/')[0]!.body!
|
||||
expect(body).not.toHaveProperty('site_name')
|
||||
expect(body).not.toHaveProperty('title_template')
|
||||
expect(body).not.toHaveProperty('default_description')
|
||||
})
|
||||
|
||||
it('creates a site with the SEO values', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
await typeInto(field('https://blog.example.com'), 'https://notes.example')
|
||||
await typeInto(siteNameField(), 'Renew Shop')
|
||||
await typeInto(templateField(), '%title% - %site%')
|
||||
await typeInto(descriptionField(), 'A shop about renewal.')
|
||||
await submit('Add site')
|
||||
|
||||
expect(callsTo(backend, 'POST', '/v1/site/')[0]!.body).toMatchObject({
|
||||
site_name: 'Renew Shop',
|
||||
title_template: '%title% - %site%',
|
||||
default_description: 'A shop about renewal.',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a site without the new fields on the defaults and sends nothing on save', async () => {
|
||||
const backend = createBackend()
|
||||
for (const site of backend.sites) {
|
||||
delete site.site_name
|
||||
delete site.title_template
|
||||
delete site.default_description
|
||||
}
|
||||
await openEdit('Renew', backend)
|
||||
|
||||
expect(siteNameField().value).toBe('')
|
||||
expect(descriptionField().value).toBe('')
|
||||
expect(templateField().value).toBe('%title% | %site%')
|
||||
await submit()
|
||||
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['site name', 'template', 'description'])(
|
||||
'asks to discard a changed %s',
|
||||
async (which) => {
|
||||
await openEdit()
|
||||
const target =
|
||||
which === 'site name'
|
||||
? siteNameField()
|
||||
: which === 'template'
|
||||
? templateField()
|
||||
: descriptionField()
|
||||
await typeInto(target, 'changed %title%')
|
||||
await click('Cancel', box())
|
||||
|
||||
expect(textOf(document.body.querySelector('[role="alertdialog"]'))).toContain(
|
||||
'Discard changes?',
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Sites SEO defaults: save failed with %s', (_name, responder) => {
|
||||
it('keeps the panel, the notice and the three values', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/1'] = responder
|
||||
await openEdit('Renew', backend)
|
||||
await typeInto(siteNameField(), 'Renew Journal')
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await typeInto(descriptionField(), 'Notes.')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
|
||||
expect(siteNameField().value).toBe('Renew Journal')
|
||||
expect(templateField().value).toBe('%title% – %site%')
|
||||
expect(descriptionField().value).toBe('Notes.')
|
||||
expect(button('Save changes', box())?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites SEO defaults: sessions', () => {
|
||||
it('shows the owner-only toast when the PATCH answers 403', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/1'] = failWith(403, 'owner_only')
|
||||
await openEdit('Renew', backend)
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await submit()
|
||||
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
})
|
||||
|
||||
it('shows the 403 page to an admin', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
|
||||
expect(document.body.textContent).toContain("You don't have access to Sites")
|
||||
expect(document.body.textContent).not.toContain('Title template')
|
||||
expect(vi.mocked(useApi)).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,25 @@ export interface EnvelopeParams {
|
||||
q?: string
|
||||
status?: ArticleStatus | ''
|
||||
sort?: string
|
||||
site?: string
|
||||
seo?: '' | 'suggestions'
|
||||
}
|
||||
|
||||
export const NEEDS_ATTENTION_TOTAL = 3
|
||||
export const NEEDS_ATTENTION_ROUTE = 'GET /v1/article/?seo=suggestions&page=1&size=5'
|
||||
|
||||
function fold(value: string) {
|
||||
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '', seo = '' } = params
|
||||
const filtered = all
|
||||
.filter(
|
||||
(article) =>
|
||||
!seo ||
|
||||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
|
||||
)
|
||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||
.filter((article) => !q || fold(article.title).includes(fold(q)))
|
||||
if (sort) {
|
||||
@@ -35,8 +45,17 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
|
||||
}
|
||||
}
|
||||
|
||||
export function isNeedsAttentionCount(url: string) {
|
||||
const query = new URL(url, 'http://localhost').searchParams
|
||||
return query.get('seo') === 'suggestions' && query.get('size') === '5' && !query.has('format')
|
||||
}
|
||||
|
||||
export function needsAttentionCountPage(total = NEEDS_ATTENTION_TOTAL): ArticlePage {
|
||||
return { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
|
||||
}
|
||||
|
||||
export function articleListUrl(params: EnvelopeParams = {}) {
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
|
||||
const query = new URLSearchParams({
|
||||
format: 'html',
|
||||
page: String(page),
|
||||
@@ -51,6 +70,12 @@ export function articleListUrl(params: EnvelopeParams = {}) {
|
||||
if (sort) {
|
||||
query.set('sort', sort)
|
||||
}
|
||||
if (site) {
|
||||
query.set('site', site)
|
||||
}
|
||||
if (seo) {
|
||||
query.set('seo', seo)
|
||||
}
|
||||
return `/v1/article/?${query.toString()}`
|
||||
}
|
||||
|
||||
@@ -66,5 +91,7 @@ export function parseListUrl(url: string): EnvelopeParams {
|
||||
q: query.get('q') ?? '',
|
||||
status: (query.get('status') ?? '') as ArticleStatus | '',
|
||||
sort: query.get('sort') ?? '',
|
||||
...(query.get('site') ? { site: query.get('site') ?? '' } : {}),
|
||||
...(query.get('seo') ? { seo: 'suggestions' as const } : {}),
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,15 @@ export function sitesFixture(): Site[] {
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index' as const,
|
||||
locale: null,
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
}
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
site_name: 'Renew',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
is_default: true,
|
||||
@@ -51,6 +54,7 @@ export function sitesFixture(): Site[] {
|
||||
...base,
|
||||
id: 2,
|
||||
name: 'Journal',
|
||||
site_name: 'Journal',
|
||||
key: 'journal',
|
||||
base_url: 'https://journal.example',
|
||||
is_default: false,
|
||||
@@ -60,6 +64,7 @@ export function sitesFixture(): Site[] {
|
||||
...base,
|
||||
id: 3,
|
||||
name: 'Projects',
|
||||
site_name: 'Projects',
|
||||
key: 'projects',
|
||||
base_url: 'https://projects.example',
|
||||
default_robots: 'noindex',
|
||||
@@ -70,6 +75,7 @@ export function sitesFixture(): Site[] {
|
||||
...base,
|
||||
id: 4,
|
||||
name: 'Archive',
|
||||
site_name: 'Archive',
|
||||
key: 'archive',
|
||||
base_url: 'https://archive.example',
|
||||
is_default: false,
|
||||
|
||||
@@ -174,6 +174,11 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
|
||||
.filter((article) => !siteKey || article.site_id === siteId)
|
||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||
.filter((article) => !q || article.title.toLowerCase().includes(q))
|
||||
.filter(
|
||||
(article) =>
|
||||
query.get('seo') !== 'suggestions' ||
|
||||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
|
||||
)
|
||||
const online = backend.articles.filter((article) => article.is_online).length
|
||||
return {
|
||||
items: structuredClone(matching.slice((page - 1) * size, page * size)),
|
||||
|
||||
Reference in new issue
Block a user