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