import { beforeAll, afterAll, describe, it, expect } from "vitest"; import pg from "pg"; import { randomUUID } from "node:crypto"; import { createApp } from "./app.js"; import { migrate } from "./migrations.js"; import { seedDatabase } from "./seed-data.js"; import { GameService } from "./game-service.js"; import { digest, type Identity } from "./auth.js"; import type { GameAction, GameState } from "@botanica/shared"; import { saleValue } from "./game-config.js"; import { DEMO_PLAYER_ID } from "./constants.js"; // Separate schema on the disposable Compose test database, never the application database. const url = process.env.TEST_DATABASE_URL, schema = "game_" + randomUUID().replaceAll("-", ""); describe.skipIf(!url)( "Game ownership, monetary integrity and offline acceptance", () => { const pool = new pg.Pool({ connectionString: url, options: "-c search_path=" + schema, }); const app = createApp(() => pool.query("SELECT 1"), false, pool); let now = new Date("2026-01-01T00:00:00Z"); const game = new GameService(pool, () => now); const advance = (seconds: number) => { now = new Date(now.getTime() + seconds * 1000); }; type Account = { identity: Identity; cookie: string; csrf: string; codes: string[]; username: string; }; async function account(): Promise { const username = "test_" + randomUUID().slice(0, 8), r = await app.inject({ method: "POST", url: "/api/v1/auth/register", headers: { origin: "http://localhost:8155" }, payload: { username, password: "fixture-password-123" }, }); expect(r.statusCode, r.body).toBe(201); const cookie = String(r.headers["set-cookie"]).split(";")[0], csrf = r.json().csrfToken, me = await app.inject({ url: "/api/v1/me", headers: { cookie } }); expect(me.statusCode).toBe(200); return { identity: me.json(), cookie, csrf, codes: r.json().recoveryCodes, username, }; } const act = ( a: Account, action: GameAction["action"], data: Record, requestId = randomUUID(), ) => game.action(a.identity.id, { action, data, requestId }); const httpAction = ( a: Account, action: GameAction["action"], data: Record, ) => app.inject({ method: "POST", url: "/api/v1/game/action", headers: { cookie: a.cookie, origin: "http://localhost:8155", "x-csrf-token": a.csrf, }, payload: { action, data, requestId: randomUUID() }, }); async function state(a: Account) { return game.state(a.identity); } async function grow(a: Account, count = 1) { const s = await state(a), slots = s.greenhouses[0].plants.slice(0, count); for (const slot of slots) await act(a, "plant", { slotId: slot.id, seedType: "starter" }); advance(121); const result = []; for (const slot of slots) result.push(await act(a, "harvest", { slotId: slot.id })); return result as { plantId: string }[]; } beforeAll(async () => { const admin = new pg.Pool({ connectionString: url }); try { await admin.query("CREATE SCHEMA " + schema); } finally { await admin.end(); } await migrate(pool); await seedDatabase(pool); }); afterAll(async () => { await app.close(); await pool.end(); }); it("authenticates with hashed credentials, CSRF and one-use recovery; bootstraps isolated accounts", async () => { const a = await account(), b = await account(), s = await state(a); expect(s.player.coins).toBe(120); expect(s.seeds.starter).toBe(3); expect(s.greenhouses[0].plants).toHaveLength(4); expect((await app.inject("/api/v1/plants")).statusCode).toBe(401); expect((await app.inject("/health")).statusCode).toBe(200); const row = ( await pool.query("SELECT password_hash FROM players WHERE id=$1", [ a.identity.id, ]) ).rows[0]; expect(row.password_hash).toMatch(/^\$argon2id\$/); expect( ( await pool.query( "SELECT token_hash FROM sessions WHERE player_id=$1", [a.identity.id], ) ).rows[0].token_hash, ).toBe(digest(a.cookie.split("=")[1])); expect( ( await pool.query( "SELECT code_hash FROM recovery_codes WHERE player_id=$1", [a.identity.id], ) ).rows.map((r) => r.code_hash), ).not.toContain(a.codes[0]); expect( ( await app.inject({ method: "POST", url: "/api/v1/game/action", headers: { cookie: a.cookie, origin: "http://localhost:8155" }, payload: { action: "purchase", requestId: randomUUID(), data: { itemId: "seed_starter" }, }, }) ).statusCode, ).toBe(403); const [plant] = await grow(a); for (const path of [ "/plants/" + plant.plantId, "/plants/" + plant.plantId + "/birth", ]) expect( ( await app.inject({ url: "/api/v1" + path, headers: { cookie: b.cookie }, }) ).statusCode, ).toBe(404); expect( (await httpAction(b, "sell", { plantId: plant.plantId })).statusCode, ).toBe(409); expect( ( await httpAction(b, "flags", { plantId: plant.plantId, favorite: true, protected: true, }) ).statusCode, ).toBe(404); expect( ( await httpAction(b, "plant", { slotId: s.greenhouses[0].plants[0].id, seedType: "starter", }) ).statusCode, ).toBe(404); expect( ( await app.inject({ url: "/api/v1/plants", headers: { cookie: b.cookie }, }) ).json().total, ).toBe(0); expect( Number( ( await pool.query("SELECT count(*) FROM plants WHERE owner_id=$1", [ DEMO_PLAYER_ID, ]) ).rows[0].count, ), ).toBe(100); const recover = { username: a.username, password: "replacement-password-123", recoveryCode: a.codes[0], }, headers = { origin: "http://localhost:8155" }; expect( ( await app.inject({ method: "POST", url: "/api/v1/auth/recover", headers, payload: recover, }) ).statusCode, ).toBe(200); expect( (await app.inject({ url: "/api/v1/me", headers: { cookie: a.cookie } })) .statusCode, ).toBe(401); expect( ( await app.inject({ method: "POST", url: "/api/v1/auth/recover", headers, payload: recover, }) ).statusCode, ).toBe(401); }, 20000); it("serializes competing purchases, rejects overspending and replays each receipt without a second debit", async () => { const a = await account(); await pool.query("UPDATE players SET coin_balance=10 WHERE id=$1", [ a.identity.id, ]); const results = await Promise.allSettled([ act(a, "purchase", { itemId: "seed_starter" }), act(a, "purchase", { itemId: "seed_starter" }), ]); expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); expect((await state(a)).player.coins).toBe(0); expect((await state(a)).seeds.starter).toBe(4); await pool.query("UPDATE players SET coin_balance=20 WHERE id=$1", [ a.identity.id, ]); const key = randomUUID(), input = { itemId: "seed_starter" }; const retried = await Promise.all([ act(a, "purchase", input, key), act(a, "purchase", input, key), ]); expect(retried[0]).toEqual(retried[1]); expect((await state(a)).player.coins).toBe(10); expect((await state(a)).seeds.starter).toBe(5); await expect( act(a, "purchase", { itemId: "seed_uncommon" }, key), ).rejects.toMatchObject({ code: "IDEMPOTENCY_CONFLICT" }); }); it("harvests and sells once under concurrent requests, recycles seeds and protects favorites", async () => { const a = await account(), slot = (await state(a)).greenhouses[0].plants[0]; await act(a, "plant", { slotId: slot.id, seedType: "starter" }); await expect( act(a, "harvest", { slotId: slot.id }), ).rejects.toMatchObject({ code: "NOT_READY" }); advance(21); const results = await Promise.allSettled([ act(a, "harvest", { slotId: slot.id }), act(a, "harvest", { slotId: slot.id }), ]); expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); const harvested = results.find( (r) => r.status === "fulfilled", ) as PromiseFulfilledResult<{ plantId: string }>; expect((await state(a)).seeds.starter).toBe(3); expect((await state(a)).player.coins).toBe(130); // only first-discovery reward, no harvest sale payout const plantId = harvested.value.plantId; await act(a, "flags", { plantId, favorite: true, protected: true }); await expect(act(a, "sell", { plantId })).rejects.toMatchObject({ code: "PROTECTED_PLANT", }); await act(a, "flags", { plantId, favorite: false, protected: false }); const sales = await Promise.allSettled([ act(a, "sell", { plantId }), act(a, "sell", { plantId }), ]); expect(sales.filter((r) => r.status === "fulfilled")).toHaveLength(1); const plant = ( await pool.query("SELECT * FROM plants WHERE id=$1", [plantId]) ).rows[0]; expect(plant.sold_at).toBeTruthy(); expect((await state(a)).player.coins).toBe( 130 + saleValue(plant.rarity_tier, plant.rarity_score)!, ); expect( ( await pool.query( "SELECT count(*) FROM coin_ledger WHERE player_id=$1 AND reason='sale'", [a.identity.id], ) ).rows[0].count, ).toBe("1"); }); it("charges a real inherited cross once, preserves lineage and rejects foreign or designed parents", async () => { const a = await account(), b = await account(), [left, right] = await grow(a, 2), [foreign] = await grow(b); await pool.query("UPDATE players SET xp=160 WHERE id=$1", [ a.identity.id, ]); const input = { parentAId: left.plantId, parentBId: right.plantId, idempotencyKey: randomUUID(), }, before = (await state(a)).player.coins; const [first, retry] = (await Promise.all([ game.cross(a.identity.id, input), game.cross(a.identity.id, input), ])) as [ { plant: { id: string; parents: string[]; gameEligible: boolean } }, unknown, ]; expect(first).toEqual(retry); expect(first.plant.parents).toEqual([left.plantId, right.plantId]); expect(first.plant.gameEligible).toBe(true); expect( ( await pool.query( "SELECT amount FROM coin_ledger WHERE player_id=$1 AND reason='breeding'", [a.identity.id], ) ).rows, ).toEqual([{ amount: -75 }]); expect((await state(a)).player.coins).toBeGreaterThanOrEqual(before - 75); await expect( game.cross(a.identity.id, { ...input, parentBId: foreign.plantId, idempotencyKey: randomUUID(), }), ).rejects.toMatchObject({ code: "PARENT_UNAVAILABLE" }); const designed = await app.inject({ method: "POST", url: "/api/v1/plants", headers: { cookie: a.cookie, origin: "http://localhost:8155", "x-csrf-token": a.csrf, }, payload: { name: "Study", speciesId: "velvetleaf", genome: ( await pool.query("SELECT genome FROM plants WHERE id=$1", [ left.plantId, ]) ).rows[0].genome, }, }); expect(designed.statusCode).toBe(201); await expect( game.cross(a.identity.id, { ...input, parentBId: designed.json().id, idempotencyKey: randomUUID(), }), ).rejects.toMatchObject({ code: "PARENT_UNAVAILABLE" }); await expect( act(a, "sell", { plantId: designed.json().id }), ).rejects.toMatchObject({ code: "STUDY_ONLY" }); }); it("reconciles a finite hour for gardeners and breeders once; protects keepsakes and retains capped backlog", async () => { const a = await account(), b = await account(), parents = await grow(a, 2); // controlled test fixtures unlock late game without waiting through the whole progression await pool.query( "UPDATE players SET xp=3000,coin_balance=10000 WHERE id=$1", [a.identity.id], ); await act(a, "flags", { plantId: parents[0].plantId, favorite: true, protected: true, }); await act(a, "hire", { kind: "gardener" }); await act(a, "hire", { kind: "breeder" }); let s = await state(a); expect(s.workers.every((w) => !w.enabled)).toBe(true); const gardener = s.workers.find((w) => w.kind === "gardener")!, breeder = s.workers.find((w) => w.kind === "breeder")!, house = s.greenhouses[0].id; const policy = { mode: "repeat_crop", seedType: "starter", autoSell: true, reserveCoins: 100, }; await act(a, "assign", { workerId: gardener.id, greenhouseId: house, enabled: true, policy, }); await act(a, "assign", { workerId: breeder.id, greenhouseId: house, enabled: true, policy: { ...policy, mode: "repeat_cross", autoSell: false, parentAId: parents[0].plantId, parentBId: parents[1].plantId, }, }); expect( ( await httpAction(b, "assign", { workerId: gardener.id, greenhouseId: house, enabled: true, policy, }) ).statusCode, ).toBe(404); advance(3600); const summaries = await Promise.all([ game.reconcile(a.identity.id), game.reconcile(a.identity.id), ]); expect(summaries.filter(Boolean)).toHaveLength(1); const report = summaries.find(Boolean)!; expect(report.events).toBe(42); expect(report.harvested).toBeGreaterThan(0); expect(report.breeds).toBe(12); expect(report.seedsUsed).toBeGreaterThanOrEqual(report.harvested); s = await state(a); expect(s.report).toBeNull(); expect(s.player.coins).toBeGreaterThanOrEqual(0); const keepsake = ( await pool.query("SELECT sold_at FROM plants WHERE id=$1", [ parents[0].plantId, ]) ).rows[0]; expect(keepsake.sold_at).toBeNull(); expect( ( await pool.query( "SELECT count(*) FROM plants WHERE owner_id=$1 AND sold_at IS NOT NULL AND (is_favorite OR is_protected OR first_discovery OR rarity_tier!='Common')", [a.identity.id], ) ).rows[0].count, ).toBe("0"); expect(s.recentRuns.reduce((n, r) => n + r.summary.events, 0)).toBe(42); expect(s.recentRuns.reduce((n, r) => n + r.summary.coinDelta, 0)).toBe( report.coinDelta, ); advance(48 * 3600); const capped = await game.reconcile(a.identity.id); expect(capped!.events).toBe(100); expect((await game.state(a.identity)).pendingWork).toBe(true); }); }, );