/** @jest-environment jsdom */ import { resolveStamp, JourneyStepStamp } from '../../core'; import type { StepRecord } from '../../core/types'; import { instrumentFetch, type InstrumentFetchOptions } from '../../integrations/fetch'; import { onHttpComplete } from '../../integrations/http-report'; import { requestKey } from '../../integrations/request-key'; jest.mock('../../core', () => ({ ...jest.requireActual('../../core'), resolveStamp: jest.fn(), })); jest.mock('../../integrations/http-report', () => ({ ...jest.requireActual('../../integrations/http-report'), onHttpComplete: jest.fn(), })); jest.mock('../../integrations/request-key'); describe('[journey] instrumentFetch', () => { const KEY = 'resolved-endpoint-key'; const record = { name: 'load', journey: { name: 'j', closed: false }, } as unknown as StepRecord; const stamp = new JourneyStepStamp(record); let target: { fetch: jest.Mock }; let originalFetch: jest.Mock; let response: { status: number }; let input: RequestInfo | URL; let init: RequestInit | undefined; let options: InstrumentFetchOptions; let now: number; beforeEach(() => { jest.clearAllMocks(); response = { status: 200 }; originalFetch = jest.fn().mockResolvedValue(response); target = { fetch: originalFetch }; input = 'https://api.example.com/orders'; init = undefined; options = { target }; jest.mocked(resolveStamp).mockReturnValue(stamp); jest.mocked(requestKey).mockReturnValue(KEY); now = 1_000; jest.spyOn(globalThis.performance, 'now').mockImplementation(() => (now += 100)); }); afterEach(() => { jest.restoreAllMocks(); }); const subject = () => { instrumentFetch(options); return target.fetch(input, init); }; test('replaces the target fetch', () => { instrumentFetch(options); expect(target.fetch).not.toBe(originalFetch); }); test('is idempotent for the same target', async () => { const first = target.fetch; instrumentFetch(options); const wrapped = target.fetch; const restoreNoop = instrumentFetch(options); expect(target.fetch).toBe(wrapped); expect(target.fetch).not.toBe(first); restoreNoop(); expect(target.fetch).toBe(wrapped); await target.fetch(input, init); expect(originalFetch).toHaveBeenCalledTimes(1); expect(onHttpComplete).toHaveBeenCalledTimes(1); }); test('warns and soft-skips when options are null', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); const restore = instrumentFetch(null); expect(() => restore()).not.toThrow(); expect(warn).toHaveBeenCalledWith( expect.stringContaining('[journey] instrumentFetch: skipped — missing') ); expect(error).not.toHaveBeenCalled(); expect(target.fetch).toBe(originalFetch); }); test('warns and soft-skips when target is null', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); const restore = instrumentFetch({ target: null }); expect(() => restore()).not.toThrow(); expect(warn).toHaveBeenCalledWith( expect.stringContaining('[journey] instrumentFetch: skipped — missing') ); expect(error).not.toHaveBeenCalled(); }); test.each([{}, { fetch: 1 }, { fetch: undefined }])( 'errors and soft-skips when target has invalid shape (%p)', bad => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); const restore = instrumentFetch({ target: bad as never }); expect(() => restore()).not.toThrow(); expect(error).toHaveBeenCalledWith( expect.stringContaining('[journey] instrumentFetch: skipped — invalid shape (') ); expect(error.mock.calls[0]).toHaveLength(1); expect(warn).not.toHaveBeenCalled(); } ); test('delegates to the original fetch with the same arguments', async () => { init = { method: 'POST' }; await subject(); expect(originalFetch).toHaveBeenCalledWith(input, init); }); test('returns the original response', async () => { const result = await subject(); expect(result).toBe(response); }); test('reports the finished request against the active step', async () => { await subject(); expect(onHttpComplete).toHaveBeenCalledWith( expect.objectContaining({ stamp, status: 200, durationMs: 100, requestKey: KEY, method: 'GET', }) ); }); test('reports method from init', async () => { init = { method: 'POST' }; await subject(); expect(onHttpComplete).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', status: 200, requestKey: KEY }) ); }); test('reports the Request method when init has none', async () => { input = new Request('https://api.example.com/orders', { method: 'PUT' }); await subject(); expect(onHttpComplete).toHaveBeenCalledWith(expect.objectContaining({ method: 'PUT' })); }); test('prefers init.method over the Request method', async () => { input = new Request('https://api.example.com/orders', { method: 'PUT' }); init = { method: 'PATCH' }; await subject(); expect(onHttpComplete).toHaveBeenCalledWith(expect.objectContaining({ method: 'PATCH' })); }); describe('when no journey step is active', () => { beforeEach(() => jest.mocked(resolveStamp).mockReturnValue(undefined)); test('does not report the request', async () => { await subject(); expect(onHttpComplete).not.toHaveBeenCalled(); }); test('still delegates to the original fetch', async () => { await subject(); expect(originalFetch).toHaveBeenCalledWith(input, init); }); }); describe('when the active step changes before the response settles', () => { beforeEach(() => { originalFetch.mockImplementation(() => { jest.mocked(resolveStamp).mockReturnValue( new JourneyStepStamp({ name: 'other', journey: { name: 'j2', closed: false }, } as unknown as StepRecord) ); return Promise.resolve(response); }); }); test('reports against the step captured at call time', async () => { await subject(); expect(onHttpComplete).toHaveBeenCalledWith( expect.objectContaining({ stamp, status: 200, requestKey: KEY }) ); }); }); describe('when the fetch rejects', () => { const error = new Error('network down'); beforeEach(() => originalFetch.mockRejectedValue(error)); test('re-throws the original error', async () => { await expect(subject()).rejects.toBe(error); }); test('reports the failure with an undefined status', async () => { await subject().catch(() => {}); expect(onHttpComplete).toHaveBeenCalledWith( expect.objectContaining({ stamp, status: undefined, durationMs: 100, requestKey: KEY, aborted: false, }) ); }); }); describe('endpoint key resolution', () => { const baseURL = 'https://api.example.com'; beforeEach(() => (options.baseURL = baseURL)); describe('with a string url', () => { beforeEach(() => (input = 'https://api.example.com/orders')); test('passes the string and baseURL to requestKey', async () => { await subject(); expect(requestKey).toHaveBeenCalledWith( 'https://api.example.com/orders', undefined, baseURL ); }); }); describe('with a URL object', () => { beforeEach(() => (input = new URL('https://api.example.com/orders'))); test('passes its href to requestKey', async () => { await subject(); expect(requestKey).toHaveBeenCalledWith( 'https://api.example.com/orders', undefined, baseURL ); }); }); describe('with a Request object', () => { let request: Request; beforeEach(() => { request = new Request('https://api.example.com/orders'); input = request; }); test('passes its url to requestKey', async () => { await subject(); expect(requestKey).toHaveBeenCalledWith(request.url, undefined, baseURL); }); }); describe('with an unrecognized input', () => { beforeEach(() => (input = { notAUrl: true } as unknown as RequestInfo)); test('passes undefined to requestKey', async () => { await subject(); expect(requestKey).toHaveBeenCalledWith(undefined, undefined, baseURL); }); }); }); describe('when init carries an explicit stamp', () => { const explicit = new JourneyStepStamp({ name: 'explicit', journey: { name: 'j', closed: false }, } as unknown as StepRecord); beforeEach(() => { init = { stamp: explicit } as RequestInit; jest.mocked(resolveStamp).mockImplementation(value => (value ? explicit : stamp)); }); test('prefers the explicit step over ambient', async () => { await subject(); expect(onHttpComplete).toHaveBeenCalledWith( expect.objectContaining({ stamp: explicit, status: 200, requestKey: KEY }) ); }); }); describe('when init.ignore is true', () => { beforeEach(() => { init = { ignore: true } as RequestInit; }); test('does not report the request', async () => { await subject(); expect(onHttpComplete).not.toHaveBeenCalled(); expect(originalFetch).toHaveBeenCalledWith(input, init); }); }); describe('when the stamp is ignore', () => { beforeEach(() => { jest.mocked(resolveStamp).mockReturnValue( new JourneyStepStamp(record, { ignore: true }) ); }); test('does not report the request', async () => { await subject(); expect(onHttpComplete).not.toHaveBeenCalled(); expect(originalFetch).toHaveBeenCalledWith(input, init); }); }); describe('the returned restore function', () => { test('puts the original fetch back', () => { const restore = instrumentFetch(options); restore(); expect(target.fetch).toBe(originalFetch); }); test('allows re-instrumentation after restore', () => { const restore = instrumentFetch(options); restore(); instrumentFetch(options); expect(target.fetch).not.toBe(originalFetch); }); test('is a no-op when restore is called twice', () => { const restore = instrumentFetch(options); restore(); target.fetch = originalFetch; restore(); expect(target.fetch).toBe(originalFetch); }); }); describe('without an explicit target', () => { let originalGlobalFetch: typeof globalThis.fetch; beforeEach(() => { originalGlobalFetch = globalThis.fetch; options = {}; }); afterEach(() => { globalThis.fetch = originalGlobalFetch; }); test('wraps the global fetch', () => { const restore = instrumentFetch(options); expect(globalThis.fetch).not.toBe(originalGlobalFetch); restore(); }); }); });