From a7401b534a8091c62f0f5c339fcf322cbf0adc83 Mon Sep 17 00:00:00 2001 From: edmand46 Date: Sun, 6 Sep 2026 21:44:54 +0300 Subject: [PATCH] Slug-based scenarios, quests and offers per the environments contract; regenerated models; e2e seed passes environment to admin mirrors Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V --- src/battlepass/BattlePassService.ts | 6 +-- src/domains/StoresDomain.ts | 8 ++-- src/effects/EffectsCenter.ts | 4 +- src/generated/api.ts | 4 +- src/generated/battlepass.ts | 8 ++-- src/generated/quests.ts | 6 +-- src/generated/scenarios.ts | 6 +-- src/generated/stores.ts | 3 +- src/quests/QuestMetrics.ts | 4 +- src/quests/QuestsService.ts | 6 +-- src/scenario/ScenarioService.ts | 34 +++++++-------- src/state/shops.ts | 9 +++- test/RudderClient.test.ts | 2 +- test/ScenarioService.test.ts | 12 +++--- test/e2e-prod/_setup/harness.ts | 2 +- test/e2e-prod/_setup/seed.ts | 66 ++++++++++++++++------------- test/e2e-prod/_setup/types.ts | 6 +-- test/e2e-prod/purchase.e2e.test.ts | 2 +- test/e2e/boundary.e2e.test.ts | 16 +++---- test/e2e/offerJourney.e2e.test.ts | 16 +++---- test/e2e/persistence.e2e.test.ts | 6 +-- test/helpers/fakeGateway.ts | 14 +++--- test/helpers/plan.ts | 2 +- 23 files changed, 127 insertions(+), 115 deletions(-) diff --git a/src/battlepass/BattlePassService.ts b/src/battlepass/BattlePassService.ts index 3d99a18..8b11a8d 100644 --- a/src/battlepass/BattlePassService.ts +++ b/src/battlepass/BattlePassService.ts @@ -2,7 +2,7 @@ * BattlePassService — call-and-response access to the battle pass endpoints. * * Battle pass state is tied to a scenario battle pass node, so every call - * carries a `scenarioId` + `nodeId` (and a `runId` for the mutating calls). + * carries a `scenarioSlug` + `nodeId` (and a `runId` for the mutating calls). * Battle pass has no sync-revision key, so this is a plain service, not an * observable domain; re-fetch progress explicitly after a mutation. */ @@ -23,8 +23,8 @@ export class BattlePassService { constructor(private readonly ctx: RudderContext) {} /** Reads current progress (xp, level, premium, claimed tiers) for a node. */ - getProgress(scenarioId: string, nodeId: string): Promise { - return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId }); + getProgress(scenarioSlug: string, nodeId: string): Promise { + return api.getBattlePassProgress(this.ctx, { scenarioSlug, nodeId }); } /** Credits XP from a configured source. Returns the new xp/level. */ diff --git a/src/domains/StoresDomain.ts b/src/domains/StoresDomain.ts index 0542d65..6473379 100644 --- a/src/domains/StoresDomain.ts +++ b/src/domains/StoresDomain.ts @@ -14,7 +14,7 @@ import type { PurchaseOfferResponse } from '../generated/stores.js'; export class StoresDomain extends SyncedState { readonly syncKey = 'stores'; - /** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */ + /** Shared purchase executor: `stores.purchase(slug, offerSlug, opts)`. */ readonly purchase: PurchaseOffer; private readonly ctx: RudderContext; @@ -24,15 +24,15 @@ export class StoresDomain extends SyncedState { // handles created in the loader all delegate to this one executor. const purchase: PurchaseOffer = async ( storeSlug: string, - offerId: string, + offerSlug: string, options: BuyOptions = {}, ): Promise => { const response = await api.purchaseOffer( ctx, - { storeSlug, offerId }, + { storeSlug, offerSlug }, { storeSlug, - offerId, + offerSlug, idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), }, ); diff --git a/src/effects/EffectsCenter.ts b/src/effects/EffectsCenter.ts index d38e2f9..cfc3795 100644 --- a/src/effects/EffectsCenter.ts +++ b/src/effects/EffectsCenter.ts @@ -40,12 +40,12 @@ export interface WaitEffect { export interface ScenarioCompletedEffect { readonly runId: string; - readonly scenarioId: string; + readonly scenarioSlug: string; } export interface ScenarioFailedEffect { readonly runId: string; - readonly scenarioId: string; + readonly scenarioSlug: string; readonly nodeId: string; readonly error: Error; } diff --git a/src/generated/api.ts b/src/generated/api.ts index afd3736..6afb89f 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -163,10 +163,10 @@ export const api = { purchaseOffer: ( t: Transport, - path: { storeSlug: string; offerId: string }, + path: { storeSlug: string; offerSlug: string }, body: PurchaseOfferRequest, ): Promise => - t.request('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerId)}/purchase`, body), + t.request('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body), refreshAccessToken: ( t: Transport, diff --git a/src/generated/battlepass.ts b/src/generated/battlepass.ts index f712268..3f11aca 100644 --- a/src/generated/battlepass.ts +++ b/src/generated/battlepass.ts @@ -6,7 +6,7 @@ export interface AddBattlePassXpRequest { "amount"?: number; "nodeId"?: string; "runId"?: string; - "scenarioId"?: string; + "scenarioSlug"?: string; "source"?: string; } @@ -21,7 +21,7 @@ export interface ClaimBattlePassRewardRequest { "level"?: number; "nodeId"?: string; "runId"?: string; - "scenarioId"?: string; + "scenarioSlug"?: string; "track"?: "free" | "premium"; } @@ -39,7 +39,7 @@ export interface ClaimedTier { export interface GetBattlePassProgressRequest { "nodeId"?: string; - "scenarioId"?: string; + "scenarioSlug"?: string; } export interface GetBattlePassProgressResponse { @@ -53,7 +53,7 @@ export interface PurchaseBattlePassPremiumRequest { "idempotencyKey"?: string; "nodeId"?: string; "runId"?: string; - "scenarioId"?: string; + "scenarioSlug"?: string; } export interface PurchaseBattlePassPremiumResponse { diff --git a/src/generated/quests.ts b/src/generated/quests.ts index 43b1782..21342f1 100644 --- a/src/generated/quests.ts +++ b/src/generated/quests.ts @@ -3,7 +3,7 @@ import type { Reward } from './common.js'; export interface ClaimQuestRequest { - "questId"?: string; + "questSlug"?: string; } export interface ClaimQuestResponse { @@ -18,10 +18,10 @@ export interface ListQuestsResponse { } export interface Quest { - "id"?: string; "name"?: string; "objectives"?: QuestObjectiveProgress[]; "rewards"?: Reward[]; + "slug"?: string; "status"?: "active" | "claimed" | "completed"; } @@ -39,6 +39,6 @@ export interface ReportQuestProgressRequest { } export interface ReportQuestProgressResponse { - "completedQuestIds"?: string[]; + "completedQuestSlugs"?: string[]; } diff --git a/src/generated/scenarios.ts b/src/generated/scenarios.ts index cd78abf..09ba4fa 100644 --- a/src/generated/scenarios.ts +++ b/src/generated/scenarios.ts @@ -4,7 +4,7 @@ export interface HandleScenarioCallbackRequest { "handle"?: string; "nodeId"?: string; "runId"?: string; - "scenarioId"?: string; + "scenarioSlug"?: string; } export interface HandleScenarioCallbackResponse { @@ -19,7 +19,7 @@ export interface PendingEffect { "data": { [key: string]: unknown }; "nodeId": string; "runId": string; - "scenarioId": string; + "scenarioSlug": string; "type": string; "waitDeadline"?: string; } @@ -37,7 +37,7 @@ export interface UpdateScenarioCounterRequest { "counterKey"?: string; "nodeId"?: string; "runId"?: string; - "scenarioId"?: string; + "scenarioSlug"?: string; } export interface UpdateScenarioCounterResponse { diff --git a/src/generated/stores.ts b/src/generated/stores.ts index 8e372a9..78dce9e 100644 --- a/src/generated/stores.ts +++ b/src/generated/stores.ts @@ -12,6 +12,7 @@ export interface Offer { "maxPurchases"?: number; "name"?: string; "price"?: OfferPrice; + "slug"?: string; "updatedAt"?: string; } @@ -27,7 +28,7 @@ export interface OfferPrice { export interface PurchaseOfferRequest { "idempotencyKey"?: string; - "offerId"?: string; + "offerSlug"?: string; "storeSlug"?: string; } diff --git a/src/quests/QuestMetrics.ts b/src/quests/QuestMetrics.ts index d73598a..f933287 100644 --- a/src/quests/QuestMetrics.ts +++ b/src/quests/QuestMetrics.ts @@ -9,8 +9,8 @@ */ /** Metric for purchasing a store offer; auto-reported on purchase. */ -export function purchaseOffer(offerId: string): string { - return `purchase.offer:${offerId}`; +export function purchaseOffer(offerSlug: string): string { + return `purchase.offer:${offerSlug}`; } /** Metric for purchasing a catalog item; auto-reported on purchase. */ diff --git a/src/quests/QuestsService.ts b/src/quests/QuestsService.ts index 73a7ce7..349f583 100644 --- a/src/quests/QuestsService.ts +++ b/src/quests/QuestsService.ts @@ -20,13 +20,13 @@ export class QuestsService { } /** Claims a completed quest's rewards (idempotent server-side). */ - claim(questId: string): Promise { - return api.claimQuest(this.ctx, { questId }); + claim(questSlug: string): Promise { + return api.claimQuest(this.ctx, { questSlug }); } /** Reports metric progress; returns ids of quests completed by this report. */ async reportProgress(metric: string, amount: number): Promise { const response = await api.reportQuestProgress(this.ctx, { metric, amount }); - return response.completedQuestIds ?? []; + return response.completedQuestSlugs ?? []; } } diff --git a/src/scenario/ScenarioService.ts b/src/scenario/ScenarioService.ts index 747b30c..18e848c 100644 --- a/src/scenario/ScenarioService.ts +++ b/src/scenario/ScenarioService.ts @@ -22,7 +22,7 @@ const JITTER_RATIO = 0.2; type ActiveRecord = { runId: string; nodeId: string; - scenarioId: string; + scenarioSlug: string; storePurchased?: boolean; lastPurchase?: PurchaseOfferResponse; }; @@ -177,7 +177,7 @@ export class ScenarioService { for (const [key, record] of [...this.active.entries()]) { if (incomingKeys.has(key)) continue; this.deactivate(key); - this.emitCompletedIfIdle(record.runId, record.scenarioId); + this.emitCompletedIfIdle(record.runId, record.scenarioSlug); } const after = new Set(this.active.keys()); @@ -206,7 +206,7 @@ export class ScenarioService { const record: ActiveRecord = { runId: pending.runId, nodeId: pending.nodeId, - scenarioId: pending.scenarioId, + scenarioSlug: pending.scenarioSlug, }; this.active.set(key, record); this.completedRuns.delete(pending.runId); @@ -289,16 +289,16 @@ export class ScenarioService { } private dispatchBattlePass(record: ActiveRecord): void { - const { scenarioId, nodeId, runId } = record; + const { scenarioSlug, nodeId, runId } = record; this.ctx.effects.emitBattlePass({ - getProgress: () => this.battlePass.getProgress(scenarioId, nodeId), + getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId), addXp: (source, amount) => - this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }), + this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }), claimReward: (level, track) => - this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }), + this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }), purchasePremium: async () => { const response = await this.battlePass.purchasePremium({ - scenarioId, + scenarioSlug, nodeId, idempotencyKey: crypto.randomUUID(), runId, @@ -346,7 +346,7 @@ export class ScenarioService { ): Promise { try { const response = await api.updateScenarioCounter(this.ctx, { - scenarioId: record.scenarioId, + scenarioSlug: record.scenarioSlug, nodeId: record.nodeId, counterKey: objectiveId, amount, @@ -362,7 +362,7 @@ export class ScenarioService { if (response.effect) { this.ingest([response.effect]); } else { - this.emitCompletedIfIdle(record.runId, record.scenarioId); + this.emitCompletedIfIdle(record.runId, record.scenarioSlug); } } catch { } @@ -374,7 +374,7 @@ export class ScenarioService { if (this.droppedRuns.has(record.runId)) return; try { const response = await api.handleScenarioCallback(this.ctx, { - scenarioId: record.scenarioId, + scenarioSlug: record.scenarioSlug, nodeId: record.nodeId, handle, runId: record.runId, @@ -386,7 +386,7 @@ export class ScenarioService { if (response?.effect) { this.ingest([response.effect]); } else { - this.emitCompletedIfIdle(record.runId, record.scenarioId); + this.emitCompletedIfIdle(record.runId, record.scenarioSlug); } } catch (err) { if (isTransientHttpError(err)) return; @@ -425,10 +425,10 @@ export class ScenarioService { private dropRun(runId: string, nodeId: string, error: Error): void { this.droppedRuns.add(runId); - let scenarioId = ''; + let scenarioSlug = ''; for (const [key, record] of [...this.active.entries()]) { if (record.runId !== runId) continue; - if (!scenarioId) scenarioId = record.scenarioId; + if (!scenarioSlug) scenarioSlug = record.scenarioSlug; this.deactivate(key); } console.warn( @@ -436,19 +436,19 @@ export class ScenarioService { ); this.ctx.effects.emitScenarioFailed({ runId, - scenarioId, + scenarioSlug, nodeId, error, }); } - private emitCompletedIfIdle(runId: string, scenarioId: string): void { + private emitCompletedIfIdle(runId: string, scenarioSlug: string): void { if (this.droppedRuns.has(runId)) return; if (this.completedRuns.has(runId)) return; for (const record of this.active.values()) { if (record.runId === runId) return; } this.completedRuns.add(runId); - this.ctx.effects.emitScenarioCompleted({ runId, scenarioId }); + this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug }); } } diff --git a/src/state/shops.ts b/src/state/shops.ts index 257067a..0b14482 100644 --- a/src/state/shops.ts +++ b/src/state/shops.ts @@ -16,12 +16,13 @@ export interface BuyOptions { */ export type PurchaseOffer = ( storeSlug: string, - offerId: string, + offerSlug: string, options?: BuyOptions, ) => Promise; export class OfferHandle { public readonly id: string; + public readonly slug: string; public readonly name?: string; public readonly price?: OfferPrice; public readonly contents: OfferContent[]; @@ -35,7 +36,11 @@ export class OfferHandle { if (!offer.id) { throw new Error('Rudder Stores: offer.id is required'); } + if (!offer.slug) { + throw new Error('Rudder Stores: offer.slug is required'); + } this.id = offer.id; + this.slug = offer.slug; this.name = offer.name; this.price = offer.price; this.contents = offer.contents ?? []; @@ -43,7 +48,7 @@ export class OfferHandle { } async buy(options: BuyOptions = {}): Promise { - return this.purchase(this.storeSlug, this.id, options); + return this.purchase(this.storeSlug, this.slug, options); } } diff --git a/test/RudderClient.test.ts b/test/RudderClient.test.ts index 153bb89..b186dac 100644 --- a/test/RudderClient.test.ts +++ b/test/RudderClient.test.ts @@ -184,7 +184,7 @@ describe('lazy scenario runtime', () => { return Promise.resolve(new Response(JSON.stringify({ effects: [{ runId: 'run-1', - scenarioId: 'scenario-1', + scenarioSlug: 'scenario-1', nodeId: 'start', type: 'notification', data: { message: 'Welcome!' }, diff --git a/test/ScenarioService.test.ts b/test/ScenarioService.test.ts index 1d6bb6a..ec76267 100644 --- a/test/ScenarioService.test.ts +++ b/test/ScenarioService.test.ts @@ -88,7 +88,7 @@ describe('ScenarioService', () => { }); } if (path === '/sdk/v1/stores/starter') { - return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] }); + return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] }); } return jsonResponse({}); }); @@ -203,7 +203,7 @@ describe('ScenarioService', () => { return url.includes('/sdk/v1/scenarios/callback'); }); expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({ - scenarioId: 'scenario-1', + scenarioSlug: 'scenario-1', nodeId: 'n1', handle: 'output', runId: 'run-1', @@ -227,7 +227,7 @@ describe('ScenarioService', () => { expect(onCompleted).toHaveBeenCalledOnce(); expect(onCompleted.mock.calls[0][0]).toEqual({ runId: 'run-1', - scenarioId: 'scenario-1', + scenarioSlug: 'scenario-1', }); expect(scenarios.isRunning).toBe(false); }); @@ -359,10 +359,10 @@ describe('ScenarioService', () => { return jsonResponse({ name: 'Starter', slug: 'starter', - offers: [{ id: 'pack_1', name: 'Starter Pack' }], + offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }], }); } - if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack_1/purchase') { + if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack-1/purchase') { return jsonResponse({ success: true, purchaseId: 'purchase-1' }); } return jsonResponse({}); @@ -387,7 +387,7 @@ describe('ScenarioService', () => { return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] }); } if (path === '/sdk/v1/stores/starter') { - return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1' }] }); + return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1', slug: 'pack-1' }] }); } return jsonResponse({}); }); diff --git a/test/e2e-prod/_setup/harness.ts b/test/e2e-prod/_setup/harness.ts index 4c2ce8f..7055a46 100644 --- a/test/e2e-prod/_setup/harness.ts +++ b/test/e2e-prod/_setup/harness.ts @@ -82,7 +82,7 @@ export async function grantCurrency( amount: number, ): Promise { await adminApi(artifact).post( - `/platform/v1/projects/${artifact.projectId}/players/wallet/adjust`, + `/platform/v1/projects/${artifact.projectId}/players/wallet/adjust?environment=${artifact.environment}`, { items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] }, ); } diff --git a/test/e2e-prod/_setup/seed.ts b/test/e2e-prod/_setup/seed.ts index a5cd5cd..69f513e 100644 --- a/test/e2e-prod/_setup/seed.ts +++ b/test/e2e-prod/_setup/seed.ts @@ -19,6 +19,7 @@ interface ReleaseResponse { interface QuestResponse { id: string; + slug: string; name: string; status: string; objectives?: Array<{ id: string; metric: string; target: number }>; @@ -28,10 +29,10 @@ interface QuestResponse { interface StoreResponse { id: string; name: string; - slug?: string; - data?: { slug?: string }; + slug: string; offers?: Array<{ id: string; + slug: string; name: string; contents?: Array<{ itemId: string; amount: number }>; }>; @@ -176,18 +177,24 @@ export async function runSeed( }); // 7. Store with a free offer and a paid (currency) offer with a purchase limit. - // The runtime resolves a store's slug from data.slug, and the CreateStore API has - // no slug field — so we set it via data. + // Stores and offers carry an authored slug that survives promotes. const storeSlug = 'starter-shop'; + const paidOfferSlug = 'gold-pack'; const store = await api.post(`${projPath}/stores${q}`, { projectId: project.id, environment: authoringEnv, + slug: storeSlug, name: 'Starter Shop', description: 'E2E shop', - data: { slug: storeSlug }, offers: [ - { name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency, amount: 0 } }, { + slug: 'welcome-gift', + name: 'Welcome Gift', + contents: [{ itemId: item.id, amount: 10 }], + price: { currency, amount: 0 }, + }, + { + slug: paidOfferSlug, name: 'Gold Pack', contents: [{ itemId: item.id, amount: 50 }], price: { currency, amount: 100 }, @@ -196,20 +203,18 @@ export async function runSeed( ], }); log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`); - const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack'); - if (!paidOffer?.id) { - throw new Error('seeded paid offer did not return an id'); - } // 8. Global quest: buying the paid offer completes it; claim grants extra item reward. const questName = 'Buy Gold Pack Quest'; const questObjectiveId = 'buy-gold-pack'; const questTarget = 1; - const questMetric = `purchase.offer:${paidOffer.id}`; + const questSlug = 'buy-gold-pack-quest'; + const questMetric = `purchase.offer:${paidOfferSlug}`; const questRewardAmount = 7; const quest = await api.post(`${projPath}/quests${q}`, { projectId: project.id, environment: authoringEnv, + slug: questSlug, name: questName, objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }], rewards: [{ itemId: item.id, amount: questRewardAmount }], @@ -220,9 +225,11 @@ export async function runSeed( const startAt = new Date(ts - 3600_000).toISOString(); const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString(); const scenarioEvent = 'session_start'; + const scenarioSlug = 'onboarding'; const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, { projectId: project.id, environment: authoringEnv, + slug: scenarioSlug, name: 'Onboarding', description: 'e2e onboarding', startAt, @@ -230,9 +237,8 @@ export async function runSeed( }); // 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification). - await api.put(`${projPath}/scenarios/${scenario.id}`, { + await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, { projectId: project.id, - scenarioId: scenario.id, flow: buildFlow(storeSlug), }); log(`scenario ${scenario.id} flow set`); @@ -245,15 +251,15 @@ export async function runSeed( }); await pollRelease(api, projPath, runtimeEnv, release.id, log); - // Promotion re-creates every entity with fresh ids — read the prod ones back. + // Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug. const prodStores = await api.get(`${projPath}/stores${prodQ}`); - const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop'); + const prodStore = prodStores.find((s) => s.slug === storeSlug); if (!prodStore) { throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`); } - const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack'); - if (!prodPaidOffer?.id) { - throw new Error('promoted paid offer did not return an id'); + const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug); + if (!prodPaidOffer?.slug) { + throw new Error('promoted paid offer did not return a slug'); } const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId; if (!prodRewardItemId) { @@ -261,25 +267,25 @@ export async function runSeed( } const prodQuests = await api.get(`${projPath}/quests${prodQ}`); - const prodQuest = prodQuests.find((q) => q.name === questName); - if (!prodQuest?.id) { - throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`); + const prodQuest = prodQuests.find((qq) => qq.slug === questSlug); + if (!prodQuest?.slug) { + throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`); } const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric; if (!prodQuestMetric) { - throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`); + throw new Error(`promoted quest ${questSlug} did not return metric ${questObjectiveId}`); } const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId; if (!prodQuestRewardItemId) { - throw new Error(`promoted quest ${questName} did not return reward item id`); + throw new Error(`promoted quest ${questSlug} did not return reward item id`); } - const prodScenarios = await api.get>( + const prodScenarios = await api.get>( `${projPath}/scenarios${prodQ}`, ); - const prodScenario = prodScenarios.find((s) => s.name === 'Onboarding'); - if (!prodScenario?.id) { - throw new Error(`promoted scenario Onboarding was not found in ${runtimeEnv}`); + const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug); + if (!prodScenario?.slug) { + throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`); } return { @@ -296,14 +302,14 @@ export async function runSeed( slug: storeSlug, freeOfferName: 'Welcome Gift', paidOfferName: 'Gold Pack', - paidOfferId: prodPaidOffer.id, + paidOfferSlug: prodPaidOffer.slug, paidPrice: { currency, amount: 100 }, paidMaxPurchases: 2, grantedItemName: 'Gold Coin', paidGrantAmount: 50, }, quest: { - id: prodQuest.id, + slug: prodQuest.slug, name: questName, objectiveId: questObjectiveId, metric: prodQuestMetric, @@ -321,7 +327,7 @@ export async function runSeed( shopLayout: { cols: 3 }, spawnRateOverride: 3.0, }, - scenario: { id: prodScenario.id, event: scenarioEvent }, + scenario: { slug: prodScenario.slug, event: scenarioEvent }, }; } diff --git a/test/e2e-prod/_setup/types.ts b/test/e2e-prod/_setup/types.ts index 5bfaef6..5d5d8eb 100644 --- a/test/e2e-prod/_setup/types.ts +++ b/test/e2e-prod/_setup/types.ts @@ -16,14 +16,14 @@ export interface SeedArtifact { slug: string; freeOfferName: string; paidOfferName: string; - paidOfferId: string; + paidOfferSlug: string; paidPrice: { currency: string; amount: number }; paidMaxPurchases: number; grantedItemName: string; paidGrantAmount: number; }; quest: { - id: string; + slug: string; name: string; objectiveId: string; metric: string; @@ -42,5 +42,5 @@ export interface SeedArtifact { /** spawn_rate value the scenario's remote_config_override applies. */ spawnRateOverride: number; }; - scenario: { id: string; event: string }; + scenario: { slug: string; event: string }; } diff --git a/test/e2e-prod/purchase.e2e.test.ts b/test/e2e-prod/purchase.e2e.test.ts index fb12d7f..4de0392 100644 --- a/test/e2e-prod/purchase.e2e.test.ts +++ b/test/e2e-prod/purchase.e2e.test.ts @@ -64,7 +64,7 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => { // 3. Inventory credited (verified via admin player details). const details = await api.get( - `/platform/v1/projects/${artifact.projectId}/players/${playerId}`, + `/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`, ); const inv = details.inventory?.find((i) => i.slug === artifact.item.id); expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount); diff --git a/test/e2e/boundary.e2e.test.ts b/test/e2e/boundary.e2e.test.ts index 7dc7c7f..c8150e0 100644 --- a/test/e2e/boundary.e2e.test.ts +++ b/test/e2e/boundary.e2e.test.ts @@ -24,7 +24,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => { stores: [{ slug: 'starter', name: 'Starter', - offers: [{ id: 'pack_1', name: 'Starter Pack' }], + offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }], }], }); gateway.install(); @@ -35,12 +35,12 @@ describe('E2E: boundary nodes (server-side continuation)', () => { it('purchase crosses the boundary → callback fires and continues the scenario', async () => { const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, { - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, { - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'reward', }); @@ -52,7 +52,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => { let offer: StoreOfferEffect | undefined; client.effects.onStoreOffer((e) => { offer = e; }); client.effects.onNotification((e) => { messages.push(e.message); }); - client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioId); }); + client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioSlug); }); const token = (await client.auth.loginWithDevice()).accessToken; await vi.waitFor(() => expect(offer).toBeDefined()); @@ -61,7 +61,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => { const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback'); expect(callback?.body).toEqual({ - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run', @@ -75,12 +75,12 @@ describe('E2E: boundary nodes (server-side continuation)', () => { it('decline posts callback and continues with the server-supplied next effect', async () => { const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, { - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); const consolation = effect('notification', { message: 'Maybe next time!' }, { - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'consolation', }); @@ -102,7 +102,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => { it('a failing callback does not fail or crash the run', async () => { const offerEffect = effect('store', { storeSlug: 'starter' }, { - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); diff --git a/test/e2e/offerJourney.e2e.test.ts b/test/e2e/offerJourney.e2e.test.ts index 38fb045..0800eeb 100644 --- a/test/e2e/offerJourney.e2e.test.ts +++ b/test/e2e/offerJourney.e2e.test.ts @@ -18,29 +18,29 @@ function makeClient(): RudderClient { function offerScenario(now: number) { const waitIntro = effect('wait', {}, { - scenarioId: 'offer_flow', + scenarioSlug: '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', + const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, { + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); const waitReminder = effect('wait', {}, { - scenarioId: 'offer_flow', + scenarioSlug: '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', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'reminder', }); const thanks = effect('notification', { message: 'Thanks for your purchase!' }, { - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'thanks', }); @@ -58,7 +58,7 @@ describe('E2E: player offer journey', () => { stores: [{ slug: 'starter', name: 'Starter', - offers: [{ id: 'pack_1', name: 'Starter Pack' }], + offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }], }], }); gateway.install(); @@ -139,7 +139,7 @@ describe('E2E: player offer journey', () => { expect(purchase.success).toBe(true); expect(gateway.purchases).toEqual([ - { storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken }, + { storeSlug: 'starter', offerSlug: 'pack-1', idempotencyKey: 'idem-key-123', authToken: accessToken }, ]); expect(notifications).toHaveLength(1); diff --git a/test/e2e/persistence.e2e.test.ts b/test/e2e/persistence.e2e.test.ts index 3f4b10c..4b65164 100644 --- a/test/e2e/persistence.e2e.test.ts +++ b/test/e2e/persistence.e2e.test.ts @@ -24,17 +24,17 @@ describe('E2E: pending effects resume after a new login', () => { stubDeterministicUuid(); vi.useFakeTimers(); gateway = createFakeGateway({ - stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] }], + stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] }], }); const now = Date.now(); const wait = effect('wait', {}, { - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'wait_intro', waitDeadline: new Date(now + MINUTE).toISOString(), }); const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, { - scenarioId: 'offer_flow', + scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); diff --git a/test/helpers/fakeGateway.ts b/test/helpers/fakeGateway.ts index 7079587..b49d632 100644 --- a/test/helpers/fakeGateway.ts +++ b/test/helpers/fakeGateway.ts @@ -33,8 +33,8 @@ export interface FakeGatewayState { export interface FakeGateway { state: FakeGatewayState; recorded: RecordedRequest[]; - purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>; - questClaims: Array<{ questId: string; authToken: string | null }>; + purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>; + questClaims: Array<{ questSlug: string; authToken: string | null }>; install(): void; onEvent(event: string, ...effects: PendingEffect[]): void; onCallback(nodeId: string, handle: string, next: PendingEffect | null): void; @@ -175,7 +175,7 @@ export function createFakeGateway(seed?: Partial): FakeGateway const b = body as { idempotencyKey?: string }; purchases.push({ storeSlug: decodeURIComponent(purchaseMatch[1]), - offerId: decodeURIComponent(purchaseMatch[2]), + offerSlug: decodeURIComponent(purchaseMatch[2]), idempotencyKey: b.idempotencyKey ?? '', authToken: req.authToken, }); @@ -192,10 +192,10 @@ export function createFakeGateway(seed?: Partial): FakeGateway return json(state.quests); } if (method === 'POST' && path === '/sdk/v1/quests/claim') { - const b = body as { questId?: string }; - const questId = b.questId ?? ''; - questClaims.push({ questId, authToken: req.authToken }); - return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' }); + const b = body as { questSlug?: string }; + const questSlug = b.questSlug ?? ''; + questClaims.push({ questSlug, authToken: req.authToken }); + return json(state.questClaims.get(questSlug) ?? { success: false, error: 'not found' }); } if (path === '/sdk/v1/storage') { diff --git a/test/helpers/plan.ts b/test/helpers/plan.ts index 00b86ff..0dfd051 100644 --- a/test/helpers/plan.ts +++ b/test/helpers/plan.ts @@ -7,7 +7,7 @@ export function effect( ): PendingEffect { return { runId: 'run-1', - scenarioId: 'scenario-1', + scenarioSlug: 'scenario-1', nodeId: `${type}-1`, type, data,