diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index e2e4ab5..fb65e35 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -586,6 +586,8 @@ const patchQuestRewards = (state: GameState): number => { return `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`; })); for (const reward of defaultQuest.rewards) { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ const key = `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`; if (!existingKeys.has(key)) { savedQuest.rewards.push(structuredClone(reward)); diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index f0dab75..1765f59 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -557,6 +557,192 @@ describe("debug route", () => { }); }); + const syncNewContent = () => + app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" })); + + describe("POST /sync-new-content", () => { + it("returns 404 when no game state found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await syncNewContent(); + expect(res.status).toBe(404); + }); + + it("returns 200 with zero counts when state already has all content", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + }); + + it("injects missing entries when arrays are empty", async () => { + const state = makeState({ adventurers: [], bosses: [], quests: [], upgrades: [], achievements: [], equipment: [], zones: [] }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { adventurersAdded: number; bossesAdded: number }; + expect(body.adventurersAdded).toBeGreaterThan(0); + expect(body.bossesAdded).toBeGreaterThan(0); + }); + + it("injects missing exploration areas when exploration has no areas", async () => { + const state = makeState({ exploration: makeExploration([]) }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { explorationAreasAdded: number }; + expect(body.explorationAreasAdded).toBeGreaterThan(0); + }); + + it("skips existing exploration areas when building the id set", async () => { + const state = makeState({ exploration: makeExploration([ + { id: "verdant_meadow", status: "available", completedOnce: false } as GameState["exploration"]["areas"][0], + ]) }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { explorationAreasAdded: number }; + // One area already existed so total injected is one less than full count + expect(body.explorationAreasAdded).toBeGreaterThan(0); + }); + + it("returns explorationAreasAdded=0 when exploration state is undefined", async () => { + const state = makeState({ exploration: undefined as unknown as GameState["exploration"] }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { explorationAreasAdded: number }; + expect(body.explorationAreasAdded).toBe(0); + }); + + it("patches quest rewards when saved quest has fewer rewards than default", async () => { + const state = makeState({ + quests: [{ id: "first_steps", status: "available", rewards: [] }] as GameState["quests"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + const quest = body.state.quests.find((q) => q.id === "first_steps"); + expect(quest?.rewards.length).toBeGreaterThan(0); + }); + + it("skips quest reward patching for quests not in defaults", async () => { + const state = makeState({ + quests: [{ id: "nonexistent_quest", status: "available", rewards: [] }] as GameState["quests"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + const quest = body.state.quests.find((q) => q.id === "nonexistent_quest"); + expect(quest?.rewards).toStrictEqual([]); + }); + + it("does not re-add rewards that are already present in the saved quest", async () => { + const state = makeState({ + quests: [{ id: "first_steps", status: "available", rewards: [{ type: "adventurer", targetId: "militia" }] }] as GameState["quests"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + const quest = body.state.quests.find((q) => q.id === "first_steps"); + // Reward already present so count stays the same + expect(quest?.rewards.filter((r) => r.targetId === "militia").length).toBe(1); + }); + + it("patches boss upgrade rewards when saved boss has fewer rewards than default", async () => { + const state = makeState({ + bosses: [{ id: "troll_king", status: "available", upgradeRewards: [] }] as GameState["bosses"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + const boss = body.state.bosses.find((b) => b.id === "troll_king"); + expect(boss?.upgradeRewards.length).toBeGreaterThan(0); + }); + + it("skips boss reward patching for bosses not in defaults", async () => { + const state = makeState({ + bosses: [{ id: "nonexistent_boss", status: "available", upgradeRewards: [] }] as GameState["bosses"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + const boss = body.state.bosses.find((b) => b.id === "nonexistent_boss"); + expect(boss?.upgradeRewards).toStrictEqual([]); + }); + + it("sorts multiple legacy items to the end when their ids are not in the defaults", async () => { + const state = makeState({ + achievements: [ + { id: "legacy_achievement_a", status: "locked" }, + { id: "legacy_achievement_b", status: "locked" }, + ] as GameState["achievements"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + }); + + it("uses amount field when building the reward key for quests with amount-based rewards", async () => { + const state = makeState({ + quests: [{ id: "dragon_lair", status: "available", rewards: [{ type: "gold", amount: 500 }] }] as GameState["quests"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + }); + + it("falls back to empty string when reward has neither targetId nor amount", async () => { + const state = makeState({ + quests: [{ id: "first_steps", status: "available", rewards: [{ type: "unknown" }] }] as GameState["quests"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + }); + + it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => { + process.env.ANTI_CHEAT_SECRET = "test_secret"; + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { signature: string | undefined }; + expect(body.signature).toBeDefined(); + delete process.env.ANTI_CHEAT_SECRET; + }); + + it("returns 500 when DB throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await syncNewContent(); + expect(res.status).toBe(500); + }); + + it("returns 500 when DB throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error"); + const res = await syncNewContent(); + expect(res.status).toBe(500); + }); + }); + describe("POST /hard-reset", () => { it("returns 404 when no player found", async () => { vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null); diff --git a/apps/api/test/routes/explore.spec.ts b/apps/api/test/routes/explore.spec.ts index 396cda3..848be98 100644 --- a/apps/api/test/routes/explore.spec.ts +++ b/apps/api/test/routes/explore.spec.ts @@ -77,6 +77,99 @@ describe("explore route", () => { body: JSON.stringify(body), })); + const getClaimable = (areaId?: string) => { + const url = areaId === undefined + ? "http://localhost/explore/claimable" + : `http://localhost/explore/claimable?areaId=${areaId}`; + return app.fetch(new Request(url)); + }; + + describe("GET /claimable", () => { + it("returns 400 when areaId is missing", async () => { + const res = await getClaimable(); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown area", async () => { + const res = await getClaimable("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 getClaimable(TEST_AREA_ID); + expect(res.status).toBe(404); + }); + + it("returns claimable=false when no exploration state exists", async () => { + const state = makeState({ exploration: undefined }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(false); + }); + + it("returns claimable=false when area is not in_progress", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(false); + }); + + it("returns claimable=false when exploration is still in progress", async () => { + const state = makeState({ + exploration: { + areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.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 getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(false); + }); + + it("returns claimable=true when exploration is complete", async () => { + 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); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(true); + }); + + it("returns 500 when the database throws", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await getClaimable(TEST_AREA_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 getClaimable(TEST_AREA_ID); + expect(res.status).toBe(500); + }); + }); + describe("POST /start", () => { it("returns 400 when areaId is missing", async () => { const res = await postStart({});