feat: modules and resources domains, drop removed domains, regenerated models
This commit is contained in:
@@ -2,16 +2,11 @@
|
||||
// reach the admin API for wallet grants / player inspection.
|
||||
|
||||
import { RudderClient } from '../../../src/client/RudderClient.js';
|
||||
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
|
||||
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
||||
import { createAdminClient, type AdminClient } from './adminClient.js';
|
||||
import { readArtifact } from './artifact.js';
|
||||
import type { SeedArtifact } from './types.js';
|
||||
|
||||
type RuntimeOptions = {
|
||||
loginEvent?: string | null;
|
||||
};
|
||||
|
||||
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
|
||||
export function loadArtifact(): SeedArtifact {
|
||||
const file = readArtifact();
|
||||
@@ -44,25 +39,22 @@ export function createInMemoryTokenStore(): TokenStore {
|
||||
};
|
||||
}
|
||||
|
||||
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
|
||||
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||
/** A client with no auth yet (in-memory tokens). */
|
||||
export function makeProdClient(
|
||||
artifact: SeedArtifact = loadArtifact(),
|
||||
runtime: RuntimeOptions = { loginEvent: null },
|
||||
): RudderClient<TConfig> {
|
||||
return new RudderClient<TConfig>({
|
||||
): RudderClient {
|
||||
return new RudderClient({
|
||||
baseUrl: artifact.baseUrl,
|
||||
projectKey: artifact.projectKey,
|
||||
tokenStore: createInMemoryTokenStore(),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
|
||||
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||
export async function freshPlayer(
|
||||
artifact: SeedArtifact = loadArtifact(),
|
||||
runtime?: RuntimeOptions,
|
||||
): Promise<RudderClient<TConfig>> {
|
||||
const client = makeProdClient<TConfig>(artifact, runtime);
|
||||
): Promise<RudderClient> {
|
||||
const client = makeProdClient(artifact);
|
||||
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -1,108 +1,20 @@
|
||||
// Production seed: registers an operator, creates a project, configures staging
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// Production seed: registers an operator and creates a project. Creating a
|
||||
// project provisions staging + prod environments; the prod SDK key is what
|
||||
// the runtime SDK sends as projectKey.
|
||||
|
||||
import { createAdminClient, type AdminClient } from './adminClient.js';
|
||||
import { createAdminClient } 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;
|
||||
slug: 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;
|
||||
slug: string;
|
||||
offers?: Array<{
|
||||
id: string;
|
||||
slug: 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' },
|
||||
@@ -111,8 +23,6 @@ export async function runSeed(
|
||||
api.setToken(auth.accessToken);
|
||||
log(`registered ${adminEmail}`);
|
||||
|
||||
// 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}`,
|
||||
});
|
||||
@@ -126,168 +36,6 @@ export async function runSeed(
|
||||
}
|
||||
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',
|
||||
slug: 'gold-coin',
|
||||
});
|
||||
|
||||
// 5. Remote configs — one per value type. RCs are live per-env flags served
|
||||
// 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' },
|
||||
{ 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.
|
||||
// Stores and offers carry an authored slug that survives promotes.
|
||||
const storeSlug = 'starter-shop';
|
||||
const paidOfferSlug = 'gold-pack';
|
||||
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
|
||||
projectId: project.id,
|
||||
environment: authoringEnv,
|
||||
slug: storeSlug,
|
||||
name: 'Starter Shop',
|
||||
description: 'E2E shop',
|
||||
offers: [
|
||||
{
|
||||
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 },
|
||||
maxPurchases: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
|
||||
|
||||
// 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 questSlug = 'buy-gold-pack-quest';
|
||||
const questMetric = `purchase.offer:${paidOfferSlug}`;
|
||||
const questRewardAmount = 7;
|
||||
const quest = await api.post<QuestResponse>(`${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 }],
|
||||
});
|
||||
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 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,
|
||||
endAt,
|
||||
});
|
||||
|
||||
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
|
||||
await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
|
||||
projectId: project.id,
|
||||
flow: buildFlow(storeSlug),
|
||||
});
|
||||
log(`scenario ${scenario.id} flow set`);
|
||||
|
||||
// 11. Promote staging content to prod (the runtime serves the prod snapshot).
|
||||
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
|
||||
projectId: project.id,
|
||||
fromEnvironment: authoringEnv,
|
||||
toEnvironment: runtimeEnv,
|
||||
});
|
||||
await pollRelease(api, projPath, runtimeEnv, release.id, log);
|
||||
|
||||
// Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
|
||||
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
|
||||
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.slug === paidOfferSlug);
|
||||
if (!prodPaidOffer?.slug) {
|
||||
throw new Error('promoted paid offer did not return a slug');
|
||||
}
|
||||
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((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 ${questSlug} did not return metric ${questObjectiveId}`);
|
||||
}
|
||||
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
|
||||
if (!prodQuestRewardItemId) {
|
||||
throw new Error(`promoted quest ${questSlug} did not return reward item id`);
|
||||
}
|
||||
|
||||
const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
|
||||
`${projPath}/scenarios${prodQ}`,
|
||||
);
|
||||
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
|
||||
if (!prodScenario?.slug) {
|
||||
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
projectId: project.id,
|
||||
@@ -296,38 +44,6 @@ export async function runSeed(
|
||||
adminEmail,
|
||||
environment: runtimeEnv,
|
||||
seededAt: new Date(ts).toISOString(),
|
||||
releaseId: release.id,
|
||||
store: {
|
||||
name: 'Starter Shop',
|
||||
slug: storeSlug,
|
||||
freeOfferName: 'Welcome Gift',
|
||||
paidOfferName: 'Gold Pack',
|
||||
paidOfferSlug: prodPaidOffer.slug,
|
||||
paidPrice: { currency, amount: 100 },
|
||||
paidMaxPurchases: 2,
|
||||
grantedItemName: 'Gold Coin',
|
||||
paidGrantAmount: 50,
|
||||
},
|
||||
quest: {
|
||||
slug: prodQuest.slug,
|
||||
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: { slug: prodScenario.slug, event: scenarioEvent },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,37 +10,4 @@ export interface SeedArtifact {
|
||||
adminEmail: string;
|
||||
environment: string;
|
||||
seededAt: string;
|
||||
releaseId: string;
|
||||
store: {
|
||||
name: string;
|
||||
slug: string;
|
||||
freeOfferName: string;
|
||||
paidOfferName: string;
|
||||
paidOfferSlug: string;
|
||||
paidPrice: { currency: string; amount: number };
|
||||
paidMaxPurchases: number;
|
||||
grantedItemName: string;
|
||||
paidGrantAmount: number;
|
||||
};
|
||||
quest: {
|
||||
slug: string;
|
||||
name: string;
|
||||
objectiveId: string;
|
||||
metric: string;
|
||||
target: number;
|
||||
rewardItemId: string;
|
||||
rewardAmount: number;
|
||||
};
|
||||
leaderboard: { slug: string; metric: string };
|
||||
item: { id: string; name: string };
|
||||
remoteConfigs: {
|
||||
maxEnergy: number;
|
||||
spawnRate: number;
|
||||
featureX: boolean;
|
||||
welcomeText: string;
|
||||
shopLayout: Record<string, unknown>;
|
||||
/** spawn_rate value the scenario's remote_config_override applies. */
|
||||
spawnRateOverride: number;
|
||||
};
|
||||
scenario: { slug: string; event: string };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ describe('e2e-prod: health', () => {
|
||||
expect(res.ok).toBe(true);
|
||||
const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
|
||||
expect(body.status).toBe('ready');
|
||||
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
|
||||
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
|
||||
import type { SeedArtifact } from './_setup/types.js';
|
||||
|
||||
describe('e2e-prod: leaderboards', () => {
|
||||
let artifact: SeedArtifact;
|
||||
beforeAll(() => {
|
||||
artifact = loadArtifact();
|
||||
});
|
||||
|
||||
it('submits a score and sees it in the ranking', async () => {
|
||||
const client = await freshPlayer(artifact);
|
||||
const me = (await client.player.reload()).player?.id;
|
||||
expect(me).toBeTruthy();
|
||||
|
||||
const lb = client.leaderboards.findBySlug(artifact.leaderboard.slug);
|
||||
const score = 4242;
|
||||
await lb.submit(score);
|
||||
|
||||
const entries = await withReadRetry(
|
||||
() => lb.list(50),
|
||||
(list) => list.some((e) => e.playerId === me),
|
||||
);
|
||||
const mine = entries.find((e) => e.playerId === me);
|
||||
expect(mine).toBeDefined();
|
||||
// int64 fields (score, rank) serialize as JSON strings via protojson — coerce to number.
|
||||
expect(Number(mine?.score)).toBe(score);
|
||||
expect(Number(mine?.rank ?? 0)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
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';
|
||||
|
||||
// Exercises the (newly implemented) wallet + inventory + purchase backend end-to-end:
|
||||
// admin grant -> debit on buy -> inventory credit -> idempotency -> max_purchases -> insufficient funds.
|
||||
interface AdminWalletDetails {
|
||||
currencyCode: string;
|
||||
balance: number;
|
||||
}
|
||||
interface AdminInventoryItem {
|
||||
slug: string;
|
||||
amount: number;
|
||||
}
|
||||
interface AdminPlayerDetails {
|
||||
wallets?: AdminWalletDetails[];
|
||||
inventory?: AdminInventoryItem[];
|
||||
}
|
||||
|
||||
describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
|
||||
let artifact: SeedArtifact;
|
||||
beforeAll(() => {
|
||||
artifact = loadArtifact();
|
||||
});
|
||||
|
||||
async function resolvePaidOffer(client: RudderClient): Promise<OfferHandle> {
|
||||
const stores = await withReadRetry(
|
||||
() => client.stores.reload(),
|
||||
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
|
||||
);
|
||||
const store = stores.find((s) => s.name === artifact.store.name)!;
|
||||
return store.offers.find((o) => o.name === artifact.store.paidOfferName)!;
|
||||
}
|
||||
|
||||
async function balance(client: RudderClient, currency: string): Promise<number> {
|
||||
const profile = await client.player.reload();
|
||||
// int64 fields serialize as JSON strings via protojson — coerce to number.
|
||||
return Number(profile.wallets?.find((w) => w.currency === currency)?.balance ?? 0);
|
||||
}
|
||||
|
||||
it('grants currency, debits on buy, credits inventory, is idempotent, and enforces the purchase limit', async () => {
|
||||
const client = await freshPlayer(artifact);
|
||||
const playerId = (await client.player.reload()).player!.id!;
|
||||
const api = adminApi(artifact);
|
||||
const currency = artifact.store.paidPrice.currency;
|
||||
const price = artifact.store.paidPrice.amount;
|
||||
const offer = await resolvePaidOffer(client);
|
||||
|
||||
// 1. Grant currency via the admin endpoint.
|
||||
await grantCurrency(artifact, playerId, currency, 1000);
|
||||
const granted = await withReadRetry(
|
||||
() => balance(client, currency),
|
||||
(b) => b === 1000,
|
||||
);
|
||||
expect(granted).toBe(1000);
|
||||
|
||||
// 2. First paid buy: success + wallet debited.
|
||||
const buy1 = await offer.buy();
|
||||
expect(buy1.success).toBe(true);
|
||||
expect(buy1.purchaseId).toBeTruthy();
|
||||
expect(await balance(client, currency)).toBe(1000 - price);
|
||||
|
||||
// 3. Inventory credited (verified via admin player details).
|
||||
const details = await api.get<AdminPlayerDetails>(
|
||||
`/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);
|
||||
|
||||
// 4. Idempotency: the same key twice charges once.
|
||||
const key = crypto.randomUUID();
|
||||
const a = await offer.buy({ idempotencyKey: key });
|
||||
const b = await offer.buy({ idempotencyKey: key });
|
||||
expect(a.success).toBe(true);
|
||||
expect(b.success).toBe(true);
|
||||
expect(await balance(client, currency)).toBe(1000 - price * 2);
|
||||
|
||||
// 5. max_purchases (2) reached -> a new distinct buy fails (no charge).
|
||||
const overLimit = await offer.buy();
|
||||
expect(overLimit.success).toBe(false);
|
||||
expect(overLimit.error ?? '').toContain('limit');
|
||||
expect(await balance(client, currency)).toBe(1000 - price * 2);
|
||||
});
|
||||
|
||||
it('rejects a paid purchase with insufficient funds', async () => {
|
||||
const poor = await freshPlayer(artifact);
|
||||
const offer = await resolvePaidOffer(poor);
|
||||
const res = await offer.buy();
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error ?? '').toContain('insufficient');
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
|
||||
import type { SeedArtifact } from './_setup/types.js';
|
||||
|
||||
interface GameRemoteConfig extends Record<string, unknown> {
|
||||
max_energy: number;
|
||||
spawn_rate: number;
|
||||
feature_x: boolean;
|
||||
welcome_text: string;
|
||||
shop_layout: Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('e2e-prod: remoteConfig', () => {
|
||||
let artifact: SeedArtifact;
|
||||
beforeAll(() => {
|
||||
artifact = loadArtifact();
|
||||
});
|
||||
|
||||
it('loads typed configs matching the seeded values', async () => {
|
||||
const rc = artifact.remoteConfigs;
|
||||
const client = await withReadRetry(
|
||||
() => freshPlayer<GameRemoteConfig>(artifact),
|
||||
(candidate) => candidate.remoteConfig.get('max_energy', 0) === rc.maxEnergy,
|
||||
);
|
||||
expect(client.remoteConfig.status).toBe('ready');
|
||||
|
||||
expect(client.remoteConfig.get('max_energy', 0)).toBe(rc.maxEnergy);
|
||||
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(rc.spawnRate);
|
||||
expect(client.remoteConfig.get('feature_x', false)).toBe(rc.featureX);
|
||||
expect(client.remoteConfig.get('welcome_text', '')).toBe(rc.welcomeText);
|
||||
expect(client.remoteConfig.get('shop_layout', {} as Record<string, unknown>)).toEqual(rc.shopLayout);
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
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';
|
||||
|
||||
describe('e2e-prod: scenarios', () => {
|
||||
let artifact: SeedArtifact;
|
||||
beforeAll(() => {
|
||||
artifact = loadArtifact();
|
||||
});
|
||||
|
||||
it('runs onboarding: notification -> rc override -> store -> boundary callback -> notification', async () => {
|
||||
const notifications: NotificationEffect[] = [];
|
||||
let storeOffer: StoreOfferEffect | undefined;
|
||||
let runCompleted = false;
|
||||
|
||||
const client = makeProdClient(artifact, {
|
||||
loginEvent: artifact.scenario.event,
|
||||
});
|
||||
|
||||
client.effects.onNotification((effect) => { notifications.push(effect); });
|
||||
client.effects.onStoreOffer((effect) => {
|
||||
storeOffer = effect;
|
||||
});
|
||||
const runtime = await getScenarioRuntime(client);
|
||||
client.effects.onScenarioCompleted(() => {
|
||||
runCompleted = true;
|
||||
});
|
||||
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
||||
|
||||
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
|
||||
const playerId = (await client.player.reload()).player!.id!;
|
||||
await grantCurrency(
|
||||
artifact,
|
||||
playerId,
|
||||
artifact.store.paidPrice.currency,
|
||||
artifact.store.paidPrice.amount,
|
||||
);
|
||||
|
||||
// First notification dispatched.
|
||||
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
|
||||
await notifications[0].done();
|
||||
|
||||
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
|
||||
await client.remoteConfig.reload();
|
||||
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
|
||||
artifact.remoteConfigs.spawnRateOverride,
|
||||
);
|
||||
|
||||
// Buy crosses the server boundary (POST /sdk/v1/scenarios/callback) -> second notification.
|
||||
const offer = storeOffer!.offers.find((o) => o.name === artifact.store.paidOfferName)!;
|
||||
await storeOffer!.buy(offer);
|
||||
await vi.waitFor(() => expect(notifications.length).toBe(2), { timeout: 15_000 });
|
||||
await notifications[1].done();
|
||||
|
||||
await vi.waitFor(() => expect(runtime.isRunning).toBe(false), { timeout: 10_000 });
|
||||
expect(runCompleted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
|
||||
import type { SeedArtifact } from './_setup/types.js';
|
||||
|
||||
describe('e2e-prod: stores', () => {
|
||||
let artifact: SeedArtifact;
|
||||
beforeAll(() => {
|
||||
artifact = loadArtifact();
|
||||
});
|
||||
|
||||
it('lists the seeded store with its free + paid offers', async () => {
|
||||
const client = await freshPlayer(artifact);
|
||||
const stores = await withReadRetry(
|
||||
() => client.stores.reload(),
|
||||
(r) => r.length > 0,
|
||||
);
|
||||
const store = stores.find((s) => s.name === artifact.store.name);
|
||||
expect(store).toBeDefined();
|
||||
|
||||
const offerNames = store!.offers.map((o) => o.name);
|
||||
expect(offerNames).toContain(artifact.store.freeOfferName);
|
||||
expect(offerNames).toContain(artifact.store.paidOfferName);
|
||||
});
|
||||
|
||||
it('buys the free offer successfully', async () => {
|
||||
const client = await freshPlayer(artifact);
|
||||
const stores = await withReadRetry(
|
||||
() => client.stores.reload(),
|
||||
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
|
||||
);
|
||||
const store = stores.find((s) => s.name === artifact.store.name)!;
|
||||
const free = store.offers.find((o) => o.name === artifact.store.freeOfferName)!;
|
||||
const res = await free.buy();
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.purchaseId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user