diff --git a/frontend/src/lib/stores/auth.test.ts b/frontend/src/lib/stores/auth.test.ts new file mode 100644 index 0000000..062fd61 --- /dev/null +++ b/frontend/src/lib/stores/auth.test.ts @@ -0,0 +1,591 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/* ------------------------------------------------------------------ */ +/* Hoisted mocks for SvelteKit virtual modules */ +/* ------------------------------------------------------------------ */ +const mockEnv = vi.hoisted(() => ({ browser: true })); +const mockNav = vi.hoisted(() => ({ goto: vi.fn() })); +vi.mock('$app/environment', () => mockEnv); +vi.mock('$app/navigation', () => mockNav); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ +function makeToken(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = btoa(JSON.stringify(payload)); + return `${header}.${body}.fake-sig`; +} + +function makeStorage(initial: Record) { + const store: Record = { ...initial }; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: vi.fn(() => { for (const k of Object.keys(store)) delete store[k]; }), + key: vi.fn((i: number) => Object.keys(store)[i] ?? null), + get length() { return Object.keys(store).length; } + }; +} + +function makeBroadcastChannel() { + return vi.fn(function () { + return { + postMessage: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + close: vi.fn() + }; + }); +} + +type JsonResponse = { status?: number; body?: unknown; ok?: boolean }; + +function defaultProfileBody() { + return { + id: 'usr_test', email: 'test@example.com', role: 'verified_email', + firstName: 'Test', lastName: 'User', + twoFactorEnabled: false, twoFactorRequired: false + }; +} + +function makeJsonResponse(data: unknown, status = 200) { + const ok = status >= 200 && status < 300; + return { + ok, + status, + json: async () => data, + headers: new Headers({ 'Content-Type': 'application/json' }) + }; +} + +interface LoadOptions { + browser?: boolean; + localStorage?: Record; + fetch?: ReturnType; + profileBody?: unknown; +} + +async function loadAuthStore(opts: LoadOptions = {}) { + vi.resetModules(); + mockEnv.browser = opts.browser ?? false; + mockNav.goto = vi.fn(); + + vi.stubGlobal('$state', (v: unknown) => v); + const storage = makeStorage(opts.localStorage ?? {}); + vi.stubGlobal('localStorage', storage); + vi.stubGlobal('window', { addEventListener: vi.fn() }); + vi.stubGlobal('BroadcastChannel', makeBroadcastChannel()); + vi.stubGlobal('setInterval', vi.fn()); + vi.stubGlobal('clearInterval', vi.fn()); + + if (opts.fetch) { + vi.stubGlobal('fetch', opts.fetch); + } else { + const pb = opts.profileBody ?? defaultProfileBody(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeJsonResponse(pb))); + } + + const mod = await import('./auth.svelte'); + return { store: mod.authStore, storage }; +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe('authStore — login (setToken)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('setToken stores the token and populates user state from the JWT', async () => { + const { store } = await loadAuthStore(); + const token = makeToken({ user_id: 'usr_123', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + + expect(store.currentToken).toBe(token); + expect(store.isAuthenticated).toBe(true); + expect(store.currentUser?.id).toBe('usr_123'); + expect(store.currentUser?.role).toBe('verified_email'); + }); + + it('setToken writes the token and refresh token to localStorage', async () => { + const { store, storage } = await loadAuthStore({ browser: true }); + const token = makeToken({ user_id: 'usr_123', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token, 'rt_secret'); + + expect(storage.setItem).toHaveBeenCalledWith('authToken', token); + expect(storage.setItem).toHaveBeenCalledWith('authRefreshToken', 'rt_secret'); + }); + + it('setToken without refresh token does not write authRefreshToken', async () => { + const { store, storage } = await loadAuthStore({ browser: true }); + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + + expect(storage.setItem).toHaveBeenCalledWith('authToken', token); + expect(storage.setItem).not.toHaveBeenCalledWith('authRefreshToken', expect.anything()); + }); + + it('setToken fetches the user profile after decoding', async () => { + const profile = { + id: 'usr_profile', email: 'profile@test.com', role: 'admin', + firstName: 'Admin', lastName: 'User', + twoFactorEnabled: true, twoFactorRequired: true + }; + const fetchFn = vi.fn().mockResolvedValue(makeJsonResponse(profile)); + const { store } = await loadAuthStore({ fetch: fetchFn }); + + const token = makeToken({ user_id: 'usr_profile', role: 'unverified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + + expect(fetchFn).toHaveBeenCalledWith('/api/user/profile', expect.objectContaining({ headers: expect.anything() })); + await vi.waitFor(() => { + expect(store.currentUser?.firstName).toBe('Admin'); + expect(store.currentUser?.email).toBe('profile@test.com'); + }); + }); + + it('setToken with an undecodable token still sets the token but user stays null until profile', async () => { + const { store } = await loadAuthStore(); + store.setToken('not-a-valid-token'); + expect(store.currentToken).toBe('not-a-valid-token'); + }); + + it('isAuthenticated is true only when both token and user are present', async () => { + const { store } = await loadAuthStore(); + expect(store.isAuthenticated).toBe(false); + }); +}); + +describe('authStore — logout', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('logout sends POST /api/logout, clears state, and navigates to /', async () => { + const profile = { id: 'usr_1', email: 'a@b.com', role: 'verified_email', firstName: 'A', lastName: 'B' }; + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse(profile)) + .mockResolvedValue({ ok: true, status: 200, json: async () => ({}), headers: new Headers() }); + const { store } = await loadAuthStore({ fetch: fetchFn, browser: true }); + + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.currentToken).toBe(token); + await vi.waitFor(() => expect(store.currentUser?.firstName).toBe('A')); + + await store.logout(); + + expect(fetchFn).toHaveBeenCalledWith('/api/logout', expect.objectContaining({ method: 'POST' })); + expect(store.currentToken).toBeNull(); + expect(store.currentUser).toBeNull(); + expect(store.isAuthenticated).toBe(false); + expect(mockNav.goto).toHaveBeenCalledWith('/', { invalidateAll: true }); + }); + + it('logout still clears local state when the POST fails (network error)', async () => { + const profile = { id: 'usr_1', email: 'a@b.com', role: 'verified_email', firstName: 'A', lastName: 'B' }; + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse(profile)) + .mockRejectedValue(new Error('Network error')); + const { store } = await loadAuthStore({ fetch: fetchFn, browser: true }); + + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.currentToken).toBe(token); + await vi.waitFor(() => expect(store.currentUser?.firstName).toBe('A')); + + await store.logout(); + + expect(store.currentToken).toBeNull(); + expect(store.currentUser).toBeNull(); + expect(mockNav.goto).toHaveBeenCalled(); + }); + + it('logout does not call /api/logout when not authenticated', async () => { + const fetchFn = vi.fn(); + const { store } = await loadAuthStore({ fetch: fetchFn, browser: true }); + await store.logout(); + expect(fetchFn).not.toHaveBeenCalled(); + expect(mockNav.goto).toHaveBeenCalledWith('/', { invalidateAll: true }); + }); +}); + +describe('authStore — isAuthenticated / currentUser / isLoading', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('starts not authenticated and not loading after init (no stored token)', async () => { + const { store } = await loadAuthStore({ browser: true }); + expect(store.isAuthenticated).toBe(false); + expect(store.isLoading).toBe(false); + expect(store.hasLoaded).toBe(true); + }); + + it('currentUser returns null when not logged in', async () => { + const { store } = await loadAuthStore(); + expect(store.currentUser).toBeNull(); + }); + + it('currentToken returns null when not logged in', async () => { + const { store } = await loadAuthStore(); + expect(store.currentToken).toBeNull(); + }); +}); + +describe('authStore — token refresh (refreshTokenIfNeeded)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('does nothing when no token is set', async () => { + const fetchFn = vi.fn(); + const { store } = await loadAuthStore({ fetch: fetchFn }); + await store.refreshTokenIfNeeded(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('clears auth when token is undecodable', async () => { + const { store } = await loadAuthStore(); + (store as any).token = 'bad.token.here'; + await store.refreshTokenIfNeeded(); + expect(store.currentToken).toBeNull(); + expect(store.currentUser).toBeNull(); + }); + + it('skips refresh when token is not close to expiry (>5 min remaining)', async () => { + const fetchFn = vi.fn(); + const { store } = await loadAuthStore({ fetch: fetchFn }); + const farFuture = Math.floor(Date.now() / 1000) + 3600; + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: farFuture }); + store.setToken(token); + + await store.refreshTokenIfNeeded(); + const refreshCalls = fetchFn.mock.calls.filter((c: [string]) => c[0] === '/api/refresh-token'); + expect(refreshCalls).toHaveLength(0); + }); + + it('clears auth when no refresh token is stored (pre-B5 session)', async () => { + const fetchFn = vi.fn(); + const { store } = await loadAuthStore({ fetch: fetchFn }); + const nearExpiry = Math.floor(Date.now() / 1000) + 60; + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: nearExpiry }); + store.setToken(token); + await vi.waitFor(() => expect(store.currentUser?.firstName).toBeDefined()); + fetchFn.mockClear(); + (store as any).refreshToken = null; + + await store.refreshTokenIfNeeded(); + expect(store.currentToken).toBeNull(); + }); + + it('refreshes successfully and stores the rotated pair', async () => { + const newToken = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: Math.floor(Date.now() / 1000) + 3600 }); + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse({ id: 'usr_1', role: 'verified_email', firstName: 'A', lastName: 'B' })) + .mockResolvedValueOnce(makeJsonResponse({ token: newToken, refreshToken: 'new_rt' })); + const { store } = await loadAuthStore({ fetch: fetchFn }); + + const nearExpiry = Math.floor(Date.now() / 1000) + 60; + const oldToken = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: nearExpiry }); + store.setToken(oldToken); + (store as any).refreshToken = 'old_rt'; + await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1)); + fetchFn.mockClear(); + + await store.refreshTokenIfNeeded(); + + expect(fetchFn).toHaveBeenCalledWith( + '/api/refresh-token', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer old_rt' }) + }) + ); + expect(store.currentToken).toBe(newToken); + }); + + it('clears auth when refresh fails with a non-ok response', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse({ id: 'usr_1', role: 'verified_email', firstName: 'A', lastName: 'B' })) + .mockResolvedValueOnce(makeJsonResponse({ error: 'invalid refresh token' }, 401)); + const { store } = await loadAuthStore({ fetch: fetchFn }); + + const nearExpiry = Math.floor(Date.now() / 1000) + 60; + store.setToken(makeToken({ user_id: 'usr_1', role: 'verified_email', exp: nearExpiry })); + (store as any).refreshToken = 'old_rt'; + await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1)); + fetchFn.mockClear(); + + await store.refreshTokenIfNeeded(); + + expect(store.currentToken).toBeNull(); + expect(store.currentUser).toBeNull(); + }); + + it('handles a network error during refresh gracefully (keeps existing token)', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse({ id: 'usr_1', role: 'verified_email', firstName: 'A', lastName: 'B' })) + .mockResolvedValueOnce(makeJsonResponse({}, 503)); + const { store } = await loadAuthStore({ fetch: fetchFn }); + + const nearExpiry = Math.floor(Date.now() / 1000) + 60; + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: nearExpiry }); + store.setToken(token); + (store as any).refreshToken = 'old_rt'; + await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1)); + fetchFn.mockClear(); + + await store.refreshTokenIfNeeded(); + + expect(store.currentToken).toBeNull(); + }); +}); + +describe('authStore — state persistence (initializeAuth)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('restores session from localStorage when a valid token exists', async () => { + const validToken = makeToken({ user_id: 'usr_restored', role: 'admin', exp: Math.floor(Date.now() / 1000) + 3600 }); + const { store } = await loadAuthStore({ + browser: true, + localStorage: { authToken: validToken, authRefreshToken: 'rt_restored' }, + profileBody: { id: 'usr_restored', role: 'admin', firstName: 'Restored', lastName: 'User' } + }); + + await vi.waitFor(() => expect(store.currentUser?.firstName).toBeDefined()); + expect(store.currentToken).toBe(validToken); + expect(store.currentUser?.id).toBe('usr_restored'); + expect(store.currentUser?.role).toBe('admin'); + expect(store.isAuthenticated).toBe(true); + }); + + it('clears auth when the stored token is expired', async () => { + const expiredToken = makeToken({ user_id: 'usr_expired', role: 'verified_email', exp: Math.floor(Date.now() / 1000) - 3600 }); + const { store } = await loadAuthStore({ + browser: true, + localStorage: { authToken: expiredToken } + }); + + await vi.waitFor(() => expect(store.isLoading).toBe(false)); + expect(store.currentToken).toBeNull(); + expect(store.currentUser).toBeNull(); + expect(store.isAuthenticated).toBe(false); + }); + + it('starts clean when no token is in localStorage', async () => { + const { store } = await loadAuthStore({ browser: true }); + expect(store.currentToken).toBeNull(); + expect(store.currentUser).toBeNull(); + expect(store.isAuthenticated).toBe(false); + }); + + it('clears auth when stored token is undecodable', async () => { + const { store } = await loadAuthStore({ + browser: true, + localStorage: { authToken: 'not-a-valid-jwt' } + }); + expect(store.currentToken).toBeNull(); + }); +}); + +describe('authStore — role checks', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('isAdmin returns true for admin role', async () => { + const { store } = await loadAuthStore(); + const token = makeToken({ user_id: 'usr_admin', role: 'admin', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.isAdmin()).toBe(true); + }); + + it('isAdmin returns false for non-admin roles', async () => { + const { store } = await loadAuthStore(); + const token = makeToken({ user_id: 'usr_user', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.isAdmin()).toBe(false); + }); + + it('isAdmin returns false when no user is set', async () => { + const { store } = await loadAuthStore(); + expect(store.isAdmin()).toBe(false); + }); + + it('isVerified returns true for verified_email', async () => { + const { store } = await loadAuthStore(); + const token = makeToken({ user_id: 'usr_v', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.isVerified()).toBe(true); + }); + + it('isVerified returns true for admin', async () => { + const { store } = await loadAuthStore(); + const token = makeToken({ user_id: 'usr_a', role: 'admin', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.isVerified()).toBe(true); + }); + + it('isVerified returns false for unverified_email', async () => { + const { store } = await loadAuthStore(); + const token = makeToken({ user_id: 'usr_u', role: 'unverified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.isVerified()).toBe(false); + }); + + it('hasRole checks a single role', async () => { + const { store } = await loadAuthStore(); + const token = makeToken({ user_id: 'usr_1', role: 'admin', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.hasRole('admin')).toBe(true); + expect(store.hasRole('verified_email')).toBe(false); + }); + + it('hasRole checks multiple roles (any match)', async () => { + const { store } = await loadAuthStore(); + const token = makeToken({ user_id: 'usr_1', role: 'guest', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + expect(store.hasRole(['guest', 'affiliate'])).toBe(true); + expect(store.hasRole(['admin', 'verified_email'])).toBe(false); + }); + + it('hasRole returns false when no user', async () => { + const { store } = await loadAuthStore(); + expect(store.hasRole('admin')).toBe(false); + }); +}); + +describe('authStore — savedCardChargeRequires2FACode', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns true when user has twoFactorRequired', async () => { + const { store } = await loadAuthStore({ + profileBody: { + id: 'usr_2fa', email: 'a@b.com', role: 'verified_email', + firstName: 'A', lastName: 'B', twoFactorEnabled: true, twoFactorRequired: true + } + }); + const token = makeToken({ user_id: 'usr_2fa', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + await vi.waitFor(() => expect(store.currentUser?.firstName).toBe('A')); + expect(store.savedCardChargeRequires2FACode).toBe(true); + }); + + it('returns false when user has twoFactorRequired false', async () => { + const { store } = await loadAuthStore({ + profileBody: { + id: 'usr_no2fa', email: 'a@b.com', role: 'verified_email', + firstName: 'A', lastName: 'B', twoFactorEnabled: false, twoFactorRequired: false + } + }); + const token = makeToken({ user_id: 'usr_no2fa', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + await vi.waitFor(() => expect(store.currentUser?.firstName).toBe('A')); + expect(store.savedCardChargeRequires2FACode).toBe(false); + }); + + it('returns false when no user is logged in', async () => { + const { store } = await loadAuthStore(); + expect(store.savedCardChargeRequires2FACode).toBe(false); + }); +}); + +describe('authStore — refreshProfile', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('refreshProfile re-fetches the user profile', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse({ id: 'usr_1', email: 'initial@test.com', role: 'verified_email', firstName: 'Initial', lastName: 'User' })); + const { store } = await loadAuthStore({ fetch: fetchFn }); + + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + await vi.waitFor(() => expect(store.currentUser?.firstName).toBe('Initial')); + fetchFn.mockClear(); + + fetchFn.mockResolvedValueOnce(makeJsonResponse({ id: 'usr_1', email: 'updated@test.com', role: 'admin', firstName: 'Updated', lastName: 'User' })); + + await store.refreshProfile(); + expect(store.currentUser?.firstName).toBe('Updated'); + expect(store.currentUser?.role).toBe('admin'); + }); + + it('refreshProfile clears auth on 401', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse({ id: 'usr_1', email: 'a@b.com', role: 'verified_email', firstName: 'A', lastName: 'B' })); + const { store } = await loadAuthStore({ fetch: fetchFn }); + + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + await vi.waitFor(() => expect(store.currentUser?.firstName).toBe('A')); + fetchFn.mockClear(); + + fetchFn.mockResolvedValueOnce(makeJsonResponse({ error: 'unauthorized' }, 401)); + + await store.refreshProfile(); + expect(store.currentToken).toBeNull(); + expect(store.currentUser).toBeNull(); + }); + + it('refreshProfile preserves session on transient error (500)', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse({ id: 'usr_1', email: 'a@b.com', role: 'verified_email', firstName: 'A', lastName: 'B' })); + const { store } = await loadAuthStore({ fetch: fetchFn }); + + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: Date.now() / 1000 + 3600 }); + store.setToken(token); + await vi.waitFor(() => expect(store.currentUser?.firstName).toBe('A')); + fetchFn.mockClear(); + + fetchFn.mockResolvedValueOnce(makeJsonResponse({}, 500)); + + await store.refreshProfile(); + expect(store.currentToken).toBe(token); + expect(store.currentUser).not.toBeNull(); + }); +}); + +describe('authStore — cross-tab coordination', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('tries to acquire refresh lock and releases it after refresh', async () => { + const newToken = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: Math.floor(Date.now() / 1000) + 3600 }); + const fetchFn = vi.fn() + .mockResolvedValueOnce(makeJsonResponse({ id: 'usr_1', role: 'verified_email', firstName: 'A', lastName: 'B' })) + .mockResolvedValueOnce(makeJsonResponse({ token: newToken, refreshToken: 'new_rt' })); + const { store, storage } = await loadAuthStore({ fetch: fetchFn, browser: true }); + + const nearExpiry = Math.floor(Date.now() / 1000) + 60; + const token = makeToken({ user_id: 'usr_1', role: 'verified_email', exp: nearExpiry }); + store.setToken(token); + (store as any).refreshToken = 'old_rt'; + await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1)); + fetchFn.mockClear(); + + await store.refreshTokenIfNeeded(); + + expect(storage.removeItem).toHaveBeenCalledWith('authRefreshInProgress'); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/stores/businessInfo.test.ts b/frontend/src/lib/stores/businessInfo.test.ts new file mode 100644 index 0000000..97f2787 --- /dev/null +++ b/frontend/src/lib/stores/businessInfo.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +const MOCK_BUSINESS_SETTINGS = { + business_name: 'Test Salon', + business_address: '123 High Street', + business_phone: '07123456789', + business_email: 'salon@test.com', + vat_registration_number: 'GB123456789', + is_vat_registered: true, + default_vat_rate: 20, + currency_code: 'GBP', + website_url: 'https://testsalon.example.com', + gift_card_expiry_months: 24, + voucher_type: 'SPV' as const +}; + +interface LoadOptions { + fetch?: ReturnType; +} + +async function loadBusinessInfo(opts: LoadOptions = {}) { + vi.resetModules(); + vi.stubGlobal('$state', (v: unknown) => v); + + if (opts.fetch) { + vi.stubGlobal('fetch', opts.fetch); + } else { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_BUSINESS_SETTINGS, + headers: new Headers({ 'Content-Type': 'application/json' }) + }) + ); + } + + return await import('./businessInfo.svelte'); +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe('ensureBusinessInfo', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('fetches business info from /api/business-info on first call', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_BUSINESS_SETTINGS, + headers: new Headers({ 'Content-Type': 'application/json' }) + }); + const mod = await loadBusinessInfo({ fetch: fetchFn }); + + const result = await mod.ensureBusinessInfo(); + + expect(fetchFn).toHaveBeenCalledWith('/api/business-info'); + expect(result).toEqual(MOCK_BUSINESS_SETTINGS); + }); + + it('caches the result — does not re-fetch on subsequent calls', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_BUSINESS_SETTINGS, + headers: new Headers({ 'Content-Type': 'application/json' }) + }); + const mod = await loadBusinessInfo({ fetch: fetchFn }); + + const first = await mod.ensureBusinessInfo(); + const second = await mod.ensureBusinessInfo(); + const third = await mod.ensureBusinessInfo(); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(first).toEqual(MOCK_BUSINESS_SETTINGS); + expect(second).toEqual(MOCK_BUSINESS_SETTINGS); + expect(third).toEqual(MOCK_BUSINESS_SETTINGS); + }); + + it('deduplicates concurrent calls — only one fetch', async () => { + let resolveFetch!: (v: unknown) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + const fetchFn = vi.fn().mockReturnValue(fetchPromise); + const mod = await loadBusinessInfo({ fetch: fetchFn }); + + const [call1, call2] = [mod.ensureBusinessInfo(), mod.ensureBusinessInfo()]; + + expect(fetchFn).toHaveBeenCalledTimes(1); + + resolveFetch!({ + ok: true, + json: async () => MOCK_BUSINESS_SETTINGS, + headers: new Headers({ 'Content-Type': 'application/json' }) + }); + + const [r1, r2] = await Promise.all([call1, call2]); + expect(r1).toEqual(MOCK_BUSINESS_SETTINGS); + expect(r2).toEqual(MOCK_BUSINESS_SETTINGS); + }); + + it('returns null when the API returns a non-ok status', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: 'server error' }), + headers: new Headers() + }); + const mod = await loadBusinessInfo({ fetch: fetchFn }); + + const result = await mod.ensureBusinessInfo(); + expect(result).toBeNull(); + }); + + it('returns null when the fetch throws (network error)', async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error('Network is down')); + const mod = await loadBusinessInfo({ fetch: fetchFn }); + + const result = await mod.ensureBusinessInfo(); + expect(result).toBeNull(); + }); + + it('still caches null result after a fetch failure (does not retry)', async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error('Network is down')); + const mod = await loadBusinessInfo({ fetch: fetchFn }); + + const first = await mod.ensureBusinessInfo(); + const second = await mod.ensureBusinessInfo(); + + expect(first).toBeNull(); + expect(second).toBeNull(); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('caches null result after a non-ok API response', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({}), + headers: new Headers() + }); + const mod = await loadBusinessInfo({ fetch: fetchFn }); + + const first = await mod.ensureBusinessInfo(); + const second = await mod.ensureBusinessInfo(); + + expect(first).toBeNull(); + expect(second).toBeNull(); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('getBusinessInfo', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns null before ensureBusinessInfo is called', async () => { + const mod = await loadBusinessInfo(); + expect(mod.getBusinessInfo()).toBeNull(); + }); + + it('returns the cached business info after a successful fetch', async () => { + const mod = await loadBusinessInfo(); + await mod.ensureBusinessInfo(); + expect(mod.getBusinessInfo()).toEqual(MOCK_BUSINESS_SETTINGS); + }); + + it('returns null after a failed fetch', async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error('fail')); + const mod = await loadBusinessInfo({ fetch: fetchFn }); + await mod.ensureBusinessInfo(); + expect(mod.getBusinessInfo()).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/stores/savedCards.test.ts b/frontend/src/lib/stores/savedCards.test.ts new file mode 100644 index 0000000..d1a5265 --- /dev/null +++ b/frontend/src/lib/stores/savedCards.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockApiFetch = vi.hoisted(() => vi.fn()); +vi.mock('$lib/utils/api', () => ({ apiFetch: mockApiFetch })); + +function makeCard(overrides: Partial<{ + id: string; brand: string; last_4: string; exp_month: number; + exp_year: number; cardholder_name: string; is_default: boolean; square_card_id: string; +}> = {}) { + return { + id: 'card_001', brand: 'VISA', last_4: '1234', exp_month: 12, exp_year: 2028, + cardholder_name: 'Test User', is_default: false, square_card_id: 'ccof:test-card-001', + ...overrides + }; +} + +function okResponse(data: unknown) { + return { ok: true, json: async () => data }; +} + +async function loadSavedCardsStore() { + vi.resetModules(); + vi.stubGlobal('$state', (v: unknown) => v); + mockApiFetch.mockReset(); + const mod = await import('./savedCards.svelte'); + return mod.savedCardsStore; +} + +describe('savedCardsStore', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('starts with an empty cards array, not loading, not loaded', async () => { + const store = await loadSavedCardsStore(); + expect(store.cards).toEqual([]); + expect(store.loading).toBe(false); + expect(store.loaded).toBe(false); + }); + + it('load() fetches cards from the API and populates state', async () => { + const cards = [makeCard({ id: 'c1' }), makeCard({ id: 'c2', brand: 'MASTERCARD' })]; + const store = await loadSavedCardsStore(); + mockApiFetch.mockResolvedValueOnce(okResponse(cards)); + + await store.fetch(); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/user/payment-methods'); + expect(store.cards).toEqual(cards); + expect(store.loaded).toBe(true); + expect(store.loading).toBe(false); + }); + + it('load() sets cards to an empty array when the API returns a non-ok status', async () => { + const store = await loadSavedCardsStore(); + mockApiFetch.mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'forbidden' }) }); + + await store.fetch(); + + expect(store.cards).toEqual([]); + expect(store.loaded).toBe(true); + }); + +it('load() gracefully handles a network error and sets an empty array', async () => { + const store = await loadSavedCardsStore(); + mockApiFetch.mockRejectedValueOnce(new Error('Network failure')); + + await store.fetch(); + + expect(store.cards).toEqual([]); + // loaded stays false — the try block's `loaded = true` is skipped on throw + expect(store.loaded).toBe(false); + expect(store.loading).toBe(false); +}); + + it('load() is idempotent — does not re-fetch when already loaded', async () => { + const cards = [makeCard()]; + const store = await loadSavedCardsStore(); + mockApiFetch.mockResolvedValueOnce(okResponse(cards)); + + await store.fetch(); + expect(mockApiFetch).toHaveBeenCalledTimes(1); + + await store.fetch(); + expect(mockApiFetch).toHaveBeenCalledTimes(1); + expect(store.cards).toEqual(cards); + }); + + it('load() is idempotent while loading — does not re-fetch', async () => { + let resolveFetch!: (v: unknown) => void; + const fetchPromise = new Promise((resolve) => { resolveFetch = resolve; }); + + const store = await loadSavedCardsStore(); + mockApiFetch.mockReturnValueOnce(fetchPromise); + + const firstLoad = store.fetch(); + await store.fetch(); + + resolveFetch!(okResponse([makeCard()])); + await firstLoad; + + expect(mockApiFetch).toHaveBeenCalledTimes(1); + expect(store.loaded).toBe(true); + }); + + it('invalidate() resets the loaded flag and re-fetches', async () => { + const store = await loadSavedCardsStore(); + mockApiFetch + .mockResolvedValueOnce(okResponse([makeCard({ id: 'first' })])) + .mockResolvedValueOnce(okResponse([makeCard({ id: 'second' })])); + + await store.fetch(); + expect(store.cards).toEqual([expect.objectContaining({ id: 'first' })]); + + await store.invalidate(); + expect(mockApiFetch).toHaveBeenCalledTimes(2); + expect(store.cards).toEqual([expect.objectContaining({ id: 'second' })]); + }); + + it('load() manages loading state correctly', async () => { + let resolveFetch!: (v: unknown) => void; + const fetchPromise = new Promise((resolve) => { resolveFetch = resolve; }); + + const store = await loadSavedCardsStore(); + mockApiFetch.mockReturnValueOnce(fetchPromise); + + const loadPromise = store.fetch(); + + expect(store.loading).toBe(true); + expect(store.loaded).toBe(false); + + resolveFetch!(okResponse([makeCard()])); + await loadPromise; + + expect(store.loading).toBe(false); + expect(store.loaded).toBe(true); + }); +}); \ No newline at end of file