TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import { afterAll, beforeAll, describe, expect, it } from "vitest";2import { randomUUID } from "node:crypto";3import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database";4import { syncGames } from "@spinza/database/sync-games";5import { verifyCommitment } from "@spinza/game-core";6import { buildApp } from "../src/app";7import { closeRedis, redis } from "../src/lib/redis";8import type { FastifyInstance } from "fastify";910let app: FastifyInstance;11let cookie = "";12let userId = "";13const username = `c_${randomUUID().slice(0, 8)}`;14const origin = "http://localhost:8230";15const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));1617beforeAll(async () => {18 process.env.NODE_ENV = "test";19 app = await buildApp();20 await app.ready();21 // Tests register several accounts per run: reset the per-IP rate limits first.22 const keys = await redis().keys("rl:*");23 if (keys.length) await redis().del(...keys);24 await syncGames(db);25 await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`);26 const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });27 cookie = res.cookies.find((c) => c.name === "spinza_session")!.value;28 userId = res.json().user.id;29});3031afterAll(async () => {32 if (userId) await db.delete(users).where(eq(users.id, userId));33 await app.close();34 await closeDb();35 await closeRedis();36});3738const headers = () => ({ origin, cookie: `spinza_session=${cookie}` });3940describe("crash games", () => {41 it("starts a round (bet debited, commitment given, crash hidden) and cashes out at the displayed multiplier", async () => {42 const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 100, clientRoundId: randomUUID() } });43 expect(start.statusCode).toBe(200);44 const r = start.json().round;45 expect(r.status).toBe("running");46 expect(r.crashMultiplier).toBeNull();47 expect(r.seed).toBeNull();48 expect(r.commitment).toHaveLength(64);49 expect(start.json().balance).toBe(9900);50 await wait(150);51 const out = await app.inject({ method: "POST", url: `/api/crash/skyfall/cashout`, headers: headers(), payload: { roundId: r.roundId, claimedMultiplier: 1.0 } });52 expect(out.statusCode).toBe(200);53 const settled = out.json().round;54 expect(["cashed", "crashed"]).toContain(settled.status);55 expect(settled.crashMultiplier).toBeGreaterThanOrEqual(1);56 expect(verifyCommitment(settled.seed, settled.crashMultiplier, settled.commitment)).toBe(true);57 if (settled.status === "cashed") {58 expect(settled.cashoutMultiplier).toBe(1);59 expect(settled.win).toBe(100);60 expect(out.json().balance).toBeGreaterThanOrEqual(10000); // bet returned (+ possible first-round achievement reward)61 }62 const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));63 expect(rounds).toHaveLength(1);64 const ledger = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));65 expect(ledger.filter((t) => t.type === "BET")).toHaveLength(1);66 });6768 it("refuses a cash-out above the server multiplier and never pays past the crash", async () => {69 const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 50, clientRoundId: randomUUID() } });70 const r = start.json().round;71 const out = await app.inject({ method: "POST", url: `/api/crash/skyfall/cashout`, headers: headers(), payload: { roundId: r.roundId, claimedMultiplier: 9999 } });72 const s = out.json().round;73 if (s.status === "cashed") {74 expect(s.cashoutMultiplier).toBeLessThan(s.crashMultiplier);75 expect(s.cashoutMultiplier).toBeLessThanOrEqual(1.05); // ~0 s elapsed → server multiplier ≈ 1.0076 } else expect(s.win).toBe(0);77 });7879 it("start is idempotent and only one round may run at a time", async () => {80 const id = randomUUID();81 const a = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: id } });82 const b = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: id } });83 expect(b.json().round.roundId).toBe(a.json().round.roundId);84 expect(b.json().replayed).toBe(true);85 const c = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } });86 expect([409, 200]).toContain(c.statusCode); // 200 only if the first round already crashed (instant crash)87 await app.inject({ method: "POST", url: `/api/crash/elevator-999/cashout`, headers: headers(), payload: { roundId: a.json().round.roundId } });88 const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId));89 const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) });90 expect(Number(total)).toBe(w!.balance);91 });9293 it("auto cash-out settles at the target when reached", async () => {94 const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID(), autoCashout: 1.01 } });95 const r = start.json().round;96 await wait(400); // curve reaches 1.01 in ~130 ms (k = 0.075)97 const st = await app.inject({ method: "GET", url: `/api/crash/skyfall/rounds/${r.roundId}`, headers: headers() });98 const s = st.json().round;99 expect(["cashed", "crashed"]).toContain(s.status);100 if (s.status === "cashed") expect(s.cashoutMultiplier).toBe(1.01);101 });102});103