SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
5.8 KB · 103 lines typescript
Raw Blame History
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