import { afterEach, beforeEach, 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 App from '@/App.vue' import { useSessionStore, type Session } from '@/stores/session' import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) let toastAdd: ReturnType let overlayMock: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd }), useOverlay: () => overlayMock, } }) function createOverlayMock() { let props: Record | null = null let resolveOpen: ((value: unknown) => void) | null = null const create = vi.fn((_component: unknown, options: { props: Record }) => { props = options.props return { open: () => new Promise((resolve) => { resolveOpen = resolve }), } }) return { create, getProps: () => props, resolveOpen: (value: unknown) => resolveOpen?.(value), } } const MARTA: Session = { id: 1, verified: true, username: 'marta', email: 'marta@renew.example', displayName: '', } const DISPLAY_NAME_PATH = '/v1/user/me' const RESET_PATH = '/v1/auth/password-reset' const LOGOUT_PATH = '/v1/auth/logout' const LIMIT_BODY = 'Display name must be 60 characters or fewer.' const SAVE_FAILED_COPY = "Couldn't save your display name. Your text is still here. Try again in a moment." type Handler = (init?: RequestInit) => Promise interface Mounted { wrapper: VueWrapper request: ReturnType getMe: ReturnType } function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } interface MountOptions { session?: Session | null status?: 'idle' | 'ready' getMe?: () => Promise handlers?: Record path?: string } let mountedWrapper: VueWrapper | null = null async function mountApp(options: MountOptions = {}): Promise { const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options setViewportWidth(DESKTOP_WIDTH) setActivePinia(createPinia()) const store = useSessionStore() store.session = session ? { ...session } : null store.status = status const request = vi.fn((requestPath: string, init?: RequestInit) => { const key = `${init?.method ?? 'GET'} ${requestPath}` const handler = handlers[key] if (handler) { return handler(init) } if (requestPath === LOGOUT_PATH) { return Promise.resolve(undefined) } return Promise.resolve([]) }) const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA }))) vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType) await router.push(path) const wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) mountedWrapper = wrapper await flushPromises() return { wrapper, request, getMe } } function displayNameField() { return document.body.querySelector('input[placeholder="e.g. Marta Alves"]') as HTMLInputElement } async function typeInto(value: string) { const input = displayNameField() input.value = value input.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() } function pressEnter() { displayNameField().dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ) } function buttonByText(pattern: RegExp) { return Array.from(document.body.querySelectorAll('button')).find((button) => pattern.test(button.textContent?.trim() ?? ''), ) as HTMLButtonElement | undefined } function saveButton() { return buttonByText(/^(Save|Saving…)$/)! } function resetButton() { return buttonByText(/^(Send reset link|Sending…|Send again)$/)! } function byTestId(id: string) { return document.body.querySelector(`[data-testid="${id}"]`) as HTMLElement | null } function counter() { return byTestId('display-name-counter')! } function footer() { return byTestId('display-name-status')! } function isEnabled(button: HTMLButtonElement) { return !button.disabled } async function clickSave() { saveButton().click() await flushPromises() } async function clickReset() { resetButton().click() await flushPromises() } function patchBodies(request: ReturnType) { return request.mock.calls .filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH') .map(([, init]) => JSON.parse((init as RequestInit).body as string)) } function callsTo(request: ReturnType, path: string, method?: string) { return request.mock.calls.filter( ([requestPath, init]) => requestPath === path && (method === undefined || (init as RequestInit)?.method === method), ) } function chip() { return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement } function activate(element: HTMLElement) { element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) element.dispatchEvent(new MouseEvent('click', { bubbles: true })) } function savedResponse(displayName: string) { return Promise.resolve({ id: 1, username: 'marta', display_name: displayName }) } const NETWORK_ERROR = () => Promise.reject(new NetworkError()) const SERVER_ERROR = () => Promise.reject(new ApiError(500)) beforeEach(() => { toastAdd = vi.fn() overlayMock = createOverlayMock() localStorage.clear() authRedirectPending.value = false }) afterEach(async () => { vi.useRealTimers() mountedWrapper?.unmount() mountedWrapper = null document.body.innerHTML = '' await router.push('/login') }) describe('Route guards for /profile', () => { it('renders ProfilePage for a resolved, verified session', async () => { await mountApp() expect(router.currentRoute.value.path).toBe('/profile') expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.') }) it('redirects a resolved, unverified session to /register/pending', async () => { await mountApp({ session: { ...MARTA, verified: false } }) await flushPromises() expect(router.currentRoute.value.path).toBe('/register/pending') }) it('redirects a 401 from /auth/me to /login?redirect=/profile&reason=expired', async () => { await mountApp({ session: null, status: 'idle', getMe: () => Promise.reject(new ApiError(401)), }) await flushPromises() expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired') }) }) describe('Default: the usual first visit with no display name', () => { it('shows the header, the empty field, the account details and the reset button', async () => { const { request } = await mountApp() const text = document.body.textContent ?? '' expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain( 'Profile', ) expect(document.querySelector('h1')?.textContent?.trim()).toBe('Profile') expect(text).toContain('Your account on this RenewCMS workspace.') expect(displayNameField().value).toBe('') expect(displayNameField().getAttribute('placeholder')).toBe('e.g. Marta Alves') expect(counter().textContent?.trim()).toBe('0 / 60') expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.') expect(isEnabled(saveButton())).toBe(false) expect(text).toContain('Display name (optional)') const accountInputs = Array.from( document.body.querySelectorAll('input:disabled'), ) expect(accountInputs.map((input) => input.value)).toEqual([ 'marta', 'marta@renew.example', '••••••••••••', ]) expect(text).toContain( "To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.", ) expect(resetButton().textContent?.trim()).toBe('Send reset link') expect(byTestId('reset-note')).toBeNull() expect(request).not.toHaveBeenCalled() }) it('marks none of the seven navigation items as the current page', async () => { await mountApp() const links = document.body.querySelectorAll('nav[aria-label="Main"] a') expect(links).toHaveLength(7) links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull()) }) it('places the three cards in order Display name, Account, Password', async () => { await mountApp() const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) => el.textContent?.trim(), ) expect(titles).toEqual(['Display name', 'Account', 'Password']) }) }) describe('Loading: opened by URL before /auth/me has returned', () => { it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => { const pending = deferred() await mountApp({ session: null, status: 'idle', getMe: () => pending.promise, }) expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain( 'Profile', ) expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.') const busy = document.body.querySelector('[aria-busy="true"]')! expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3) expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull() expect(displayNameField()).toBeNull() pending.resolve({ ...MARTA }) await flushPromises() expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(displayNameField()).not.toBeNull() expect(counter().textContent?.trim()).toBe('0 / 60') }) }) describe('Opened from the menu, the cached user renders at once', () => { it('navigates from /home through the account menu with no skeleton and no second /auth/me', async () => { const { getMe } = await mountApp({ path: '/home' }) chip().click() await flushPromises() const profileItem = Array.from( document.body.querySelectorAll('[role="menuitem"]'), ).find((item) => item.textContent?.trim() === 'Profile')! activate(profileItem) await flushPromises() expect(router.currentRoute.value.path).toBe('/profile') expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(displayNameField()).not.toBeNull() expect(getMe).not.toHaveBeenCalled() }) }) describe('Editing: typing turns Save on', () => { it('shows the count, Unsaved changes in the warning color and an enabled Save, sending nothing', async () => { const { request } = await mountApp() await typeInto('Marta Alves') expect(counter().textContent?.trim()).toBe('11 / 60') expect(footer().textContent?.trim()).toBe('Unsaved changes') expect(footer().classList.contains('text-warning')).toBe(true) expect(isEnabled(saveButton())).toBe(true) expect(request).not.toHaveBeenCalled() }) }) describe('The counter counts Unicode characters after trimming', () => { it.each([ ['"Marta Alves"', 'Marta Alves', 11, 'text-muted', true], ['" Marta Alves "', ' Marta Alves ', 11, 'text-muted', true], ['60 repetitions of "a"', 'a'.repeat(60), 60, 'text-muted', true], ['61 repetitions of "a"', 'a'.repeat(61), 61, 'text-error', false], ['60 repetitions of an emoji', '😀'.repeat(60), 60, 'text-muted', true], ['61 repetitions of an emoji', '😀'.repeat(61), 61, 'text-error', false], ['"Cafe" followed by U+0301', 'Café', 5, 'text-muted', true], ['5 spaces, 60 "a", 5 spaces', ` ${'a'.repeat(60)} `, 60, 'text-muted', true], ])('%s counts %i, %s, Save enabled: %s', async (_label, value, count, colorClass, enabled) => { await mountApp() await typeInto(value) expect(counter().textContent?.trim()).toBe(`${count} / 60`) expect(counter().classList.contains(colorClass)).toBe(true) expect(isEnabled(saveButton())).toBe(enabled) }) }) describe('Too long: over 60 characters shows a field error and keeps Save off', () => { it('shows the error, ignores Enter and recovers once shortened', async () => { const { request } = await mountApp() await typeInto('Marta Alves, Head of Renovations, Brussels and Wallonia office') expect(counter().textContent?.trim()).toBe('62 / 60') expect(counter().classList.contains('text-error')).toBe(true) const error = byTestId('display-name-error')! expect(error.textContent).toContain('Use 60 characters or fewer.') expect(error.querySelector('svg, [class*="i-lucide-circle-alert"]')).not.toBeNull() expect(displayNameField().getAttribute('aria-invalid')).toBe('true') expect(footer().textContent?.trim()).toBe('Unsaved changes') expect(footer().classList.contains('text-warning')).toBe(true) expect(isEnabled(saveButton())).toBe(false) pressEnter() await flushPromises() expect(patchBodies(request)).toHaveLength(0) await typeInto('Marta Alves') expect(byTestId('display-name-error')).toBeNull() expect(counter().textContent?.trim()).toBe('11 / 60') expect(isEnabled(saveButton())).toBe(true) }) }) describe('Dirty is measured on the trimmed draft against the saved name', () => { it.each([ ['', ' ', false, 'You appear as “marta”, your username.'], ['Marta Alves', 'Marta Alves', false, 'You appear as “Marta Alves”.'], ['Marta Alves', 'Marta Alves ', false, 'You appear as “Marta Alves”.'], ['Marta Alves', '', true, 'Unsaved changes'], ['Marta Alves', 'Marta Alves', true, 'Unsaved changes'], ])( 'saved %j and typed %j gives Save enabled: %s and %s', async (saved, typed, enabled, text) => { await mountApp({ session: { ...MARTA, displayName: saved } }) await typeInto(typed) expect(isEnabled(saveButton())).toBe(enabled) expect(footer().textContent?.trim()).toBe(text) }, ) }) describe('Enter saves when Save is enabled', () => { it('sends PATCH /v1/user/me once with the trimmed display name', async () => { const { request } = await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') }, }) await typeInto('Marta Alves') pressEnter() await flushPromises() expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }]) }) it('does nothing when the draft is not dirty', async () => { const { request } = await mountApp() pressEnter() await flushPromises() expect(patchBodies(request)).toHaveLength(0) }) }) describe('Saving: the field is disabled and a second submit is ignored', () => { it('sends the trimmed value, disables and dims the field, and ignores Enter and a second click', async () => { const pending = deferred() const { request } = await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => pending.promise }, }) await typeInto(' Marta Alves ') await clickSave() expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }]) expect(displayNameField().disabled).toBe(true) expect(saveButton().textContent?.trim()).toBe('Saving…') expect(saveButton().querySelector('[class*="i-lucide-loader-circle"], svg')).not.toBeNull() expect(footer().textContent?.trim()).not.toBe('Unsaved changes') pressEnter() saveButton().click() await flushPromises() expect(patchBodies(request)).toHaveLength(1) }) }) describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => { it('replaces the session name, syncs the field, and raises the success toast', async () => { const { request } = await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') }, }) await typeInto('Marta Alves') await clickSave() expect(useSessionStore().session?.displayName).toBe('Marta Alves') expect(chip().textContent).toContain('Marta Alves') expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA') expect(displayNameField().value).toBe('Marta Alves') expect(displayNameField().disabled).toBe(false) expect(counter().textContent?.trim()).toBe('11 / 60') expect(footer().textContent?.trim()).toBe('You appear as “Marta Alves”.') expect(isEnabled(saveButton())).toBe(false) expect(toastAdd).toHaveBeenCalledWith({ id: 'display-name-saved', color: 'success', icon: 'i-lucide-circle-check', title: 'Display name saved', description: 'You now appear as Marta Alves across RenewCMS and in emails.', }) expect(request).toHaveBeenCalledTimes(1) }) it('opens no leave confirmation when navigating away afterwards', async () => { await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') }, }) await typeInto('Marta Alves') await clickSave() await router.push('/users') await flushPromises() expect(overlayMock.create).not.toHaveBeenCalled() expect(router.currentRoute.value.path).toBe('/users') }) }) describe("The server's value wins over what was typed", () => { it('uses the response display_name in the field, the chip and the toast', async () => { await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') }, }) await typeInto(' Marta Alves ') await clickSave() expect(displayNameField().value).toBe('Marta Alves') expect(chip().textContent).toContain('Marta Alves') expect(toastAdd.mock.calls[0]![0].description).toContain('Marta Alves across RenewCMS') }) }) describe('Clearing the display name goes back to the username', () => { it('sends an empty display_name and shows the username with the removed toast', async () => { const { request } = await mountApp({ session: { ...MARTA, displayName: 'Marta Alves' }, handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') }, }) await typeInto('') await clickSave() expect(patchBodies(request)).toEqual([{ display_name: '' }]) expect(chip().textContent).toContain('marta') expect(chip().textContent).not.toContain('Marta Alves') expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA') expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.') expect(toastAdd).toHaveBeenCalledWith({ id: 'display-name-saved', color: 'success', icon: 'i-lucide-circle-check', title: 'Display name removed', description: 'You now appear as marta, your username.', }) }) }) describe('Save failed: a 500 or a network failure keeps the text and offers a retry', () => { it.each([ ['a 500 response', SERVER_ERROR], ['a network failure', NETWORK_ERROR], ])( 'after %s shows the alert, keeps the value and retries with the same body', async (_label, failure) => { const attempts = [failure, () => savedResponse('Marta Alves')] const { request } = await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() }, }) await typeInto('Marta Alves') await clickSave() const alert = document.body.querySelector('[role="alert"]')! expect(alert.textContent).toContain(SAVE_FAILED_COPY) expect(displayNameField().value).toBe('Marta Alves') expect(displayNameField().disabled).toBe(false) expect(footer().textContent?.trim()).toBe('Unsaved changes') expect(isEnabled(saveButton())).toBe(true) expect(useSessionStore().session?.displayName).toBe('') expect(toastAdd).not.toHaveBeenCalled() await clickSave() expect(patchBodies(request)).toEqual([ { display_name: 'Marta Alves' }, { display_name: 'Marta Alves' }, ]) expect(document.body.querySelector('[role="alert"]')).toBeNull() }, ) it('hides the alert while the retry is in flight', async () => { const retry = deferred() const attempts = [SERVER_ERROR, () => retry.promise] await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() }, }) await typeInto('Marta Alves') await clickSave() expect(document.body.querySelector('[role="alert"]')).not.toBeNull() await clickSave() expect(document.body.querySelector('[role="alert"]')).toBeNull() expect(saveButton().textContent?.trim()).toBe('Saving…') }) }) describe('A 400 from the API', () => { it('shows the field error and disables Save for the display-name message', async () => { await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => Promise.reject(new ApiError(400, undefined, LIMIT_BODY)), }, }) await typeInto('Marta Alves') await clickSave() expect(byTestId('display-name-error')?.textContent).toContain('Use 60 characters or fewer.') expect(isEnabled(saveButton())).toBe(false) expect(document.body.querySelector('[role="alert"]')).toBeNull() }) it('shows the Save failed alert and keeps the text for any other 400', async () => { await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => Promise.reject(new ApiError(400, undefined, 'invalid request body')), }, }) await typeInto('Marta Alves') await clickSave() expect(document.body.querySelector('[role="alert"]')?.textContent).toContain( SAVE_FAILED_COPY, ) expect(displayNameField().value).toBe('Marta Alves') expect(byTestId('display-name-error')).toBeNull() }) }) describe('Leaving with unsaved changes asks for confirmation', () => { it('opens a neutral ConfirmDialog, stays on Keep editing and leaves on Discard changes', async () => { await mountApp() await typeInto('Marta Alves') const first = router.push('/users') await flushPromises() const props = overlayMock.getProps()! expect(props.title).toBe('Leave without saving?') expect(props.description).toBe("Your display name change hasn't been saved.") expect(props.confirmLabel).toBe('Keep editing') expect(props.cancelLabel).toBe('Discard changes') expect(props.tone).toBe('neutral') overlayMock.resolveOpen(true) await first expect(router.currentRoute.value.path).toBe('/profile') expect(displayNameField().value).toBe('Marta Alves') const second = router.push('/users') await flushPromises() overlayMock.resolveOpen(false) await second expect(router.currentRoute.value.path).toBe('/users') expect(useSessionStore().session?.displayName).toBe('') }) it('opens nothing when nothing is unsaved', async () => { await mountApp() await router.push('/users') await flushPromises() expect(overlayMock.create).not.toHaveBeenCalled() expect(router.currentRoute.value.path).toBe('/users') }) it('opens the dialog when Log out is used with a dirty draft', async () => { const { request } = await mountApp() await typeInto('Marta Alves') chip().click() await flushPromises() const logOut = Array.from( document.body.querySelectorAll('[role="menuitem"]'), ).find((item) => item.textContent?.trim() === 'Log out')! activate(logOut) await flushPromises() expect(callsTo(request, LOGOUT_PATH, 'POST')).toHaveLength(1) expect(overlayMock.create).toHaveBeenCalledTimes(1) }) it('redirects without the dialog when a 401 while saving is handled globally', async () => { await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => { authRedirectPending.value = true router.push({ path: '/login', query: { redirect: '/profile', reason: 'expired' }, }) return Promise.reject(new ApiError(401)) }, }, }) await typeInto('Marta Alves') await clickSave() await flushPromises() expect(overlayMock.create).not.toHaveBeenCalled() expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired') expect(document.body.textContent).not.toContain(SAVE_FAILED_COPY) }) }) describe('The Home greeting follows a display name saved on /profile', () => { it('shows the saved name on /home without a reload', async () => { vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9) await mountApp({ handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') }, }) await typeInto('Marta Alves') await clickSave() await router.push('/home') await flushPromises() expect(document.body.querySelector('[data-testid="greeting"]')?.textContent?.trim()).toBe( 'Good morning, Marta Alves', ) expect(chip().textContent).toContain('Marta Alves') vi.restoreAllMocks() }) }) describe('Reset: sending, sent and Send again', () => { it('posts the session email with no confirmation and shows Sending… with no note', async () => { const pending = deferred() const { request } = await mountApp({ session: { ...MARTA, displayName: 'Marta Alves' }, handlers: { [`POST ${RESET_PATH}`]: () => pending.promise }, }) await clickReset() const [call] = callsTo(request, RESET_PATH, 'POST') expect(JSON.parse((call![1] as RequestInit).body as string)).toEqual({ email: 'marta@renew.example', }) expect(resetButton().textContent?.trim()).toBe('Sending…') expect(resetButton().disabled).toBe(true) expect(byTestId('reset-note')).toBeNull() expect(chip().textContent).toContain('Marta Alves') }) it('shows the sent note, sends again with the note hidden meanwhile, and resets on remount', async () => { const second = deferred() const responses = [() => Promise.resolve({ message: 'ok' }), () => second.promise] const { request } = await mountApp({ handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() }, }) await clickReset() expect(resetButton().textContent?.trim()).toBe('Send again') expect(resetButton().disabled).toBe(false) const note = byTestId('reset-note')! expect(note.getAttribute('role')).toBe('status') expect(note.textContent).toContain('Reset link sent') expect(note.textContent).toContain( 'Check marta@renew.example. The link works once, within 1 hour.', ) await clickReset() expect(callsTo(request, RESET_PATH, 'POST')).toHaveLength(2) expect(byTestId('reset-note')).toBeNull() second.resolve({ message: 'ok' }) await flushPromises() expect(byTestId('reset-note')?.textContent).toContain('Reset link sent') await router.push('/home') await flushPromises() await router.push('/profile') await flushPromises() expect(resetButton().textContent?.trim()).toBe('Send reset link') expect(byTestId('reset-note')).toBeNull() }) }) describe('Reset: too many requests disables the button for the Retry-After window', () => { it.each([ ['a Retry-After of 42', 42, 42], ['a Retry-After of 300', 300, 300], ['no Retry-After header', undefined, 300], ['a Retry-After of 0', 0, 300], ])( 'with %s the button is enabled again after %i seconds', async (_label, retryAfter, seconds) => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) await mountApp({ handlers: { [`POST ${RESET_PATH}`]: () => Promise.reject(new ApiError(429, retryAfter)), }, }) await clickReset() const note = byTestId('reset-note')! expect(note.getAttribute('role')).toBe('status') expect(note.textContent).toContain('Too many requests') expect(note.textContent).toContain( "You've asked for several links recently. Wait a few minutes, then try again.", ) expect(resetButton().textContent?.trim()).toBe('Send reset link') expect(resetButton().disabled).toBe(true) vi.advanceTimersByTime((seconds - 1) * 1000) await flushPromises() expect(resetButton().disabled).toBe(true) vi.advanceTimersByTime(1000) await flushPromises() expect(resetButton().disabled).toBe(false) expect(byTestId('reset-note')).toBeNull() }, ) }) describe('Reset: failed leaves the button pressable', () => { it.each([ ['a 500 response', SERVER_ERROR], ['a network failure', NETWORK_ERROR], ])( 'after %s shows the error note and recovers on a successful retry', async (_label, failure) => { const responses = [failure, () => Promise.resolve({ message: 'ok' })] await mountApp({ handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() }, }) await clickReset() const note = byTestId('reset-note')! expect(note.getAttribute('role')).toBe('status') expect(note.textContent).toContain("Couldn't send the link") expect(note.textContent).toContain('Check your connection and try again.') expect(resetButton().textContent?.trim()).toBe('Send reset link') expect(resetButton().disabled).toBe(false) await clickReset() expect(byTestId('reset-note')?.textContent).toContain('Reset link sent') expect(resetButton().textContent?.trim()).toBe('Send again') }, ) })