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 ResetConfirmView from '../ResetConfirmView.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 mountResetConfirm( path: string, handlers: Record Response | Promise> = {}, ) { setActivePinia(createPinia()) const fetchMock = fetchRouter(handlers) global.fetch = fetchMock as unknown as typeof fetch await router.push(path) const wrapper = mount(ResetConfirmView, { attachTo: document.body, global: { plugins: [router, uiPlugin], }, }) currentWrapper = wrapper await flushPromises() return { wrapper, fetchMock } } const DEFAULT_PATH = '/register/reset/validate?c=abc123&email=marta%40renew.example' function newPasswordInput(wrapper: Awaited>) { return wrapper.find('#reset-new-password') } function confirmPasswordInput(wrapper: Awaited>) { return wrapper.find('#reset-confirm-password') } async function fillPasswords( wrapper: Awaited>, password: string, confirm: string, ) { await newPasswordInput(wrapper).setValue(password) await confirmPasswordInput(wrapper).setValue(confirm) } afterEach(async () => { currentWrapper?.unmount() currentWrapper = null await router.push('/login') }) describe('Default — both parameters present, an untouched form', () => { it('renders the empty form', async () => { const { wrapper } = await mountResetConfirm(DEFAULT_PATH) expect(wrapper.find('h1').text()).toBe('Choose a new password') expect(wrapper.text()).toContain( "Pick something you haven't used here before. You'll log in with it next.", ) const emailField = wrapper.find('input[placeholder="you@example.com"]') expect((emailField.element as HTMLInputElement).value).toBe('marta@renew.example') expect((emailField.element as HTMLInputElement).readOnly).toBe(true) expect(wrapper.text()).toContain('From your reset link.') expect((newPasswordInput(wrapper).element as HTMLInputElement).value).toBe('') expect(wrapper.text()).toContain('At least 8 characters') expect((confirmPasswordInput(wrapper).element as HTMLInputElement).value).toBe('') expect(wrapper.text()).toContain('Passwords match') expect(wrapper.find('button[type="submit"]').text()).toBe('Change password') }) }) describe('Too short — fewer than 8 characters blocks submission', () => { it('never calls the endpoint and marks the length rule as errored', async () => { const confirm = vi.fn(() => mockResponse(200)) const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { 'POST /v1/auth/password-reset/confirm': confirm, }) await fillPasswords(wrapper, 'lime', 'lime') await wrapper.find('form').trigger('submit') await flushPromises() expect(confirm).not.toHaveBeenCalled() const items = wrapper.findAll('li') const lengthItem = items.find((item) => item.text().includes('At least 8 characters')) expect(lengthItem?.text()).toContain('not met') const matchItem = items.find((item) => item.text().includes('Passwords match')) expect(matchItem?.text()).toContain('met') }) }) describe('Mismatch — caught on blur and on submit', () => { it('shows the mismatch error and blocks the request', async () => { const confirm = vi.fn(() => mockResponse(200)) const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { 'POST /v1/auth/password-reset/confirm': confirm, }) await newPasswordInput(wrapper).setValue('limewash2026') await confirmPasswordInput(wrapper).setValue('limewash2025') await confirmPasswordInput(wrapper).trigger('blur') expect(wrapper.text()).toContain("The passwords don't match.") const matchItemAfterBlur = wrapper .findAll('li') .find((item) => item.text().includes('Passwords match')) expect(matchItemAfterBlur?.find('svg').classes()).toContain('text-error') await wrapper.find('form').trigger('submit') await flushPromises() expect(confirm).not.toHaveBeenCalled() expect(wrapper.text()).toContain("The passwords don't match.") const matchItemAfterSubmit = wrapper .findAll('li') .find((item) => item.text().includes('Passwords match')) expect(matchItemAfterSubmit?.find('svg').classes()).toContain('text-error') }) }) describe('Submitting — fields and button are disabled while the request is in flight', () => { it('disables the fields and shows the loading label', async () => { let resolveConfirm: (() => void) | null = null const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { 'POST /v1/auth/password-reset/confirm': () => new Promise((resolve) => { resolveConfirm = () => resolve(mockResponse(200)) }), }) await fillPasswords(wrapper, 'limewash2026', 'limewash2026') await wrapper.find('form').trigger('submit') await flushPromises() expect((newPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(true) expect((confirmPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(true) const submitButton = wrapper.find('button[type="submit"]') expect(submitButton.text()).toBe('Changing password…') expect((submitButton.element as HTMLButtonElement).disabled).toBe(true) resolveConfirm?.() await flushPromises() }) }) describe('Success — a 200 hands off to the log-in screen', () => { it('navigates to /login?reason=password-changed', async () => { const { wrapper, fetchMock } = await mountResetConfirm(DEFAULT_PATH, { 'POST /v1/auth/password-reset/confirm': () => mockResponse(200), }) await fillPasswords(wrapper, 'limewash2026', 'limewash2026') await wrapper.find('form').trigger('submit') await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/login?reason=password-changed'), ) expect(fetchMock).toHaveBeenCalledWith( '/v1/auth/password-reset/confirm', expect.objectContaining({ method: 'POST', body: JSON.stringify({ email: 'marta@renew.example', code: 'abc123', password: 'limewash2026', }), }), ) }) }) describe('Invalid or expired — a 403 replaces the form with a ResultPanel', () => { it('shows the panel and navigates depending on the action', async () => { const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { 'POST /v1/auth/password-reset/confirm': () => mockResponse(403), }) await fillPasswords(wrapper, 'limewash2026', 'limewash2026') await wrapper.find('form').trigger('submit') await flushPromises() expect(wrapper.find('form').exists()).toBe(false) expect(wrapper.text()).toContain('This reset link has expired') expect(wrapper.text()).toContain( 'Reset links work once, for 1 hour. Request a new one and use it within the hour.', ) const resultPanel = wrapper.findComponent(ResultPanel) expect(resultPanel.props('tone')).toBe('error') expect(resultPanel.props('icon')).toBe('i-lucide-link-2-off') const buttons = wrapper.findAll('button') const requestNew = buttons.find((button) => button.text() === 'Request a new link') expect(requestNew).toBeTruthy() await requestNew?.trigger('click') await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/reset/request'), ) }) it('navigates to /login when Back to log in is activated', async () => { const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { 'POST /v1/auth/password-reset/confirm': () => mockResponse(403), }) await fillPasswords(wrapper, 'limewash2026', 'limewash2026') await wrapper.find('form').trigger('submit') await flushPromises() const buttons = wrapper.findAll('button') const backToLogin = buttons.find((button) => button.text() === 'Back to log in') expect(backToLogin).toBeTruthy() await backToLogin?.trigger('click') await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login')) }) }) describe('Server or network error — the form keeps its values 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 values for $name', async ({ handler }) => { const confirm = vi.fn(handler) const { wrapper } = await mountResetConfirm(DEFAULT_PATH, { 'POST /v1/auth/password-reset/confirm': confirm, }) await fillPasswords(wrapper, 'limewash2026', 'limewash2026') await wrapper.find('form').trigger('submit') await flushPromises() expect(confirm).toHaveBeenCalled() expect(wrapper.text()).toContain("Couldn't reach RenewCMS") expect(wrapper.text()).toContain('Check your connection and try again.') expect(wrapper.find('form').exists()).toBe(true) expect((newPasswordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026') expect((confirmPasswordInput(wrapper).element as HTMLInputElement).value).toBe( 'limewash2026', ) expect((newPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(false) expect((confirmPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(false) const submitButton = wrapper.find('button[type="submit"]') expect(submitButton.text()).toBe('Change password') expect((submitButton.element as HTMLButtonElement).disabled).toBe(false) }) }) describe('Missing parameters — either missing parameter blocks the request', () => { it.each([ { path: '/register/reset/validate?email=marta%40renew.example', }, { path: '/register/reset/validate?c=abc123' }, { path: '/register/reset/validate' }, ])('renders the missing-parameters panel for $path', async ({ path }) => { const confirm = vi.fn(() => mockResponse(200)) const { wrapper } = await mountResetConfirm(path, { 'POST /v1/auth/password-reset/confirm': confirm, }) expect(confirm).not.toHaveBeenCalled() expect(wrapper.find('form').exists()).toBe(false) expect(wrapper.text()).toContain('This link is incomplete') expect(wrapper.text()).toContain( 'Part of the reset link is missing. Open it from your email again, or request a new one.', ) const resultPanel = wrapper.findComponent(ResultPanel) expect(resultPanel.props('tone')).toBe('error') expect(resultPanel.props('icon')).toBe('i-lucide-link-2-off') expect(resultPanel.props('actions')).toHaveLength(1) const action = wrapper.find('button') expect(action.text()).toBe('Request a new link') await action.trigger('click') await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/reset/request'), ) }) })