feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

@@ -0,0 +1,327 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import USelect from '@nuxt/ui/components/Select.vue'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import { initializeLocale } from '@/i18n'
import { AVAILABLE, SITES } from './localizationData'
import {
WRITE_FAILS,
callsTo,
control,
deferred,
failWith,
kit,
mountRoute,
press,
settle,
text,
unmountRoute,
type Route,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
const JOURNAL = {
key: 'journal',
name: 'Journal',
locale: 'en',
article_count: 8,
suggestion: { lang: 'en', substituted: false },
}
const NOTES = { key: 'notes', name: 'Notes', locale: null, article_count: 3, suggestion: null }
const ARCHIVE = {
key: 'archive',
name: 'Archive',
locale: 'pt-BR',
article_count: 5,
suggestion: { lang: 'pt-BR', substituted: false },
}
const GALLERY = {
key: 'gallery',
name: 'Gallery',
locale: 'en-GB',
article_count: 0,
suggestion: { lang: 'en-GB', substituted: false },
}
const BELGIAN = {
key: 'belgium',
name: 'Belgium',
locale: 'fr-BE',
article_count: 2,
suggestion: { lang: 'fr', substituted: true },
}
const POST = 'POST /v1/language/setup'
function pending(...sites: unknown[]) {
return { sites, available: AVAILABLE }
}
function open(setup: unknown, extra: Record<string, Route> = {}, role = 'owner', key = 'journal') {
return mountRoute(`/settings/sites/${key}/languages/setup`, {
role,
routes: { 'GET /v1/site/': SITES, 'GET /v1/language/setup': setup, ...extra },
})
}
async function choose(siteKey: string, label: string, tag: string) {
const selects = kit.wrapper!.findAllComponents(LocaleSelect)
const target = selects.find((select) => select.props('ariaLabel') === label)
if (!target) {
throw new Error(`No select ${label} for ${siteKey}`)
}
target.vm.$emit('update:modelValue', tag)
await settle()
}
function confirmButton() {
return control(/^Confirm and assign to/) as HTMLButtonElement
}
describe('loc.l2.ready: Ready to assign', () => {
beforeEach(async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE))
})
it('shows the heading, the info alert and one row per pending site', () => {
const page = text()
expect(page).toContain('Set article languages')
expect(page).toContain(
"Existing articles don't have a language yet. Confirm or change the language for each site.",
)
expect(page).toContain("This labels articles. It doesn't translate them.")
expect(page).toContain(
"Each existing article becomes the only version of its own article group. Each site's language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.",
)
expect(page).toContain('8 articles · Site locale en')
expect(page).toContain('3 articles · No site locale')
expect(page).toContain('5 articles · Site locale pt-BR')
expect(page).toContain('Seeded from site locale en. Confirm or change.')
expect(page).toContain('Seeded from site locale pt-BR. Confirm or change.')
expect(page).toContain('Required before you can apply.')
})
it('seeds Journal and Archive and leaves Notes empty', () => {
const selects = kit.wrapper!.findAllComponents(USelect)
const values = selects.map((select) => select.props('modelValue'))
expect(values).toEqual(['en', undefined, 'pt-BR'])
})
it('keeps the confirm button disabled until every row has a language, with the total', async () => {
expect(confirmButton().textContent?.trim()).toBe('Confirm and assign to 16 articles')
expect(confirmButton().disabled).toBe(true)
expect(text()).toContain('Counts are articles in 3 sites.')
await choose('notes', 'Language for Notes', 'en')
expect(confirmButton().disabled).toBe(false)
})
})
describe('The page sends one request for all rows', () => {
it('posts Journal en, Notes en, Archive pt-BR and shows Done with the count', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), {
[POST]: { assigned: 16, skipped: [], sites: [] },
})
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(callsTo('POST', '/v1/language/setup')).toHaveLength(1)
expect(callsTo('POST', '/v1/language/setup')[0]!.body).toEqual({
sites: [
{ key: 'journal', lang: 'en' },
{ key: 'notes', lang: 'en' },
{ key: 'archive', lang: 'pt-BR' },
],
})
})
})
describe('loc.l2.noLocale: No-locale site', () => {
it('shows the error alert and a disabled confirm button', async () => {
await open(pending(NOTES))
expect(text()).toContain('Choose a language for Notes')
expect(text()).toContain(
'Notes has no site locale, so there is nothing to start from. Choose the language its articles are written in.',
)
expect(confirmButton().disabled).toBe(true)
})
})
describe('loc.l2.empty: Site with no articles', () => {
it('shows the page as Languages with the card, the seeded field and a Save button', async () => {
await open(pending(GALLERY), {}, 'owner', 'gallery')
const page = text()
expect(page).toContain('Languages')
expect(page).toContain('Content languages for Gallery.')
expect(page).toContain('Confirm the default language')
expect(page).toContain(
'Gallery has no articles yet. New articles will be written in this language.',
)
expect(page).toContain('Default content language')
expect(page).toContain(
'Seeded from site locale en-GB. The full locale is kept. Confirm or change it.',
)
expect(kit.wrapper!.findComponent(USelect).props('modelValue')).toBe('en-GB')
expect((control('Save') as HTMLButtonElement).disabled).toBe(false)
})
it('posts the single site on Save', async () => {
await open(
pending(GALLERY),
{ [POST]: { assigned: 0, skipped: [], sites: [] } },
'owner',
'gallery',
)
await press('Save')
expect(callsTo('POST', '/v1/language/setup')[0]!.body).toEqual({
sites: [{ key: 'gallery', lang: 'en-GB' }],
})
})
})
describe('loc.l2.applying: Applying', () => {
it('shows the busy button and the alert while the POST is in flight', async () => {
const inflight = deferred<unknown>()
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: () => inflight.promise })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Assigning languages')
expect(text()).toContain(
"Articles created while this runs get the site's default language.",
)
const busy = control(/^Assigning…/) as HTMLButtonElement
expect(busy.disabled).toBe(true)
inflight.resolve({ assigned: 16, skipped: [], sites: [] })
})
})
describe('loc.l2.inProgress: Setup already running', () => {
beforeEach(async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: failWith(409, 'setup_in_progress') })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
})
it('explains it, makes the rows read-only and disables confirm', () => {
expect(text()).toContain('Setup is already running')
expect(text()).toContain(
"Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site's default language. Starting it again is safe: sites that already have a language are skipped.",
)
expect(kit.wrapper!.findAllComponents(USelect)).toHaveLength(0)
expect(confirmButton().disabled).toBe(true)
})
it('repeats GET /v1/language/setup on Check again', async () => {
await press('Check again')
expect(callsTo('GET', '/v1/language/setup')).toHaveLength(2)
})
})
describe('loc.l2.done: Done', () => {
it('shows the success alert with the count and Open Articles, and no confirm button', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), {
[POST]: { assigned: 16, skipped: [], sites: [] },
})
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Languages assigned')
expect(text()).toContain(
'16 articles now have a language. Nothing was translated, and no articles were grouped.',
)
const link = Array.from(document.querySelectorAll('a')).find(
(anchor) => anchor.textContent?.trim() === 'Open Articles',
)
expect(link?.getAttribute('href')).toBe('/article')
expect(control(/^Confirm and assign to/)).toBeUndefined()
})
})
describe('loc.l2.fail: Failed', () => {
it('keeps the choices and retries with Try again', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: WRITE_FAILS })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Nothing was changed')
expect(text()).toContain(
'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.',
)
expect(kit.wrapper!.findAllComponents(USelect).map((s) => s.props('modelValue'))).toEqual([
'en',
'en',
'pt-BR',
])
expect(confirmButton().disabled).toBe(false)
await press('Try again')
expect(callsTo('POST', '/v1/language/setup')).toHaveLength(2)
})
})
describe('Nothing pending', () => {
it('shows Done without an article count', async () => {
await open(pending())
expect(text()).toContain('Languages assigned')
expect(text()).not.toMatch(/\d+ articles? now has?\b/)
expect(text()).toContain('Nothing was translated, and no articles were grouped.')
})
it('shows the same once Check again finds nothing pending', async () => {
let reads = 0
await open(pending(JOURNAL), {
'GET /v1/language/setup': () => (reads++ === 0 ? pending(JOURNAL) : pending()),
[POST]: failWith(409, 'setup_in_progress'),
})
await press(/^Confirm and assign to/)
await press('Check again')
expect(text()).toContain('Languages assigned')
})
})
describe('Substituted seed', () => {
it('shows the seeded broader tag with the broader wording', async () => {
await open(pending(BELGIAN))
expect(kit.wrapper!.findComponent(USelect).props('modelValue')).toBe('fr')
expect(text()).toContain(
'Only the broader language French (fr) is supported. Pages and the API will say fr, not fr-BE.',
)
})
})
describe('Unknown or already complete :key', () => {
it('lists the pending sites and links back to the Languages page of a known key', async () => {
await open(pending(JOURNAL, NOTES), {}, 'owner', 'archive')
expect(text()).toContain('Journal')
const back = document.querySelector('a[href="/settings/sites/archive/languages"]')
expect(back).not.toBeNull()
})
it('falls back to Settings, Sites for an unknown key', async () => {
await open(pending(JOURNAL), {}, 'owner', 'nowhere')
expect(document.querySelector('a[href="/settings/sites/nowhere/languages"]')).toBeNull()
expect(document.querySelector('a[href="/settings/sites"]')).not.toBeNull()
})
})
describe('Route guard', () => {
it('keeps a reader out of the page and sends nothing', async () => {
await open(pending(JOURNAL), {}, 'viewer')
expect(text()).toContain("You don't have access to this page")
expect(callsTo('GET', '/v1/language/setup')).toHaveLength(0)
})
})