From 68991a37224e210561c380418240010ebafa230d Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Mon, 31 Aug 2026 21:08:47 +0200 Subject: [PATCH] feat: user invites in account panel --- src/api/__tests__/invites.spec.ts | 101 ++++ src/api/invites.ts | 26 ++ src/components/BottomNav.vue | 2 +- src/components/InvitesPanel.vue | 441 ++++++++++++++++++ src/components/__tests__/InvitesPanel.spec.ts | 130 ++++++ src/router/index.ts | 6 +- src/types/invite.ts | 6 + src/views/{AboutView.vue => AccountView.vue} | 3 + 8 files changed, 711 insertions(+), 4 deletions(-) create mode 100644 src/api/__tests__/invites.spec.ts create mode 100644 src/api/invites.ts create mode 100644 src/components/InvitesPanel.vue create mode 100644 src/components/__tests__/InvitesPanel.spec.ts create mode 100644 src/types/invite.ts rename src/views/{AboutView.vue => AccountView.vue} (96%) 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/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/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..4c1772e --- /dev/null +++ b/src/components/InvitesPanel.vue @@ -0,0 +1,441 @@ + + + + + diff --git a/src/components/__tests__/InvitesPanel.spec.ts b/src/components/__tests__/InvitesPanel.spec.ts new file mode 100644 index 0000000..3e17d0a --- /dev/null +++ b/src/components/__tests__/InvitesPanel.spec.ts @@ -0,0 +1,130 @@ +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 }) + }) + + 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('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/index.ts b/src/router/index.ts index 3d02a73..df221a1 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -24,9 +24,9 @@ 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'), }, ], }) 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/views/AboutView.vue b/src/views/AccountView.vue similarity index 96% rename from src/views/AboutView.vue rename to src/views/AccountView.vue index 404193e..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() @@ -34,6 +35,8 @@ onMounted(() => { + +

Pending changes