/** * Integration tests against the local Postgres/Redis (DATABASE_URL, REDIS_URL). * Run: pnpm --filter @spinza/api test */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { randomUUID } from "node:crypto"; import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; import { syncGames } from "@spinza/database/sync-games"; import { buildApp } from "../src/app"; import { closeRedis, redis } from "../src/lib/redis"; import type { FastifyInstance } from "fastify"; let app: FastifyInstance; let cookie = ""; let userId = ""; const username = `t_${randomUUID().slice(0, 8)}`; const origin = "http://localhost:8230"; beforeAll(async () => { process.env.NODE_ENV = "test"; app = await buildApp(); await app.ready(); // Tests register several accounts per run: reset the per-IP rate limits first. const keys = await redis().keys("rl:*"); if (keys.length) await redis().del(...keys); await syncGames(db); // Force-publish one game for tests regardless of certification. await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`); }); afterAll(async () => { if (userId) await db.delete(users).where(eq(users.id, userId)); await app.close(); await closeDb(); await closeRedis(); }); describe("auth + wallet", () => { it("registers with 10,000 SC and a recovery code", async () => { const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.balance).toBe(10_000); expect(body.recoveryCode).toMatch(/^SPZ-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/); cookie = res.cookies.find((c) => c.name === "spinza_session")!.value; userId = body.user.id; const ledger = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId)); expect(ledger).toHaveLength(1); expect(ledger[0].type).toBe("INITIAL_GRANT"); }); it("rejects duplicate and reserved usernames", async () => { const dup = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); expect(dup.statusCode).toBe(409); const reserved = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username: "admin", password: "password123", confirmPassword: "password123", ageConfirmed: true } }); expect(reserved.statusCode).toBe(400); }); it("login errors do not reveal whether the username exists", async () => { const a = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username, password: "wrong-password" } }); const b = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username: "nobody_here_xyz", password: "wrong-password" } }); expect(a.statusCode).toBe(401); expect(b.statusCode).toBe(401); expect(a.json().message).toBe(b.json().message); }); it("rejects cross-site state changes", async () => { const res = await app.inject({ method: "POST", url: "/api/auth/logout", headers: { origin: "https://evil.example", cookie: `spinza_session=${cookie}` } }); expect(res.statusCode).toBe(403); }); it("spins atomically, writes BET + WIN ledger rows, and is idempotent", async () => { const clientRoundId = randomUUID(); const headers = { origin, cookie: `spinza_session=${cookie}` }; const first = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 100, clientRoundId } }); expect(first.statusCode).toBe(200); const r1 = first.json(); expect(r1.roundId).toMatch(/^spz_rnd_/); const second = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 100, clientRoundId } }); expect(second.statusCode).toBe(200); const r2 = second.json(); expect(r2.roundId).toBe(r1.roundId); expect(r2.replayed).toBe(true); const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); expect(rounds).toHaveLength(1); const bets = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId)); expect(bets.filter((t) => t.type === "BET")).toHaveLength(1); expect(bets.filter((t) => t.type === "WIN")).toHaveLength(r1.win > 0 ? 1 : 0); }); it("concurrent duplicate spins charge once", async () => { const clientRoundId = randomUUID(); const headers = { origin, cookie: `spinza_session=${cookie}` }; const results = await Promise.all(Array.from({ length: 6 }, () => app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 50, clientRoundId } }))); const ids = new Set(results.map((r) => r.json().roundId)); expect(ids.size).toBe(1); const bets = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId)); expect(bets.filter((t) => t.type === "BET" && t.amount === -50)).toHaveLength(1); }); it("keeps the ledger invariant: sum(ledger) == balance", async () => { const headers = { origin, cookie: `spinza_session=${cookie}` }; for (let i = 0; i < 15; i++) await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 20, clientRoundId: randomUUID() } }); const [{ total }] = await db.select({ total: sql`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId)); const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) }); expect(Number(total)).toBe(w!.balance); expect(w!.balance).toBeGreaterThanOrEqual(0); }); it("refuses bets above balance", async () => { await db.update(wallets).set({ balance: 5 }).where(eq(wallets.userId, userId)); const headers = { origin, cookie: `spinza_session=${cookie}` }; const res = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 10, clientRoundId: randomUUID() } }); expect(res.statusCode).toBe(402); expect(res.json().error).toBe("INSUFFICIENT_CREDITS"); }); it("rescue credits are available at zero balance", async () => { await db.update(wallets).set({ balance: 0 }).where(eq(wallets.userId, userId)); const headers = { origin, cookie: `spinza_session=${cookie}` }; const status = await app.inject({ method: "GET", url: "/api/rewards/rescue", headers }); expect(status.json().eligible).toBe(true); const claim = await app.inject({ method: "POST", url: "/api/rewards/rescue/claim", headers }); expect(claim.statusCode).toBe(200); expect(claim.json().balance).toBe(2500); const again = await app.inject({ method: "POST", url: "/api/rewards/rescue/claim", headers }); expect(again.statusCode).toBe(409); }); it("daily reward claims once per day", async () => { const headers = { origin, cookie: `spinza_session=${cookie}` }; const claim = await app.inject({ method: "POST", url: "/api/rewards/daily/claim", headers }); expect(claim.statusCode).toBe(200); expect(claim.json().amount).toBe(1000); const again = await app.inject({ method: "POST", url: "/api/rewards/daily/claim", headers }); expect(again.statusCode).toBe(409); }); it("recovers the account with the recovery code and rotates it", async () => { // Register a second user to obtain a fresh code. const u2 = `r_${randomUUID().slice(0, 8)}`; const reg = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username: u2, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); const code = reg.json().recoveryCode as string; const bad = await app.inject({ method: "POST", url: "/api/auth/recover", headers: { origin }, payload: { username: u2, recoveryCode: "SPZ-AAAA-BBBB-CCCC", newPassword: "newpassword1" } }); expect(bad.statusCode).toBe(401); const ok = await app.inject({ method: "POST", url: "/api/auth/recover", headers: { origin }, payload: { username: u2, recoveryCode: code, newPassword: "newpassword1" } }); expect(ok.statusCode).toBe(200); expect(ok.json().recoveryCode).not.toBe(code); const login = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username: u2, password: "newpassword1" } }); expect(login.statusCode).toBe(200); await db.delete(users).where(eq(users.id, reg.json().user.id)); }); });