/** @jest-environment jsdom */ import { baseJourneyDef, lastJourney, useJourneyTestRuntime, } from '../../__test-utils__/test-runtime'; import { configureJourney } from '../../config'; import { completeJourney, journeyStep, reportBackendRequest, type JourneyDef, type JourneyStepStamp, } from '../../core'; import type { StepRecord } from '../../core/types'; import { moduleRuntime } from '../../core/runtime'; const emitMock = jest.fn(); const baseDef = { ...baseJourneyDef, name: 'requests-journey' }; describe('[journey] reportBackendRequest', () => { let stepRef: StepRecord | undefined; let status: number | undefined; let durationMs: number; let key: string; useJourneyTestRuntime(emitMock); beforeEach(() => { stepRef = undefined; status = 500; durationMs = 10; key = '/api'; }); const subject = () => reportBackendRequest(stepRef, status, durationMs, key); async function withOpenStep( run: (ref: StepRecord) => void | Promise, def: JourneyDef = { ...baseDef, slowRequestMs: 5_000 } ) { moduleRuntime.startJourney(def); await journeyStep(baseDef.name, 'do-work', async step => { stepRef = (step.stamp() as JourneyStepStamp).step; await run(stepRef!); }); } describe('without a step ref', () => { beforeEach(() => { stepRef = undefined; }); test('is a no-op', () => { expect(() => subject()).not.toThrow(); expect(emitMock).not.toHaveBeenCalled(); }); }); describe('when status is a 4xx', () => { beforeEach(() => { status = 404; }); test('does not mark the journey bad', async () => { await withOpenStep(() => { subject(); }); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); describe('when the request is under the latency threshold', () => { beforeEach(() => { status = 200; durationMs = 10; }); test('does not mark the journey bad', async () => { await withOpenStep(() => { subject(); }); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); describe('when the request is slow', () => { beforeEach(() => { status = 200; durationMs = 200; key = '/slow'; }); test('marks request-latency', async () => { await withOpenStep( () => { subject(); }, { ...baseDef, slowRequestMs: 50 } ); expect(lastJourney(emitMock).reason).toBe('request-latency'); }); }); describe('when status is a 5xx', () => { beforeEach(() => { status = 502; }); test('records httpStatus on the step', async () => { await withOpenStep(() => { subject(); }); expect(lastJourney(emitMock).steps[0].httpStatus).toBe(502); expect(lastJourney(emitMock).steps[0].reason).toBe('request-error'); }); }); describe('when the step already has a reason', () => { test('preserves the first reason', async () => { await withOpenStep(ref => { ref.reason = 'step-error'; status = 502; subject(); }); expect(lastJourney(emitMock).steps[0].reason).toBe('step-error'); expect(lastJourney(emitMock).reason).toBe('request-error'); }); }); describe('when status is undefined', () => { beforeEach(() => { status = undefined; }); test('marks request-error without httpStatus', async () => { await withOpenStep(() => { subject(); }); expect(lastJourney(emitMock).reason).toBe('request-error'); expect(lastJourney(emitMock).steps[0].httpStatus).toBeUndefined(); }); }); describe('when the journey endpoint is ignored', () => { test('never marks the journey bad', async () => { await withOpenStep( () => { status = 500; key = '/flaky/thing'; subject(); }, { ...baseDef, slowRequestMs: 5_000, endpoints: [{ match: '/flaky', ignore: true }], } ); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); describe('when a journey endpoint slowRequestMs is 0', () => { test('never scores latency for that prefix', async () => { moduleRuntime.startJourney({ ...baseDef, slowRequestMs: 30, endpoints: [{ match: '/special', slowRequestMs: 0 }], }); await journeyStep(baseDef.name, 'do-work', step => { stepRef = (step.stamp() as JourneyStepStamp).step; status = 200; durationMs = 100; key = '/special/path'; subject(); }); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); describe('when journey endpoint timeoutMs is set', () => { test('wins over the journey slowRequestMs', async () => { await withOpenStep( () => { status = 200; durationMs = 50; key = '/special/path'; subject(); }, { ...baseDef, slowRequestMs: 5_000, endpoints: [{ match: '/special', slowRequestMs: 20 }], } ); expect(lastJourney(emitMock).reason).toBe('request-latency'); }); }); describe('when the journey endpoint ignore overrides the app-wide policy', () => { beforeEach(() => { configureJourney({ slowRequests: { defaultMs: 4_000, endpoints: [{ match: '/flaky', ignore: true }], }, }); }); test('journey ignore:false still counts errors', async () => { await withOpenStep( () => { status = 500; key = '/flaky/x'; subject(); }, { ...baseDef, slowRequestMs: 5_000, endpoints: [{ match: '/flaky', ignore: false }], } ); expect(lastJourney(emitMock).reason).toBe('request-error'); }); }); describe('when only app-wide endpoint policies apply', () => { beforeEach(() => { configureJourney({ slowRequests: { defaultMs: 4_000, endpoints: [ { match: '/ignored', ignore: true }, { match: '/tight', slowRequestMs: 30 }, ], }, }); }); test('honors app ignore', async () => { await withOpenStep(() => { status = 500; key = '/ignored/x'; subject(); }); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('honors app timeoutMs', async () => { await withOpenStep(() => { status = 200; durationMs = 100; key = '/tight/x'; subject(); }); expect(lastJourney(emitMock).reason).toBe('request-latency'); }); }); describe('when app endpoint slowRequestMs is 0', () => { beforeEach(() => { configureJourney({ slowRequests: { defaultMs: 40, endpoints: [{ match: '/loose', slowRequestMs: 0 }], }, }); }); test('never scores latency for that prefix', async () => { moduleRuntime.startJourney(baseDef); await journeyStep(baseDef.name, 'do-work', step => { stepRef = (step.stamp() as JourneyStepStamp).step; status = 200; durationMs = 100; key = '/loose/x'; subject(); }); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); describe('when the journey has no slowRequestMs', () => { beforeEach(() => { configureJourney({ slowRequests: { defaultMs: 40, endpoints: [] } }); }); test('uses the app defaultMs', async () => { moduleRuntime.startJourney(baseDef); await journeyStep(baseDef.name, 'do-work', step => { stepRef = (step.stamp() as JourneyStepStamp).step; status = 200; durationMs = 100; key = '/any'; subject(); }); expect(lastJourney(emitMock).reason).toBe('request-latency'); }); }); describe('against a closed journey step ref', () => { let closedRef: StepRecord | undefined; beforeEach(async () => { await withOpenStep(ref => { closedRef = ref; }); completeJourney(baseDef.name); emitMock.mockClear(); stepRef = closedRef; status = 500; }); test('is a no-op', () => { subject(); expect(emitMock).not.toHaveBeenCalled(); }); }); describe('when many distinct keys are attributed', () => { test('stops recording after 50 keys', async () => { await withOpenStep(ref => { for (let i = 0; i < 55; i += 1) { reportBackendRequest(ref, 200, 1, `/api/${i}`); } expect(ref.attributedRequestKeys).toHaveLength(50); expect( moduleRuntime.getDebugSnapshot().openJourneys[0].steps[0].attributedRequestKeys ).toHaveLength(50); }); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); });