import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { randomUUID } from "node:crypto"; import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; import { syncGames } from "@spinza/database/sync-games"; import { verifyCommitment } from "@spinza/game-core"; import { buildApp } from "../src/app"; import { closeRedis, redis } from "../src/lib/redis"; import type { FastifyInstance } from "fastify"; let app: FastifyInstance; let cookie = ""; let userId = ""; const username = `c_${randomUUID().slice(0, 8)}`; const origin = "http://localhost:8230"; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); beforeAll(async () => { process.env.NODE_ENV = "test"; app = await buildApp(); await app.ready(); // Tests register several accounts per run: reset the per-IP rate limits first. const keys = await redis().keys("rl:*"); if (keys.length) await redis().del(...keys); await syncGames(db); await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`); const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); cookie = res.cookies.find((c) => c.name === "spinza_session")!.value; userId = res.json().user.id; }); afterAll(async () => { if (userId) await db.delete(users).where(eq(users.id, userId)); await app.close(); await closeDb(); await closeRedis(); }); const headers = () => ({ origin, cookie: `spinza_session=${cookie}` }); describe("crash games", () => { it("starts a round (bet debited, commitment given, crash hidden) and cashes out at the displayed multiplier", async () => { const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 100, clientRoundId: randomUUID() } }); expect(start.statusCode).toBe(200); const r = start.json().round; expect(r.status).toBe("running"); expect(r.crashMultiplier).toBeNull(); expect(r.seed).toBeNull(); expect(r.commitment).toHaveLength(64); expect(start.json().balance).toBe(9900); await wait(150); const out = await app.inject({ method: "POST", url: `/api/crash/skyfall/cashout`, headers: headers(), payload: { roundId: r.roundId, claimedMultiplier: 1.0 } }); expect(out.statusCode).toBe(200); const settled = out.json().round; expect(["cashed", "crashed"]).toContain(settled.status); expect(settled.crashMultiplier).toBeGreaterThanOrEqual(1); expect(verifyCommitment(settled.seed, settled.crashMultiplier, settled.commitment)).toBe(true); if (settled.status === "cashed") { expect(settled.cashoutMultiplier).toBe(1); expect(settled.win).toBe(100); expect(out.json().balance).toBeGreaterThanOrEqual(10000); // bet returned (+ possible first-round achievement reward) } const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); expect(rounds).toHaveLength(1); const ledger = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId)); expect(ledger.filter((t) => t.type === "BET")).toHaveLength(1); }); it("refuses a cash-out above the server multiplier and never pays past the crash", async () => { const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 50, clientRoundId: randomUUID() } }); const r = start.json().round; const out = await app.inject({ method: "POST", url: `/api/crash/skyfall/cashout`, headers: headers(), payload: { roundId: r.roundId, claimedMultiplier: 9999 } }); const s = out.json().round; if (s.status === "cashed") { expect(s.cashoutMultiplier).toBeLessThan(s.crashMultiplier); expect(s.cashoutMultiplier).toBeLessThanOrEqual(1.05); // ~0 s elapsed → server multiplier ≈ 1.00 } else expect(s.win).toBe(0); }); it("start is idempotent and only one round may run at a time", async () => { const id = randomUUID(); const a = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: id } }); const b = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: id } }); expect(b.json().round.roundId).toBe(a.json().round.roundId); expect(b.json().replayed).toBe(true); const c = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } }); expect([409, 200]).toContain(c.statusCode); // 200 only if the first round already crashed (instant crash) await app.inject({ method: "POST", url: `/api/crash/elevator-999/cashout`, headers: headers(), payload: { roundId: a.json().round.roundId } }); const [{ total }] = await db.select({ total: sql`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId)); const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) }); expect(Number(total)).toBe(w!.balance); }); it("auto cash-out settles at the target when reached", async () => { const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID(), autoCashout: 1.01 } }); const r = start.json().round; await wait(400); // curve reaches 1.01 in ~130 ms (k = 0.075) const st = await app.inject({ method: "GET", url: `/api/crash/skyfall/rounds/${r.roundId}`, headers: headers() }); const s = st.json().round; expect(["cashed", "crashed"]).toContain(s.status); if (s.status === "cashed") expect(s.cashoutMultiplier).toBe(1.01); }); });