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 AcceptInviteView from '../AcceptInviteView.vue' type Handler = () => Response | Promise function mockResponse(status: number, body: unknown = null, text = '') { return { ok: status >= 200 && status < 300, status, headers: { get: () => null }, json: async () => body, text: async () => text, } as unknown as Response } const invalidText = 'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.' let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', () => ({ useToast: () => ({ add: toastAdd }), })) let currentWrapper: Awaited> | null = null async function mountAccept(path: string, handlers: Record = {}) { setActivePinia(createPinia()) toastAdd = vi.fn() const fetchMock = vi.fn((url: string, init?: RequestInit) => { const key = `${init?.method ?? 'GET'} ${url}` const handler = handlers[key] if (!handler) { throw new Error(`Unexpected fetch call: ${key}`) } return handler() }) global.fetch = fetchMock as unknown as typeof fetch await router.push(path) const wrapper = mount(AcceptInviteView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) currentWrapper = wrapper await flushPromises() return { wrapper, fetchMock } } type Wrapper = Awaited>['wrapper'] const validInvite: Record = { 'GET /v1/auth/invite?c=abc123': () => mockResponse(200, { email: 'lea.martin@renew.example' }), } function withAccept(accept: Handler): Record { return { ...validInvite, 'POST /v1/auth/invite/accept': accept } } function usernameInput(wrapper: Wrapper) { return wrapper.find('input[placeholder="e.g. lea"]') } function emailInput(wrapper: Wrapper) { return wrapper.find('input[placeholder="you@example.com"]') } function passwordInput(wrapper: Wrapper) { return wrapper.find('input[autocomplete="new-password"]') } async function fill(wrapper: Wrapper, username: string, password: string) { await usernameInput(wrapper).setValue(username) await passwordInput(wrapper).setValue(password) } async function submit(wrapper: Wrapper) { await wrapper.find('form').trigger('submit') await flushPromises() } afterEach(async () => { currentWrapper?.unmount() currentWrapper = null vi.useRealTimers() await router.push('/login') }) describe('Checking the link', () => { it('shows a full-card spinner while the invite is looked up', async () => { const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', { 'GET /v1/auth/invite?c=abc123': () => new Promise(() => {}), }) expect(wrapper.text()).toContain('Checking your invite…') expect(wrapper.find('form').exists()).toBe(false) expect(fetchMock).toHaveBeenCalledTimes(1) }) }) describe('Default', () => { it('renders the form with the invited email read-only', async () => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite) expect(wrapper.find('h1').text()).toBe('Accept your invite') expect(wrapper.text()).toContain( 'Choose a username and password to finish setting up your account.', ) const email = emailInput(wrapper).element as HTMLInputElement expect(email.value).toBe('lea.martin@renew.example') expect(email.readOnly).toBe(true) expect(wrapper.text()).toContain('From your invitation.') expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('') expect(wrapper.text()).toContain('0 / 20') expect(wrapper.text()).toContain( "Letters and numbers, 3 to 20 characters. You'll use it to log in.", ) expect(passwordInput(wrapper).attributes('type')).toBe('password') expect(wrapper.text()).toContain('At least 8 characters') expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite') expect(wrapper.text()).toContain('Already have an account?') expect(wrapper.find('a[href="/login"]').text()).toBe('Log in') }) it('shows the same form to a logged-in visitor without redirecting', async () => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite) expect(router.currentRoute.value.path).toBe('/invite/accept') expect(wrapper.find('form').exists()).toBe(true) }) }) describe('Validation errors', () => { it('shows the errors and never calls the accept endpoint', async () => { const accept = vi.fn(() => mockResponse(200)) const { wrapper } = await mountAccept('/invite/accept?c=abc123', withAccept(accept)) await usernameInput(wrapper).setValue('ma') await usernameInput(wrapper).trigger('blur') await passwordInput(wrapper).setValue('lime') await passwordInput(wrapper).trigger('blur') await submit(wrapper) expect(accept).not.toHaveBeenCalled() expect(wrapper.text()).toContain('2 / 20') expect(wrapper.text()).toContain('Use 3 to 20 characters.') const item = wrapper.findAll('li').find((li) => li.text().includes('At least 8 characters')) expect(item?.find('.text-error').exists()).toBe(true) }) }) describe('Filled, valid', () => { it('shows the counter and a met checklist item without errors', async () => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite) await fill(wrapper, 'lea', 'limewash2026') await wrapper.find('button[aria-label="Show password"]').trigger('click') expect(wrapper.text()).toContain('3 / 20') expect(passwordInput(wrapper).attributes('type')).toBe('text') expect(wrapper.text()).not.toContain('Use 3 to 20 characters.') const item = wrapper.findAll('li').find((li) => li.text().includes('At least 8 characters')) expect(item?.find('.text-success').exists()).toBe(true) }) }) describe('Submitting', () => { it('sends the code, username and password and disables the form', async () => { const accept = vi.fn(() => new Promise(() => {})) const { wrapper, fetchMock } = await mountAccept( '/invite/accept?c=abc123', withAccept(accept), ) await fill(wrapper, 'lea', 'limewash2026') await submit(wrapper) expect(fetchMock).toHaveBeenCalledWith( '/v1/auth/invite/accept', expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123', username: 'lea', password: 'limewash2026', }), }), ) const button = wrapper.find('button[type="submit"]') expect(button.text()).toBe('Creating account…') expect(button.attributes('disabled')).toBeDefined() expect(usernameInput(wrapper).attributes('disabled')).toBeDefined() expect(passwordInput(wrapper).attributes('disabled')).toBeDefined() }) }) describe('Username taken (409)', () => { it('keeps the values, focuses the username and shows the error', async () => { const { wrapper } = await mountAccept( '/invite/accept?c=abc123', withAccept(() => mockResponse(409, null, 'That username is taken. Try another.')), ) await fill(wrapper, 'marta', 'limewash2026') await submit(wrapper) expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta') expect(wrapper.text()).toContain('5 / 20') expect(wrapper.text()).toContain('That username is taken. Try another.') expect(document.activeElement).toBe(usernameInput(wrapper).element) expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026') expect(passwordInput(wrapper).attributes('disabled')).toBeUndefined() expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite') }) }) describe('The link does not work', () => { async function expectInvalidPanel(wrapper: Wrapper) { expect(wrapper.find('form').exists()).toBe(false) expect(wrapper.text()).toContain("This invite link doesn't work") expect(wrapper.text()).toContain(invalidText) expect(wrapper.text()).toContain( 'Open the link from your email again, or copy the full address into your browser.', ) const buttons = wrapper.find('[role="alert"]').findAll('button') expect(buttons.map((button) => button.text())).toEqual(['Go to log in']) await buttons[0]!.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/login') } it('renders it without any request when there is no c parameter', async () => { const { wrapper, fetchMock } = await mountAccept('/invite/accept') expect(fetchMock).not.toHaveBeenCalled() await expectInvalidPanel(wrapper) }) it('renders it without any request when c is empty', async () => { const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=') expect(fetchMock).not.toHaveBeenCalled() await expectInvalidPanel(wrapper) }) it('renders it when the lookup answers 404', async () => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', { 'GET /v1/auth/invite?c=abc123': () => mockResponse(404), }) await expectInvalidPanel(wrapper) }) it('replaces the form when the submit answers 404', async () => { const { wrapper } = await mountAccept( '/invite/accept?c=abc123', withAccept(() => mockResponse(404)), ) await fill(wrapper, 'lea', 'limewash2026') await submit(wrapper) await expectInvalidPanel(wrapper) }) it('replaces the form when the submit answers the email-has-account 409', async () => { const { wrapper } = await mountAccept( '/invite/accept?c=abc123', withAccept(() => mockResponse(409, null, 'This address already has an account.')), ) await fill(wrapper, 'lea', 'limewash2026') await submit(wrapper) await expectInvalidPanel(wrapper) }) }) describe('Expired link (410)', () => { async function expectExpiredPanel(wrapper: Wrapper) { expect(wrapper.find('form').exists()).toBe(false) expect(wrapper.text()).toContain('This invite has expired') expect(wrapper.text()).toContain( 'Invites work once, for 3 days. Ask the person who invited you to send a new one.', ) const buttons = wrapper.find('[role="alert"]').findAll('button') expect(buttons.map((button) => button.text())).toEqual(['Go to log in']) await buttons[0]!.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/login') } it('renders it when the lookup answers 410', async () => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', { 'GET /v1/auth/invite?c=abc123': () => mockResponse(410), }) await expectExpiredPanel(wrapper) }) it('replaces the form when the submit answers 410', async () => { const { wrapper } = await mountAccept( '/invite/accept?c=abc123', withAccept(() => mockResponse(410)), ) await fill(wrapper, 'lea', 'limewash2026') await submit(wrapper) await expectExpiredPanel(wrapper) }) }) describe('Server error', () => { it.each([ { name: 'resolves 500', handler: () => mockResponse(500) }, { name: 'resolves 429', handler: () => mockResponse(429) }, { name: 'never reaches the server', handler: () => Promise.reject(new TypeError('Failed to fetch')), }, ])('shows the alert and keeps the form when the submit $name', async ({ handler }) => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', withAccept(handler)) await fill(wrapper, 'lea', 'limewash2026') await submit(wrapper) expect(wrapper.text()).toContain("Couldn't create the account") expect(wrapper.text()).toContain( 'Something went wrong on the server. Try again in a moment.', ) expect((emailInput(wrapper).element as HTMLInputElement).value).toBe( 'lea.martin@renew.example', ) expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('lea') expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026') expect(usernameInput(wrapper).attributes('disabled')).toBeUndefined() expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite') }) }) describe('Success', () => { it('shows the panel and toast, then navigates to /home after 800 ms', async () => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', { ...withAccept(() => mockResponse(200, { message: 'ok' })), 'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }), }) vi.useFakeTimers() await fill(wrapper, 'lea', 'limewash2026') await submit(wrapper) expect(wrapper.text()).toContain('Your account is ready') expect(wrapper.text()).toContain('Taking you to your dashboard…') expect(wrapper.find('form').exists()).toBe(false) expect(toastAdd).toHaveBeenCalledWith({ color: 'success', icon: 'i-lucide-circle-check', title: 'Welcome to RenewCMS', description: "You're logged in as lea.", }) expect(router.currentRoute.value.path).toBe('/invite/accept') await vi.advanceTimersByTimeAsync(800) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home')) }) }) describe('Failed lookup', () => { const lookupKey = 'GET /v1/auth/invite?c=abc123' const alertTitle = "Couldn't check your invite" const alertText = 'Something went wrong on the server. Try again in a moment.' function tryAgainButton(wrapper: Wrapper) { return wrapper.findAll('button').find((button) => button.text() === 'Try again') } function expectFailedLookup(wrapper: Wrapper) { expect(wrapper.text()).toContain(alertTitle) expect(wrapper.text()).toContain(alertText) expect(tryAgainButton(wrapper)).toBeTruthy() expect(wrapper.text()).not.toContain('Checking your invite…') expect(wrapper.find('form').exists()).toBe(false) expect(emailInput(wrapper).exists()).toBe(false) expect(wrapper.text()).not.toContain("This invite link doesn't work") } it.each([ { name: 'answers 500', handler: () => mockResponse(500) }, { name: 'answers 502', handler: () => mockResponse(502) }, { name: 'fails with a network error', handler: () => Promise.reject(new TypeError('Failed to fetch')), }, ])('shows the alert and a Try again button when the lookup $name', async ({ handler }) => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', { [lookupKey]: handler }) expectFailedLookup(wrapper) expect(wrapper.find('[role="alert"]').exists()).toBe(true) }) it('looks the invite up again when Try again is activated', async () => { let calls = 0 const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', { [lookupKey]: () => { calls += 1 return calls === 1 ? mockResponse(500) : new Promise((resolve) => setTimeout( () => resolve( mockResponse(200, { email: 'lea.martin@renew.example' }), ), 0, ), ) }, }) await tryAgainButton(wrapper)!.trigger('click') expect(wrapper.text()).toContain('Checking your invite…') expect(wrapper.text()).not.toContain(alertTitle) expect(fetchMock).toHaveBeenCalledTimes(2) await new Promise((resolve) => setTimeout(resolve, 5)) await flushPromises() expect((emailInput(wrapper).element as HTMLInputElement).value).toBe( 'lea.martin@renew.example', ) expect(wrapper.find('h1').text()).toBe('Accept your invite') }) it('shows the alert again with an enabled button when the lookup fails again', async () => { const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', { [lookupKey]: () => mockResponse(500), }) await tryAgainButton(wrapper)!.trigger('click') await flushPromises() expect(fetchMock).toHaveBeenCalledTimes(2) expectFailedLookup(wrapper) expect(tryAgainButton(wrapper)!.attributes('disabled')).toBeUndefined() }) it.each([ { status: 404, text: "This invite link doesn't work" }, { status: 410, text: 'This invite has expired' }, ])('keeps the design state when the lookup answers $status', async ({ status, text }) => { const { wrapper } = await mountAccept('/invite/accept?c=abc123', { [lookupKey]: () => mockResponse(status), }) expect(wrapper.text()).toContain(text) expect(wrapper.text()).not.toContain(alertTitle) expect(tryAgainButton(wrapper)).toBeUndefined() }) })