import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { http, HttpResponse } from 'msw'; import { server } from '../../helpers/msw/server'; import { buildUser } from '../../helpers/factories'; // The global setup.ts mocks websocket with getSocketId returning null. // We need to be able to control what getSocketId returns per-test. // Re-mock here to get full control. vi.mock('../../../src/api/websocket', () => ({ connect: vi.fn(), disconnect: vi.fn(), getSocketId: vi.fn(() => 'mock-socket-id'), setRefetchCallback: vi.fn(), joinTrip: vi.fn(), leaveTrip: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); const wsMock = await import('../../../src/api/websocket'); // Import the API client AFTER the mock is set up so it picks up our getSocketId mock const { authApi } = await import('../../../src/api/client'); describe('API client interceptors', () => { beforeEach(() => { vi.clearAllMocks(); // Default: socket ID available (wsMock.getSocketId as ReturnType).mockReturnValue('mock-socket-id'); }); afterEach(() => { // Reset window.location to a neutral path Object.defineProperty(window, 'location', { writable: true, value: { href: 'http://localhost/', pathname: '/', search: '', hash: '' }, }); }); it('FE-API-001: requests include X-Socket-Id header when getSocketId returns a value', async () => { let receivedSocketId: string | null = null; server.use( http.get('/api/auth/me', ({ request }) => { receivedSocketId = request.headers.get('X-Socket-Id'); return HttpResponse.json({ user: buildUser() }); }) ); await authApi.me(); expect(receivedSocketId).toBe('mock-socket-id'); }); it('FE-API-002: X-Socket-Id header is absent when getSocketId returns null', async () => { (wsMock.getSocketId as ReturnType).mockReturnValue(null); let receivedSocketId: string | null = 'sentinel'; server.use( http.get('/api/auth/me', ({ request }) => { receivedSocketId = request.headers.get('X-Socket-Id'); return HttpResponse.json({ user: buildUser() }); }) ); await authApi.me(); expect(receivedSocketId).toBeNull(); }); it('FE-API-003: 401 with AUTH_REQUIRED → redirects to /login with redirect param', async () => { Object.defineProperty(window, 'location', { writable: true, value: { href: 'http://localhost/', pathname: '/dashboard', search: '', hash: '' }, }); server.use( http.get('/api/auth/me', () => { return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 }); }) ); try { await authApi.me(); } catch { // Expected to reject } expect(window.location.href).toBe('/login?redirect=%2Fdashboard'); }); it('FE-API-003b: 401 without AUTH_REQUIRED code does not redirect', async () => { Object.defineProperty(window, 'location', { writable: true, value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' }, }); const originalHref = window.location.href; server.use( http.get('/api/auth/me', () => { return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }); }) ); try { await authApi.me(); } catch { // Expected to reject } expect(window.location.href).toBe(originalHref); }); it('FE-API-003c: 401 on /login page does not redirect', async () => { Object.defineProperty(window, 'location', { writable: true, value: { href: 'http://localhost/login', pathname: '/login', search: '' }, }); server.use( http.get('/api/auth/me', () => { return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 }); }) ); try { await authApi.me(); } catch { // Expected to reject } // href should NOT have been changed to /login?redirect=... expect(window.location.href).toBe('http://localhost/login'); }); it('FE-API-004: 403 with MFA_REQUIRED → redirects to /settings?mfa=required', async () => { Object.defineProperty(window, 'location', { writable: true, value: { href: 'http://localhost/', pathname: '/dashboard', search: '' }, }); server.use( http.get('/api/auth/me', () => { return HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 }); }) ); try { await authApi.me(); } catch { // Expected to reject } expect(window.location.href).toBe('/settings?mfa=required'); }); it('FE-API-004b: 403 with MFA_REQUIRED on /settings page does not redirect', async () => { Object.defineProperty(window, 'location', { writable: true, value: { href: 'http://localhost/settings', pathname: '/settings', search: '' }, }); server.use( http.get('/api/auth/me', () => { return HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 }); }) ); try { await authApi.me(); } catch { // Expected to reject } // Should NOT redirect when already on /settings expect(window.location.href).toBe('http://localhost/settings'); }); it('FE-API-005: successful API call returns response data', async () => { const user = buildUser(); server.use( http.get('/api/auth/me', () => { return HttpResponse.json({ user }); }) ); const data = await authApi.me(); expect(data).toMatchObject({ user: { id: user.id, email: user.email } }); }); it('FE-API-006: socket ID header reflects current value from getSocketId at request time', async () => { const headers: Array = []; (wsMock.getSocketId as ReturnType) .mockReturnValueOnce('socket-A') .mockReturnValueOnce('socket-B'); server.use( http.get('/api/auth/me', ({ request }) => { headers.push(request.headers.get('X-Socket-Id')); return HttpResponse.json({ user: buildUser() }); }) ); await authApi.me(); await authApi.me(); expect(headers[0]).toBe('socket-A'); expect(headers[1]).toBe('socket-B'); }); it('FE-API-007: non-401/403 errors are passed through as rejections', async () => { server.use( http.get('/api/auth/me', () => { return HttpResponse.json({ error: 'Internal error' }, { status: 500 }); }) ); await expect(authApi.me()).rejects.toThrow(); }); });