160 lines
6.6 KiB
TypeScript
160 lines
6.6 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
|
|
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
|
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
|
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
|
import { plan } from '../helpers/plan.js';
|
|
import type { StoreOfferEffect } from '../../src/index.js';
|
|
import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js';
|
|
|
|
function makeClient(): RudderClient {
|
|
return new RudderClient({
|
|
baseUrl: 'https://api.test.rudder.build',
|
|
projectKey: 'test-project',
|
|
tokenStore: createFakeTokenStore(),
|
|
runtime: { planStateStore: null },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* An offer whose `onPurchase` continuation lives server-side (boundary node):
|
|
* the client doesn't hold the post-purchase DAG — it must call back so the
|
|
* server can validate the transaction and decide what happens next.
|
|
*
|
|
* `onDecline`, by contrast, is a plain local edge handled entirely on-device.
|
|
*/
|
|
function offerWithBoundary() {
|
|
return plan('offer_flow')
|
|
.runId('offer_flow-run')
|
|
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy the starter pack!' })
|
|
.node('consolation', 'notification', { message: 'Maybe next time!' })
|
|
.edge('offer', 'onDecline', 'consolation')
|
|
.boundary('offer', 'onPurchase') // server owns what comes after a purchase
|
|
.build();
|
|
}
|
|
|
|
/** What the server returns from the callback — a fresh plan = a new run. */
|
|
function rewardContinuation() {
|
|
return plan('reward_flow')
|
|
.runId('reward_flow-run')
|
|
.node('reward', 'notification', { message: 'Reward granted: 500 gems!' })
|
|
.build();
|
|
}
|
|
|
|
describe('E2E: boundary nodes (server-side continuation)', () => {
|
|
let gateway: FakeGateway;
|
|
let client: RudderClient;
|
|
|
|
beforeEach(() => {
|
|
stubDeterministicUuid();
|
|
gateway = createFakeGateway({
|
|
stores: [{
|
|
slug: 'starter',
|
|
name: 'Starter',
|
|
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
|
|
}],
|
|
});
|
|
gateway.install();
|
|
client = makeClient();
|
|
});
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
|
|
gateway.onEvent('player_login', offerWithBoundary());
|
|
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
|
|
|
|
const messages: string[] = [];
|
|
const completed: PlanRun[] = [];
|
|
let offer: StoreOfferEffect | undefined;
|
|
const runtime = await getScenarioRuntime(client);
|
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
|
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
|
runtime.onRunCompleted = (r) => completed.push(r);
|
|
|
|
const token = (await client.auth.loginWithDevice()).accessToken;
|
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
|
|
|
await offer!.buy(offer!.offers[0]);
|
|
|
|
// The callback hit the gateway with the boundary's source node/handle + token.
|
|
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
|
|
expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' });
|
|
expect(callback?.authToken).toBe(token);
|
|
|
|
// The server-supplied continuation ran (as a new run), not the local edge.
|
|
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
|
expect(messages).not.toContain('Maybe next time!');
|
|
|
|
// The original run finished; the continuation ('reward_flow') is now active.
|
|
expect(completed.map((r) => r.scenarioId)).toContain('offer_flow');
|
|
expect(runtime.activeRuns.map((r) => r.scenarioId)).toEqual(['reward_flow']);
|
|
});
|
|
|
|
it('decline stays local → no callback, local edge is followed', async () => {
|
|
gateway.onEvent('player_login', offerWithBoundary());
|
|
|
|
const messages: string[] = [];
|
|
let offer: StoreOfferEffect | undefined;
|
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
|
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
|
|
|
await client.auth.loginWithDevice();
|
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
|
await offer!.dismiss();
|
|
|
|
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(false);
|
|
expect(messages).toEqual(['Maybe next time!']);
|
|
});
|
|
|
|
it('a boundary takes precedence over a local edge on the SAME handle', async () => {
|
|
// `offer` has BOTH a boundary AND a local edge on onPurchase.
|
|
const conflicting = plan('offer_flow')
|
|
.runId('offer_flow-conflict')
|
|
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy!' })
|
|
.node('local_next', 'notification', { message: 'LOCAL branch' })
|
|
.edge('offer', 'onPurchase', 'local_next')
|
|
.boundary('offer', 'onPurchase')
|
|
.build();
|
|
gateway.onEvent('player_login', conflicting);
|
|
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
|
|
|
|
const messages: string[] = [];
|
|
let offer: StoreOfferEffect | undefined;
|
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
|
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
|
|
|
await client.auth.loginWithDevice();
|
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
|
await offer!.buy(offer!.offers[0]);
|
|
|
|
// Only the server continuation ran; the local edge was suppressed.
|
|
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
|
expect(messages).not.toContain('LOCAL branch');
|
|
});
|
|
|
|
it('a failing callback does not fail or crash the run', async () => {
|
|
gateway.onEvent('player_login', offerWithBoundary());
|
|
gateway.onCallbackError('offer', 'onPurchase', 500);
|
|
|
|
const failures: ScenarioRunFailedEvent[] = [];
|
|
const completed: PlanRun[] = [];
|
|
let offer: StoreOfferEffect | undefined;
|
|
const runtime = await getScenarioRuntime(client);
|
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
|
runtime.onRunFailed = (e) => failures.push(e);
|
|
runtime.onRunCompleted = (r) => completed.push(r);
|
|
|
|
await client.auth.loginWithDevice();
|
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
|
|
|
// Must not throw despite the 500 from the callback endpoint.
|
|
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
|
|
|
|
expect(failures).toHaveLength(0); // 500 is transient — no terminal failure
|
|
// The run stays pending because the boundary call failed transiently.
|
|
// On reconnection the consumer can retry the purchase completion.
|
|
expect(runtime.isRunning).toBe(true);
|
|
});
|
|
});
|