import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import ui from '@nuxt/ui/vue-plugin' import App from '@/App.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue' import { useApi, ApiError } from '@/composables/useApi' import { useSessionStore } from '@/stores/session' import { useColorMode } from '@/composables/useColorMode' import router from '@/router' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import { initializeLocale, setLocale, t } from './index' import { messages } from './messages' import { formatAbsoluteDateFromIso } from '@/lib/relativeDate' import { formatSize, pluralArticles } from '@/lib/mediaFormat' import { articleEnvelope, articleListUrl } from '@/views/__tests__/articleEnvelope' vi.mock('@/composables/useApi', async () => { const { ref } = await import('vue') return { useApi: vi.fn(), authRedirectPending: ref(false), ApiError: class extends Error { constructor( public status: number, public retryAfter?: number, public body?: string, ) { super('API failure') } }, } }) let wrapper: VueWrapper | undefined const request = vi.fn() beforeEach(() => { localStorage.clear() initializeLocale() setViewportWidth(DESKTOP_WIDTH) setActivePinia(createPinia()) useSessionStore().status = 'ready' request.mockReset().mockResolvedValue([]) vi.mocked(useApi).mockReturnValue({ request, getMe: vi.fn().mockResolvedValue(null), } as unknown as ReturnType) }) afterEach(async () => { vi.unstubAllGlobals() vi.restoreAllMocks() wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' localStorage.clear() initializeLocale() await router.push('/login') }) async function mountRoute(path: string) { const session = useSessionStore() session.session = path === '/login' ? null : { id: 1, verified: true, username: 'marta', displayName: 'Marta', email: 'marta@example.com', role: 'owner', } session.status = 'ready' await router.push(path) wrapper = mount(App, { attachTo: document.body, global: { plugins: [ui, router] }, }) await flushPromises() return wrapper } describe('Translate the entire interface', () => { it('provides nonempty equivalent interpolation parameters for every English and French key', () => { const placeholders = (text: string) => [...text.matchAll(/\{\d+\}/g)].map((match) => match[0]).sort() expect( Object.entries(messages).filter( ([english, french]) => !french.trim() || JSON.stringify(placeholders(english)) !== JSON.stringify(placeholders(french)), ), ).toEqual([]) }) it('formats French dates, decimal sizes and plural counts', () => { setLocale('fr') expect([ formatAbsoluteDateFromIso('2025-02-14T12:00:00Z'), formatSize(1572864), pluralArticles(1234), ]).toEqual(['14 févr. 2025', '1,5 Mo', '1 234 articles']) }) it('falls back to the English key for an unavailable French message', () => { setLocale('fr') expect(t('Unavailable message' as keyof typeof messages)).toBe('Unavailable message') }) }) describe('Switch language without losing work', () => { it('retranslates added and retried bulk failures while preserving filenames and retry targets', async () => { let items = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp'].map( (filename, index) => ({ id: index + 1, filename, url: `/media/${filename}`, size: 1024, content_type: 'image/jpeg', width: 100, height: 100, sha256: filename, uploaded_by: 'Marta', created_at: '2025-02-14T12:00:00Z', used_in: 0, duplicate: false, }), ) let retry = false request.mockImplementation(async (path: string, options?: RequestInit) => { if (options?.method === 'DELETE') { const id = Number(path.substring(path.lastIndexOf('/') + 1)) if (id === 3 || (id === 2 && !retry)) throw new ApiError(500) items = items.filter((item) => item.id !== id) return undefined } return { items, total: items.length, page: 1, size: 25, counts: { all: items.length, unused: items.length, duplicates: 0 }, } }) await mountRoute('/media') for (const item of items) { document.body .querySelector( `[role="checkbox"][aria-label="Select ${item.filename}"]`, )! .click() } await flushPromises() document.body.querySelector('[data-testid="bulk-delete"]')!.click() await flushPromises() Array.from(document.body.querySelectorAll('[role="alertdialog"] button')) .find((button) => button.textContent?.trim() === 'Delete 3 images')! .click() await flushPromises() const snapshots: boolean[] = [] async function captureBothWays(english: string, french: string) { for (const [language, description, label] of [ ['en', english, 'Retry'], ['fr', french, 'Réessayer'], ['en', english, 'Retry'], ] as const) { setLocale(language) await flushPromises() snapshots.push( document.body.textContent!.includes(description) && Array.from(document.body.querySelectorAll('button')).some( (button) => button.textContent?.trim() === label, ), ) } } await captureBothWays( '1 image was deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.', '1 image a été supprimée. kitchen-after.webp et kitchen-before.webp sont toujours dans votre bibliothèque. Réessayez.', ) retry = true Array.from(document.body.querySelectorAll('button')) .find((button) => button.textContent?.trim() === 'Retry')! .click() await flushPromises() await new Promise((resolve) => setTimeout(resolve, 250)) await flushPromises() await captureBothWays( '2 images were deleted. kitchen-before.webp is still in your library. Try again.', '2 images ont été supprimées. kitchen-before.webp est toujours dans votre bibliothèque. Réessayez.', ) expect({ snapshots, deleted: request.mock.calls .filter(([, options]) => options?.method === 'DELETE') .map(([path]) => path), }).toEqual({ snapshots: [true, true, true, true, true, true], deleted: ['/v1/media/1', '/v1/media/2', '/v1/media/3', '/v1/media/2', '/v1/media/3'], }) }) it.each(['fallback', 'French catalog error'])( 'updates an active confirmation %s when switching both ways', async (failure) => { wrapper = mount(ConfirmDialog, { attachTo: document.body, props: { title: 'Delete', description: 'Confirm deletion', confirmLabel: 'Delete', cancelLabel: 'Cancel', pendingLabel: 'Deleting', tone: 'danger', action: async () => { if (failure === 'French catalog error') { setLocale('fr') throw new Error(t('Something went wrong. Try again.')) } throw null }, }, global: { plugins: [ui] }, }) await flushPromises() Array.from( document.body.querySelectorAll('[role="alertdialog"] button'), ) .find((button) => button.textContent?.trim() === 'Delete')! .click() await flushPromises() setLocale('en') await flushPromises() const english = document.body.querySelector('[role="alert"]')?.textContent?.trim() setLocale('fr') await flushPromises() const french = document.body.querySelector('[role="alert"]')?.textContent?.trim() setLocale('en') await flushPromises() expect([ english, french, document.body.querySelector('[role="alert"]')?.textContent?.trim(), ]).toEqual([ 'Something went wrong. Try again.', 'Une erreur est survenue. Réessayez.', 'Something went wrong. Try again.', ]) }, ) it('switches both ways while preserving profile draft, route, session, theme and request count', async () => { await mountRoute('/profile') const input = wrapper!.find('input[autocomplete="off"]') await input.setValue('Unsaved name') const session = JSON.stringify(useSessionStore().session) const theme = useColorMode().preference const count = request.mock.calls.length setLocale('fr') await flushPromises() const french = document.body.textContent!.includes('Nom d’affichage') setLocale('en') await flushPromises() expect({ french, draft: (input.element as HTMLInputElement).value, path: router.currentRoute.value.path, session: JSON.stringify(useSessionStore().session), theme: useColorMode().preference, requests: request.mock.calls.length, english: document.body.textContent!.includes('Display name'), }).toEqual({ french: true, draft: 'Unsaved name', path: '/profile', session, theme, requests: count, english: true, }) }) it('localizes profile save failure and retains the user draft', async () => { setLocale('fr') await mountRoute('/profile') await wrapper!.find('input[autocomplete="off"]').setValue('Name stays') request.mockRejectedValueOnce(new ApiError(500, undefined, 'private backend error')) await wrapper!.find('form').trigger('submit') await flushPromises() expect({ alert: document.body.querySelector('[role="alert"]')?.textContent?.trim(), draft: (wrapper!.find('input[autocomplete="off"]').element as HTMLInputElement).value, }).toEqual({ alert: 'Impossible d’enregistrer votre nom d’affichage. Votre texte est conservé. Réessayez dans un instant.', draft: 'Name stays', }) }) it('updates an already-visible authentication alert and its validation', async () => { await mountRoute('/login?reason=expired') await wrapper!.find('form').trigger('submit') setLocale('fr') await flushPromises() expect([ document.body.textContent!.includes('Votre session a expiré'), document.body.textContent!.includes('Utilisez au moins 3 caractères.'), document.body.textContent!.includes('Saisissez votre mot de passe.'), ]).toEqual([true, true, true]) }) }) describe('Hardener localization outcomes', () => { it.each([false, true])( 'updates not-found breadcrumbs round-trip with authenticated=%s', async (authenticated) => { const { default: NotFoundView } = await import('@/views/NotFoundView.vue') useSessionStore().session = authenticated ? { verified: true, username: 'marta', displayName: '' } : null wrapper = mount(NotFoundView, { attachTo: document.body, global: { plugins: [ui, router] }, }) await flushPromises() const snapshots = [] for (const language of ['en', 'fr', 'en'] as const) { setLocale(language) await flushPromises() snapshots.push( document.body.querySelector('[role="alert"] nav')?.textContent?.trim(), ) } expect(snapshots).toEqual(['Not found', 'Introuvable', 'Not found']) }, ) it('updates the default error breadcrumb round-trip', async () => { const { default: ErrorView } = await import('@/components/ErrorView.vue') wrapper = mount(ErrorView, { attachTo: document.body, props: { kind: 'error' }, global: { plugins: [ui, router] }, }) const snapshots = [] for (const language of ['en', 'fr', 'en'] as const) { setLocale(language) await flushPromises() snapshots.push(wrapper.find('nav').text()) } expect(snapshots).toEqual(['Home', 'Accueil', 'Home']) }) it('focuses the invalid username in French', async () => { setLocale('fr') await mountRoute('/login') await wrapper!.find('form').trigger('submit') await flushPromises() expect(document.activeElement).toBe(wrapper!.find('input[autocomplete="username"]').element) }) it('focuses article search after clearing French no-results', async () => { const article = { id: 12, title: 'Data title', body: '', is_online: false, created_at: '2025-02-14T12:00:00Z', updated_at: '2025-02-14T12:00:00Z', } request.mockImplementation(async (url: string) => articleEnvelope([article], { q: new URL(url, 'http://localhost').searchParams.get('q') ?? '', }), ) setLocale('fr') await mountRoute('/article?q=absent') Array.from(document.body.querySelectorAll('button')) .find((b) => b.textContent?.trim() === 'Effacer la recherche')! .click() await flushPromises() expect([ document.activeElement === document.body.querySelector('input[type="search"]'), router.currentRoute.value.query.q, ]).toEqual([true, undefined]) }) it('copies the unchanged integration URL with French feedback and protocol examples', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) setLocale('fr') useSessionStore().session = { verified: true, username: 'marta', displayName: '' } const { default: IntegrationView } = await import('@/views/IntegrationView.vue') wrapper = mount(IntegrationView, { attachTo: document.body, global: { plugins: [ui, router] }, }) await flushPromises() Array.from(document.body.querySelectorAll('button')) .find((b) => b.textContent?.trim() === 'Copier')! .click() await flushPromises() expect([ writeText.mock.calls[0]?.[0], document.body.textContent?.includes('Copié'), document.body.textContent?.includes('/v1/article/'), document.body.textContent?.includes('GET'), ]).toEqual([window.location.origin, true, true, true]) document.body .querySelector('button[aria-label^="GET /v1/article/. "]')! .click() await vi.waitFor(() => expect(document.body.textContent).toContain( `const res = await fetch('${window.location.origin}/v1/article/')`, ), ) vi.unstubAllGlobals() }) it.each(['/article', '/article/12/edit'])( 'updates already-open delete descriptions round-trip from %s preserving title and image sentence', async (path) => { const article = { id: 12, title: 'Log in', body: '

', is_online: false, images: [], created_at: '2025-02-14T12:00:00Z', updated_at: '2025-02-14T12:00:00Z', } request.mockImplementation(async (url: string) => url === articleListUrl() ? articleEnvelope([article]) : article, ) await mountRoute(path) if (path === '/article') { document.body .querySelector('[aria-label="Actions for Log in"]')! .click() await flushPromises() Array.from(document.body.querySelectorAll('[role="menuitem"]')) .find((b) => b.textContent?.trim() === 'Delete…')! .click() } else Array.from(document.body.querySelectorAll('button')) .find((b) => b.textContent?.trim() === 'Delete')! .click() await flushPromises() const snapshots = [] for (const language of ['en', 'fr', 'en'] as const) { setLocale(language) await flushPromises() snapshots.push(document.body.querySelector('[role="alertdialog"]')?.textContent) } expect( snapshots.map((text) => [ text?.includes('Log in'), text?.includes('will be deleted'), text?.includes('sera supprimé'), ]), ).toEqual([ [true, true, false], [true, false, true], [true, true, false], ]) expect(snapshots[0]).toContain('Its images stay in your library.') expect(snapshots[1]).toContain('Ses images restent dans votre bibliothèque.') Array.from( document.body.querySelectorAll('[role="alertdialog"] button'), ) .find((b) => b.textContent?.trim() === 'Cancel')! .click() await flushPromises() }, ) it('translates the editor mobile heading', async () => { request.mockResolvedValue({ id: 12, title: 'Data title', body: '', images: [], is_online: false, created_at: '2025-02-14T12:00:00Z', updated_at: '2025-02-14T12:00:00Z', }) setViewportWidth(390) setLocale('fr') await mountRoute('/article/12/edit') expect(document.body.textContent).toContain('Modifier l’article') }) it('updates a visible 429 upload alert round-trip', async () => { let upload: LimitedUpload | undefined const captureUpload = (instance: LimitedUpload) => { upload = instance } class LimitedUpload extends EventTarget { upload = new EventTarget() status = 0 responseText = '' withCredentials = false open() {} send() { captureUpload(this) } abort() {} getResponseHeader() { return '42' } respond() { this.status = 429 this.dispatchEvent(new Event('load')) } } vi.stubGlobal('XMLHttpRequest', LimitedUpload) vi.stubGlobal('crypto', { subtle: { digest: async () => new Uint8Array(32).buffer } }) request.mockResolvedValue({ items: [ { id: 1, filename: 'existing.jpg', url: '/media/existing.jpg', content_type: 'image/jpeg', size: 1024, width: 100, height: 100, created_at: '2025-02-14T12:00:00Z', used_in: 0, sha256: 'abcdef', uploaded_by: 'Marta', duplicate: false, }, ], total: 1, page: 1, size: 25, counts: { all: 1, unused: 1, duplicates: 0 }, }) const listing = await request() request.mockImplementation(async (path: string) => path.includes('?hash=') ? { items: [], total: 0, page: 1, size: 25, counts: { all: 0, unused: 0, duplicates: 0 }, } : listing, ) await mountRoute('/media') const input = document.body.querySelector('input[type="file"]')! Object.defineProperty(input, 'files', { value: [new File(['photo'], 'photo.jpg', { type: 'image/jpeg' })], configurable: true, }) input.dispatchEvent(new Event('change', { bubbles: true })) await vi.waitFor(() => expect(upload).toBeDefined()) upload!.respond() await flushPromises() const snapshots = [] for (const language of ['en', 'fr', 'en'] as const) { setLocale(language) await flushPromises() snapshots.push( Array.from(document.body.querySelectorAll('[role="status"]')).some( (e) => e.textContent?.includes( language === 'fr' ? 'Trop d’importations' : 'Too many uploads', ) && e.textContent.includes( language === 'fr' ? 'Pour votre sécurité, patientez avant d’importer à nouveau.' : 'For your security, wait a moment before uploading again.', ), ), ) } expect(snapshots).toEqual([true, true, true]) vi.unstubAllGlobals() }) })