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, PaginatedInvites } from '@/types/invite' function paginated(data: Invite[], total = data.length): PaginatedInvites { return { data, total, count: data.length } } 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') // Every mount fetches status counts once on mount; give it a harmless // default so tests that don't care about counts don't hit real fetch. vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({ active: 0, expired: 0, used: 0, }) }) 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('fetches invite status counts on mount and shows them in the header while collapsed', async () => { const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValueOnce({ active: 12, expired: 2, used: 8, }) const wrapper = mount(InvitesPanel) await flushPromises() expect(statusSpy).toHaveBeenCalledTimes(1) expect(wrapper.find('#invites-panel').exists()).toBe(false) expect(wrapper.text()).toContain('12 active') expect(wrapper.text()).toContain('2 expired') expect(wrapper.text()).toContain('8 used') expect(wrapper.text()).toContain('22 total') }) it('does not fetch status counts on mount when offline', () => { Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }) const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi') mount(InvitesPanel) expect(statusSpy).not.toHaveBeenCalled() }) it('hides status badges without an error banner when the status endpoint fails', async () => { vi.spyOn(invitesApi, 'getInviteStatusApi').mockRejectedValueOnce(new Error('boom')) const wrapper = mount(InvitesPanel) await flushPromises() expect(wrapper.find('.invites-stats').exists()).toBe(false) expect(wrapper.find('.banner-error').exists()).toBe(false) }) it('does not refetch status counts when paginating', async () => { const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({ active: 11, expired: 0, used: 0, }) const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` })) const page2 = [{ id: 'invite-10', code: 'CODE10' }] vi.spyOn(invitesApi, 'getInvitesApi') .mockResolvedValueOnce(paginated(page1, 11)) .mockResolvedValueOnce(paginated(page2, 11)) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() expect(statusSpy).toHaveBeenCalledTimes(1) const [, nextBtn] = wrapper.findAll('.page-btn') await nextBtn?.trigger('click') await flushPromises() expect(statusSpy).toHaveBeenCalledTimes(1) }) it('refetches status counts after creating an invite', async () => { const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({ active: 1, expired: 0, used: 0, }) const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' } vi.spyOn(invitesApi, 'getInvitesApi') .mockResolvedValueOnce(paginated([])) .mockResolvedValueOnce(paginated([newInvite])) vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() expect(statusSpy).toHaveBeenCalledTimes(1) await wrapper.find('.generate-btn').trigger('click') await flushPromises() expect(statusSpy).toHaveBeenCalledTimes(2) }) it('refetches status counts after deleting an invite', async () => { const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({ active: 0, expired: 0, used: 0, }) const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } vi.spyOn(invitesApi, 'getInvitesApi') .mockResolvedValueOnce(paginated([mockInvite])) .mockResolvedValueOnce(paginated([])) vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce() const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() expect(statusSpy).toHaveBeenCalledTimes(1) await wrapper.find('.ticket-btn-danger').trigger('click') const confirmButtons = wrapper.findAll('.ticket-btn-danger') await confirmButtons[confirmButtons.length - 1]?.trigger('click') await flushPromises() expect(statusSpy).toHaveBeenCalledTimes(2) }) 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', }, ] const getSpy = vi .spyOn(invitesApi, 'getInvitesApi') .mockResolvedValueOnce(paginated(mockInvites)) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() expect(getSpy).toHaveBeenCalledWith({ page: 1, count: 10 }) 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(paginated([])) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() expect(wrapper.text()).toContain('No invites yet') }) it('does not show pagination controls when everything fits on one page', async () => { vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce( paginated([{ id: 'invite-1', code: 'ABC123' }], 1), ) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() expect(wrapper.find('.invites-pagination').exists()).toBe(false) }) it('shows pagination controls and total count when there is more than one page', async () => { const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` })) vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated(page1, 15)) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() expect(wrapper.find('.invites-pagination').text()).toContain('15 invites total') const [prevBtn, nextBtn] = wrapper.findAll('.page-btn') expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(true) expect((nextBtn!.element as HTMLButtonElement).disabled).toBe(false) }) it('navigates to the next page when the forward button is clicked', async () => { const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` })) const page2 = [{ id: 'invite-10', code: 'CODE10' }] const getSpy = vi .spyOn(invitesApi, 'getInvitesApi') .mockResolvedValueOnce(paginated(page1, 11)) .mockResolvedValueOnce(paginated(page2, 11)) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() const [, nextBtn] = wrapper.findAll('.page-btn') await nextBtn?.trigger('click') await flushPromises() expect(getSpy).toHaveBeenLastCalledWith({ page: 2, count: 10 }) expect(wrapper.text()).toContain('CODE10') const [prevBtn, nextBtnAfter] = wrapper.findAll('.page-btn') expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(false) expect((nextBtnAfter!.element as HTMLButtonElement).disabled).toBe(true) }) it('generates a new invite, reloads page one, and prepends it to the list', async () => { const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' } vi.spyOn(invitesApi, 'getInvitesApi') .mockResolvedValueOnce(paginated([])) .mockResolvedValueOnce(paginated([newInvite])) 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 () => { const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' } vi.spyOn(invitesApi, 'getInvitesApi') .mockResolvedValueOnce(paginated([])) .mockResolvedValueOnce(paginated([newInvite])) 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(paginated([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(paginated([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(paginated([mockInvite])) .mockResolvedValueOnce(paginated([])) 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('steps back a page when deleting the last item on a page past the first', async () => { const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` })) const page2 = [{ id: 'invite-10', code: 'CODE10' }] const getSpy = vi .spyOn(invitesApi, 'getInvitesApi') .mockResolvedValueOnce(paginated(page1, 11)) .mockResolvedValueOnce(paginated(page2, 11)) .mockResolvedValueOnce(paginated(page1, 10)) vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce() const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() const [, nextBtn] = wrapper.findAll('.page-btn') await nextBtn?.trigger('click') await flushPromises() await wrapper.find('.ticket-btn-danger').trigger('click') const confirmButtons = wrapper.findAll('.ticket-btn-danger') await confirmButtons[confirmButtons.length - 1]?.trigger('click') await flushPromises() expect(getSpy).toHaveBeenLastCalledWith({ page: 1, count: 10 }) }) 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(paginated([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('disables share for used and expired invites, but leaves expired invites deletable', async () => { const usedInvite: Invite = { id: 'invite-1', code: 'USEDCODE', consumed_at: '2026-01-01T00:00:00.000Z', } const expiredInvite: Invite = { id: 'invite-2', code: 'EXPCODE', expires_at: '2020-01-01T00:00:00.000Z', } vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce( paginated([usedInvite, expiredInvite]), ) const wrapper = mount(InvitesPanel) await wrapper.find('.invites-toggle').trigger('click') await flushPromises() const shareButtons = wrapper.findAll('.ticket-btn:not(.ticket-btn-danger)') expect((shareButtons[0]!.element as HTMLButtonElement).disabled).toBe(true) expect((shareButtons[1]!.element as HTMLButtonElement).disabled).toBe(true) const deleteButtons = wrapper.findAll('.ticket-btn-danger') expect((deleteButtons[0]!.element as HTMLButtonElement).disabled).toBe(true) expect((deleteButtons[1]!.element as HTMLButtonElement).disabled).toBe(false) }) 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)) }