Files
rudder-js-sdk/test/e2e-prod/_setup/seed.ts
T
edmand46 1013f2395f
CI / check (push) Successful in 58s
CI / publish (push) Has been skipped
e2e-prod: platform wallet adjust route, fund scenario player, RCs seeded directly in prod env
2026-08-20 11:35:21 +03:00

317 lines
11 KiB
TypeScript

// Production seed: registers an operator, creates a project, configures staging
// content (items, 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.
import { createAdminClient, type AdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
interface ReleaseResponse {
id: string;
status: string;
version: number;
}
interface QuestResponse {
id: string;
name: string;
status: string;
objectives?: Array<{ id: string; metric: string; target: number }>;
rewards?: Array<{ itemId?: string; currency?: string; amount: number }>;
}
interface StoreResponse {
id: string;
name: string;
data?: { slug?: string };
offers?: Array<{
id: string;
name: string;
contents?: Array<{ itemId: string; amount: number }>;
}>;
}
function buildFlow(scenarioSlug: string) {
return {
nodes: [
{ id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } },
{ id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } },
{
id: 'rc_1',
type: 'remote_config_override',
data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] },
},
{ id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } },
{ id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } },
{ id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } },
],
edges: [
{ id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' },
{ id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' },
{ id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' },
{ id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' },
{ id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' },
],
};
}
async function pollRelease(
api: AdminClient,
projPath: string,
env: string,
releaseId: string,
log: (m: string) => void,
): Promise<void> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const releases = await api.get<ReleaseResponse[]>(
`${projPath}/releases?environment=${env}&limit=20&offset=0`,
);
const rel = releases.find((r) => r.id === releaseId);
if (rel) {
if (rel.status === 'completed') {
log(`release ${releaseId} completed (v${rel.version})`);
return;
}
if (rel.status === 'failed') {
throw new Error(`release ${releaseId} failed`);
}
}
await sleep(1500);
}
throw new Error(`release ${releaseId} did not complete within 60s`);
}
export async function runSeed(
baseUrl: string,
log: (m: string) => void = () => {},
): Promise<SeedArtifact> {
const api = createAdminClient(baseUrl);
const ts = Date.now();
const authoringEnv = 'staging';
const runtimeEnv = 'prod';
const q = `?environment=${authoringEnv}`;
const prodQ = `?environment=${runtimeEnv}`;
const adminEmail = `e2e+${ts}@rudder.build`;
const password = `Passw0rd!e2e-${ts}`;
// 1. Register operator (open registration; no approval gate at the API layer).
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
'/platform/v1/authorization/register',
{ email: adminEmail, password, fullName: 'E2E Bot' },
{ auth: false },
);
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}` },
);
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
}
// 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'],
});
// 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.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
{ key: 'feature_x', value: 'true', valueType: 'bool' },
{ key: 'welcome_text', value: 'hi', valueType: 'string' },
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
];
for (const rc of remoteConfigs) {
await api.post(`${projPath}/remote-configs${prodQ}`, {
projectId: project.id,
environment: runtimeEnv,
...rc,
});
}
log(`created ${remoteConfigs.length} remote configs`);
// 6. Leaderboard.
await api.post(`${projPath}/leaderboards${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Weekly Score',
slug: 'weekly-score',
metric: 'score',
resetPeriod: 'weekly',
sortingOrder: 'desc',
maxEntries: 100,
});
// 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.
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,
},
],
},
);
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 questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
});
log(`quest ${quest.id} staged for metric ${questMetric}`);
// 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 scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
endAt,
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}`, {
projectId: project.id,
scenarioId: scenario.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves prod/latest.json).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id,
fromEnvironment: authoringEnv,
toEnvironment: runtimeEnv,
});
await pollRelease(api, projPath, runtimeEnv, release.id, log);
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
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 prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
throw new Error('promoted paid offer did not return a reward item id');
}
const prodQuests = await api.get<QuestResponse[]>(`${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 prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) {
throw new Error(`promoted quest ${questName} 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`);
}
// 12. Settle: let the game service hot-reload caches from the new snapshot.
await sleep(3000);
return {
baseUrl,
projectId: project.id,
projectKey: project.key,
adminToken: auth.accessToken,
adminEmail,
environment: runtimeEnv,
seededAt: new Date(ts).toISOString(),
releaseId: release.id,
store: {
name: 'Starter Shop',
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id,
paidPrice: { currency: 'soft', amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
id: prodQuest.id,
name: questName,
objectiveId: questObjectiveId,
metric: prodQuestMetric,
target: questTarget,
rewardItemId: prodQuestRewardItemId,
rewardAmount: questRewardAmount,
},
leaderboard: { slug: 'weekly-score', metric: 'score' },
item: { id: prodRewardItemId, name: item.name },
remoteConfigs: {
maxEnergy: 100,
spawnRate: 1.5,
featureX: true,
welcomeText: 'hi',
shopLayout: { cols: 3 },
spawnRateOverride: 3.0,
},
scenario: { id: scenario.id, event: 'session_start' },
};
}
export async function deleteProject(artifact: SeedArtifact): Promise<void> {
const api = createAdminClient(artifact.baseUrl);
api.setToken(artifact.adminToken);
await api.del(`/platform/v1/projects/${artifact.projectId}`);
}