import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ConsentAPI } from './ConsentAPI'; import type { ConsentConfig, ConsentState } from '../types'; describe('ConsentAPI', () => { let api: ConsentAPI; const mockConfig: ConsentConfig = { apiEndpoint: 'https://api.example.com/', siteId: 'test-site', debug: false, }; const mockConsent: ConsentState = { categories: { essential: true, functional: true, analytics: false, marketing: false, social: false, }, vendors: {}, timestamp: '2024-01-15T10:00:00.000Z', version: '1.0.0', }; beforeEach(() => { api = new ConsentAPI(mockConfig); vi.clearAllMocks(); }); describe('constructor', () => { it('should strip trailing slash from apiEndpoint', () => { expect(api).toBeDefined(); }); }); describe('saveConsent', () => { it('should POST consent to the API', async () => { const mockResponse = { consentId: 'consent-123', timestamp: '2024-01-15T10:00:00.000Z', expiresAt: '2025-01-15T10:00:00.000Z', }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(mockResponse), } as Response); const result = await api.saveConsent({ siteId: 'test-site', deviceFingerprint: 'fp_123', consent: mockConsent, }); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith( 'https://api.example.com/consent', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'Content-Type': 'application/json', Accept: 'application/json', }), credentials: 'include', }) ); expect(result.consentId).toBe('consent-123'); }); it('should include metadata in the request', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ consentId: '123' }), } as Response); await api.saveConsent({ siteId: 'test-site', deviceFingerprint: 'fp_123', consent: mockConsent, }); const call = vi.mocked(fetch).mock.calls[0]; const body = JSON.parse(call[1]?.body as string); expect(body.metadata).toBeDefined(); expect(body.metadata.platform).toBe('web'); }); it('should throw on non-ok response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 500, } as Response); await expect( api.saveConsent({ siteId: 'test-site', deviceFingerprint: 'fp_123', consent: mockConsent, }) ).rejects.toThrow('Failed to save consent: 500'); }); it('should include signature headers', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ consentId: '123' }), } as Response); await api.saveConsent({ siteId: 'test-site', deviceFingerprint: 'fp_123', consent: mockConsent, }); const call = vi.mocked(fetch).mock.calls[0]; const headers = call[1]?.headers as Record; expect(headers['X-Consent-Timestamp']).toBeDefined(); expect(headers['X-Consent-Signature']).toMatch(/^sha256=/); }); }); describe('getConsent', () => { it('should GET consent from the API', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ consent: mockConsent }), } as Response); const result = await api.getConsent('test-site', 'fp_123'); expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/consent?siteId=test-site&deviceFingerprint=fp_123'), expect.objectContaining({ headers: expect.any(Object), credentials: 'include', }) ); expect(result?.categories.essential).toBe(true); }); it('should return null on 404', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, } as Response); const result = await api.getConsent('test-site', 'fp_123'); expect(result).toBeNull(); }); it('should throw on other errors', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 500, } as Response); await expect(api.getConsent('test-site', 'fp_123')).rejects.toThrow( 'Failed to get consent: 500' ); }); }); describe('revokeConsent', () => { it('should DELETE consent from the API', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204, } as Response); await api.revokeConsent('consent-123'); expect(fetch).toHaveBeenCalledWith( 'https://api.example.com/consent/consent-123', expect.objectContaining({ method: 'DELETE', }) ); }); it('should throw on non-ok response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, } as Response); await expect(api.revokeConsent('consent-123')).rejects.toThrow( 'Failed to revoke consent: 404' ); }); }); describe('getSiteConfig', () => { it('should GET site configuration', async () => { const mockSiteConfig = { siteId: 'test-site', name: 'Test Site', categories: ['essential', 'analytics'], }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(mockSiteConfig), } as Response); const result = await api.getSiteConfig('test-site'); expect(fetch).toHaveBeenCalledWith( 'https://api.example.com/config/test-site', expect.any(Object) ); expect(result.siteId).toBe('test-site'); }); it('should throw on error', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, } as Response); await expect(api.getSiteConfig('unknown-site')).rejects.toThrow( 'Failed to get site config: 404' ); }); }); describe('exportConsent', () => { it('should GET consent export for user', async () => { const mockExport = { userId: 'user-123', consents: [mockConsent], }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(mockExport), } as Response); const result = await api.exportConsent('user-123'); expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/consent/export?userId=user-123'), expect.any(Object) ); expect(result).toEqual(mockExport); }); it('should throw on error', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, } as Response); await expect(api.exportConsent('user-123')).rejects.toThrow( 'Failed to export consent: 403' ); }); }); describe('network errors', () => { it('should propagate fetch errors', async () => { vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); await expect( api.saveConsent({ siteId: 'test-site', deviceFingerprint: 'fp_123', consent: mockConsent, }) ).rejects.toThrow('Network error'); }); }); describe('debug mode', () => { it('should log when debug is enabled', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const debugApi = new ConsentAPI({ ...mockConfig, debug: true, }); vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ consentId: '123' }), } as Response); await debugApi.saveConsent({ siteId: 'test-site', deviceFingerprint: 'fp_123', consent: mockConsent, }); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); }); });