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%
8.5 KB · 154 lines typescript
Raw Blame History
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, redis } 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  // Tests register several accounts per run: reset the per-IP rate limits first.24  const keys = await redis().keys("rl:*");25  if (keys.length) await redis().del(...keys);26  await syncGames(db);27  // Force-publish one game for tests regardless of certification.28  await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`);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});3738describe("auth + wallet", () => {39  it("registers with 10,000 SC and a recovery code", async () => {40    const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });41    expect(res.statusCode).toBe(200);42    const body = res.json();43    expect(body.balance).toBe(10_000);44    expect(body.recoveryCode).toMatch(/^SPZ-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/);45    cookie = res.cookies.find((c) => c.name === "spinza_session")!.value;46    userId = body.user.id;47    const ledger = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));48    expect(ledger).toHaveLength(1);49    expect(ledger[0].type).toBe("INITIAL_GRANT");50  });5152  it("rejects duplicate and reserved usernames", async () => {53    const dup = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });54    expect(dup.statusCode).toBe(409);55    const reserved = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username: "admin", password: "password123", confirmPassword: "password123", ageConfirmed: true } });56    expect(reserved.statusCode).toBe(400);57  });5859  it("login errors do not reveal whether the username exists", async () => {60    const a = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username, password: "wrong-password" } });61    const b = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username: "nobody_here_xyz", password: "wrong-password" } });62    expect(a.statusCode).toBe(401);63    expect(b.statusCode).toBe(401);64    expect(a.json().message).toBe(b.json().message);65  });6667  it("rejects cross-site state changes", async () => {68    const res = await app.inject({ method: "POST", url: "/api/auth/logout", headers: { origin: "https://evil.example", cookie: `spinza_session=${cookie}` } });69    expect(res.statusCode).toBe(403);70  });7172  it("spins atomically, writes BET + WIN ledger rows, and is idempotent", async () => {73    const clientRoundId = randomUUID();74    const headers = { origin, cookie: `spinza_session=${cookie}` };75    const first = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 100, clientRoundId } });76    expect(first.statusCode).toBe(200);77    const r1 = first.json();78    expect(r1.roundId).toMatch(/^spz_rnd_/);79    const second = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 100, clientRoundId } });80    expect(second.statusCode).toBe(200);81    const r2 = second.json();82    expect(r2.roundId).toBe(r1.roundId);83    expect(r2.replayed).toBe(true);84    const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));85    expect(rounds).toHaveLength(1);86    const bets = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));87    expect(bets.filter((t) => t.type === "BET")).toHaveLength(1);88    expect(bets.filter((t) => t.type === "WIN")).toHaveLength(r1.win > 0 ? 1 : 0);89  });9091  it("concurrent duplicate spins charge once", async () => {92    const clientRoundId = randomUUID();93    const headers = { origin, cookie: `spinza_session=${cookie}` };94    const results = await Promise.all(Array.from({ length: 6 }, () => app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 50, clientRoundId } })));95    const ids = new Set(results.map((r) => r.json().roundId));96    expect(ids.size).toBe(1);97    const bets = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));98    expect(bets.filter((t) => t.type === "BET" && t.amount === -50)).toHaveLength(1);99  });100101  it("keeps the ledger invariant: sum(ledger) == balance", async () => {102    const headers = { origin, cookie: `spinza_session=${cookie}` };103    for (let i = 0; i < 15; i++) await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 20, clientRoundId: randomUUID() } });104    const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId));105    const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) });106    expect(Number(total)).toBe(w!.balance);107    expect(w!.balance).toBeGreaterThanOrEqual(0);108  });109110  it("refuses bets above balance", async () => {111    await db.update(wallets).set({ balance: 5 }).where(eq(wallets.userId, userId));112    const headers = { origin, cookie: `spinza_session=${cookie}` };113    const res = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 10, clientRoundId: randomUUID() } });114    expect(res.statusCode).toBe(402);115    expect(res.json().error).toBe("INSUFFICIENT_CREDITS");116  });117118  it("rescue credits are available at zero balance", async () => {119    await db.update(wallets).set({ balance: 0 }).where(eq(wallets.userId, userId));120    const headers = { origin, cookie: `spinza_session=${cookie}` };121    const status = await app.inject({ method: "GET", url: "/api/rewards/rescue", headers });122    expect(status.json().eligible).toBe(true);123    const claim = await app.inject({ method: "POST", url: "/api/rewards/rescue/claim", headers });124    expect(claim.statusCode).toBe(200);125    expect(claim.json().balance).toBe(2500);126    const again = await app.inject({ method: "POST", url: "/api/rewards/rescue/claim", headers });127    expect(again.statusCode).toBe(409);128  });129130  it("daily reward claims once per day", async () => {131    const headers = { origin, cookie: `spinza_session=${cookie}` };132    const claim = await app.inject({ method: "POST", url: "/api/rewards/daily/claim", headers });133    expect(claim.statusCode).toBe(200);134    expect(claim.json().amount).toBe(1000);135    const again = await app.inject({ method: "POST", url: "/api/rewards/daily/claim", headers });136    expect(again.statusCode).toBe(409);137  });138139  it("recovers the account with the recovery code and rotates it", async () => {140    // Register a second user to obtain a fresh code.141    const u2 = `r_${randomUUID().slice(0, 8)}`;142    const reg = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username: u2, password: "password123", confirmPassword: "password123", ageConfirmed: true } });143    const code = reg.json().recoveryCode as string;144    const bad = await app.inject({ method: "POST", url: "/api/auth/recover", headers: { origin }, payload: { username: u2, recoveryCode: "SPZ-AAAA-BBBB-CCCC", newPassword: "newpassword1" } });145    expect(bad.statusCode).toBe(401);146    const ok = await app.inject({ method: "POST", url: "/api/auth/recover", headers: { origin }, payload: { username: u2, recoveryCode: code, newPassword: "newpassword1" } });147    expect(ok.statusCode).toBe(200);148    expect(ok.json().recoveryCode).not.toBe(code);149    const login = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username: u2, password: "newpassword1" } });150    expect(login.statusCode).toBe(200);151    await db.delete(users).where(eq(users.id, reg.json().user.id));152  });153});154