diff --git a/src/scenario/ScenarioService.ts b/src/scenario/ScenarioService.ts index 3d5d16e..747b30c 100644 --- a/src/scenario/ScenarioService.ts +++ b/src/scenario/ScenarioService.ts @@ -40,7 +40,9 @@ function isTransientHttpError(err: unknown): boolean { function isDroppedRunError(err: unknown): boolean { if (!(err instanceof RudderHttpError)) return false; return ( - err.code === RudderErrorCodes.unknownRun || err.code === RudderErrorCodes.runExpired + err.code === RudderErrorCodes.unknownRun || + err.code === RudderErrorCodes.runExpired || + err.code === RudderErrorCodes.runNotActive ); } diff --git a/src/state/RemoteConfigState.ts b/src/state/RemoteConfigState.ts index 8cc5554..923edaf 100644 --- a/src/state/RemoteConfigState.ts +++ b/src/state/RemoteConfigState.ts @@ -23,22 +23,6 @@ export class RemoteConfigState< if (!config || config.value == null) return defaultValue; return parseValue(config.value, config.valueType, defaultValue); } - - /** - * Applies a scenario remote_config_override patch to the current snapshot. - * - * @internal - */ - applyOverride(key: string, value: string, valueType = 'json'): void { - const next = new Map(this.data ?? []); - next.set(key, { - key, - value, - valueType, - active: true, - } as RemoteConfig); - this.set(next); - } } function parseValue( diff --git a/test/e2e-prod/_setup/harness.ts b/test/e2e-prod/_setup/harness.ts index 1b1494a..4c2ce8f 100644 --- a/test/e2e-prod/_setup/harness.ts +++ b/test/e2e-prod/_setup/harness.ts @@ -74,6 +74,19 @@ export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient { return api; } +/** Credits a player's wallet through the admin batch endpoint. */ +export async function grantCurrency( + artifact: SeedArtifact, + playerId: string, + currencyCode: string, + amount: number, +): Promise { + await adminApi(artifact).post( + `/platform/v1/projects/${artifact.projectId}/players/wallet/adjust`, + { items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] }, + ); +} + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** Retries a read until ok(result) holds — absorbs release/cache reload lag. */ diff --git a/test/e2e-prod/_setup/seed.ts b/test/e2e-prod/_setup/seed.ts index 9d410a3..a5cd5cd 100644 --- a/test/e2e-prod/_setup/seed.ts +++ b/test/e2e-prod/_setup/seed.ts @@ -1,11 +1,10 @@ // Production seed: registers an operator, creates a project, configures staging -// content (items, remote configs, leaderboard, store with free+paid offers, a +// content (counter, item, remote configs, leaderboard, store with free+paid offers, a // global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves. -// Excludes realtime & analytics. // -// The scenario flow uses the SDK runtime's exact node `data` keys and handle names — -// the game's plan builder copies node.data verbatim into the execution plan -// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs. +// The scenario flow uses the server engine's node types, `data` keys and handle names — +// the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder +// and .../fsm), so what we store is what the server runs. import { createAdminClient, type AdminClient } from './adminClient.js'; import type { SeedArtifact } from './types.js'; @@ -29,6 +28,7 @@ interface QuestResponse { interface StoreResponse { id: string; name: string; + slug?: string; data?: { slug?: string }; offers?: Array<{ id: string; @@ -110,31 +110,43 @@ export async function runSeed( api.setToken(auth.accessToken); log(`registered ${adminEmail}`); - // 2. Project. - const project = await api.post<{ id: string; key: string; name: string }>( - '/platform/v1/projects', - { name: `e2e-${ts}` }, - ); + // 2. Project. Creating one provisions the staging + prod environments, each with + // its own SDK key — that key is what the runtime SDK sends as projectKey. + const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', { + name: `e2e-${ts}`, + }); const projPath = `/platform/v1/projects/${project.id}`; - log(`project ${project.id} key=${project.key}`); - - // 3. Staging environment (idempotent — may already exist). - try { - await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv }); - } catch { - // already exists / not required — content POSTs below are the real test + const environments = await api.get>( + `${projPath}/environments`, + ); + const projectKey = environments.find((e) => e.name === runtimeEnv)?.sdkKey; + if (!projectKey) { + throw new Error(`project ${project.id} has no ${runtimeEnv} environment sdk key`); } + log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`); + + // 3. Soft currency counter — offer prices are validated against counter slugs, and + // the purchase debit resolves the currency through the catalog counter cache. + const currency = 'soft'; + await api.post(`${projPath}/counters${q}`, { + projectId: project.id, + environment: authoringEnv, + name: 'Soft Currency', + slug: currency, + kind: 'currency', + }); // 4. Item (granted as a paid offer's contents). const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, { projectId: project.id, environment: authoringEnv, name: 'Gold Coin', - tags: ['currency'], + slug: 'gold-coin', }); // 5. Remote configs — one per value type. RCs are live per-env flags served - // straight from the outbox/cache, so they go directly into the runtime env. + // straight from the cache and are not copied by a promote, so they go directly + // into the runtime env. const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [ { key: 'max_energy', value: '100', valueType: 'int' }, { key: 'spawn_rate', value: '1.5', valueType: 'float' }, @@ -164,28 +176,25 @@ 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 (liveops-game shop cache), and - // the CreateStore API has no slug field — so we set it via data. + // The runtime resolves a store's slug from data.slug, and the CreateStore API has + // no slug field — so we set it via data. const storeSlug = 'starter-shop'; - const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>( - `${projPath}/stores${q}`, - { - projectId: project.id, - environment: authoringEnv, - name: 'Starter Shop', - description: 'E2E shop', - data: { slug: storeSlug }, - offers: [ - { name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } }, - { - name: 'Gold Pack', - contents: [{ itemId: item.id, amount: 50 }], - price: { currency: 'soft', amount: 100 }, - maxPurchases: 2, - }, - ], - }, - ); + const store = await api.post(`${projPath}/stores${q}`, { + projectId: project.id, + environment: authoringEnv, + name: 'Starter Shop', + description: 'E2E shop', + data: { slug: storeSlug }, + offers: [ + { name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency, amount: 0 } }, + { + name: 'Gold Pack', + contents: [{ itemId: item.id, amount: 50 }], + price: { currency, amount: 100 }, + maxPurchases: 2, + }, + ], + }); log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`); const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack'); if (!paidOffer?.id) { @@ -210,6 +219,7 @@ export async function runSeed( // 9. Scenario (must be live now: startAt <= now <= endAt). const startAt = new Date(ts - 3600_000).toISOString(); const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString(); + const scenarioEvent = 'session_start'; const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, { projectId: project.id, environment: authoringEnv, @@ -219,7 +229,7 @@ export async function runSeed( endAt, }); - // 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification). + // 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification). await api.put(`${projPath}/scenarios/${scenario.id}`, { projectId: project.id, scenarioId: scenario.id, @@ -227,7 +237,7 @@ export async function runSeed( }); log(`scenario ${scenario.id} flow set`); - // 11. Promote staging content to prod (the runtime serves prod/latest.json). + // 11. Promote staging content to prod (the runtime serves the prod snapshot). const release = await api.post(`${projPath}/releases/promote`, { projectId: project.id, fromEnvironment: authoringEnv, @@ -235,6 +245,7 @@ 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. const prodStores = await api.get(`${projPath}/stores${prodQ}`); const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop'); if (!prodStore) { @@ -263,13 +274,18 @@ export async function runSeed( throw new Error(`promoted quest ${questName} did not return reward item id`); } - // 12. Settle: let the game service hot-reload caches from the new snapshot. - await sleep(3000); + 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}`); + } return { baseUrl, projectId: project.id, - projectKey: project.key, + projectKey, adminToken: auth.accessToken, adminEmail, environment: runtimeEnv, @@ -281,7 +297,7 @@ export async function runSeed( freeOfferName: 'Welcome Gift', paidOfferName: 'Gold Pack', paidOfferId: prodPaidOffer.id, - paidPrice: { currency: 'soft', amount: 100 }, + paidPrice: { currency, amount: 100 }, paidMaxPurchases: 2, grantedItemName: 'Gold Coin', paidGrantAmount: 50, @@ -305,7 +321,7 @@ export async function runSeed( shopLayout: { cols: 3 }, spawnRateOverride: 3.0, }, - scenario: { id: scenario.id, event: 'session_start' }, + scenario: { id: prodScenario.id, event: scenarioEvent }, }; } diff --git a/test/e2e-prod/health.e2e.test.ts b/test/e2e-prod/health.e2e.test.ts index 5feffb1..109ea01 100644 --- a/test/e2e-prod/health.e2e.test.ts +++ b/test/e2e-prod/health.e2e.test.ts @@ -15,12 +15,11 @@ describe('e2e-prod: health', () => { expect(body.status).toBe('ok'); }); - it('content + game gRPC backends are serving', async () => { - // /readyz may be 503 if analytics is not_serving (out of scope here); assert the - // backends this suite actually uses are serving. + it('GET /readyz reports every backing store ready', async () => { const res = await fetch(`${baseUrl}/readyz`); - const body = (await res.json()) as { grpc?: Record }; - expect(body.grpc?.content).toBe('serving'); - expect(body.grpc?.game).toBe('serving'); + expect(res.ok).toBe(true); + const body = (await res.json()) as { status?: string; checks?: Record }; + expect(body.status).toBe('ready'); + expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' }); }); }); diff --git a/test/e2e-prod/purchase.e2e.test.ts b/test/e2e-prod/purchase.e2e.test.ts index 2555c37..fb12d7f 100644 --- a/test/e2e-prod/purchase.e2e.test.ts +++ b/test/e2e-prod/purchase.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { adminApi, freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js'; +import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js'; import type { SeedArtifact } from './_setup/types.js'; import type { RudderClient } from '../../src/client/RudderClient.js'; import type { OfferHandle } from '../../src/state/shops.js'; @@ -48,12 +48,8 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => { const price = artifact.store.paidPrice.amount; const offer = await resolvePaidOffer(client); - // 1. Grant currency via the new admin endpoint. - await api.post(`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, { - currencyCode: currency, - amount: 1000, - reason: 'e2e grant', - }); + // 1. Grant currency via the admin endpoint. + await grantCurrency(artifact, playerId, currency, 1000); const granted = await withReadRetry( () => balance(client, currency), (b) => b === 1000, diff --git a/test/e2e-prod/scenarios.e2e.test.ts b/test/e2e-prod/scenarios.e2e.test.ts index 7da4051..7dee01a 100644 --- a/test/e2e-prod/scenarios.e2e.test.ts +++ b/test/e2e-prod/scenarios.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; -import { adminApi, loadArtifact, makeProdClient } from './_setup/harness.js'; +import { grantCurrency, loadArtifact, makeProdClient } from './_setup/harness.js'; import type { SeedArtifact } from './_setup/types.js'; import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js'; import { getScenarioRuntime } from '../../src/client/RudderClient.js'; @@ -31,13 +31,11 @@ describe('e2e-prod: scenarios', () => { // The boundary buy uses the paid offer — fund the wallet so the purchase succeeds. const playerId = (await client.player.reload()).player!.id!; - await adminApi(artifact).post( - `/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, - { - currencyCode: artifact.store.paidPrice.currency, - amount: artifact.store.paidPrice.amount, - reason: 'e2e scenario funding', - }, + await grantCurrency( + artifact, + playerId, + artifact.store.paidPrice.currency, + artifact.store.paidPrice.amount, ); // First notification dispatched.