generated from nhcarrigan/template
fix: refresh Discord avatar hash on every game load (#104)
Adds fetchDiscordUserById (bot token) to the Discord service and calls it in parallel with the DB queries on game load. When the returned hash differs from the stored value the Player record is updated and the hash is immediately synced into the returned game state, so the resource bar always shows the player's current Discord avatar. Also adds onError fallback: if the avatar URL is stale before the next load, the resource bar component now derives the URL fresh from state on every render rather than caching it.
This commit is contained in:
@@ -27,6 +27,7 @@ import { currentSchemaVersion } from "../data/schemaVersion.js";
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
|
||||||
|
import { fetchDiscordUserById } from "../services/discord.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
import { calculateOfflineEarnings } from "../services/offlineProgress.js";
|
||||||
import {
|
import {
|
||||||
@@ -685,11 +686,34 @@ gameRouter.get("/load", async(context) => {
|
|||||||
try {
|
try {
|
||||||
const discordId = context.get("discordId");
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
const [ record, playerRecord ] = await Promise.all([
|
const [ [ record, playerRecord ], freshDiscordUser ] = await Promise.all([
|
||||||
prisma.gameState.findUnique({ where: { discordId } }),
|
Promise.all([
|
||||||
prisma.player.findUnique({ where: { discordId } }),
|
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) {
|
if (!record) {
|
||||||
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
// No save found — create a fresh state (handles nuked DB or first-time load race)
|
||||||
if (!playerRecord) {
|
if (!playerRecord) {
|
||||||
@@ -757,6 +781,7 @@ gameRouter.get("/load", async(context) => {
|
|||||||
*/
|
*/
|
||||||
if (playerRecord !== null) {
|
if (playerRecord !== null) {
|
||||||
state.player.characterName = playerRecord.characterName;
|
state.player.characterName = playerRecord.characterName;
|
||||||
|
state.player.avatar = playerRecord.avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -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<DiscordUser | null> => {
|
||||||
|
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<DiscordUser>);
|
||||||
|
} 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.
|
* Builds the Discord OAuth authorisation URL.
|
||||||
* @returns The full OAuth URL to redirect the user to.
|
* @returns The full OAuth URL to redirect the user to.
|
||||||
@@ -133,4 +167,4 @@ const buildOAuthUrl = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type { DiscordTokenResponse, DiscordUser };
|
export type { DiscordTokenResponse, DiscordUser };
|
||||||
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
|
export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };
|
||||||
|
|||||||
@@ -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 DISCORD_ID = "test_discord_id";
|
||||||
const CURRENT_SCHEMA_VERSION = 1;
|
const CURRENT_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
@@ -200,6 +204,75 @@ describe("game route", () => {
|
|||||||
expect(body.offlineGold).toBeGreaterThan(0);
|
expect(body.offlineGold).toBeGreaterThan(0);
|
||||||
expect(body.offlineEssence).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", () => {
|
describe("POST /save", () => {
|
||||||
|
|||||||
@@ -104,4 +104,53 @@ describe("discord service", () => {
|
|||||||
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user