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)
291 lines
13 KiB
TypeScript
291 lines
13 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: {
|
|
player: { findUnique: vi.fn(), update: vi.fn() },
|
|
gameState: { findUnique: 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";
|
|
|
|
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
|
|
discordId: DISCORD_ID,
|
|
characterName: "Hero",
|
|
username: "hero",
|
|
discriminator: "0",
|
|
avatar: null,
|
|
pronouns: "she/her",
|
|
characterRace: "Elf",
|
|
characterClass: "Mage",
|
|
bio: "A brave hero",
|
|
guildName: "Brave Guild",
|
|
guildDescription: "We are brave",
|
|
profileSettings: null,
|
|
createdAt: 1000,
|
|
lastSavedAt: 2000,
|
|
lifetimeGoldEarned: 500,
|
|
lifetimeClicks: 100,
|
|
lifetimeBossesDefeated: 5,
|
|
lifetimeQuestsCompleted: 10,
|
|
lifetimeAdventurersRecruited: 20,
|
|
lifetimeAchievementsUnlocked: 3,
|
|
unlockedTitles: null,
|
|
activeTitle: null,
|
|
loginStreak: 1,
|
|
lastLoginDate: null,
|
|
...overrides,
|
|
});
|
|
|
|
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
|
player: { discordId: DISCORD_ID, username: "hero", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "Hero" },
|
|
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("profile route", () => {
|
|
let app: Hono;
|
|
let prisma: {
|
|
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
|
gameState: { findUnique: ReturnType<typeof vi.fn> };
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const { profileRouter } = await import("../../src/routes/profile.js");
|
|
const { prisma: p } = await import("../../src/db/client.js");
|
|
prisma = p as typeof prisma;
|
|
app = new Hono();
|
|
app.route("/profile", profileRouter);
|
|
});
|
|
|
|
describe("GET /:discordId", () => {
|
|
it("returns 404 when player is not found", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await app.fetch(new Request("http://localhost/profile/unknown_id"));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns player profile with game state data", async () => {
|
|
const state = makeState({
|
|
prestige: { count: 3, runestones: 10, productionMultiplier: 1.45, purchasedUpgradeIds: [] },
|
|
bosses: [{ id: "b1", status: "defeated" }] as GameState["bosses"],
|
|
quests: [{ id: "q1", status: "completed" }] as GameState["quests"],
|
|
achievements: [{ id: "a1", unlockedAt: 1000 }] as GameState["achievements"],
|
|
transcendence: { count: 1, echoes: 10, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
|
apotheosis: { count: 1 },
|
|
});
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as {
|
|
characterName: string;
|
|
prestigeCount: number;
|
|
bossesDefeated: number;
|
|
questsCompleted: number;
|
|
achievementsUnlocked: number;
|
|
transcendenceCount: number;
|
|
apotheosisCount: number;
|
|
};
|
|
expect(body.characterName).toBe("Hero");
|
|
expect(body.prestigeCount).toBe(3);
|
|
expect(body.bossesDefeated).toBe(1);
|
|
expect(body.questsCompleted).toBe(1);
|
|
expect(body.achievementsUnlocked).toBe(1);
|
|
expect(body.transcendenceCount).toBe(1);
|
|
expect(body.apotheosisCount).toBe(1);
|
|
});
|
|
|
|
it("returns empty strings for null nullable player fields", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ pronouns: null, characterRace: null, characterClass: null, bio: null, guildName: null, guildDescription: null }) as never,
|
|
);
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { pronouns: string; characterRace: string; bio: string };
|
|
expect(body.pronouns).toBe("");
|
|
expect(body.characterRace).toBe("");
|
|
expect(body.bio).toBe("");
|
|
});
|
|
|
|
it("returns defaults when no game state exists", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { prestigeCount: number; bossesDefeated: number };
|
|
expect(body.prestigeCount).toBe(0);
|
|
expect(body.bossesDefeated).toBe(0);
|
|
});
|
|
|
|
it("parses profileSettings when it is a valid object", async () => {
|
|
const settings = { showTotalGold: false, showOnLeaderboards: false, numberFormat: "scientific" };
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ profileSettings: settings }) as never);
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { profileSettings: { numberFormat: string; showTotalGold: boolean } };
|
|
expect(body.profileSettings.numberFormat).toBe("scientific");
|
|
expect(body.profileSettings.showTotalGold).toBe(false);
|
|
});
|
|
|
|
it("falls back to suffix numberFormat in GET when stored profileSettings has invalid format", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never,
|
|
);
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { profileSettings: { numberFormat: string } };
|
|
expect(body.profileSettings.numberFormat).toBe("suffix");
|
|
});
|
|
|
|
it("maps known and unknown unlocked title IDs to name and fallback id", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ unlockedTitles: ["the_adventurous", "unknown_title_id"] }) as never,
|
|
);
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { unlockedTitles: Array<{ id: string; name: string }> };
|
|
const known = body.unlockedTitles.find((t) => t.id === "the_adventurous");
|
|
expect(known?.name).toBe("The Adventurous");
|
|
const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id");
|
|
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: {
|
|
unlockedChapterIds: [ "boss_troll_king" ],
|
|
completedChapters: [ { chapterId: "boss_troll_king", choiceId: "fight" } ],
|
|
},
|
|
});
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as {
|
|
completedChapters: Array<{ chapterId: string; choiceId: string }>;
|
|
};
|
|
expect(body.completedChapters).toHaveLength(1);
|
|
expect(body.completedChapters[0]).toMatchObject({ chapterId: "boss_troll_king", choiceId: "fight" });
|
|
});
|
|
});
|
|
|
|
describe("PUT /", () => {
|
|
const put = (body: Record<string, unknown>) =>
|
|
app.fetch(new Request("http://localhost/profile", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}));
|
|
|
|
it("returns 400 when character name is empty after trim", async () => {
|
|
const res = await put({ characterName: " " });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when characterName is absent from request body", async () => {
|
|
const res = await put({});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("updates profile and returns updated data", async () => {
|
|
const updatedPlayer = {
|
|
characterName: "NewName", pronouns: "they/them", characterRace: "Human", characterClass: "Rogue",
|
|
bio: "Updated bio", guildName: "New Guild", guildDescription: "Desc",
|
|
profileSettings: null, activeTitle: "the_first",
|
|
};
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce(updatedPlayer as never);
|
|
const res = await put({
|
|
characterName: "NewName",
|
|
pronouns: "they/them",
|
|
characterRace: "Human",
|
|
characterClass: "Rogue",
|
|
bio: "Updated bio",
|
|
guildName: "New Guild",
|
|
guildDescription: "Desc",
|
|
profileSettings: { numberFormat: "engineering", showTotalGold: true, showOnLeaderboards: true },
|
|
activeTitle: "the_first",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { characterName: string; activeTitle: string };
|
|
expect(body.characterName).toBe("NewName");
|
|
expect(body.activeTitle).toBe("the_first");
|
|
});
|
|
|
|
it("uses suffix numberFormat when invalid value is provided", async () => {
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({
|
|
characterName: "Hero", pronouns: null, characterRace: null, characterClass: null,
|
|
bio: null, guildName: null, guildDescription: null, profileSettings: null, activeTitle: null,
|
|
} as never);
|
|
const res = await put({
|
|
characterName: "Hero",
|
|
profileSettings: { numberFormat: "invalid_format" },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
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);
|
|
});
|
|
});
|
|
});
|