import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { jwt } from '../../__tests__/helpers/jwt'; import { MARGIN_MS, MIN_DELAY_MS, TokenRefresher } from '../tokenRefresh'; const jwtExpiringIn = (ms: number) => jwt({ exp: Math.floor((Date.now() + ms) / 1000) }, { alg: 'none' }); describe('TokenRefresher', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); }); afterEach(() => vi.useRealTimers()); it('refetches at exp minus margin and re-arms', async () => { const lifetime = 10 * 60_000; const tokens = [ jwtExpiringIn(lifetime), jwtExpiringIn(2 * lifetime), jwtExpiringIn(3 * lifetime), ]; const fetchToken = vi .fn<() => Promise>() .mockResolvedValueOnce(tokens[0]) .mockResolvedValueOnce(tokens[1]) .mockResolvedValueOnce(tokens[2]); const onToken = vi.fn(); new TokenRefresher(fetchToken).start(onToken, vi.fn()); await vi.advanceTimersByTimeAsync(0); expect(onToken).toHaveBeenCalledTimes(1); expect(onToken).toHaveBeenLastCalledWith(tokens[0]); await vi.advanceTimersByTimeAsync(lifetime - MARGIN_MS - 1); expect(onToken).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); expect(onToken).toHaveBeenLastCalledWith(tokens[1]); await vi.advanceTimersByTimeAsync(lifetime - 1); expect(onToken).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(1); expect(onToken).toHaveBeenLastCalledWith(tokens[2]); }); it.each(['resolve', 'reject'] as const)( 'stop() drops an in-flight fetch that %ss', async (outcome) => { let settle!: () => void; const fetchToken = () => new Promise((res, rej) => { settle = () => outcome === 'resolve' ? res(jwtExpiringIn(10 * 60_000)) : rej(new Error('backend down')); }); const onToken = vi.fn(); const onError = vi.fn(); const refresher = new TokenRefresher(fetchToken); refresher.start(onToken, onError); refresher.stop(); settle(); await vi.advanceTimersByTimeAsync(0); expect(onToken).not.toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }, ); it('start() replaces a running chain', async () => { const lifetime = 10 * 60_000; const fetchToken = vi.fn(async () => jwtExpiringIn(lifetime)); const refresher = new TokenRefresher(fetchToken); refresher.start(vi.fn(), vi.fn()); refresher.start(vi.fn(), vi.fn()); await vi.advanceTimersByTimeAsync(0); expect(fetchToken).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(lifetime - MARGIN_MS); expect(fetchToken).toHaveBeenCalledTimes(3); }); it('clamps delays beyond the setTimeout limit', async () => { const fetchToken = vi.fn(async () => jwtExpiringIn(60 * 24 * 3600_000)); new TokenRefresher(fetchToken).start(vi.fn(), vi.fn()); await vi.advanceTimersByTimeAsync(1000); expect(fetchToken).toHaveBeenCalledTimes(1); }); it('errors instead of delivering an already expired token', async () => { const onToken = vi.fn(); const onError = vi.fn(); new TokenRefresher(() => Promise.resolve(jwtExpiringIn(-1000))).start( onToken, onError, ); await vi.advanceTimersByTimeAsync(0); expect(onToken).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); }); it('delivers a token inside the margin and refetches after the floor', async () => { const onToken = vi.fn(); const fetchToken = vi.fn(() => Promise.resolve(jwtExpiringIn(MARGIN_MS / 2)), ); new TokenRefresher(fetchToken).start(onToken, vi.fn()); await vi.advanceTimersByTimeAsync(0); expect(onToken).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(MIN_DELAY_MS - 1); expect(fetchToken).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); expect(fetchToken).toHaveBeenCalledTimes(2); }); it('does not arm a timer for a token without a readable exp', async () => { const onToken = vi.fn(); const token = 'not-a-jwt'; new TokenRefresher(() => Promise.resolve(token)).start(onToken, vi.fn()); await vi.advanceTimersByTimeAsync(0); expect(onToken).toHaveBeenCalledWith(token); expect(vi.getTimerCount()).toBe(0); }); it('reports a synchronous fetchToken throw through onError', async () => { const onError = vi.fn(); const refresher = new TokenRefresher(() => { throw new Error('boom'); }); expect(() => refresher.start(vi.fn(), onError)).not.toThrow(); await vi.advanceTimersByTimeAsync(0); expect(onError).toHaveBeenCalledWith(new Error('boom')); }); });