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 { useSessionStore, type Session } from '@/stores/session' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import EmailConfirmView from '../EmailConfirmView.vue' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd }) } }) const CONFIRM_PATH = '/v1/auth/email/confirm' const LOGIN_PATH = '/v1/auth/login' const NEW = 'marta.alves@atelier-renew.example' const LOGGED_IN: Session = { verified: true, username: 'marta', email: 'marta@renew.example', displayName: '', } const SERVER_COPY = 'Something went wrong on our side. Nothing was changed. Try again in a moment.' type Handler = (init?: RequestInit) => Promise let mounted: VueWrapper | null = null async function mountLanding( path: string, handlers: Record = {}, session: Session | null = null, ) { setActivePinia(createPinia()) if (session) { const store = useSessionStore() store.session = { ...session } store.status = 'ready' } const request = vi.fn((requestPath: string, init?: RequestInit) => { const handler = handlers[`${init?.method ?? 'GET'} ${requestPath}`] if (!handler) { throw new Error(`Unexpected request: ${init?.method ?? 'GET'} ${requestPath}`) } return handler(init) }) const getMe = vi.fn(() => Promise.resolve({ verified: true, username: 'marta', email: NEW, displayName: '' }), ) vi.mocked(useApi).mockReturnValue({ request, getMe } as unknown as ReturnType) await router.push(path) mounted = mount(EmailConfirmView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return { wrapper: mounted, request, getMe } } function confirmCalls(request: ReturnType) { return request.mock.calls.filter(([path]) => path === CONFIRM_PATH) } function button(wrapper: VueWrapper, label: string) { return wrapper.findAll('button').find((candidate) => candidate.text() === label) } beforeEach(() => { toastAdd = vi.fn() }) afterEach(async () => { vi.useRealTimers() mounted?.unmount() mounted = null document.body.innerHTML = '' await router.push('/login') }) describe('Confirming: the code is sent on load', () => { it('shows the spinner copy and sends the code once', async () => { const { wrapper, request } = await mountLanding('/account/email/confirm?c=abc123', { [`POST ${CONFIRM_PATH}`]: () => new Promise(() => {}), }) expect(wrapper.find('[role="status"]').text()).toContain('Confirming your new email…') expect(confirmCalls(request)).toHaveLength(1) const [, init] = confirmCalls(request)[0]! expect(JSON.parse((init as RequestInit).body as string)).toEqual({ code: 'abc123' }) }) }) describe('Success: 200 replaces the session email, toasts and goes to the profile', () => { it('shows the new address, raises the toast, updates the session and navigates after 800 ms', async () => { const { wrapper } = await mountLanding( '/account/email/confirm?c=abc123', { [`POST ${CONFIRM_PATH}`]: () => Promise.resolve({ email: NEW }) }, LOGGED_IN, ) expect(wrapper.find('h1').text()).toBe('Your email address has changed') expect(wrapper.text()).toContain( `Your account now uses ${NEW}. Use it the next time you log in.`, ) expect(button(wrapper, 'Go to profile')).toBeDefined() expect(toastAdd).toHaveBeenCalledWith({ color: 'success', icon: 'i-lucide-circle-check', title: 'Email address changed', description: `Your account now uses ${NEW}.`, }) expect(useSessionStore().session?.email).toBe(NEW) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/profile'), { timeout: 2000, }) }) }) describe('Invalid or expired (403)', () => { it('shows the dead-link copy and the profile action', async () => { const { wrapper } = await mountLanding( '/account/email/confirm?c=abc123', { [`POST ${CONFIRM_PATH}`]: () => Promise.reject(new ApiError(403, undefined, 'invalid_email_change_code')), }, LOGGED_IN, ) expect(wrapper.find('h1').text()).toBe("This link doesn't work") expect(wrapper.text()).toContain( 'It may have expired (links last 2 hours), been cancelled or already been used.', ) expect(wrapper.text()).toContain('Request a new link from your profile.') await button(wrapper, 'Go to profile')!.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/profile') }) }) describe('Address unavailable (409)', () => { it('shows one message whether the address is taken or rejected', async () => { const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', { [`POST ${CONFIRM_PATH}`]: () => Promise.reject(new ApiError(409, undefined, 'email_unavailable')), }) expect(wrapper.find('h1').text()).toBe("We couldn't change your email address") expect(wrapper.text()).toContain( "This address can't be used for your account. Go back to your profile and try a different one.", ) expect(button(wrapper, 'Go to profile')).toBeDefined() }) }) describe('No session (401): the inline log-in', () => { it('shows the log-in form with the confirm copy', async () => { const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', { [`POST ${CONFIRM_PATH}`]: () => Promise.reject(new ApiError(401)), }) expect(wrapper.find('h1').text()).toBe('Log in to confirm your new email') expect(wrapper.text()).toContain( "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.", ) expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and confirm') }) it('logs in, then sends the confirmation once with the same code and reaches Success', async () => { const confirm = vi.fn() confirm.mockRejectedValueOnce(new ApiError(401)) confirm.mockResolvedValueOnce({ email: NEW }) const { wrapper, request } = await mountLanding('/account/email/confirm?c=abc123', { [`POST ${CONFIRM_PATH}`]: confirm, [`POST ${LOGIN_PATH}`]: () => Promise.resolve({}), }) await wrapper.find('input[placeholder="Your username"]').setValue('marta') await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026') await wrapper.find('form').trigger('submit') await flushPromises() expect(confirm).toHaveBeenCalledTimes(2) expect(wrapper.find('h1').text()).toBe('Your email address has changed') const bodies = confirmCalls(request).map(([, init]) => (init as RequestInit).body) expect(bodies).toEqual([ JSON.stringify({ code: 'abc123' }), JSON.stringify({ code: 'abc123' }), ]) expect(router.currentRoute.value.fullPath).toBe('/account/email/confirm') const [, loginInit] = request.mock.calls.find(([path]) => path === LOGIN_PATH)! expect((loginInit as RequestInit).body).not.toContain('abc123') }) }) describe('Missing code', () => { it('sends no request and offers Go to log in', async () => { const { wrapper, request } = await mountLanding('/account/email/confirm') expect(wrapper.find('h1').text()).toBe('This link is incomplete') expect(wrapper.text()).toContain( 'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.', ) expect(request).not.toHaveBeenCalled() await button(wrapper, 'Go to log in')!.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/login') }) }) describe('Server error', () => { it.each([ ['a 500', () => Promise.reject(new ApiError(500))], ['a network failure', () => Promise.reject(new NetworkError())], ])('after %s offers Try again, which sends the POST again', async (_label, failure) => { const confirm = vi.fn() confirm.mockImplementationOnce(failure) confirm.mockResolvedValueOnce({ email: NEW }) const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', { [`POST ${CONFIRM_PATH}`]: confirm, }) expect(wrapper.find('h1').text()).toBe("Couldn't confirm the change") expect(wrapper.text()).toContain(SERVER_COPY) await button(wrapper, 'Try again')!.trigger('click') await flushPromises() expect(confirm).toHaveBeenCalledTimes(2) expect(wrapper.find('h1').text()).toBe('Your email address has changed') }) }) describe('The code never stays in the URL', () => { it('removes ?c= from the address while still confirming with it, and Try again reuses it', async () => { const confirm = vi.fn() confirm.mockRejectedValueOnce(new ApiError(500)) confirm.mockResolvedValueOnce({ email: NEW }) const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', { [`POST ${CONFIRM_PATH}`]: confirm, }) expect(router.currentRoute.value.fullPath).toBe('/account/email/confirm') expect(router.currentRoute.value.query).toEqual({}) await button(wrapper, 'Try again')!.trigger('click') await flushPromises() const bodies = confirm.mock.calls.map(([init]) => (init as RequestInit).body) expect(bodies).toEqual([ JSON.stringify({ code: 'abc123' }), JSON.stringify({ code: 'abc123' }), ]) }) })