import { CoreApiClient, CoreApiClientError } from '../../src/adapters/notification/core-api-client'; describe('CoreApiClient', () => { const API_URL = 'http://core.local'; const CLIENT_ID = 'cid'; const CLIENT_SECRET = 'csec'; beforeEach(() => { (global as any).fetch = jest.fn(); jest.clearAllMocks(); }); it('requests token successfully and caches it', async () => { // token endpoint response (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't1', token_type: 'Bearer', expires_in: 3600 } }) }); const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }); const token = await client.getToken(); expect(token).toBe('t1'); // second call should not call fetch again for token const token2 = await client.getToken(); expect(token2).toBe('t1'); expect(global.fetch).toHaveBeenCalledTimes(1); }); it('throws on invalid credentials (401)', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 401, json: async () => ({}) }); const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }); await expect(client.getToken()).rejects.toThrow(CoreApiClientError); }); it('retries once on 401 during authenticatedRequest', async () => { // initial token fetch (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't1', token_type: 'Bearer', expires_in: 3600 } }) }); // first API call returns 401 (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 401, json: async () => ({}) }); // token refresh fetch returns new token (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't2', token_type: 'Bearer', expires_in: 3600 } }) }); // retried API call returns 200 (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ ok: true }) }); const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }); const res = await client.authenticatedRequest(`${API_URL}/api/v1/users`, { method: 'GET' }); expect(res.status).toBe(200); expect(global.fetch).toHaveBeenCalledTimes(4); }); it('does not retry on 403', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't1', token_type: 'Bearer', expires_in: 3600 } }) }); (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 403, json: async () => ({}) }); const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }); const res = await client.authenticatedRequest(`${API_URL}/api/v1/forbidden`, { method: 'GET' }); expect(res.status).toBe(403); // only token fetch + one API call expect(global.fetch).toHaveBeenCalledTimes(2); }); it('handles token refresh before expiry', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't1', token_type: 'Bearer', expires_in: 2 } }) }); const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }); const token1 = await client.getToken(); expect(token1).toBe('t1'); // force expiry by manipulating internal state (client as any).tokenExpiry = Date.now() - 1000; (global.fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ success: true, data: { access_token: 't2', token_type: 'Bearer', expires_in: 3600 } }) }); const token2 = await client.getToken(); expect(token2).toBe('t2'); }); it('handles network timeout', async () => { (global.fetch as jest.Mock).mockRejectedValueOnce(Object.assign(new Error('AbortError'), { name: 'AbortError' })); const client = new CoreApiClient({ apiUrl: API_URL, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, timeoutMs: 1 }); await expect(client.getToken()).rejects.toThrow(CoreApiClientError); }); });