Align e2e-prod suite with the monolith API; drop dead applyOverride; treat run_not_active as terminal
CI / check (push) Successful in 18s
CI / publish (push) Has been skipped

This commit is contained in:
edmand46
2026-09-06 09:53:45 +03:00
parent 8863ef80e2
commit e863cc3bf7
7 changed files with 93 additions and 85 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ function isTransientHttpError(err: unknown): boolean {
function isDroppedRunError(err: unknown): boolean { function isDroppedRunError(err: unknown): boolean {
if (!(err instanceof RudderHttpError)) return false; if (!(err instanceof RudderHttpError)) return false;
return ( return (
err.code === RudderErrorCodes.unknownRun || err.code === RudderErrorCodes.runExpired err.code === RudderErrorCodes.unknownRun ||
err.code === RudderErrorCodes.runExpired ||
err.code === RudderErrorCodes.runNotActive
); );
} }
-16
View File
@@ -23,22 +23,6 @@ export class RemoteConfigState<
if (!config || config.value == null) return defaultValue; if (!config || config.value == null) return defaultValue;
return parseValue(config.value, config.valueType, 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<T>( function parseValue<T>(
+13
View File
@@ -74,6 +74,19 @@ export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
return api; 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<void> {
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)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */ /** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
+51 -35
View File
@@ -1,11 +1,10 @@
// Production seed: registers an operator, creates a project, configures staging // 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. // 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 scenario flow uses the server engine's node types, `data` keys and handle names —
// the game's plan builder copies node.data verbatim into the execution plan // the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs. // and .../fsm), so what we store is what the server runs.
import { createAdminClient, type AdminClient } from './adminClient.js'; import { createAdminClient, type AdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js'; import type { SeedArtifact } from './types.js';
@@ -29,6 +28,7 @@ interface QuestResponse {
interface StoreResponse { interface StoreResponse {
id: string; id: string;
name: string; name: string;
slug?: string;
data?: { slug?: string }; data?: { slug?: string };
offers?: Array<{ offers?: Array<{
id: string; id: string;
@@ -110,31 +110,43 @@ export async function runSeed(
api.setToken(auth.accessToken); api.setToken(auth.accessToken);
log(`registered ${adminEmail}`); log(`registered ${adminEmail}`);
// 2. Project. // 2. Project. Creating one provisions the staging + prod environments, each with
const project = await api.post<{ id: string; key: string; name: string }>( // its own SDK key — that key is what the runtime SDK sends as projectKey.
'/platform/v1/projects', const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
{ name: `e2e-${ts}` }, name: `e2e-${ts}`,
); });
const projPath = `/platform/v1/projects/${project.id}`; const projPath = `/platform/v1/projects/${project.id}`;
log(`project ${project.id} key=${project.key}`); const environments = await api.get<Array<{ name: string; sdkKey: string }>>(
`${projPath}/environments`,
// 3. Staging environment (idempotent — may already exist). );
try { const projectKey = environments.find((e) => e.name === runtimeEnv)?.sdkKey;
await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv }); if (!projectKey) {
} catch { throw new Error(`project ${project.id} has no ${runtimeEnv} environment sdk key`);
// already exists / not required — content POSTs below are the real test
} }
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). // 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, { const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
name: 'Gold Coin', name: 'Gold Coin',
tags: ['currency'], slug: 'gold-coin',
}); });
// 5. Remote configs — one per value type. RCs are live per-env flags served // 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 }> = [ const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' }, { key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' }, { 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. // 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 runtime resolves a store's slug from data.slug, and the CreateStore API has
// the CreateStore API has no slug field — so we set it via data. // no slug field — so we set it via data.
const storeSlug = 'starter-shop'; const storeSlug = 'starter-shop';
const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>( const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
`${projPath}/stores${q}`,
{
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
name: 'Starter Shop', name: 'Starter Shop',
description: 'E2E shop', description: 'E2E shop',
data: { slug: storeSlug }, data: { slug: storeSlug },
offers: [ offers: [
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } }, { name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency, amount: 0 } },
{ {
name: 'Gold Pack', name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }], contents: [{ itemId: item.id, amount: 50 }],
price: { currency: 'soft', amount: 100 }, price: { currency, amount: 100 },
maxPurchases: 2, maxPurchases: 2,
}, },
], ],
}, });
);
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`); log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack'); const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack');
if (!paidOffer?.id) { if (!paidOffer?.id) {
@@ -210,6 +219,7 @@ export async function runSeed(
// 9. Scenario (must be live now: startAt <= now <= endAt). // 9. Scenario (must be live now: startAt <= now <= endAt).
const startAt = new Date(ts - 3600_000).toISOString(); const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 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}`, { const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
@@ -219,7 +229,7 @@ export async function runSeed(
endAt, 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}`, { await api.put(`${projPath}/scenarios/${scenario.id}`, {
projectId: project.id, projectId: project.id,
scenarioId: scenario.id, scenarioId: scenario.id,
@@ -227,7 +237,7 @@ export async function runSeed(
}); });
log(`scenario ${scenario.id} flow set`); 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<ReleaseResponse>(`${projPath}/releases/promote`, { const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id, projectId: project.id,
fromEnvironment: authoringEnv, fromEnvironment: authoringEnv,
@@ -235,6 +245,7 @@ export async function runSeed(
}); });
await pollRelease(api, projPath, runtimeEnv, release.id, log); 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<StoreResponse[]>(`${projPath}/stores${prodQ}`); const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop'); const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
if (!prodStore) { if (!prodStore) {
@@ -263,13 +274,18 @@ export async function runSeed(
throw new Error(`promoted quest ${questName} did not return reward item id`); 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. const prodScenarios = await api.get<Array<{ id: string; name: string }>>(
await sleep(3000); `${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 { return {
baseUrl, baseUrl,
projectId: project.id, projectId: project.id,
projectKey: project.key, projectKey,
adminToken: auth.accessToken, adminToken: auth.accessToken,
adminEmail, adminEmail,
environment: runtimeEnv, environment: runtimeEnv,
@@ -281,7 +297,7 @@ export async function runSeed(
freeOfferName: 'Welcome Gift', freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack', paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id, paidOfferId: prodPaidOffer.id,
paidPrice: { currency: 'soft', amount: 100 }, paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2, paidMaxPurchases: 2,
grantedItemName: 'Gold Coin', grantedItemName: 'Gold Coin',
paidGrantAmount: 50, paidGrantAmount: 50,
@@ -305,7 +321,7 @@ export async function runSeed(
shopLayout: { cols: 3 }, shopLayout: { cols: 3 },
spawnRateOverride: 3.0, spawnRateOverride: 3.0,
}, },
scenario: { id: scenario.id, event: 'session_start' }, scenario: { id: prodScenario.id, event: scenarioEvent },
}; };
} }
+5 -6
View File
@@ -15,12 +15,11 @@ describe('e2e-prod: health', () => {
expect(body.status).toBe('ok'); expect(body.status).toBe('ok');
}); });
it('content + game gRPC backends are serving', async () => { it('GET /readyz reports every backing store ready', async () => {
// /readyz may be 503 if analytics is not_serving (out of scope here); assert the
// backends this suite actually uses are serving.
const res = await fetch(`${baseUrl}/readyz`); const res = await fetch(`${baseUrl}/readyz`);
const body = (await res.json()) as { grpc?: Record<string, string> }; expect(res.ok).toBe(true);
expect(body.grpc?.content).toBe('serving'); const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
expect(body.grpc?.game).toBe('serving'); expect(body.status).toBe('ready');
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
}); });
}); });
+3 -7
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from 'vitest'; 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 { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js'; import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.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 price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client); const offer = await resolvePaidOffer(client);
// 1. Grant currency via the new admin endpoint. // 1. Grant currency via the admin endpoint.
await api.post(`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, { await grantCurrency(artifact, playerId, currency, 1000);
currencyCode: currency,
amount: 1000,
reason: 'e2e grant',
});
const granted = await withReadRetry( const granted = await withReadRetry(
() => balance(client, currency), () => balance(client, currency),
(b) => b === 1000, (b) => b === 1000,
+6 -8
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, vi } from 'vitest'; 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 { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js'; import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.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. // The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
const playerId = (await client.player.reload()).player!.id!; const playerId = (await client.player.reload()).player!.id!;
await adminApi(artifact).post( await grantCurrency(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, artifact,
{ playerId,
currencyCode: artifact.store.paidPrice.currency, artifact.store.paidPrice.currency,
amount: artifact.store.paidPrice.amount, artifact.store.paidPrice.amount,
reason: 'e2e scenario funding',
},
); );
// First notification dispatched. // First notification dispatched.