diff --git a/src/api/__tests__/invites.spec.ts b/src/api/__tests__/invites.spec.ts new file mode 100644 index 0000000..35cb970 --- /dev/null +++ b/src/api/__tests__/invites.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { getInvitesApi, createInviteApi, deleteInviteApi } from '../invites' +import { useAuthStore } from '@/stores/auth' +import { API_BASE_URL } from '@/api/http' + +describe('invites API', () => { + const originalFetch = global.fetch + + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.restoreAllMocks() + + const authStore = useAuthStore() + authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' }) + }) + + afterEach(() => { + global.fetch = originalFetch + localStorage.clear() + }) + + it('getInvitesApi sends GET to /user/invites and returns the invites', async () => { + const mockInvites = [{ id: 'invite-1', code: 'ABC123' }] + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockInvites, + } as unknown as Response) + global.fetch = fetchMock + + const result = await getInvitesApi() + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/user/invites`, + expect.objectContaining({ method: 'GET' }), + ) + expect(result).toEqual(mockInvites) + }) + + it('getInvitesApi throws on failure', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Failed' }), + } as unknown as Response) + + await expect(getInvitesApi()).rejects.toThrow('Failed') + }) + + it('createInviteApi sends POST to /user/invites and returns the created invite', async () => { + const mockInvite = { id: 'invite-1', code: 'ABC123' } + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => mockInvite, + } as unknown as Response) + global.fetch = fetchMock + + const result = await createInviteApi() + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/user/invites`, + expect.objectContaining({ method: 'POST' }), + ) + expect(result).toEqual(mockInvite) + }) + + it('createInviteApi throws on failure', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Failed to create invite' }), + } as unknown as Response) + + await expect(createInviteApi()).rejects.toThrow('Failed to create invite') + }) + + it('deleteInviteApi sends DELETE to /user/invites/:id', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 204, + } as unknown as Response) + global.fetch = fetchMock + + await deleteInviteApi('invite-1') + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/user/invites/invite-1`, + expect.objectContaining({ method: 'DELETE' }), + ) + }) + + it('deleteInviteApi throws on failure', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Failed to delete invite' }), + } as unknown as Response) + + await expect(deleteInviteApi('invite-1')).rejects.toThrow('Failed to delete invite') + }) +}) diff --git a/src/api/__tests__/users.spec.ts b/src/api/__tests__/users.spec.ts new file mode 100644 index 0000000..67d8fc7 --- /dev/null +++ b/src/api/__tests__/users.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { changePasswordApi, createUserApi } from '../users' +import { useAuthStore } from '@/stores/auth' +import { API_BASE_URL } from '@/api/http' + +describe('users API', () => { + const originalFetch = global.fetch + + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.restoreAllMocks() + }) + + afterEach(() => { + global.fetch = originalFetch + localStorage.clear() + }) + + describe('changePasswordApi', () => { + it('sends POST to /user/password with the old and new password', async () => { + const authStore = useAuthStore() + authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' }) + + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 204, + } as unknown as Response) + global.fetch = fetchMock + + await changePasswordApi({ old_password: 'old', new_password: 'new12345' }) + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/user/password`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ old_password: 'old', new_password: 'new12345' }), + }), + ) + }) + + it('throws on failure', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Failed' }), + } as unknown as Response) + + await expect( + changePasswordApi({ old_password: 'old', new_password: 'new12345' }), + ).rejects.toThrow('Failed') + }) + }) + + describe('createUserApi', () => { + it('sends POST to /users with the registration payload and returns the created user', async () => { + const mockUser = { id: 'user-1', email: 'new@example.com', name: 'New User' } + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => mockUser, + } as unknown as Response) + global.fetch = fetchMock + + const payload = { + email: 'new@example.com', + password: 'password123', + name: 'New User', + invite_code: 'invite-abc', + } + const result = await createUserApi(payload) + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/users`, + expect.objectContaining({ method: 'POST', body: JSON.stringify(payload) }), + ) + expect(result).toEqual(mockUser) + }) + + it('throws on failure', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Invalid invite code' }), + } as unknown as Response) + + await expect( + createUserApi({ + email: 'new@example.com', + password: 'password123', + name: 'New User', + invite_code: 'bad-code', + }), + ).rejects.toThrow('Invalid invite code') + }) + }) +}) diff --git a/src/api/invites.ts b/src/api/invites.ts new file mode 100644 index 0000000..eb529de --- /dev/null +++ b/src/api/invites.ts @@ -0,0 +1,26 @@ +import { apiClient } from '@/api/client' +import { extractErrorMessage } from '@/api/http' +import type { Invite } from '@/types/invite' + +export async function getInvitesApi(): Promise { + const response = await apiClient.get('/user/invites') + if (!response.ok) { + throw new Error(await extractErrorMessage(response, 'Failed to load invites')) + } + return response.json() +} + +export async function createInviteApi(): Promise { + const response = await apiClient.post('/user/invites') + if (!response.ok) { + throw new Error(await extractErrorMessage(response, 'Failed to create invite')) + } + return response.json() +} + +export async function deleteInviteApi(id: string): Promise { + const response = await apiClient.delete(`/user/invites/${id}`) + if (!response.ok) { + throw new Error(await extractErrorMessage(response, 'Failed to delete invite')) + } +} diff --git a/src/api/users.ts b/src/api/users.ts index 03b25e0..1bbee6f 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -1,10 +1,19 @@ import { apiClient } from '@/api/client' import { extractErrorMessage } from '@/api/http' import type { ChangePasswordPayload } from '@/types/auth' +import type { CreateUserPayload, User } from '@/types/user' export async function changePasswordApi(payload: ChangePasswordPayload): Promise { - const response = await apiClient.post('/users/password', payload) + const response = await apiClient.post('/user/password', payload) if (!response.ok) { throw new Error(await extractErrorMessage(response, 'Failed to change password')) } } + +export async function createUserApi(payload: CreateUserPayload): Promise { + const response = await apiClient.post('/users', payload) + if (!response.ok) { + throw new Error(await extractErrorMessage(response, 'Failed to create account')) + } + return response.json() +} diff --git a/src/components/BottomNav.vue b/src/components/BottomNav.vue index 3e633da..3288b16 100644 --- a/src/components/BottomNav.vue +++ b/src/components/BottomNav.vue @@ -16,7 +16,7 @@ const listsStore = useListsStore() listsStore.pendingCount }} - + Account diff --git a/src/components/InvitesPanel.vue b/src/components/InvitesPanel.vue new file mode 100644 index 0000000..2121f7c --- /dev/null +++ b/src/components/InvitesPanel.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/src/components/__tests__/InvitesPanel.spec.ts b/src/components/__tests__/InvitesPanel.spec.ts new file mode 100644 index 0000000..22dcc89 --- /dev/null +++ b/src/components/__tests__/InvitesPanel.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import InvitesPanel from '../InvitesPanel.vue' +import * as invitesApi from '@/api/invites' +import type { Invite } from '@/types/invite' + +describe('InvitesPanel', () => { + beforeEach(() => { + vi.restoreAllMocks() + Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) }, + configurable: true, + }) + // jsdom has no Web Share API; tests that want it define it explicitly. + Reflect.deleteProperty(navigator, 'share') + }) + + it('renders collapsed by default without loading invites', () => { + const getSpy = vi.spyOn(invitesApi, 'getInvitesApi') + + const wrapper = mount(InvitesPanel) + + expect(wrapper.text()).toContain('Invites') + expect(wrapper.find('#invites-panel').exists()).toBe(false) + expect(getSpy).not.toHaveBeenCalled() + }) + + it('loads and displays invites on expand', async () => { + const mockInvites: Invite[] = [ + { id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' }, + { + id: 'invite-2', + code: 'USEDCODE', + expires_at: '2099-01-01T00:00:00.000Z', + consumed_at: '2026-01-01T00:00:00.000Z', + }, + ] + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(mockInvites) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('ABC123') + expect(wrapper.text()).toContain('USEDCODE') + expect(wrapper.text()).toContain('Active') + expect(wrapper.text()).toContain('Used') + }) + + it('shows an offline message instead of fetching when expanded offline', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }) + const getSpy = vi.spyOn(invitesApi, 'getInvitesApi') + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + + expect(getSpy).not.toHaveBeenCalled() + expect(wrapper.find('.banner-error').text()).toContain('must be online') + }) + + it('shows empty state when there are no invites', async () => { + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([]) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('No invites yet') + }) + + it('generates a new invite and prepends it to the list', async () => { + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([]) + const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' } + vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + await wrapper.find('.generate-btn').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('NEWCODE1') + }) + + it('shares the newly created invite automatically', async () => { + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([]) + const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' } + vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + await wrapper.find('.generate-btn').trigger('click') + await flushPromises() + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('?invite=NEWCODE1'), + ) + }) + + it('shares an existing invite link via the clipboard when Web Share is unavailable', async () => { + const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite]) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + await wrapper.find('.ticket-btn').trigger('click') + await flushPromises() + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('?invite=ABC123'), + ) + expect(wrapper.text()).toContain('Copied!') + }) + + it('uses the Web Share API instead of the clipboard when available', async () => { + const shareMock = vi.fn<(data: ShareData) => Promise>().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true }) + + const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite]) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + await wrapper.find('.ticket-btn').trigger('click') + await flushPromises() + + expect(shareMock).toHaveBeenCalledWith( + expect.objectContaining({ url: expect.stringContaining('?invite=ABC123') }), + ) + expect(navigator.clipboard.writeText).not.toHaveBeenCalled() + }) + + it('deletes an invite after confirming', async () => { + const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite]) + const deleteSpy = vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce() + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + await wrapper.find('.ticket-btn-danger').trigger('click') + expect(wrapper.text()).toContain('Delete this invite?') + + const confirmButtons = wrapper.findAll('.ticket-btn-danger') + await confirmButtons[confirmButtons.length - 1]?.trigger('click') + await flushPromises() + + expect(deleteSpy).toHaveBeenCalledWith('invite-1') + expect(wrapper.text()).toContain('No invites yet') + }) + + it('disables delete for already-used invites', async () => { + const usedInvite: Invite = { + id: 'invite-1', + code: 'USEDCODE', + consumed_at: '2026-01-01T00:00:00.000Z', + } + vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([usedInvite]) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + const deleteBtn = wrapper.find('.ticket-btn-danger') + expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('shows an error banner when loading invites fails', async () => { + vi.spyOn(invitesApi, 'getInvitesApi').mockRejectedValueOnce(new Error('Network error')) + + const wrapper = mount(InvitesPanel) + await wrapper.find('.invites-toggle').trigger('click') + await flushPromises() + + expect(wrapper.find('.banner-error').text()).toContain('Network error') + }) +}) + +function flushPromises() { + return new Promise((resolve) => setTimeout(resolve)) +} diff --git a/src/router/__tests__/index.spec.ts b/src/router/__tests__/index.spec.ts new file mode 100644 index 0000000..6805b5e --- /dev/null +++ b/src/router/__tests__/index.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import router from '../index' +import { useAuthStore } from '@/stores/auth' + +describe('router', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + it('redirects unauthenticated access to protected routes to login', async () => { + await router.push('/') + + expect(router.currentRoute.value.name).toBe('login') + }) + + it('allows authenticated access to protected routes', async () => { + const authStore = useAuthStore() + authStore.setTokens({ access_token: 'token', refresh_token: 'refresh' }) + + await router.push('/') + + expect(router.currentRoute.value.name).toBe('lists') + }) + + it('redirects any route carrying ?invite= to the (unlisted) register route', async () => { + await router.push('/?invite=abc123') + + expect(router.currentRoute.value.name).toBe('register') + expect(router.currentRoute.value.query.invite).toBe('abc123') + }) + + it('redirects an authenticated route with an invite code to register too', async () => { + const authStore = useAuthStore() + authStore.setTokens({ access_token: 'token', refresh_token: 'refresh' }) + + await router.push('/account?invite=abc123') + + expect(router.currentRoute.value.name).toBe('register') + expect(router.currentRoute.value.query.invite).toBe('abc123') + }) + + it('does not redirect when already navigating to register with an invite code', async () => { + await router.push('/register?invite=xyz') + + expect(router.currentRoute.value.name).toBe('register') + }) + + it('does not redirect to register when there is no invite code', async () => { + await router.push('/login') + + expect(router.currentRoute.value.name).toBe('login') + }) +}) diff --git a/src/router/index.ts b/src/router/index.ts index 3d02a73..dd40680 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -24,9 +24,16 @@ const router = createRouter({ component: () => import('../views/LoginView.vue'), }, { - path: '/about', - name: 'about', - component: () => import('../views/AboutView.vue'), + path: '/account', + name: 'account', + component: () => import('../views/AccountView.vue'), + }, + // Intentionally not linked from anywhere in the UI — reached only via an + // invite link (see router.beforeEach below) or by typing the URL directly. + { + path: '/register', + name: 'register', + component: () => import('../views/RegisterView.vue'), }, ], }) @@ -34,6 +41,12 @@ const router = createRouter({ router.beforeEach((to) => { const authStore = useAuthStore() + // An invite link may point anywhere (e.g. the app root) so it still works + // if the recipient doesn't have the app installed/bookmarked at /register. + if (to.name !== 'register' && typeof to.query.invite === 'string' && to.query.invite) { + return { name: 'register', query: { invite: to.query.invite } } + } + if (to.meta.requiresAuth && !authStore.isAuthenticated) { return { name: 'login', query: { redirect: to.fullPath } } } diff --git a/src/types/invite.ts b/src/types/invite.ts new file mode 100644 index 0000000..cbf046f --- /dev/null +++ b/src/types/invite.ts @@ -0,0 +1,6 @@ +export interface Invite { + id: string + code: string + expires_at?: string + consumed_at?: string +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..eaf2ed0 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,13 @@ +export interface User { + id: string + email: string + name: string + created_at?: string +} + +export interface CreateUserPayload { + email: string + password: string + name: string + invite_code: string +} diff --git a/src/views/AboutView.vue b/src/views/AccountView.vue similarity index 94% rename from src/views/AboutView.vue rename to src/views/AccountView.vue index 103fe5c..e4d7bb9 100644 --- a/src/views/AboutView.vue +++ b/src/views/AccountView.vue @@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue' import { useListsStore } from '@/stores/lists' import { useAuthStore } from '@/stores/auth' import ChangePasswordModal from '@/components/ChangePasswordModal.vue' +import InvitesPanel from '@/components/InvitesPanel.vue' const listsStore = useListsStore() const authStore = useAuthStore() @@ -22,7 +23,7 @@ onMounted(() => {

Users

- Signed in as + Signed in as {{ authStore.email ?? 'Unknown' }}

+ +

Pending changes

diff --git a/src/views/RegisterView.vue b/src/views/RegisterView.vue new file mode 100644 index 0000000..42aa5d3 --- /dev/null +++ b/src/views/RegisterView.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/src/views/__tests__/RegisterView.spec.ts b/src/views/__tests__/RegisterView.spec.ts new file mode 100644 index 0000000..55c8cf5 --- /dev/null +++ b/src/views/__tests__/RegisterView.spec.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import RegisterView from '../RegisterView.vue' +import { useAuthStore } from '@/stores/auth' +import { useListsStore } from '@/stores/lists' +import * as usersApi from '@/api/users' + +const mockPush = vi.fn<(to: string) => void>() +let routeQuery: Record = { invite: 'invite-code-123' } + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRouter: () => ({ + push: mockPush, + }), + useRoute: () => ({ + query: routeQuery, + }), + } +}) + +describe('RegisterView', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.restoreAllMocks() + mockPush.mockClear() + routeQuery = { invite: 'invite-code-123' } + }) + + it('shows an invalid-invite message when there is no invite code in the URL', () => { + routeQuery = {} + + const wrapper = mount(RegisterView) + + expect(wrapper.find('.invalid-invite').exists()).toBe(true) + expect(wrapper.find('form').exists()).toBe(false) + }) + + it('renders the registration form when an invite code is present', () => { + const wrapper = mount(RegisterView) + + expect(wrapper.find('h2').text()).toBe('Create your account') + expect(wrapper.find('input#name').exists()).toBe(true) + expect(wrapper.find('input#email').exists()).toBe(true) + expect(wrapper.find('input#password').exists()).toBe(true) + expect(wrapper.find('input#confirm-password').exists()).toBe(true) + }) + + it('shows logged in status and hides the form if already authenticated', () => { + const authStore = useAuthStore() + authStore.setTokens({ access_token: 'active-token', refresh_token: 'active-refresh' }) + + const wrapper = mount(RegisterView) + + expect(wrapper.find('.already-logged-in').exists()).toBe(true) + expect(wrapper.find('form').exists()).toBe(false) + }) + + it('rejects passwords that are too short', async () => { + const createSpy = vi.spyOn(usersApi, 'createUserApi') + const wrapper = mount(RegisterView) + + await wrapper.find('#name').setValue('Jane Doe') + await wrapper.find('#email').setValue('jane@example.com') + await wrapper.find('#password').setValue('short') + await wrapper.find('#confirm-password').setValue('short') + await wrapper.find('form').trigger('submit.prevent') + + expect(wrapper.find('.error-banner').text()).toContain('at least 8 characters') + expect(createSpy).not.toHaveBeenCalled() + }) + + it('rejects mismatched passwords', async () => { + const createSpy = vi.spyOn(usersApi, 'createUserApi') + const wrapper = mount(RegisterView) + + await wrapper.find('#name').setValue('Jane Doe') + await wrapper.find('#email').setValue('jane@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('#confirm-password').setValue('password456') + await wrapper.find('form').trigger('submit.prevent') + + expect(wrapper.find('.error-banner').text()).toContain('do not match') + expect(createSpy).not.toHaveBeenCalled() + }) + + it('creates the account, logs in, syncs, and redirects home on success', async () => { + const createSpy = vi.spyOn(usersApi, 'createUserApi').mockResolvedValueOnce({ + id: 'user-1', + email: 'jane@example.com', + name: 'Jane Doe', + }) + const authStore = useAuthStore() + const loginSpy = vi.spyOn(authStore, 'login').mockResolvedValueOnce({ + access_token: 'access-123', + refresh_token: 'refresh-456', + }) + const listsStore = useListsStore() + const syncSpy = vi.spyOn(listsStore, 'sync').mockResolvedValueOnce() + + const wrapper = mount(RegisterView) + + await wrapper.find('#name').setValue('Jane Doe') + await wrapper.find('#email').setValue('jane@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('#confirm-password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith({ + name: 'Jane Doe', + email: 'jane@example.com', + password: 'password123', + invite_code: 'invite-code-123', + }) + expect(loginSpy).toHaveBeenCalledWith({ + email: 'jane@example.com', + password: 'password123', + }) + expect(syncSpy).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('shows an error banner when account creation fails', async () => { + vi.spyOn(usersApi, 'createUserApi').mockRejectedValueOnce(new Error('Invalid invite code')) + + const wrapper = mount(RegisterView) + + await wrapper.find('#name').setValue('Jane Doe') + await wrapper.find('#email').setValue('jane@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('#confirm-password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(wrapper.find('.error-banner').text()).toContain('Invalid invite code') + }) +}) + +function flushPromises() { + return new Promise((resolve) => setTimeout(resolve)) +}