/** @jest-environment jsdom */ /* eslint-disable @typescript-eslint/naming-convention -- st_journey_id is the deliberate snake_case correlation tag/cookie name */ import { baseJourneyDef, lastJourney, useJourneyTestRuntime, } from '../../__test-utils__/test-runtime'; import { completeJourney, defineJourney, failJourney, journeyStep, restoreJourney, serializeJourneyState, } from '../../core'; import { moduleRuntime } from '../../core/runtime'; import { JourneyStepStamp } from '../../core/step-tag'; type SerializedJourneyState = NonNullable>; const emitMock = jest.fn(); const baseDef = { ...baseJourneyDef, name: 'persist-api', timeoutMs: 1_000, expected: ['password', 'mfa', 'client-ready'] as const, }; describe('[journey] persist (serialize / restore)', () => { useJourneyTestRuntime(emitMock); beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); describe('serializeJourneyState', () => { beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'abc-123' } }); }); const subject = () => { let snapshot: SerializedJourneyState | null = null; return journeyStep(baseDef.name, 'password', step => { step.setAttribute('channel', 'web'); snapshot = serializeJourneyState(baseDef.name); return snapshot; }).then(() => snapshot as SerializedJourneyState); }; test('projects steps to the finish() shape (name/startMs/durationMs/outcome)', async () => { const snapshot = await subject(); expect(snapshot.steps).toHaveLength(1); const step = snapshot.steps[0]; expect(step.name).toBe('password'); expect(step.startMs).toBe(0); expect(step.durationMs).toBeGreaterThanOrEqual(0); expect(step.outcome).toBe('good'); expect(step.attributes).toEqual({ channel: 'web' }); }); test('carries stJourneyId, startedAtEpoch, verdict, reason, tags', async () => { const snapshot = await subject(); expect(snapshot.stJourneyId).toBe('abc-123'); expect(typeof snapshot.startedAtEpoch).toBe('number'); expect(snapshot.verdict).toBe('good'); expect(snapshot.reason).toBeNull(); expect(snapshot.tags.st_journey_id).toBe('abc-123'); }); test('does not close the journey', async () => { await subject(); expect(moduleRuntime.getOpenJourney(baseDef.name)).toBeDefined(); expect(emitMock).not.toHaveBeenCalled(); }); test('returns null when no journey is open', () => { expect(serializeJourneyState('not-open')).toBeNull(); }); test('strips non-serializable fields (no timer / engine / config / circular journey)', async () => { const snapshot = await subject(); const json = JSON.stringify(snapshot); expect(json).toContain('"steps"'); // The serialized shape is a plain projection — no engine/config/timer keys. expect(snapshot).not.toHaveProperty('timer'); expect(snapshot).not.toHaveProperty('config'); expect(snapshot).not.toHaveProperty('JOURNEY_ENGINE'); expect(snapshot.steps[0]).not.toHaveProperty('journey'); expect(snapshot.steps[0]).not.toHaveProperty('attributedRequestKeys'); expect(snapshot.steps[0]).not.toHaveProperty('onHttpAborted'); }); test('snapshots a real elapsed duration for in-flight steps (durationMs is 0 until finally)', async () => { moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'inflight' } }); let snapshot: SerializedJourneyState | null = null; await journeyStep(baseDef.name, 'password', () => { /* * Advance the clock while the step is still open — its durationMs is 0 * until journeyStep's finally block runs, so serialize must compute it. */ jest.advanceTimersByTime(50); snapshot = serializeJourneyState(baseDef.name); }); expect(snapshot).not.toBeNull(); expect(snapshot!.steps).toHaveLength(1); expect(snapshot!.steps[0].durationMs).toBeGreaterThanOrEqual(50); }); }); describe('restoreJourney', () => { const buildSnapshot = async ( overrides: Partial = {} ): Promise => { moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'xyz' } }); await journeyStep(baseDef.name, 'password', step => { step.setAttribute('channel', 'web'); }); const snap = serializeJourneyState(baseDef.name) as SerializedJourneyState; // Close the original so the runtime is clean for restore. moduleRuntime.reset({ sinks: [emitMock], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); emitMock.mockClear(); return { ...snap, ...overrides }; }; test('rebases startedAt to this page-load timeline', async () => { const snapshot = await buildSnapshot(); const before = globalThis.performance.now(); const restored = restoreJourney(snapshot, baseDef); const after = globalThis.performance.now(); expect(restored).toBe(true); const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey).toBeDefined(); expect(journey!.startedAt).toBeGreaterThanOrEqual(before); expect(journey!.startedAt).toBeLessThanOrEqual(after); }); test('re-links step.journey to the restored journey', async () => { const snapshot = await buildSnapshot(); restoreJourney(snapshot, baseDef); const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey!.steps).toHaveLength(1); expect(journey!.steps[0].journey).toBe(journey); expect(journey!.steps[0].name).toBe('password'); expect(journey!.steps[0].attributes.channel).toBe('web'); }); test('re-arms the countdown with the remaining budget', async () => { /* * Build a snapshot whose startedAtEpoch is 200ms in the past, with a * 1_000ms journey budget → ~800ms remaining. */ const snapshot = await buildSnapshot({ startedAtEpoch: Date.now() - 200, }); restoreJourney(snapshot, { ...baseDef, timeoutMs: 1_000 }); // Advance past the remaining budget. await jest.advanceTimersByTimeAsync(900); expect(emitMock).toHaveBeenCalledTimes(1); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('journey-timeout'); }); test('preserves st_journey_id tag across round-trip', async () => { const snapshot = await buildSnapshot(); restoreJourney(snapshot, baseDef); const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey!.tags.st_journey_id).toBe('xyz'); }); test('restores a failed step with reason and httpStatus', async () => { const snapshot = await buildSnapshot({ steps: [ { name: 'password', startMs: 0, durationMs: 23, outcome: 'bad', reason: 'request-error', httpStatus: 500, }, ], }); restoreJourney(snapshot, baseDef); const step = moduleRuntime.getOpenJourney(baseDef.name)!.steps[0]; expect(step.outcome).toBe('bad'); expect(step.reason).toBe('request-error'); expect(step.httpStatus).toBe(500); }); test('falls back to stJourneyId when tags.st_journey_id is absent', async () => { const snapshot = await buildSnapshot({ stJourneyId: 'orphan-id', tags: {}, }); restoreJourney(snapshot, baseDef); const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey!.tags.st_journey_id).toBe('orphan-id'); }); test('returns false and emits bad when budget exhausted in transit (explicit timeout)', async () => { const snapshot = await buildSnapshot({ startedAtEpoch: Date.now() - 2_000, }); const restored = restoreJourney(snapshot, { ...baseDef, timeoutMs: 1_000 }); expect(restored).toBe(false); expect(emitMock).toHaveBeenCalledTimes(1); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('journey-timeout'); expect(moduleRuntime.getOpenJourney(baseDef.name)).toBeUndefined(); }); test('returns false when a journey under that name is already open', async () => { const snapshot = await buildSnapshot(); moduleRuntime.startJourney(baseDef); emitMock.mockClear(); const restored = restoreJourney(snapshot, baseDef); expect(restored).toBe(false); expect(emitMock).not.toHaveBeenCalled(); }); test('emits excluded with journey-idle-timeout when idle budget exhausted in transit (non-explicit timeout)', async () => { /* * Idle timeout = timeoutMs omitted → non-explicit, ms = defaultJourneyIdleMs (15m). * The buildSnapshot uses baseDef (timeoutMs: 1_000, explicit) — override the * serialized startedAtEpoch so the restore sees the journey as ~15m old, * and restore with a config that OMITS timeoutMs so the in-transit check * takes the non-explicit (excluded / journey-idle-timeout) branch. */ const snapshot = await buildSnapshot({ startedAtEpoch: Date.now() - moduleRuntime.getDefaultJourneyIdleMs() - 1_000, }); const idleDef = { name: baseDef.name, team: baseDef.team, group: baseDef.group, service: baseDef.service, expected: baseDef.expected, }; const restored = restoreJourney(snapshot, idleDef); expect(restored).toBe(false); expect(emitMock).toHaveBeenCalledTimes(1); expect(lastJourney(emitMock).outcome).toBe('excluded'); expect(lastJourney(emitMock).reason).toBe('journey-idle-timeout'); expect(moduleRuntime.getOpenJourney(baseDef.name)).toBeUndefined(); }); test('skips the in-transit timeout check when timeoutMs is 0 (countdown disabled)', async () => { /* * timeoutMs: 0 is explicit and disables the countdown — restoreJourney must * register the journey without arming a timer or emitting. */ const snapshot = await buildSnapshot({ startedAtEpoch: Date.now() - 999_999, }); const restored = restoreJourney(snapshot, { ...baseDef, timeoutMs: 0 }); expect(restored).toBe(true); expect(emitMock).not.toHaveBeenCalled(); const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey).toBeDefined(); expect(journey!.timeoutMs).toBe(0); expect(journey!.timeoutExplicit).toBe(true); expect(journey!.timer).toBeNull(); }); test('survives a JSON round-trip (JSON.parse(JSON.stringify(snapshot)))', async () => { const snapshot = await buildSnapshot(); const jsonRoundTripped = JSON.parse(JSON.stringify(snapshot)) as SerializedJourneyState; const restored = restoreJourney(jsonRoundTripped, baseDef); expect(restored).toBe(true); const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey).toBeDefined(); expect(journey!.tags.st_journey_id).toBe('xyz'); expect(journey!.steps).toHaveLength(1); expect(journey!.steps[0].attributes.channel).toBe('web'); }); test('second concurrent restoreJourney with the same snapshot returns false (already open)', async () => { const snapshot = await buildSnapshot(); const first = restoreJourney(snapshot, baseDef); emitMock.mockClear(); const second = restoreJourney(snapshot, baseDef); expect(first).toBe(true); expect(second).toBe(false); expect(emitMock).not.toHaveBeenCalled(); expect(moduleRuntime.getOpenJourney(baseDef.name)).toBeDefined(); }); test('caller config tags override serialized tags (precedence)', async () => { const snapshot = await buildSnapshot({ tags: { st_journey_id: 'xyz', env: 'dev', region: 'us' }, }); restoreJourney(snapshot, { ...baseDef, tags: { st_journey_id: 'xyz', env: 'prod', owner: 'team-a' }, }); const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey).toBeDefined(); // config tag wins over serialized tag expect(journey!.tags.env).toBe('prod'); // config-only tag is added expect(journey!.tags.owner).toBe('team-a'); // serialized-only tag is preserved expect(journey!.tags.region).toBe('us'); // st_journey_id preserved expect(journey!.tags.st_journey_id).toBe('xyz'); }); test('restores up to MAX_ATTRIBUTE_KEYS (64) step attributes, dropping excess', async () => { const attrs: Record = {}; for (let i = 0; i < 70; i++) { attrs[`k${i}`] = 'v'; } const snapshot = await buildSnapshot({ steps: [ { name: 'password', startMs: 0, durationMs: 23, outcome: 'good', attributes: attrs, }, ], }); restoreJourney(snapshot, baseDef); const step = moduleRuntime.getOpenJourney(baseDef.name)!.steps[0]; // MAX_ATTRIBUTE_KEYS (64), not MAX_TAG_KEYS (32) — matches setSafeAttribute. expect(Object.keys(step.attributes)).toHaveLength(64); expect(step.attributes.k0).toBe('v'); expect(step.attributes.k63).toBe('v'); expect(step.attributes.k64).toBeUndefined(); }); test('serializeJourneyState returns null after the original journey has completed', async () => { moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'done' } }); await journeyStep(baseDef.name, 'password', () => {}); completeJourney(baseDef.name); emitMock.mockClear(); expect(serializeJourneyState(baseDef.name)).toBeNull(); }); }); describe('defineJourney().start() after restore', () => { test('excludes the restored journey and starts fresh (consumer must not call start() after a successful restore)', async () => { moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'rs-1' } }); await journeyStep(baseDef.name, 'password', () => {}); const snapshot = serializeJourneyState(baseDef.name) as SerializedJourneyState; moduleRuntime.reset({ sinks: [emitMock], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); emitMock.mockClear(); const restored = restoreJourney(snapshot, baseDef); expect(restored).toBe(true); // defineJourney().start() excludes the open restored journey and starts fresh. defineJourney(baseDef).start({ extra: 'tag' }); expect(emitMock).toHaveBeenCalledTimes(1); expect(lastJourney(emitMock).outcome).toBe('excluded'); const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey).toBeDefined(); expect(journey!.closed).toBe(false); expect(journey!.steps).toHaveLength(0); expect(journey!.tags.extra).toBe('tag'); }); }); describe('round-trip: serialize → restore → add step → complete', () => { test('all steps (original + new) appear in the final emission', async () => { // Page 1: start + password step + serialize. moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'rt-1' } }); await journeyStep(baseDef.name, 'password', step => { step.setAttribute('channel', 'web'); }); const snapshot = serializeJourneyState(baseDef.name) as SerializedJourneyState; expect(snapshot).not.toBeNull(); // Simulate page navigation: reset the runtime. moduleRuntime.reset({ sinks: [emitMock], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); emitMock.mockClear(); // Page 2: restore + mfa step + complete. const restored = restoreJourney(snapshot, baseDef); expect(restored).toBe(true); await journeyStep(baseDef.name, 'mfa', step => { step.setAttribute('method', 'sms'); }); completeJourney(baseDef.name); expect(emitMock).toHaveBeenCalledTimes(1); const event = lastJourney(emitMock); expect(event.outcome).toBe('good'); expect(event.steps).toHaveLength(2); expect(event.steps[0].name).toBe('password'); expect(event.steps[0].attributes).toEqual({ channel: 'web' }); expect(event.steps[1].name).toBe('mfa'); expect(event.steps[1].attributes).toEqual({ method: 'sms' }); expect(event.tags.st_journey_id).toBe('rt-1'); }); test('a restored journey can be failed', async () => { moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'rt-2' } }); await journeyStep(baseDef.name, 'password', () => {}); const snapshot = serializeJourneyState(baseDef.name) as SerializedJourneyState; moduleRuntime.reset({ sinks: [emitMock], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); emitMock.mockClear(); const restored = restoreJourney(snapshot, baseDef); expect(restored).toBe(true); failJourney(baseDef.name, 'mfa-rejected'); expect(emitMock).toHaveBeenCalledTimes(1); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('mfa-rejected'); }); test('a restored journey can record a stamped step for HTTP attribution', async () => { moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'rt-3' } }); await journeyStep(baseDef.name, 'password', () => {}); const snapshot = serializeJourneyState(baseDef.name) as SerializedJourneyState; moduleRuntime.reset({ sinks: [emitMock], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); emitMock.mockClear(); restoreJourney(snapshot, baseDef); let stamp: JourneyStepStamp | undefined; await journeyStep(baseDef.name, 'mfa', step => { stamp = step.stamp(); }); expect(stamp).toBeInstanceOf(JourneyStepStamp); // The stamp's step should belong to the restored journey. const journey = moduleRuntime.getOpenJourney(baseDef.name); expect(journey).toBeDefined(); expect(journey!.steps[1].name).toBe('mfa'); completeJourney(baseDef.name); expect(lastJourney(emitMock).steps).toHaveLength(2); }); }); describe('defineJourney + serializeJourneyState interop', () => { test('a handle-started journey can be serialized', async () => { const handle = defineJourney({ ...baseDef, tags: { st_journey_id: 'h-1' } }); handle.start(); await journeyStep(baseDef.name, 'password', () => {}); const snapshot = serializeJourneyState(baseDef.name); expect(snapshot).not.toBeNull(); expect(snapshot!.stJourneyId).toBe('h-1'); expect(snapshot!.steps).toHaveLength(1); }); }); });