diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index d83e046..f6affe1 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; +import { fetchDiscordUserById } from "../services/discord.js"; import { logger } from "../services/logger.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { @@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => { try { const discordId = context.get("discordId"); - const [ record, playerRecord ] = await Promise.all([ - prisma.gameState.findUnique({ where: { discordId } }), - prisma.player.findUnique({ where: { discordId } }), + const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([ + Promise.all([ + prisma.gameState.findUnique({ where: { discordId } }), + prisma.player.findUnique({ where: { discordId } }), + ]), + fetchDiscordUserById(discordId), ]); + // Refresh avatar in DB when Discord returns an updated hash + if ( + freshDiscordUser !== null + && playerRecord !== null + && freshDiscordUser.avatar !== playerRecord.avatar + ) { + playerRecord.avatar = freshDiscordUser.avatar; + void prisma.player.update({ + data: { avatar: freshDiscordUser.avatar }, + where: { discordId }, + }).catch((error: unknown) => { + void logger.error( + "avatar_refresh", + error instanceof Error + ? error + : new Error(String(error)), + ); + }); + } + if (!record) { // No save found — create a fresh state (handles nuked DB or first-time load race) if (!playerRecord) { @@ -757,6 +781,7 @@ gameRouter.get("/load", async(context) => { */ if (playerRecord !== null) { state.player.characterName = playerRecord.characterName; + state.player.avatar = playerRecord.avatar; } const now = Date.now(); diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index ac37348..8b82ae8 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -106,6 +106,40 @@ const fetchDiscordUser = async( } }; +/** + * Fetches a Discord user's profile by their Discord ID using the bot token. + * Returns null on any failure so callers are never blocked by Discord API issues. + * @param discordId - The Discord user ID to look up. + * @returns The Discord user object, or null if the fetch fails. + */ +const fetchDiscordUserById = async( + discordId: string, +): Promise => { + const botToken = process.env.DISCORD_BOT_TOKEN; + if (botToken === undefined || botToken === "") { + return null; + } + try { + const response = await fetch( + `https://discord.com/api/v10/users/${discordId}`, + { headers: { Authorization: `Bot ${botToken}` } }, + ); + if (!response.ok) { + return null; + } + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */ + return await (response.json() as Promise); + } catch (error) { + void logger.error( + "discord_fetch_user_by_id", + error instanceof Error + ? error + : new Error(String(error)), + ); + return null; + } +}; + /** * Builds the Discord OAuth authorisation URL. * @returns The full OAuth URL to redirect the user to. @@ -133,4 +167,4 @@ const buildOAuthUrl = (): string => { }; export type { DiscordTokenResponse, DiscordUser }; -export { buildOAuthUrl, exchangeCode, fetchDiscordUser }; +export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById }; diff --git a/apps/api/test/routes/game.spec.ts b/apps/api/test/routes/game.spec.ts index b469ecf..5638328 100644 --- a/apps/api/test/routes/game.spec.ts +++ b/apps/api/test/routes/game.spec.ts @@ -19,6 +19,10 @@ vi.mock("../../src/middleware/auth.js", () => ({ }), })); +vi.mock("../../src/services/discord.js", () => ({ + fetchDiscordUserById: vi.fn().mockResolvedValue(null), +})); + const DISCORD_ID = "test_discord_id"; const CURRENT_SCHEMA_VERSION = 1; @@ -200,6 +204,75 @@ describe("game route", () => { expect(body.offlineGold).toBeGreaterThan(0); expect(body.offlineEssence).toBeGreaterThan(0); }); + + it("syncs updated avatar from Discord into the returned state", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + expect(body.state.player.avatar).toBe("new_hash"); + }); + + it("continues loading when the avatar DB update fails", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error")); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + }); + + it("continues loading when the avatar DB update fails with a non-Error value", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error"); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({ + id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash", + }); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + }); + + it("keeps stored avatar when Discord returns null", async () => { + const todayUTC = new Date().toISOString().slice(0, 10); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce( + makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never, + ); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null); + const res = await app.fetch(new Request("http://localhost/game/load")); + expect(res.status).toBe(200); + const body = await res.json() as { state: GameState }; + expect(body.state.player.avatar).toBe("stored_hash"); + }); }); describe("POST /save", () => { diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index 5ca4e97..cf924ae 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -104,4 +104,53 @@ describe("discord service", () => { await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); }); }); + + describe("fetchDiscordUserById", () => { + it("returns null when DISCORD_BOT_TOKEN is missing", async () => { + delete process.env["DISCORD_BOT_TOKEN"]; + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when DISCORD_BOT_TOKEN is empty", async () => { + process.env["DISCORD_BOT_TOKEN"] = ""; + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when response is not ok", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found" }); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when fetch throws", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockRejectedValueOnce(new Error("network error")); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns null when fetch throws a non-Error value", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toBeNull(); + }); + + it("returns the user on success", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + const user = { id: "123456", username: "testuser", discriminator: "0", avatar: "abc123" }; + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user) }); + const { fetchDiscordUserById } = await import("../../src/services/discord.js"); + const result = await fetchDiscordUserById("123456"); + expect(result).toMatchObject({ id: "123456", avatar: "abc123" }); + }); + }); });