SPB Git

spb/spinza

Public
8.3 KB · 151 lines typescript
1/**2 * Integration tests against the local Postgres/Redis (DATABASE_URL, REDIS_URL).3 * Run: pnpm --filter @spinza/api test4 */5import { afterAll, beforeAll, describe, expect, it } from "vitest";6import { randomUUID } from "node:crypto";7import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database";8import { syncGames } from "@spinza/database/sync-games";9import { buildApp } from "../src/app";10import { closeRedis } from "../src/lib/redis";11import type { FastifyInstance } from "fastify";1213let app: FastifyInstance;14let cookie = "";15let userId = "";16const username = `t_${randomUUID().slice(0, 8)}`;17const origin = "http://localhost:8230";1819beforeAll(async () => {20  process.env.NODE_ENV = "test";21  app = await buildApp();22  await app.ready();23  await syncGames(db);24  // Force-publish one game for tests regardless of certification.25  await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`);26});2728afterAll(async () => {29  if (userId) await db.delete(users).where(eq(users.id, userId));30  await app.close();31  await closeDb();32  await closeRedis();33});3435describe("auth + wallet", () => {36  it("registers with 10,000 SC and a recovery code", async () => {37    const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });38    expect(res.statusCode).toBe(200);39    const body = res.json();40    expect(body.balance).toBe(10_000);41    expect(body.recoveryCode).toMatch(/^SPZ-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/);42    cookie = res.cookies.find((c) => c.name === "spinza_session")!.value;43    userId = body.user.id;44    const ledger = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));45    expect(ledger).toHaveLength(1);46    expect(ledger[0].type).toBe("INITIAL_GRANT");47  });4849  it("rejects duplicate and reserved usernames", async () => {50    const dup = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });51    expect(dup.statusCode).toBe(409);52    const reserved = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username: "admin", password: "password123", confirmPassword: "password123", ageConfirmed: true } });53    expect(reserved.statusCode).toBe(400);54  });5556  it("login errors do not reveal whether the username exists", async () => {57    const a = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username, password: "wrong-password" } });58    const b = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username: "nobody_here_xyz", password: "wrong-password" } });59    expect(a.statusCode).toBe(401);60    expect(b.statusCode).toBe(401);61    expect(a.json().message).toBe(b.json().message);62  });6364  it("rejects cross-site state changes", async () => {65    const res = await app.inject({ method: "POST", url: "/api/auth/logout", headers: { origin: "https://evil.example", cookie: `spinza_session=${cookie}` } });66    expect(res.statusCode).toBe(403);67  });6869  it("spins atomically, writes BET + WIN ledger rows, and is idempotent", async () => {70    const clientRoundId = randomUUID();71    const headers = { origin, cookie: `spinza_session=${cookie}` };72    const first = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 100, clientRoundId } });73    expect(first.statusCode).toBe(200);74    const r1 = first.json();75    expect(r1.roundId).toMatch(/^spz_rnd_/);76    const second = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 100, clientRoundId } });77    expect(second.statusCode).toBe(200);78    const r2 = second.json();79    expect(r2.roundId).toBe(r1.roundId);80    expect(r2.replayed).toBe(true);81    const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));82    expect(rounds).toHaveLength(1);83    const bets = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));84    expect(bets.filter((t) => t.type === "BET")).toHaveLength(1);85    expect(bets.filter((t) => t.type === "WIN")).toHaveLength(r1.win > 0 ? 1 : 0);86  });8788  it("concurrent duplicate spins charge once", async () => {89    const clientRoundId = randomUUID();90    const headers = { origin, cookie: `spinza_session=${cookie}` };91    const results = await Promise.all(Array.from({ length: 6 }, () => app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 50, clientRoundId } })));92    const ids = new Set(results.map((r) => r.json().roundId));93    expect(ids.size).toBe(1);94    const bets = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));95    expect(bets.filter((t) => t.type === "BET" && t.amount === -50)).toHaveLength(1);96  });9798  it("keeps the ledger invariant: sum(ledger) == balance", async () => {99    const headers = { origin, cookie: `spinza_session=${cookie}` };100    for (let i = 0; i < 15; i++) await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 20, clientRoundId: randomUUID() } });101    const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId));102    const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) });103    expect(Number(total)).toBe(w!.balance);104    expect(w!.balance).toBeGreaterThanOrEqual(0);105  });106107  it("refuses bets above balance", async () => {108    await db.update(wallets).set({ balance: 5 }).where(eq(wallets.userId, userId));109    const headers = { origin, cookie: `spinza_session=${cookie}` };110    const res = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 10, clientRoundId: randomUUID() } });111    expect(res.statusCode).toBe(402);112    expect(res.json().error).toBe("INSUFFICIENT_CREDITS");113  });114115  it("rescue credits are available at zero balance", async () => {116    await db.update(wallets).set({ balance: 0 }).where(eq(wallets.userId, userId));117    const headers = { origin, cookie: `spinza_session=${cookie}` };118    const status = await app.inject({ method: "GET", url: "/api/rewards/rescue", headers });119    expect(status.json().eligible).toBe(true);120    const claim = await app.inject({ method: "POST", url: "/api/rewards/rescue/claim", headers });121    expect(claim.statusCode).toBe(200);122    expect(claim.json().balance).toBe(2500);123    const again = await app.inject({ method: "POST", url: "/api/rewards/rescue/claim", headers });124    expect(again.statusCode).toBe(409);125  });126127  it("daily reward claims once per day", async () => {128    const headers = { origin, cookie: `spinza_session=${cookie}` };129    const claim = await app.inject({ method: "POST", url: "/api/rewards/daily/claim", headers });130    expect(claim.statusCode).toBe(200);131    expect(claim.json().amount).toBe(1000);132    const again = await app.inject({ method: "POST", url: "/api/rewards/daily/claim", headers });133    expect(again.statusCode).toBe(409);134  });135136  it("recovers the account with the recovery code and rotates it", async () => {137    // Register a second user to obtain a fresh code.138    const u2 = `r_${randomUUID().slice(0, 8)}`;139    const reg = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username: u2, password: "password123", confirmPassword: "password123", ageConfirmed: true } });140    const code = reg.json().recoveryCode as string;141    const bad = await app.inject({ method: "POST", url: "/api/auth/recover", headers: { origin }, payload: { username: u2, recoveryCode: "SPZ-AAAA-BBBB-CCCC", newPassword: "newpassword1" } });142    expect(bad.statusCode).toBe(401);143    const ok = await app.inject({ method: "POST", url: "/api/auth/recover", headers: { origin }, payload: { username: u2, recoveryCode: code, newPassword: "newpassword1" } });144    expect(ok.statusCode).toBe(200);145    expect(ok.json().recoveryCode).not.toBe(code);146    const login = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username: u2, password: "newpassword1" } });147    expect(login.statusCode).toBe(200);148    await db.delete(users).where(eq(users.id, reg.json().user.id));149  });150});151
Files · 3f93e652f8ac1ba573ef0454b1ad6befa8418869
Menu

Theme