test: frontend store coverage — auth, savedCards, businessInfo reactive stores
CI / Env docs check (push) Successful in 5s
CI / Go build (push) Successful in 47s
CI / Go vet (dev) (push) Successful in 1m5s
CI / Go vet (prod) (push) Successful in 1m5s
CI / go mod tidy (push) Successful in 40s
CI / Go vulnerabilities (push) Successful in 1m0s
CI / Frontend major deps (push) Successful in 40s
CI / Frontend build (push) Successful in 47s
CI / Nginx config check (push) Successful in 6s
CI / Secrets scan (push) Failing after 6s
CI / Docker compose check (push) Failing after 5s
CI / golangci-lint (push) Failing after 34s
CI / Frontend deps check (push) Failing after 46s
CI / Knip (push) Skipped
CI / Frontend a11y check (push) Skipped
CI / Svelte strict check (push) Skipped
CI / Frontend QC (audit) (push) Skipped
CI / Frontend QC (typecheck) (push) Skipped
CI / Frontend QC (lint) (push) Skipped
CI / Frontend QC (test) (push) Skipped
CI / Staticcheck (dev) (push) Failing after 1m8s
CI / Staticcheck (prod) (push) Failing after 1m9s
CI / Security scan (prod) (push) Failing after 1m51s
CI / Security scan (dev) (push) Failing after 1m55s
CI / Tests (prod) (push) Skipped
CI / Tests (dev) (push) Skipped
CI / Race (prod) (push) Skipped
CI / Race (dev) (push) Skipped

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent 5051368d9b
commit b8e521f4e7
3 changed files with 915 additions and 0 deletions
+591
View File
@@ -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, unknown>): 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<string, string>) {
const store: Record<string, string> = { ...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<string, string>;
fetch?: ReturnType<typeof vi.fn>;
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');
});
});
@@ -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<typeof vi.fn>;
}
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();
});
});
+139
View File
@@ -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);
});
});