572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
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<typeof useApi>)
|
||
})
|
||
|
||
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<HTMLElement>(
|
||
`[role="checkbox"][aria-label="Select ${item.filename}"]`,
|
||
)!
|
||
.click()
|
||
}
|
||
await flushPromises()
|
||
document.body.querySelector<HTMLButtonElement>('[data-testid="bulk-delete"]')!.click()
|
||
await flushPromises()
|
||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[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<HTMLButtonElement>('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<HTMLButtonElement>('[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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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: '<p><img src="/static/uploadedImages/photo.png"></p>',
|
||
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<HTMLButtonElement>('[aria-label="Actions for Log in"]')!
|
||
.click()
|
||
await flushPromises()
|
||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||
.find((b) => b.textContent?.trim() === 'Delete…')!
|
||
.click()
|
||
} else
|
||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>('[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<HTMLInputElement>('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()
|
||
})
|
||
})
|