Botanica/apps/server/src/game.integration.test.ts

467 lines
16 KiB
TypeScript

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<Account> {
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<string, unknown>,
requestId = randomUUID(),
) => game.action(a.identity.id, { action, data, requestId });
const httpAction = (
a: Account,
action: GameAction["action"],
data: Record<string, unknown>,
) =>
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);
});
},
);