react-native-nami-sdk
Version:
React Native SDK for Nami - No-code paywall and onboarding flows with A/B testing.
203 lines (174 loc) • 6.91 kB
text/typescript
// Mock react-native BEFORE importing the SDK — `../NamiFlowManager` resolves
// the native module via TurboModuleRegistry at import time, and the
// NativeEventEmitter constructor is called at module top-level.
const registerStepHandoff = jest.fn();
const unregisterStepHandoff = jest.fn();
const registerEventHandler = jest.fn();
const unregisterEventHandler = jest.fn();
const completeHandoff = jest.fn();
const finishMock = jest.fn();
const isFlowOpenMock = jest.fn().mockResolvedValue(false);
const resumeMock = jest.fn();
const pauseMock = jest.fn();
const subRemove = jest.fn();
// Capture the registered listener per event name so tests can fire native events.
const listeners: Record<string, (event: unknown) => void> = {};
const addListener = jest.fn(
(eventName: string, handler: (e: unknown) => void) => {
listeners[eventName] = handler;
return { remove: subRemove };
},
);
jest.mock('react-native', () => ({
TurboModuleRegistry: {
getEnforcing: jest.fn(() => ({
registerStepHandoff,
unregisterStepHandoff,
registerEventHandler,
unregisterEventHandler,
completeHandoff,
finish: finishMock,
isFlowOpen: isFlowOpenMock,
resume: resumeMock,
pause: pauseMock,
})),
},
NativeModules: {
RNNamiFlowManager: {
registerStepHandoff,
unregisterStepHandoff,
registerEventHandler,
unregisterEventHandler,
completeHandoff,
},
},
NativeEventEmitter: jest.fn().mockImplementation(() => ({
addListener,
})),
}));
import { NamiFlowManager } from '../NamiFlowManager';
import type { NamiHandoffOutcome } from '../types';
/** Fire a native `Handoff` event into the captured emitter listener. */
function fireHandoff(event: {
handoffId: string;
handoffTag: string;
handoffData?: Record<string, unknown>;
}) {
listeners.Handoff?.(event);
}
describe('NamiFlowManager (React Native bridge)', () => {
beforeEach(() => {
registerStepHandoff.mockClear();
unregisterStepHandoff.mockClear();
registerEventHandler.mockClear();
unregisterEventHandler.mockClear();
completeHandoff.mockClear();
resumeMock.mockClear();
subRemove.mockClear();
addListener.mockClear();
});
describe('registerStepHandoff', () => {
it('returns a function and triggers native register', () => {
const unsubscribe = NamiFlowManager.registerStepHandoff(() => {});
expect(typeof unsubscribe).toBe('function');
expect(registerStepHandoff).toHaveBeenCalledTimes(1);
expect(addListener).toHaveBeenCalledTimes(1);
});
it('returned unsubscribe removes the JS listener AND calls native unregister', () => {
const unsubscribe = NamiFlowManager.registerStepHandoff(() => {});
unsubscribe();
expect(subRemove).toHaveBeenCalledTimes(1);
expect(unregisterStepHandoff).toHaveBeenCalledTimes(1);
});
});
describe('handoff outcome token bridge', () => {
it('delivers (tag, data, complete) and correlates complete() back to the handoffId', () => {
const handler = jest.fn(
(_tag: string, _data: unknown, complete: () => void) => complete(),
);
NamiFlowManager.registerStepHandoff(handler);
fireHandoff({
handoffId: 'h1',
handoffTag: 'signin',
handoffData: { a: 1 },
});
expect(handler).toHaveBeenCalledWith(
'signin',
{ a: 1 },
expect.any(Function),
);
// Bare complete() is normalized to {kind:'done'} before crossing the bridge.
expect(completeHandoff).toHaveBeenCalledTimes(1);
expect(completeHandoff).toHaveBeenCalledWith('h1', { kind: 'done' });
});
it('forwards an explicit outcome verbatim (incl. primitive login attributes)', () => {
const outcome: NamiHandoffOutcome = {
kind: 'login',
result: 'success',
details: {
loginId: 'user-123',
attributes: { subscriberStatus: 'active', seats: 3, trial: false },
},
};
NamiFlowManager.registerStepHandoff((_t, _d, complete) =>
complete(outcome),
);
fireHandoff({ handoffId: 'h2', handoffTag: 'signin' });
expect(completeHandoff).toHaveBeenCalledWith('h2', outcome);
});
it('is once-only per handoff — a second complete() warns and does not re-cross the bridge', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
NamiFlowManager.registerStepHandoff((_t, _d, complete) => {
complete({ kind: 'purchase', result: 'cancelled' });
complete(); // duplicate — must be ignored
});
fireHandoff({ handoffId: 'h3', handoffTag: 'buysku' });
expect(completeHandoff).toHaveBeenCalledTimes(1);
expect(completeHandoff).toHaveBeenCalledWith('h3', {
kind: 'purchase',
result: 'cancelled',
});
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('called more than once'),
);
warn.mockRestore();
});
it('correlates concurrent handoffs to their own ids independently', () => {
const completes: Record<string, () => void> = {};
NamiFlowManager.registerStepHandoff((tag, _d, complete) => {
completes[tag] = complete;
});
fireHandoff({ handoffId: 'hA', handoffTag: 'push' });
fireHandoff({ handoffId: 'hB', handoffTag: 'location' });
completes.location();
completes.push();
expect(completeHandoff).toHaveBeenCalledWith('hB', { kind: 'done' });
expect(completeHandoff).toHaveBeenCalledWith('hA', { kind: 'done' });
});
it('legacy (tag, data) handler that calls resume() still works (degrades natively, no completeHandoff)', () => {
// Old-shape handler ignores the third arg and calls resume() directly.
const legacy = (_tag: string, _data?: Record<string, unknown>) => {
NamiFlowManager.resume();
};
// Cast at the call site only — the public signature is the 3-arg shape.
NamiFlowManager.registerStepHandoff(legacy as never);
fireHandoff({ handoffId: 'h4', handoffTag: 'complete' });
expect(resumeMock).toHaveBeenCalledTimes(1);
expect(completeHandoff).not.toHaveBeenCalled();
});
});
describe('registerEventHandler', () => {
it('returns a function and triggers native register', () => {
const unsubscribe = NamiFlowManager.registerEventHandler(() => {});
expect(typeof unsubscribe).toBe('function');
expect(registerEventHandler).toHaveBeenCalledTimes(1);
expect(addListener).toHaveBeenCalledTimes(1);
});
it('returned unsubscribe removes the JS listener AND calls native unregister', () => {
const unsubscribe = NamiFlowManager.registerEventHandler(() => {});
unsubscribe();
expect(subRemove).toHaveBeenCalledTimes(1);
expect(unregisterEventHandler).toHaveBeenCalledTimes(1);
});
});
});