From d48b53eecd495ee91f0afa467945759738b9de29 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 9 Mar 2026 19:52:27 -0700 Subject: [PATCH] test(api): achieve 100% coverage across all routes, middleware, and services - 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) --- apps/api/src/routes/about.ts | 2 + apps/api/test/middleware/auth.spec.ts | 11 ++ apps/api/test/routes/apotheosis.spec.ts | 12 ++ apps/api/test/routes/auth.spec.ts | 9 ++ apps/api/test/routes/boss.spec.ts | 12 ++ apps/api/test/routes/craft.spec.ts | 12 ++ apps/api/test/routes/explore.spec.ts | 26 ++++ apps/api/test/routes/frontend.spec.ts | 136 +++++++++++++++++++++ apps/api/test/routes/game.spec.ts | 51 ++++++++ apps/api/test/routes/leaderboards.spec.ts | 12 ++ apps/api/test/routes/prestige.spec.ts | 24 ++++ apps/api/test/routes/profile.spec.ts | 30 +++++ apps/api/test/routes/transcendence.spec.ts | 24 ++++ apps/api/test/services/discord.spec.ts | 17 +++ apps/api/test/services/webhook.spec.ts | 16 +++ 15 files changed, 394 insertions(+) create mode 100644 apps/api/test/routes/frontend.spec.ts diff --git a/apps/api/src/routes/about.ts b/apps/api/src/routes/about.ts index eded14f..ebb2ff4 100644 --- a/apps/api/src/routes/about.ts +++ b/apps/api/src/routes/about.ts @@ -54,6 +54,8 @@ aboutRouter.get("/", async(context) => { releases, }; return context.json(body); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 9 -- @preserve */ } catch (error) { void logger.error( "about", diff --git a/apps/api/test/middleware/auth.spec.ts b/apps/api/test/middleware/auth.spec.ts index 3d2e6ea..09167db 100644 --- a/apps/api/test/middleware/auth.spec.ts +++ b/apps/api/test/middleware/auth.spec.ts @@ -55,4 +55,15 @@ describe("authMiddleware", () => { })); expect(res.status).toBe(401); }); + + it("returns 401 when verifyToken throws a non-Error value", async () => { + const { app, verifyToken } = await makeApp(); + vi.mocked(verifyToken).mockImplementationOnce(() => { + throw "raw string error"; + }); + const res = await app.fetch(new Request("http://localhost/test", { + headers: { Authorization: "Bearer bad_token" }, + })); + expect(res.status).toBe(401); + }); }); diff --git a/apps/api/test/routes/apotheosis.spec.ts b/apps/api/test/routes/apotheosis.spec.ts index edf4b6f..a84cf71 100644 --- a/apps/api/test/routes/apotheosis.spec.ts +++ b/apps/api/test/routes/apotheosis.spec.ts @@ -80,6 +80,18 @@ describe("apotheosis route", () => { expect(res.status).toBe(400); }); + it("returns 500 when the database throws", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post(); + 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(); + expect(res.status).toBe(500); + }); + it("returns apotheosis count on success", async () => { // Need all 15 transcendence upgrades purchased for eligibility const allUpgradeIds = [ diff --git a/apps/api/test/routes/auth.spec.ts b/apps/api/test/routes/auth.spec.ts index 4094060..bd5de25 100644 --- a/apps/api/test/routes/auth.spec.ts +++ b/apps/api/test/routes/auth.spec.ts @@ -113,5 +113,14 @@ describe("auth route", () => { const location = res.headers.get("Location") ?? ""; expect(location).toContain("error=auth_failed"); }); + + it("redirects with error when callback throws a non-Error value", async () => { + const { app, exchangeCode } = await makeApp(); + exchangeCode.mockRejectedValueOnce("raw string error"); + const res = await app.fetch(new Request("http://localhost/auth/callback?code=bad_code")); + expect(res.status).toBe(302); + const location = res.headers.get("Location") ?? ""; + expect(location).toContain("error=auth_failed"); + }); }); }); diff --git a/apps/api/test/routes/boss.spec.ts b/apps/api/test/routes/boss.spec.ts index 4272bac..37b2aca 100644 --- a/apps/api/test/routes/boss.spec.ts +++ b/apps/api/test/routes/boss.spec.ts @@ -293,4 +293,16 @@ describe("boss route", () => { const body = await res.json() as { won: boolean }; expect(body.won).toBe(true); }); + + it("returns 500 when the database throws", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await challenge({ bossId: "test_boss" }); + 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 challenge({ bossId: "test_boss" }); + expect(res.status).toBe(500); + }); }); diff --git a/apps/api/test/routes/craft.spec.ts b/apps/api/test/routes/craft.spec.ts index 0831d39..9e2d5f0 100644 --- a/apps/api/test/routes/craft.spec.ts +++ b/apps/api/test/routes/craft.spec.ts @@ -143,4 +143,16 @@ describe("craft route", () => { expect(body.recipeId).toBe(TEST_RECIPE_ID); expect(body.bonusType).toBe("gold_income"); }); + + it("returns 500 when the database throws", 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); + }); }); diff --git a/apps/api/test/routes/explore.spec.ts b/apps/api/test/routes/explore.spec.ts index 780e872..396cda3 100644 --- a/apps/api/test/routes/explore.spec.ts +++ b/apps/api/test/routes/explore.spec.ts @@ -406,5 +406,31 @@ describe("explore route", () => { 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); + }); }); }); diff --git a/apps/api/test/routes/frontend.spec.ts b/apps/api/test/routes/frontend.spec.ts new file mode 100644 index 0000000..5ba327a --- /dev/null +++ b/apps/api/test/routes/frontend.spec.ts @@ -0,0 +1,136 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; + +vi.mock("../../src/services/logger.js", () => ({ + logger: { + log: vi.fn().mockResolvedValue(undefined), + error: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe("frontend route", () => { + let loggerMock: { log: ReturnType; error: ReturnType }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { logger } = await import("../../src/services/logger.js"); + loggerMock = logger as typeof loggerMock; + }); + + const makeApp = async () => { + const { frontendRouter } = await import("../../src/routes/frontend.js"); + const app = new Hono(); + app.route("/frontend", frontendRouter); + return app; + }; + + const postLog = async (body: unknown, contentType = "application/json") => { + const app = await makeApp(); + return app.fetch(new Request("http://localhost/frontend/log", { + method: "POST", + headers: { "Content-Type": contentType }, + body: typeof body === "string" ? body : JSON.stringify(body), + })); + }; + + const postError = async (body: unknown, contentType = "application/json") => { + const app = await makeApp(); + return app.fetch(new Request("http://localhost/frontend/error", { + method: "POST", + headers: { "Content-Type": contentType }, + body: typeof body === "string" ? body : JSON.stringify(body), + })); + }; + + describe("POST /log", () => { + it("returns 200 when level is debug and message is present", async () => { + const res = await postLog({ level: "debug", message: "test debug" }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 200 when level is info and message is present", async () => { + const res = await postLog({ level: "info", message: "test info" }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 200 when level is warn and message is present", async () => { + const res = await postLog({ level: "warn", message: "test warn" }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 400 when level is invalid", async () => { + const res = await postLog({ level: "error", message: "test" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("level and message are required"); + }); + + it("returns 400 when level is missing", async () => { + const res = await postLog({ message: "test" }); + expect(res.status).toBe(400); + }); + + it("returns 400 when message is missing", async () => { + const res = await postLog({ level: "info" }); + expect(res.status).toBe(400); + }); + + it("returns 500 when request body is invalid JSON", async () => { + const res = await postLog("not valid json at all", "application/json"); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + + it("returns 500 and covers non-Error branch when logger throws a raw value", async () => { + loggerMock.log.mockImplementationOnce(() => { throw "raw string error"; }); + const res = await postLog({ level: "info", message: "test" }); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + }); + + describe("POST /error", () => { + it("returns 200 with valid context and message", async () => { + const res = await postError({ context: "SomeComponent", message: "Something went wrong" }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("returns 400 when context field is missing", async () => { + const res = await postError({ message: "Something went wrong" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("context and message are required"); + }); + + it("returns 400 when message field is missing", async () => { + const res = await postError({ context: "SomeComponent" }); + expect(res.status).toBe(400); + }); + + it("returns 500 when request body is invalid JSON", async () => { + const res = await postError("not valid json at all", "application/json"); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + + it("returns 500 and covers non-Error branch when logger throws a raw value", async () => { + loggerMock.error.mockImplementationOnce(() => { throw "raw string error"; }); + const res = await postError({ context: "SomeComponent", message: "Something went wrong" }); + expect(res.status).toBe(500); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Internal server error"); + }); + }); +}); diff --git a/apps/api/test/routes/game.spec.ts b/apps/api/test/routes/game.spec.ts index fac2962..b469ecf 100644 --- a/apps/api/test/routes/game.spec.ts +++ b/apps/api/test/routes/game.spec.ts @@ -420,6 +420,45 @@ describe("game route", () => { }); }); + describe("GET /load error path", () => { + it("returns 500 when the database throws during load", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during load", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error"); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(500); + }); + }); + + describe("POST /save error path", () => { + const save = (body: Record) => + app.fetch(new Request("http://localhost/game/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + + it("returns 500 when the database throws during save", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await save({ state }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during save", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await save({ state }); + expect(res.status).toBe(500); + }); + }); + describe("POST /reset", () => { const reset = () => app.fetch(new Request("http://localhost/game/reset", { method: "POST" })); @@ -450,5 +489,17 @@ describe("game route", () => { const body = await res.json() as { signature: string | undefined }; expect(typeof body.signature).toBe("string"); }); + + it("returns 500 when the database throws during reset", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await reset(); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during reset", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error"); + const res = await reset(); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/routes/leaderboards.spec.ts b/apps/api/test/routes/leaderboards.spec.ts index 799502a..4c79915 100644 --- a/apps/api/test/routes/leaderboards.spec.ts +++ b/apps/api/test/routes/leaderboards.spec.ts @@ -152,6 +152,18 @@ describe("leaderboards route", () => { expect(typeof body.entries[0]?.activeTitle).toBe("string"); }); + it("returns 500 when the database throws", async () => { + vi.mocked(prisma.player.findMany).mockRejectedValueOnce(new Error("DB error")); + const res = await get(); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.player.findMany).mockRejectedValueOnce("raw string error"); + const res = await get(); + expect(res.status).toBe(500); + }); + it("defaults to 0 for game-state categories when state is missing", async () => { vi.mocked(prisma.player.findMany).mockResolvedValueOnce([makePlayer({ discordId: "p1" })] as never); vi.mocked(prisma.gameState.findMany).mockResolvedValueOnce([] as never); diff --git a/apps/api/test/routes/prestige.spec.ts b/apps/api/test/routes/prestige.spec.ts index 57fe7e9..b7ee04d 100644 --- a/apps/api/test/routes/prestige.spec.ts +++ b/apps/api/test/routes/prestige.spec.ts @@ -93,6 +93,18 @@ describe("prestige route", () => { expect(body.runestones).toBeGreaterThanOrEqual(0); }); + it("returns 500 when the database throws during prestige", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post(""); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during prestige", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post(""); + expect(res.status).toBe(500); + }); + it("updates daily challenge progress when dailyChallenges are set", async () => { const state = makeState({ dailyChallenges: { @@ -152,5 +164,17 @@ describe("prestige route", () => { expect(body.runestonesRemaining).toBe(90); // 100 - 10 expect(body.purchasedUpgradeIds).toContain("income_1"); }); + + it("returns 500 when the database throws during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post("/buy-upgrade", { upgradeId: "income_1" }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post("/buy-upgrade", { upgradeId: "income_1" }); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/routes/profile.spec.ts b/apps/api/test/routes/profile.spec.ts index 0cb3fea..e85a119 100644 --- a/apps/api/test/routes/profile.spec.ts +++ b/apps/api/test/routes/profile.spec.ts @@ -182,6 +182,18 @@ describe("profile route", () => { expect(unknown?.name).toBe("unknown_title_id"); }); + it("returns 500 when the database throws during profile get", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`)); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during profile get", async () => { + vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error"); + const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`)); + expect(res.status).toBe(500); + }); + it("includes completed story chapters in profile response", async () => { const state = makeState({ story: { @@ -256,5 +268,23 @@ describe("profile route", () => { const body = await res.json() as { profileSettings: { numberFormat: string } }; expect(body.profileSettings.numberFormat).toBe("suffix"); }); + + it("returns 500 when the database throws during profile update", async () => { + vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("DB error")); + const res = await put({ + characterName: "NewName", + profileSettings: { numberFormat: "suffix" }, + }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during profile update", async () => { + vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error"); + const res = await put({ + characterName: "NewName", + profileSettings: { numberFormat: "suffix" }, + }); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/routes/transcendence.spec.ts b/apps/api/test/routes/transcendence.spec.ts index fcba56b..270c5e5 100644 --- a/apps/api/test/routes/transcendence.spec.ts +++ b/apps/api/test/routes/transcendence.spec.ts @@ -92,6 +92,18 @@ describe("transcendence route", () => { expect(body.newTranscendenceCount).toBe(1); expect(body.echoes).toBeGreaterThanOrEqual(0); }); + + it("returns 500 when the database throws during transcendence", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post(""); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during transcendence", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post(""); + expect(res.status).toBe(500); + }); }); describe("POST /buy-upgrade", () => { @@ -149,5 +161,17 @@ describe("transcendence route", () => { expect(body.echoesRemaining).toBe(95); // 100 - 5 expect(body.purchasedUpgradeIds).toContain("echo_income_1"); }); + + it("returns 500 when the database throws during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post("/buy-upgrade", { upgradeId: "echo_income_1" }); + expect(res.status).toBe(500); + }); }); }); diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index 97a9cc8..5ca4e97 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -86,5 +86,22 @@ describe("discord service", () => { expect(result.id).toBe("123"); expect(result.username).toBe("testuser"); }); + + it("re-throws when fetch rejects with a non-Error value", async () => { + mockFetch.mockRejectedValueOnce("raw string error"); + const { fetchDiscordUser } = await import("../../src/services/discord.js"); + await expect(fetchDiscordUser("some_token")).rejects.toBe("raw string error"); + }); + }); + + describe("exchangeCode non-Error throw", () => { + it("re-throws when fetch rejects with a non-Error value", async () => { + process.env["DISCORD_CLIENT_ID"] = "cid"; + process.env["DISCORD_CLIENT_SECRET"] = "secret"; + process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { exchangeCode } = await import("../../src/services/discord.js"); + await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); + }); }); }); diff --git a/apps/api/test/services/webhook.spec.ts b/apps/api/test/services/webhook.spec.ts index e0a4f33..28680b0 100644 --- a/apps/api/test/services/webhook.spec.ts +++ b/apps/api/test/services/webhook.spec.ts @@ -69,6 +69,15 @@ describe("webhook service", () => { const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); }); + + it("swallows non-Error fetch rejections gracefully", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { grantApotheosisRole } = await import("../../src/services/webhook.js"); + await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); + }); }); describe("postMilestoneWebhook", () => { @@ -119,5 +128,12 @@ describe("webhook service", () => { const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined(); }); + + it("swallows non-Error fetch rejections gracefully", async () => { + process.env["DISCORD_MILESTONE_WEBHOOK"] = "https://discord.com/webhook/abc"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); + await expect(postMilestoneWebhook("user", "prestige", counts)).resolves.toBeUndefined(); + }); }); });