generated from nhcarrigan/template
d48b53eecd
- Add full test suite for frontend.ts (POST /log and POST /error) - Add error-path tests to all route handlers to cover catch blocks triggered by Prisma rejections - Add non-Error throw tests to cover the `new Error(String(error))` ternary false branch in middleware, services, and route catch handlers - Suppress unreachable outer catch in about.ts with v8 ignore (fetchReleases swallows all errors internally, making the outer catch genuinely dead code)
437 lines
20 KiB
TypeScript
437 lines
20 KiB
TypeScript
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
|
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
|
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Hono } from "hono";
|
|
import type { GameState } from "@elysium/types";
|
|
|
|
vi.mock("../../src/db/client.js", () => ({
|
|
prisma: {
|
|
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../src/middleware/auth.js", () => ({
|
|
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
|
c.set("discordId", "test_discord_id");
|
|
await next();
|
|
}),
|
|
}));
|
|
|
|
const DISCORD_ID = "test_discord_id";
|
|
// verdant_meadow is the first area in verdant_vale zone
|
|
const TEST_AREA_ID = "verdant_meadow";
|
|
const TEST_ZONE_ID = "verdant_vale";
|
|
|
|
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
|
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
|
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
|
adventurers: [],
|
|
upgrades: [],
|
|
quests: [],
|
|
bosses: [],
|
|
equipment: [],
|
|
achievements: [],
|
|
zones: [{ id: TEST_ZONE_ID, status: "unlocked" }] as GameState["zones"],
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "available", completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
|
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
|
baseClickPower: 1,
|
|
lastTickAt: 0,
|
|
schemaVersion: 1,
|
|
...overrides,
|
|
} as GameState);
|
|
|
|
describe("explore route", () => {
|
|
let app: Hono;
|
|
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const { exploreRouter } = await import("../../src/routes/explore.js");
|
|
const { prisma: p } = await import("../../src/db/client.js");
|
|
prisma = p as typeof prisma;
|
|
app = new Hono();
|
|
app.route("/explore", exploreRouter);
|
|
});
|
|
|
|
const postStart = (body: Record<string, unknown>) =>
|
|
app.fetch(new Request("http://localhost/explore/start", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}));
|
|
|
|
const postCollect = (body: Record<string, unknown>) =>
|
|
app.fetch(new Request("http://localhost/explore/collect", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}));
|
|
|
|
describe("POST /start", () => {
|
|
it("returns 400 when areaId is missing", async () => {
|
|
const res = await postStart({});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 404 for unknown area", async () => {
|
|
const res = await postStart({ areaId: "nonexistent_area" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 when no save is found", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 400 when zone is not unlocked", async () => {
|
|
const state = makeState({ zones: [{ id: TEST_ZONE_ID, status: "locked" }] as GameState["zones"] });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 404 when area is not found in state", async () => {
|
|
const state = makeState({ exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 } });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 400 when another exploration is already in progress", async () => {
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "available" }, { id: "other_area", status: "in_progress" }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when area is locked", async () => {
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "locked" }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("starts exploration and returns endsAt on success", async () => {
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { areaId: string; endsAt: number };
|
|
expect(body.areaId).toBe(TEST_AREA_ID);
|
|
expect(body.endsAt).toBeGreaterThan(Date.now());
|
|
});
|
|
|
|
it("backfills exploration state for old saves without exploration", async () => {
|
|
const state = makeState({ exploration: undefined });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
// Even with backfilled state, verdant_meadow may not be available initially — just check the route runs
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
// Should not be a 500; either 200 or a game-logic error
|
|
expect(res.status).not.toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("POST /collect", () => {
|
|
it("returns 400 when areaId is missing", async () => {
|
|
const res = await postCollect({});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 404 for unknown area", async () => {
|
|
const res = await postCollect({ areaId: "nonexistent_area" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 when no save is found", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 400 when no exploration state exists", async () => {
|
|
const state = makeState({ exploration: undefined });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 404 when area is not found in state", async () => {
|
|
const state = makeState({ exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 } });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 400 when area is not in progress", async () => {
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when exploration is not yet complete", async () => {
|
|
const now = Date.now();
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: now, completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("collects exploration results when complete", async () => {
|
|
// Set startedAt far in the past so it's definitely complete
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { foundNothing: boolean; materialsFound: unknown[] };
|
|
expect(typeof body.foundNothing).toBe("boolean");
|
|
expect(Array.isArray(body.materialsFound)).toBe(true);
|
|
});
|
|
|
|
it("returns foundNothing=true when random triggers the nothing path", async () => {
|
|
const mockRandom = vi.spyOn(Math, "random");
|
|
// First call: the nothing probability check (< 0.2 triggers nothing)
|
|
mockRandom.mockReturnValueOnce(0.1);
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { foundNothing: boolean; nothingMessage: string };
|
|
expect(body.foundNothing).toBe(true);
|
|
expect(typeof body.nothingMessage).toBe("string");
|
|
mockRandom.mockRestore();
|
|
});
|
|
|
|
it("applies gold_loss event and pushes new material from possibleMaterials", async () => {
|
|
const mockRandom = vi.spyOn(Math, "random");
|
|
// verdant_meadow events: [gold_gain(0), gold_loss(1), material_gain(2), essence_gain(3)]
|
|
mockRandom
|
|
.mockReturnValueOnce(0.5) // nothing check: 0.5 >= 0.2 → proceed
|
|
.mockReturnValueOnce(0.26) // event: Math.floor(0.26 * 4) = 1 → gold_loss
|
|
.mockReturnValueOnce(0) // possibleMaterials roll: 0 * 3 = 0, 0 - 3 = -3 ≤ 0 → verdant_sap
|
|
.mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 1 = 1
|
|
const state = makeState({
|
|
resources: { gold: 100, essence: 0, crystals: 0, runestones: 0 },
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { foundNothing: boolean; event: { goldChange: number }; materialsFound: Array<{ materialId: string }> };
|
|
expect(body.foundNothing).toBe(false);
|
|
expect(body.event.goldChange).toBeLessThan(0);
|
|
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
|
mockRandom.mockRestore();
|
|
});
|
|
|
|
it("applies essence_gain event during exploration collect", async () => {
|
|
const mockRandom = vi.spyOn(Math, "random");
|
|
mockRandom
|
|
.mockReturnValueOnce(0.5) // nothing check: proceed
|
|
.mockReturnValueOnce(0.76) // event: Math.floor(0.76 * 4) = 3 → essence_gain
|
|
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
|
|
.mockReturnValueOnce(0); // quantity → 1
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { event: { essenceChange: number } };
|
|
expect(body.event.essenceChange).toBeGreaterThan(0);
|
|
mockRandom.mockRestore();
|
|
});
|
|
|
|
it("pushes new material via material_gain event when material not already in list", async () => {
|
|
const mockRandom = vi.spyOn(Math, "random");
|
|
mockRandom
|
|
.mockReturnValueOnce(0.5) // nothing check: proceed
|
|
.mockReturnValueOnce(0.51) // event: Math.floor(0.51 * 4) = 2 → material_gain (verdant_sap qty=2)
|
|
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
|
|
.mockReturnValueOnce(0); // quantity → 1
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
|
|
expect(body.event.materialGained?.materialId).toBe("verdant_sap");
|
|
mockRandom.mockRestore();
|
|
});
|
|
|
|
it("increments existing material quantity via material_gain event", async () => {
|
|
const mockRandom = vi.spyOn(Math, "random");
|
|
mockRandom
|
|
.mockReturnValueOnce(0.5) // nothing check: proceed
|
|
.mockReturnValueOnce(0.51) // event: Math.floor(0.51 * 4) = 2 → material_gain (verdant_sap qty=2)
|
|
.mockReturnValueOnce(0) // possibleMaterials roll → verdant_sap
|
|
.mockReturnValueOnce(0); // quantity → 1
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [{ materialId: "verdant_sap", quantity: 5 }],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
|
|
expect(body.event.materialGained?.materialId).toBe("verdant_sap");
|
|
mockRandom.mockRestore();
|
|
});
|
|
|
|
it("increments existing material quantity when material already in list", async () => {
|
|
const mockRandom = vi.spyOn(Math, "random");
|
|
// verdant_meadow has 4 events (indices 0-3), 1 possibleMaterial (verdant_sap, weight=3)
|
|
mockRandom
|
|
.mockReturnValueOnce(0.5) // nothing check: 0.5 >= 0.2 → proceed
|
|
.mockReturnValueOnce(0) // event selection: Math.floor(0 * 4) = 0 → gold_gain (index 0)
|
|
.mockReturnValueOnce(0) // material roll: 0 * 3 = 0, then 0 - 3 = -3 <= 0 → verdant_sap selected
|
|
.mockReturnValueOnce(0); // quantity roll: Math.floor(0 * 3) + 1 = 1
|
|
const state = makeState({
|
|
exploration: {
|
|
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
|
materials: [{ materialId: "verdant_sap", quantity: 5 }],
|
|
craftedRecipeIds: [],
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { materialsFound: Array<{ materialId: string }> };
|
|
expect(body.materialsFound.some((m) => m.materialId === "verdant_sap")).toBe(true);
|
|
mockRandom.mockRestore();
|
|
});
|
|
|
|
it("returns 500 when the database throws on collect", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when the database throws a non-Error value on collect", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
|
const res = await postCollect({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("POST /start error path", () => {
|
|
it("returns 500 when the database throws on start", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when the database throws a non-Error value on start", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
|
const res = await postStart({ areaId: TEST_AREA_ID });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
});
|