Files
elysium/apps/api/test/routes/goddessCraft.spec.ts
T
hikari 0d36b255ee feat: goddess API routes, services, and tests (chunk 4)
Add six new goddess-mode API routes (boss fight, consecration,
enlightenment, upgrade purchase, crafting, exploration) alongside
matching service modules and full test suites at 100% coverage.
2026-04-13 15:48:35 -07:00

194 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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";
// prayer_amplifier requires: divine_petal×3, prayer_crystal×2; bonus: gold_income 1.1
const TEST_RECIPE_ID = "prayer_amplifier";
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
zones: [],
bosses: [],
quests: [],
disciples: [],
equipment: [],
upgrades: [],
achievements: [],
consecration: {
count: 0,
divinity: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 0,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [],
materials: [
{ materialId: "divine_petal", quantity: 5 },
{ materialId: "prayer_crystal", quantity: 5 },
],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
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: [],
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("goddessCraft route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessCraftRouter } = await import("../../src/routes/goddessCraft.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-craft", goddessCraftRouter);
});
const post = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-craft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when recipeId is missing", async () => {
const res = await post({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown recipe", async () => {
const res = await post({ recipeId: "nonexistent_recipe" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when recipe is already crafted", async () => {
const goddess = makeGoddessState();
goddess.exploration.craftedRecipeIds = [TEST_RECIPE_ID];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough material (first requirement)", async () => {
const goddess = makeGoddessState();
goddess.exploration.materials = [
{ materialId: "divine_petal", quantity: 1 }, // needs 3
{ materialId: "prayer_crystal", quantity: 5 },
];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when material is completely absent", async () => {
const goddess = makeGoddessState();
goddess.exploration.materials = []; // neither material present
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 200 with updated multipliers and materials on success", async () => {
const goddess = makeGoddessState();
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const body = await res.json() as {
recipeId: string;
bonusType: string;
bonusValue: number;
craftedPrayersMultiplier: number;
craftedDivinityMultiplier: number;
craftedCombatMultiplier: number;
materials: Array<{ materialId: string; quantity: number }>;
};
expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income");
expect(body.bonusValue).toBe(1.1);
expect(body.craftedPrayersMultiplier).toBeGreaterThan(1);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
});