refac: missing mutation tests, spa specs, checks shown to pass, touch targets, catalogue entry, production code

This commit is contained in:
Florian Sylvain committed 2026-11-04 03:04:35 +01:00
1 parent 7c15206170
commit 3e02e14da4
48 files changed
+2173 -83

No files matched your search

@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import EditSlugDialog from '../EditSlugDialog.vue'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
const SLUG = 'comment-nous-chiffrons-une-renovation'
function props(extra: Record<string, unknown> = {}) {
return { language: 'fr', slug: SLUG, published: false, pending: false, error: '', ...extra }
}
function input() {
return document.body.querySelector<HTMLInputElement>('#edit-slug-input')!
}
function confirmButton() {
return byText('button', 'Change address') as HTMLButtonElement
}
async function typeSlug(value: string) {
input().value = value
input().dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
afterEach(unmountApp)
describe('Edit slug (loc.slug)', () => {
it('shows the title, the scope, the current slug and the title note', async () => {
await mountInApp(EditSlugDialog, props())
expect(text(document.body)).toContain('Edit slug for the fr version')
expect(text(document.body)).toContain('The address changes for fr only.')
expect(text(document.body)).toContain('Editing the title never changes the slug.')
expect(input().value).toBe(SLUG)
})
it.todo(
'focuses the slug field on open: the modal auto-focus moves focus to the close button after the dialog focused the field',
)
it('keeps Change address disabled while the slug is unchanged or empty', async () => {
await mountInApp(EditSlugDialog, props())
expect(confirmButton().disabled).toBe(true)
await typeSlug(' ')
expect(confirmButton().disabled).toBe(true)
await typeSlug('couts-de-renovation')
expect(confirmButton().disabled).toBe(false)
})
it('emits the normalized slug on Change address', async () => {
const wrapper = await mountInApp(EditSlugDialog, props())
await typeSlug('Couts de Renovation')
confirmButton().click()
await flushPromises()
expect(wrapper.findComponent(EditSlugDialog).emitted('confirm')).toEqual([
['couts-de-renovation'],
])
})
it('warns about old links on a published version only', async () => {
await mountInApp(EditSlugDialog, props({ published: true }))
expect(text(document.body)).toContain('This version is published')
expect(text(document.body)).toContain(
'Links to the old address will stop working. RenewCMS does not create redirects.',
)
unmountApp()
await mountInApp(EditSlugDialog, props())
expect(text(document.body)).not.toContain('This version is published')
})
it('shows the error as an alert and marks the field invalid', async () => {
await mountInApp(
EditSlugDialog,
props({ error: `The slug "${SLUG}" is already used on Journal.` }),
)
const alert = document.body.querySelector('[role="alert"]')
expect(text(alert)).toBe(`The slug "${SLUG}" is already used on Journal.`)
expect(input().getAttribute('aria-invalid')).toBe('true')
})
it('emits close from Cancel', async () => {
const wrapper = await mountInApp(EditSlugDialog, props())
byText('button', 'Cancel')!.click()
await flushPromises()
expect(wrapper.findComponent(EditSlugDialog).emitted('close')).toHaveLength(1)
})
})
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import { PUBLIC_LOOKUP_ROUTE, PUBLIC_STORY_ROUTE } from '@/lib/publicArticleRoutes'
import IntegrationLanguages from '../IntegrationLanguages.vue'
import { mountInApp, text, unmountApp } from './directoryKit'
function card(id: string) {
return document.body.querySelector(`[data-testid="${id}"]`)
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
unmountApp()
localStorage.clear()
initializeLocale()
})
describe('Integration: Languages section', () => {
it('anchors the section under the Languages heading with its intro', async () => {
await mountInApp(IntegrationLanguages)
const section = card('integration-languages')!
expect(section.getAttribute('data-anchor')).toBe('languages')
expect(section.getAttribute('aria-labelledby')).toBe('h-languages')
expect(text(section.querySelector('#h-languages'))).toBe('Languages')
expect(text(section)).toContain(
'Read one published language of an article, or resolve an article in a language. Both requests are public.',
)
})
it('shows the exact lookup route, its no-fallback rule and a sample with alternates', async () => {
await mountInApp(IntegrationLanguages)
const lookup = text(card('integration-lookup'))
expect(lookup).toContain('1. Exact lookup: site + language + slug')
expect(lookup).toContain(PUBLIC_LOOKUP_ROUTE)
expect(lookup).toContain('It never falls back, because a slug belongs to one language.')
expect(lookup).toContain('"alternates": [{ "id": 10, "lang": "en"')
})
it('shows the story route with the default fallback rules', async () => {
await mountInApp(IntegrationLanguages)
const story = text(card('integration-story'))
expect(story).toContain('2. Resolve by article group, with fallback')
expect(story).toContain(PUBLIC_STORY_ROUTE)
expect(story).toContain('"requested_lang": "fr", "fallback": true')
expect(story).toContain('drafts are never returned.')
})
it('lists the four error codes and what each means', async () => {
await mountInApp(IntegrationLanguages)
const errors = text(card('integration-errors'))
expect(errors).toContain('3. Errors')
expect(errors).toContain(
'article_not_found · language_not_available · site_not_found · invalid_language',
)
expect(errors).toContain('site_not_found: no site has that key.')
})
it('keeps the existing article-ID routes listed as unchanged', async () => {
await mountInApp(IntegrationLanguages)
const unchanged = text(card('integration-unchanged'))
expect(unchanged).toContain('Existing, unchanged')
expect(unchanged).toContain('GET /v1/article/{id}')
expect(unchanged).toContain('PATCH /v1/article/{id}/publish')
expect(unchanged).toContain('Editor: /article/{id}/edit')
expect(unchanged).toContain(
'Each article ID is one language version. Article-ID links keep working.',
)
})
it('translates the copy and keeps the routes and codes literal in French', async () => {
setLocale('fr')
await mountInApp(IntegrationLanguages)
const section = text(card('integration-languages'))
expect(text(card('integration-languages')!.querySelector('#h-languages'))).toBe('Langues')
expect(section).toContain(PUBLIC_LOOKUP_ROUTE)
expect(section).toContain('article_not_found · language_not_available')
})
})
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale } from '@/i18n'
import type { LocalizedSeoContext } from '@/lib/localizedSeo'
import type { ResolvedSeo } from '@/lib/seoTypes'
import router from '@/router'
import SeoLanguageCards from '../SeoLanguageCards.vue'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
const CANONICAL = 'https://journal.example.com/fr/comment-nous-chiffrons-une-renovation'
const ALTERNATES = [
{
id: 10,
lang: 'en',
story_id: 5,
slug: 'how-we-price-a-bathroom-renovation',
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
},
{ id: 11, lang: 'de', story_id: 5, slug: 'so-kalkulieren-wir-eine-badsanierung', url: null },
]
function resolved(locale = 'fr_FR'): ResolvedSeo {
return {
title: 'Couts de renovation | Journal',
description: '',
canonical_url: CANONICAL,
robots: 'index, follow',
og: {
type: 'article',
title: '',
description: '',
image: '',
image_alt: '',
url: '',
site_name: 'Journal',
locale,
},
twitter: { card: 'summary', title: '', description: '', image: '', image_alt: '' },
image: { url: '', alt: '', source: 'none' },
authors: [],
published_at: null,
modified_at: '2026-09-26T11:02:00Z',
fallbacks: { title: 'title', description: 'none', image: 'none', canonical: 'pattern' },
json_ld: {},
alternates: ALTERNATES,
} as ResolvedSeo
}
function context(overrides: Partial<LocalizedSeoContext> = {}): LocalizedSeoContext {
return {
lang: 'fr',
siteName: 'Journal',
siteKey: 'journal',
pattern: PATTERN,
defaultLang: 'en',
disabledLangs: ['es'],
published: true,
socialState: 'derived',
resolved: resolved(),
...overrides,
}
}
function card(id: string) {
return document.body.querySelector(`[data-testid="${id}"]`)
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
unmountApp()
vi.restoreAllMocks()
})
describe('Search and sharing cards for one language', () => {
it('shows the canonical with its source note', async () => {
await mountInApp(SeoLanguageCards, { context: context() })
const canonical = text(card('seo-canonical-card'))
expect(canonical).toContain('Canonical')
expect(canonical).toContain(CANONICAL)
expect(canonical).toContain(`Built from the Public URL pattern ${PATTERN}.`)
})
it('lists the published alternates with their URL, or their slug without one, and the disabled note', async () => {
await mountInApp(SeoLanguageCards, { context: context() })
const rows = [...document.body.querySelectorAll('[data-testid="seo-alternate-row"]')]
expect(rows.map((row) => text(row))).toEqual([
'en https://journal.example.com/en/how-we-price-a-bathroom-renovation Published',
'de so-kalkulieren-wir-eine-badsanierung Published',
])
expect(rows[0]!.querySelector('[aria-label]')!.getAttribute('aria-label')).toBe('English')
const alternates = text(card('seo-alternates-card'))
expect(alternates).toContain('Alternate languages')
expect(alternates).toContain('Published versions in enabled languages only.')
expect(alternates).toContain('Drafts and the disabled Spanish language are left out.')
})
it('shows the derived social locale', async () => {
await mountInApp(SeoLanguageCards, { context: context() })
const social = text(card('seo-social-card'))
expect(social).toContain('Social locale')
expect(social).toContain('og:locale')
expect(social).toContain('Derived: fr_FR')
})
it('shows Social locale not set when nothing resolves', async () => {
await mountInApp(SeoLanguageCards, { context: context({ resolved: resolved('') }) })
expect(text(card('seo-social-card'))).toContain('Social locale not set')
})
it('replaces the canonical and alternates with Public URL not configured and opens its settings', async () => {
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
await mountInApp(SeoLanguageCards, { context: context({ pattern: null }) })
expect(card('seo-canonical-card')).toBeNull()
expect(card('seo-alternates-card')).toBeNull()
expect(text(document.body)).toContain('Public URL not configured')
expect(text(document.body)).toContain(
'Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work.',
)
expect(card('seo-social-card')).not.toBeNull()
byText('button', 'Open Public URL settings')!.click()
await flushPromises()
expect(push).toHaveBeenCalledWith('/settings/sites/journal/public-url')
})
})
@@ -0,0 +1,262 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, ref } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale } from '@/i18n'
import type { Article } from '@/composables/useArticles'
import type { SaveOutcome, useArticleDraft } from '@/composables/useArticleDraft'
import type { useEditorVersions } from '@/composables/useEditorVersions'
import type { Site } from '@/composables/useSites'
import { useEditorLocalizedSeo } from '@/composables/useEditorLocalizedSeo'
import type { ResolvedSeo } from '@/lib/seoTypes'
import type { SiteLanguage } from '@/lib/siteLanguages'
const fixtures = vi.hoisted(() => ({
resolved: { value: null as unknown },
load: vi.fn(),
toasts: [] as (() => { title: string; description: string; color: string })[],
}))
vi.mock('@/composables/useResolvedSeo', async () => {
const { ref: vueRef } = await import('vue')
const resolved = vueRef<unknown>(null)
fixtures.resolved = resolved
return { useResolvedSeo: () => ({ resolved, load: fixtures.load }) }
})
vi.mock('@/i18n/presentation', () => ({
useLocalizedToast: () => ({
add: (content: () => { title: string; description: string; color: string }) => {
fixtures.toasts.push(content)
},
}),
}))
const SITE = {
id: 1,
key: 'journal',
name: 'Journal (internal)',
site_name: 'Journal',
public_url_pattern: 'https://journal.example.com/{lang}/{slug}',
} as Site
function language(lang: string, extra: Partial<SiteLanguage> = {}): SiteLanguage {
return {
lang,
enabled: true,
is_default: false,
version_count: 1,
published_count: 1,
social_locale: null,
social_locale_state: 'derived',
...extra,
}
}
const LANGUAGES = [
language('en', { is_default: true }),
language('fr', { social_locale_state: 'set', social_locale: 'fr_CA' }),
language('es', { enabled: false, published_count: 2 }),
language('it', { enabled: false, published_count: 0 }),
]
function setup(
options: {
multilingual?: boolean
site?: Site | undefined
article?: Partial<Article> | null
outcome?: SaveOutcome
panelOpen?: boolean
} = {},
) {
const article = ref<Article | null>(
options.article === null
? null
: ({ id: 12, lang: 'fr', is_online: true, ...options.article } as Article),
)
const site = ref<Site | undefined>('site' in options ? options.site : SITE)
const panelOpen = ref(options.panelOpen ?? false)
const seo = ref({ slug: 'couts', seo_title: '' })
const draft = {
seo,
editSeo: vi.fn((next: Record<string, unknown>) => {
seo.value = { ...seo.value, ...next }
}),
} as unknown as ReturnType<typeof useArticleDraft>
const versions = {
multilingual: computed(() => options.multilingual ?? true),
siteLanguages: ref(LANGUAGES),
} as unknown as ReturnType<typeof useEditorVersions>
const save = vi.fn(async () => options.outcome ?? 'saved')
const seoState = useEditorLocalizedSeo({
articleId: 12,
article,
site: computed(() => site.value),
versions,
draft,
panelOpen,
save,
})
return { seoState, article, panelOpen, draft, save, seo }
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
fixtures.load.mockReset()
fixtures.toasts.length = 0
;(fixtures.resolved as { value: unknown }).value = null
})
describe('Localized SEO context', () => {
it('builds the context from the site, the version and the site languages', () => {
const { seoState } = setup()
expect(seoState.context.value).toEqual({
lang: 'fr',
siteName: 'Journal',
siteKey: 'journal',
pattern: 'https://journal.example.com/{lang}/{slug}',
defaultLang: 'en',
disabledLangs: ['es'],
published: true,
socialState: 'set',
resolved: null,
})
})
it('falls back to the site name, a null pattern and no default', () => {
const { seoState } = setup({
site: { id: 2, key: 'atelier', name: 'Atelier', site_name: '' } as Site,
article: { lang: 'de', is_online: false },
})
expect(seoState.context.value).toMatchObject({
siteName: 'Atelier',
pattern: null,
published: false,
socialState: '',
})
})
it('carries the resolved SEO once it is loaded', async () => {
const { seoState } = setup()
const answer = { canonical_url: 'https://journal.example.com/fr/couts' } as ResolvedSeo
;(fixtures.resolved as { value: unknown }).value = answer
await nextTick()
expect(seoState.context.value?.resolved).toStrictEqual(answer)
})
it('has no context on a single-language site, without a site or without a language', () => {
expect(setup({ multilingual: false }).seoState.context.value).toBeNull()
expect(setup({ site: undefined }).seoState.context.value).toBeNull()
expect(setup({ article: { lang: '' } }).seoState.context.value).toBeNull()
expect(setup({ article: null }).seoState.context.value).toBeNull()
})
})
describe('Resolved SEO loading', () => {
it('loads when the panel opens with a context, and not before', async () => {
const { panelOpen } = setup()
expect(fixtures.load).not.toHaveBeenCalled()
panelOpen.value = true
await nextTick()
expect(fixtures.load).toHaveBeenCalledTimes(1)
})
it('loads at once when the panel is already open', () => {
setup({ panelOpen: true })
expect(fixtures.load).toHaveBeenCalledTimes(1)
})
it('never loads without a context', async () => {
const { panelOpen } = setup({ multilingual: false })
panelOpen.value = true
await nextTick()
expect(fixtures.load).not.toHaveBeenCalled()
})
})
describe('Edit slug', () => {
it('opens with no error and closes on request', () => {
const { seoState } = setup()
seoState.openSlugDialog()
expect(seoState.slugDialogOpen.value).toBe(true)
expect(seoState.slugError.value).toBe('')
seoState.closeSlugDialog()
expect(seoState.slugDialogOpen.value).toBe(false)
})
it('saves the slug, closes, reloads the resolved SEO and confirms with a toast', async () => {
const { seoState, save, seo } = setup()
seoState.openSlugDialog()
const pending = seoState.confirmSlug('couts-de-renovation')
expect(seoState.slugPending.value).toBe(true)
await pending
await flushPromises()
expect(seo.value.slug).toBe('couts-de-renovation')
expect(save).toHaveBeenCalledTimes(1)
expect(seoState.slugPending.value).toBe(false)
expect(seoState.slugDialogOpen.value).toBe(false)
expect(fixtures.load).toHaveBeenCalledTimes(1)
const toast = fixtures.toasts[0]!()
expect(toast.color).toBe('success')
expect(toast.title).toBe('Address changed')
expect(toast.description).toBe('The address of the fr version is now couts-de-renovation.')
})
it('stays open with the taken slug named on the site', async () => {
const { seoState } = setup({ outcome: 'slug-taken' })
seoState.openSlugDialog()
await seoState.confirmSlug('kosten')
expect(seoState.slugDialogOpen.value).toBe(true)
expect(seoState.slugError.value).toBe('The slug "kosten" is already used on Journal.')
expect(fixtures.toasts).toHaveLength(0)
})
it('stays open with the slug rules on an invalid slug', async () => {
const { seoState } = setup({ outcome: 'invalid-slug' })
seoState.openSlugDialog()
await seoState.confirmSlug('--')
expect(seoState.slugDialogOpen.value).toBe(true)
expect(seoState.slugError.value).toBe(
'Lowercase letters, numbers and hyphens. Unique on Journal.',
)
})
it('closes on a revision conflict so the conflict dialog can take over', async () => {
const { seoState } = setup({ outcome: 'conflict' })
seoState.openSlugDialog()
await seoState.confirmSlug('couts-2')
expect(seoState.slugDialogOpen.value).toBe(false)
expect(seoState.slugError.value).toBe('')
expect(fixtures.toasts).toHaveLength(0)
})
it('stays open without a message on another failure, and reopening clears the last error', async () => {
const { seoState } = setup({ outcome: 'error' })
seoState.openSlugDialog()
await seoState.confirmSlug('couts-3')
expect(seoState.slugDialogOpen.value).toBe(true)
expect(seoState.slugError.value).toBe('')
seoState.openSlugDialog()
expect(seoState.slugError.value).toBe('')
})
})
+7 -5
View File
@@ -28,6 +28,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'PATCH /v1/article/{id}/publish',
'PATCH /v1/article/{id}/unpublish',
'DELETE /v1/article/{id}',
'DELETE /v1/story/{story_id}',
'POST /v1/article/{id}/seo/check',
],
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
@@ -245,7 +246,7 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
}
describe('Catalogue routes', () => {
it('holds exactly the 45 key-callable routes in 9 groups, in order', () => {
it('holds exactly the 46 key-callable routes in 9 groups, in order', () => {
expect(API_GROUPS.map((group) => group.title)).toEqual([
'Articles',
'Search and sharing',
@@ -262,12 +263,12 @@ describe('Catalogue routes', () => {
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(45)
expect(allEndpoints()).toHaveLength(46)
})
it('counts the routes of each group', () => {
expect(API_GROUPS.map((group) => group.endpoints.length)).toEqual([
10, 2, 5, 5, 2, 11, 3, 6, 1,
11, 2, 5, 5, 2, 11, 3, 6, 1,
])
})
@@ -297,7 +298,7 @@ describe('Catalogue routes', () => {
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(9 + 45 + 40 + 6)
expect(described).toHaveLength(9 + 46 + 40 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
@@ -632,9 +633,10 @@ describe('Response samples use the real domain JSON keys', () => {
])
})
it('answers 204 for the three deletes without a JSON body', () => {
it('answers 204 for the four deletes without a JSON body', () => {
for (const route of [
'DELETE /v1/article/{id}',
'DELETE /v1/story/{story_id}',
'DELETE /v1/media/{id}',
'DELETE /v1/user/invite/{id}',
]) {
+17
View File
@@ -1076,6 +1076,23 @@ export const API_GROUPS: ApiGroup[] = [
NO_CONTENT_RESPONSE,
{ query: [FORMAT_QUERY] },
),
endpoint(
'story-del',
'DELETE',
'/v1/story/{story_id}',
'Delete a whole article: every version of the story, readable or not, in one step. Needs the permission to delete content at scope all.',
'Verified user',
verified(
articleErrors(
e(400, ID_FORMAT),
eMissingPermission,
e(404, 'Article not found'),
e(500, 'Failed to delete article'),
),
),
NO_CONTENT_RESPONSE,
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-check',
'POST',
+2
View File
@@ -1666,6 +1666,8 @@ export const messages = {
'Publish an article.': 'Publie un article.',
'Unpublish an article.': 'Dépublie un article.',
'Delete an article.': 'Supprime un article.',
'Delete a whole article: every version of the story, readable or not, in one step. Needs the permission to delete content at scope all.':
'Supprime un article entier : toutes ses versions, lisibles ou non, en une seule fois. Demande la permission de supprimer du contenu sur la portée « tout ».',
'List images.': 'Liste les images.',
'Upload an image (multipart).': 'Téléverse une image (multipart).',
'Rename an image. The URL stays the same.': 'Renomme une image. L’URL reste la même.',
+4 -4
View File
@@ -65,9 +65,9 @@ describe('index', () => {
])
})
it('holds 91 endpoints: 45 documented, 40 session only and 6 public auth', () => {
expect(index.endpoints).toHaveLength(91)
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(45)
it('holds 92 endpoints: 46 documented, 40 session only and 6 public auth', () => {
expect(index.endpoints).toHaveLength(92)
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(46)
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(40)
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
})
@@ -202,7 +202,7 @@ describe('matching', () => {
describe('custom content types', () => {
it.each([
['media', 16, 1],
['404', 26, 1],
['404', 27, 1],
['login', 1, 1],
['entry', 7, 1],
['capacity', 2, 0],
+197
View File
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import type { ResolvedSeo } from '@/lib/seoTypes'
import {
altHelp,
breadcrumbOf,
canonicalNote,
descriptionBadge,
descriptionHelp,
disabledNote,
pageLanguage,
socialLocaleLine,
socialLocaleNote,
titleHelp,
type LocalizedSeoContext,
} from '../localizedSeo'
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
function resolved(
fallbacks: Partial<ResolvedSeo['fallbacks']> = {},
locale = 'fr_FR',
): ResolvedSeo {
return {
title: 'Couts de renovation | Journal',
description: '',
canonical_url: 'https://journal.example.com/fr/couts',
robots: 'index, follow',
og: {
type: 'article',
title: '',
description: '',
image: '',
image_alt: '',
url: '',
site_name: 'Journal',
locale,
},
twitter: { card: 'summary', title: '', description: '', image: '', image_alt: '' },
image: { url: '', alt: '', source: 'none' },
authors: [],
published_at: null,
modified_at: '2026-09-26T11:02:00Z',
fallbacks: {
title: 'title',
description: 'none',
image: 'none',
canonical: 'pattern',
...fallbacks,
},
json_ld: {},
}
}
function context(overrides: Partial<LocalizedSeoContext> = {}): LocalizedSeoContext {
return {
lang: 'fr',
siteName: 'Journal',
siteKey: 'journal',
pattern: PATTERN,
defaultLang: 'en',
disabledLangs: [],
published: true,
socialState: 'derived',
resolved: resolved(),
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Page language and breadcrumb', () => {
it('names the version language in the interface language', () => {
expect(pageLanguage(context())).toBe('French')
setLocale('fr')
expect(pageLanguage(context())).toBe('Français')
})
it('turns a URL into host › segments and keeps anything else as is', () => {
expect(breadcrumbOf('https://journal.example.com/fr/couts')).toBe(
'journal.example.com › fr › couts',
)
expect(breadcrumbOf('not a url')).toBe('not a url')
})
})
describe('Canonical note', () => {
it('says the canonical is built from the Public URL pattern', () => {
expect(canonicalNote(context())).toBe(
`Built from the Public URL pattern ${PATTERN}. Each language points to itself unless you set a canonical override.`,
)
})
it('says a canonical override was set by hand', () => {
expect(canonicalNote(context({ resolved: resolved({ canonical: 'canonical_url' }) }))).toBe(
'Set by hand as a canonical override on this version.',
)
})
})
describe('Disabled languages note', () => {
it('leaves out drafts only when no language is disabled', () => {
expect(disabledNote(context())).toBe('Drafts are left out.')
})
it('names one disabled language', () => {
expect(disabledNote(context({ disabledLangs: ['es'] }))).toBe(
'Drafts and the disabled Spanish language are left out.',
)
})
it('lists several disabled languages', () => {
expect(disabledNote(context({ disabledLangs: ['es', 'de'] }))).toBe(
'Drafts and the disabled languages Spanish, German are left out.',
)
})
})
describe('Social locale', () => {
it('reads Derived for a locale taken from the language code', () => {
const derived = context()
expect(socialLocaleLine(derived)).toBe('Derived: fr_FR')
expect(socialLocaleNote(derived)).toBe(
'Taken from the region of the language code or of the site locale. An owner can set another in Journal languages.',
)
})
it('reads Set for a locale an owner chose', () => {
const set = context({ socialState: 'set' })
expect(socialLocaleLine(set)).toBe('Set: fr_FR')
expect(socialLocaleNote(set)).toBe(
'Set by an owner for French in Journal languages. A region is never guessed from the language.',
)
})
it('reads Social locale not set when nothing resolves', () => {
const unset = context({ resolved: resolved({}, '') })
expect(socialLocaleLine(unset)).toBe('Social locale not set')
expect(socialLocaleNote(unset)).toBe(
'French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages.',
)
expect(socialLocaleLine(context({ resolved: null }))).toBe('Social locale not set')
})
})
describe('Field help follows the fallbacks of the API', () => {
it('names the title fallback only when the title is used', () => {
expect(titleHelp(context())).toBe('Fallback: French title')
expect(titleHelp(context({ resolved: resolved({ title: 'seo_title' }) }))).toBe('')
expect(titleHelp(context({ resolved: null }))).toBe('')
})
it('names each description fallback', () => {
const help = (description: string, siteTextSkipped = false) =>
descriptionHelp(
context({
resolved: resolved({ description, site_text_skipped: siteTextSkipped }),
}),
)
expect(help('excerpt')).toBe('Fallback: French excerpt')
expect(help('body')).toBe('Fallback: French body')
expect(help('site')).toBe('Fallback: French site description')
expect(help('none')).toBe('No fallback')
expect(help('none', true)).toBe(
'No fallback. Empty. English site default not used. The tag is left out.',
)
expect(help('seo_description')).toBe('')
expect(descriptionHelp(context({ resolved: null }))).toBe('')
})
it('badges the site text and the withheld tag', () => {
expect(descriptionBadge(context({ resolved: resolved({ description: 'site' }) }))).toBe(
'Uses site text',
)
expect(
descriptionBadge(
context({ resolved: resolved({ description: 'none', site_text_skipped: true }) }),
),
).toBe('Tag left out')
expect(descriptionBadge(context({ resolved: resolved({ description: 'excerpt' }) }))).toBe(
'',
)
expect(descriptionBadge(context({ resolved: null }))).toBe('')
})
it('says the image alt is written per language', () => {
expect(altHelp()).toBe('Written per language.')
})
})
@@ -40,14 +40,14 @@ afterEach(async () => {
await unmountMeta()
})
function backendFor(viewer: StoryViewer = {}, reader = false): MetaBackend {
function backendFor(viewer: StoryViewer = {}, reader = false, defaultKey = 'journal'): MetaBackend {
const [first] = renewSites()
const sites = ['journal', 'atelier', 'press'].map((key, index) => ({
...first!,
id: index + 1,
key,
name: key.charAt(0).toUpperCase() + key.slice(1),
is_default: index === 0,
is_default: key === defaultKey,
}))
const backend = createMetaBackend({ sites })
if (reader) {
@@ -584,6 +584,17 @@ describe('URL state', () => {
expect(first.get('tab')).toBe(expected.tab ?? null)
})
it('lists every site and takes the filter options from the default site when the URL names no site', async () => {
const backend = backendFor({}, false, 'atelier')
await mountMeta('articles', '/article', { backend })
await flushPromises()
expect(currentQuery()).toEqual({})
expect(listCalls(backend).every((call) => !call.query.has('site'))).toBe(true)
expect(callsTo(backend, 'GET', '/v1/site/atelier/languages')).not.toHaveLength(0)
expect(callsTo(backend, 'GET', '/v1/site/journal/languages')).toHaveLength(0)
})
it('drops a language the site does not have once the languages are known', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=pt', { backend })
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import { announce } from '@/composables/useLiveAnnouncement'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { settle, text, unmountRoute } from './localizationKit'
import {
@@ -22,7 +23,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
announce('')
})
afterEach(unmountRoute)
const LANGUAGE_NAMES = ['English', 'Arabic', 'French', 'German']
@@ -189,6 +194,35 @@ describe('The menu is keyboard operable', () => {
expect(document.activeElement?.textContent).toContain('French')
})
it('opens on Enter with focus on the current row', async () => {
await mountEditor()
languageTrigger()!.focus()
languageTrigger()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
)
await settle()
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
expect(document.activeElement?.textContent).toContain('French')
})
it('switches to the version chosen with Enter', async () => {
await mountEditor()
languageTrigger()!.focus()
languageTrigger()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
)
await settle()
const english = rowFor('English')!
english.focus()
english.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await settle()
expect(router.currentRoute.value.path).toBe('/article/10/edit')
expect(document.body.querySelector('[role="menu"]')).toBeNull()
expect(titleInput().value).toBe('Renovation costs')
})
it('focuses the current row when the sheet opens on a phone', async () => {
await mountEditor({ width: MOBILE_WIDTH })
await openLanguageMenu()
@@ -12,6 +12,8 @@ import {
} from './localizationKit'
import { EDIT_ONLY, READ, articleOf, editBody, mountEditor, titleInput } from './editorKit'
import { removeStoredDraftsOf } from '@/lib/storedDraft'
import { ApiError, redirectToLogin } from '@/composables/useApi'
import router from '@/router'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -53,6 +55,33 @@ describe('A 401 on save mirrors the draft for this user and article', () => {
})
})
describe('A 401 on save sends the writer to log in and keeps the draft for after', () => {
it('redirects to /login with the editor as redirect and reason expired, then restores the draft', async () => {
const expireSession = () => {
redirectToLogin()
throw new ApiError(401)
}
await mountEditor({ extra: { 'PATCH /v1/article/12': expireSession } })
await typeInto(titleInput(), 'Couts reels')
await editBody('<p>Edited body</p>')
await press('Save')
await settle()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toEqual({
redirect: '/article/12/edit',
reason: 'expired',
})
await unmountRoute()
await mountEditor()
expect(titleInput().value).toBe('Couts reels')
expect(editorBody()).toBe('<p>Edited body</p>')
expect(text()).toContain('Unsaved changes')
})
})
describe('loc.l11.restored: Restoring after login', () => {
it('shows the stored title and body, not the loaded ones, with the info alert and unsaved state', async () => {
store()
@@ -382,11 +382,11 @@ describe('8d More endpoints than the cap', () => {
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
'Section Errors. Status codes and messages',
)
expect(document.body.textContent).toContain('27 results')
expect(document.body.textContent).toContain('28 results')
expect(document.body.textContent).toContain(
'20 more endpoints. Keep typing to narrow the list.',
'21 more endpoints. Keep typing to narrow the list.',
)
expect(liveRegion()).toBe('27 results, showing 7')
expect(liveRegion()).toBe('28 results, showing 7')
})
})
@@ -176,7 +176,7 @@ describe('Default, every card collapsed', () => {
})
it.each([
['articles', 'Articles', '10 routes', 10],
['articles', 'Articles', '11 routes', 11],
['seo', 'Search and sharing', '2 routes', 2],
['ctypes', 'Content types', '5 routes', 5],
['entries', 'Entries', '5 routes', 5],