2026-08-12 14:02:25 +03:00
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
|
import { 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';
|
2026-09-04 14:08:48 +03:00
|
|
|
import { effect } from '../helpers/plan.js';
|
2026-08-12 14:02:25 +03:00
|
|
|
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
|
|
|
|
|
|
|
|
|
|
const MINUTE = 60_000;
|
|
|
|
|
|
|
|
|
|
function makeClient(): RudderClient {
|
|
|
|
|
return new RudderClient({
|
|
|
|
|
baseUrl: 'https://api.test.rudder.build',
|
|
|
|
|
projectKey: 'test-project',
|
|
|
|
|
tokenStore: createFakeTokenStore(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
function offerScenario(now: number) {
|
|
|
|
|
const waitIntro = effect('wait', {}, {
|
|
|
|
|
scenarioId: 'offer_flow',
|
|
|
|
|
runId: 'offer_flow-run',
|
|
|
|
|
nodeId: 'wait_intro',
|
|
|
|
|
waitDeadline: new Date(now + MINUTE).toISOString(),
|
|
|
|
|
});
|
|
|
|
|
const offer = effect('store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }, {
|
|
|
|
|
scenarioId: 'offer_flow',
|
|
|
|
|
runId: 'offer_flow-run',
|
|
|
|
|
nodeId: 'offer',
|
|
|
|
|
});
|
|
|
|
|
const waitReminder = effect('wait', {}, {
|
|
|
|
|
scenarioId: 'offer_flow',
|
|
|
|
|
runId: 'offer_flow-run',
|
|
|
|
|
nodeId: 'wait_reminder',
|
|
|
|
|
waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
|
|
|
|
|
});
|
|
|
|
|
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
|
|
|
|
|
scenarioId: 'offer_flow',
|
|
|
|
|
runId: 'offer_flow-run',
|
|
|
|
|
nodeId: 'reminder',
|
|
|
|
|
});
|
|
|
|
|
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
|
|
|
|
|
scenarioId: 'offer_flow',
|
|
|
|
|
runId: 'offer_flow-run',
|
|
|
|
|
nodeId: 'thanks',
|
|
|
|
|
});
|
|
|
|
|
return { waitIntro, offer, waitReminder, reminder, thanks };
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('E2E: player offer journey', () => {
|
|
|
|
|
let gateway: FakeGateway;
|
|
|
|
|
let client: RudderClient;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
stubDeterministicUuid();
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
gateway = createFakeGateway({
|
|
|
|
|
stores: [{
|
|
|
|
|
slug: 'starter',
|
|
|
|
|
name: 'Starter',
|
|
|
|
|
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
|
|
|
|
|
}],
|
|
|
|
|
});
|
|
|
|
|
gateway.install();
|
|
|
|
|
client = makeClient();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
vi.unstubAllGlobals();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('authenticates the device and persists tokens for later calls', async () => {
|
|
|
|
|
const res = await client.auth.loginWithDevice({ region: 'eu', language: 'en' });
|
|
|
|
|
|
|
|
|
|
expect(res.accessToken).toBeTruthy();
|
|
|
|
|
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
|
|
|
|
|
|
|
|
|
|
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device');
|
|
|
|
|
expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
|
|
|
|
|
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
|
|
|
|
|
expect(trigger?.authToken).toBe(res.accessToken);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
const now = Date.now();
|
|
|
|
|
const nodes = offerScenario(now);
|
|
|
|
|
gateway.onEvent('player_login', nodes.waitIntro);
|
|
|
|
|
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
|
|
|
|
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
|
|
|
|
|
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
|
2026-08-12 14:02:25 +03:00
|
|
|
|
|
|
|
|
const notifications: NotificationEffect[] = [];
|
|
|
|
|
let offer: StoreOfferEffect | undefined;
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onStoreOffer((e) => { offer = e; });
|
|
|
|
|
client.effects.onNotification((e) => { notifications.push(e); });
|
2026-08-12 14:02:25 +03:00
|
|
|
|
|
|
|
|
await client.auth.loginWithDevice();
|
|
|
|
|
|
|
|
|
|
expect(offer).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
|
|
|
|
|
expect(offer).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
|
|
|
|
expect(offer!.message).toBe('Limited starter pack!');
|
|
|
|
|
|
|
|
|
|
await offer!.dismiss();
|
|
|
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
|
|
|
|
|
expect(notifications).toHaveLength(0);
|
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
|
expect(notifications).toHaveLength(1);
|
|
|
|
|
expect(notifications[0].message).toBe('Your offer is still available!');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
const now = Date.now();
|
|
|
|
|
const nodes = offerScenario(now);
|
|
|
|
|
gateway.onEvent('player_login', nodes.waitIntro);
|
|
|
|
|
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
|
|
|
|
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
|
2026-08-12 14:02:25 +03:00
|
|
|
|
|
|
|
|
let offer: StoreOfferEffect | undefined;
|
|
|
|
|
const notifications: NotificationEffect[] = [];
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onStoreOffer((e) => { offer = e; });
|
|
|
|
|
client.effects.onNotification((e) => { notifications.push(e); });
|
2026-08-12 14:02:25 +03:00
|
|
|
|
|
|
|
|
const accessToken = (await client.auth.loginWithDevice()).accessToken;
|
|
|
|
|
await vi.advanceTimersByTimeAsync(MINUTE);
|
|
|
|
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
|
|
|
|
|
|
|
|
|
const selectedOffer = offer!.offers[0];
|
|
|
|
|
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
|
|
|
|
|
expect(purchase.success).toBe(true);
|
|
|
|
|
|
|
|
|
|
expect(gateway.purchases).toEqual([
|
|
|
|
|
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(notifications).toHaveLength(1);
|
|
|
|
|
expect(notifications[0].message).toBe('Thanks for your purchase!');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('declining does NOT take the purchase branch (handles are isolated)', async () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
const now = Date.now();
|
|
|
|
|
const nodes = offerScenario(now);
|
|
|
|
|
gateway.onEvent('player_login', nodes.waitIntro);
|
|
|
|
|
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
|
|
|
|
|
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
|
|
|
|
|
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
|
|
|
|
|
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
|
|
|
|
|
|
2026-08-12 14:02:25 +03:00
|
|
|
const messages: string[] = [];
|
|
|
|
|
let offer: StoreOfferEffect | undefined;
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onStoreOffer((e) => { offer = e; });
|
|
|
|
|
client.effects.onNotification((e) => { messages.push(e.message); });
|
2026-08-12 14:02:25 +03:00
|
|
|
|
|
|
|
|
await client.auth.loginWithDevice();
|
|
|
|
|
await vi.advanceTimersByTimeAsync(MINUTE);
|
|
|
|
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
|
|
|
|
await offer!.dismiss();
|
|
|
|
|
await vi.advanceTimersByTimeAsync(MINUTE);
|
|
|
|
|
|
|
|
|
|
expect(messages).toEqual(['Your offer is still available!']);
|
|
|
|
|
expect(messages).not.toContain('Thanks for your purchase!');
|
|
|
|
|
expect(gateway.purchases).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
});
|