import { afterEach, describe, expect, it, vi } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import ResultPanel from '@/components/ResultPanel.vue' import ResetRequestView from '../ResetRequestView.vue' function mockResponse(status: number, body: unknown = null, headers: Record = {}) { return { ok: status >= 200 && status < 300, status, headers: { get: (name: string) => headers[name] ?? null }, json: async () => body, } as unknown as Response } function routeFor(url: string, init?: RequestInit) { const method = init?.method ?? 'GET' return `${method} ${url}` } function fetchRouter(handlers: Record Response | Promise>) { return vi.fn((url: string, init?: RequestInit) => { const key = routeFor(url, init) const handler = handlers[key] if (!handler) { throw new Error(`Unexpected fetch call: ${key}`) } return handler() }) } let currentWrapper: Awaited> | null = null async function mountResetRequest( handlers: Record Response | Promise> = {}, ) { setActivePinia(createPinia()) const fetchMock = fetchRouter(handlers) global.fetch = fetchMock as unknown as typeof fetch await router.push('/register/reset/request') const wrapper = mount(ResetRequestView, { attachTo: document.body, global: { plugins: [router, uiPlugin], }, }) currentWrapper = wrapper await flushPromises() return { wrapper, fetchMock } } function emailInput(wrapper: Awaited>) { return wrapper.find('input[placeholder="you@example.com"]') } async function fillAndSubmit( wrapper: Awaited>, email: string, ) { await emailInput(wrapper).setValue(email) await wrapper.find('form').trigger('submit') await flushPromises() } afterEach(async () => { currentWrapper?.unmount() currentWrapper = null await router.push('/login') }) describe('Default — a fresh, empty form', () => { it('renders the empty state', async () => { const { wrapper } = await mountResetRequest() expect(wrapper.find('h1').text()).toBe('Reset your password') expect(wrapper.text()).toContain( "Enter the email for your account and we'll send you a link to choose a new password.", ) expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('') expect(wrapper.find('button[type="submit"]').text()).toBe('Send reset link') const footerLink = wrapper.find('a[href="/login"]') expect(footerLink.exists()).toBe(true) expect(footerLink.text()).toBe('Back to log in') }) }) describe('Validation error — an invalid email blocks the request', () => { it('never calls the endpoint and shows the field error', async () => { const request = vi.fn(() => mockResponse(200)) const { wrapper } = await mountResetRequest({ 'POST /v1/auth/password-reset': request, }) await fillAndSubmit(wrapper, 'marta@renew') expect(request).not.toHaveBeenCalled() expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.') }) }) describe('Submitting — the field and button are disabled while the request is in flight', () => { it('disables the field and shows the loading label', async () => { let resolveRequest: (() => void) | null = null const { wrapper } = await mountResetRequest({ 'POST /v1/auth/password-reset': () => new Promise((resolve) => { resolveRequest = () => resolve(mockResponse(200)) }), }) await emailInput(wrapper).setValue('marta@renew.example') await wrapper.find('form').trigger('submit') await flushPromises() expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true) const submitButton = wrapper.find('button[type="submit"]') expect(submitButton.text()).toBe('Sending…') expect((submitButton.element as HTMLButtonElement).disabled).toBe(true) resolveRequest?.() await flushPromises() }) }) describe('Sent — any 200 shows the same neutral confirmation', () => { it('replaces the form with a ResultPanel and navigates back to log in', async () => { const { wrapper, fetchMock } = await mountResetRequest({ 'POST /v1/auth/password-reset': () => mockResponse(200), }) await fillAndSubmit(wrapper, 'marta@renew.example') expect(fetchMock).toHaveBeenCalledWith( '/v1/auth/password-reset', expect.objectContaining({ method: 'POST', body: JSON.stringify({ email: 'marta@renew.example' }), }), ) expect(wrapper.find('form').exists()).toBe(false) expect(wrapper.text()).toContain('Check your email') expect(wrapper.text()).toContain( 'If an account exists for that email, a password reset link has been sent.', ) expect(wrapper.text()).toContain('The link expires in 1 hour.') const resultPanel = wrapper.findComponent(ResultPanel) expect(resultPanel.props('tone')).toBe('success') expect(resultPanel.props('icon')).toBe('i-lucide-mail-check') const action = wrapper.find('button') expect(action.text()).toBe('Back to log in') await action.trigger('click') await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login')) }) }) describe('Too many attempts — the button is disabled with a live countdown', () => { it('counts down from the Retry-After value and re-enables at zero', async () => { vi.useFakeTimers() const { wrapper } = await mountResetRequest({ 'POST /v1/auth/password-reset': () => mockResponse(429, null, { 'Retry-After': '58' }), }) await fillAndSubmit(wrapper, 'marta@renew.example') expect(wrapper.text()).toContain('Too many requests') expect(wrapper.text()).toContain('Wait a moment before asking for another link.') expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example') const button = wrapper.find('button[type="submit"]') expect(button.text()).toBe('Try again in 0:58') expect((button.element as HTMLButtonElement).disabled).toBe(true) expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true) await vi.advanceTimersByTimeAsync(58000) await flushPromises() expect(wrapper.find('button[type="submit"]').text()).toBe('Send reset link') expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe( false, ) expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false) vi.useRealTimers() }) }) describe('Server or network error — the field keeps its value and re-enables', () => { it.each([ { name: 'a 500 response', handler: () => mockResponse(500) }, { name: 'a network failure', handler: () => Promise.reject(new TypeError('Failed to fetch')), }, ])('shows the error alert and keeps the value for $name', async ({ handler }) => { const request = vi.fn(handler) const { wrapper } = await mountResetRequest({ 'POST /v1/auth/password-reset': request, }) await fillAndSubmit(wrapper, 'marta@renew.example') expect(request).toHaveBeenCalled() expect(wrapper.text()).toContain("Couldn't send the link") expect(wrapper.text()).toContain('Check your connection and try again.') expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example') expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false) const submitButton = wrapper.find('button[type="submit"]') expect(submitButton.text()).toBe('Send reset link') expect((submitButton.element as HTMLButtonElement).disabled).toBe(false) }) })