TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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