From b3d257048f460b0a52650bdeaf2d7699ba6fafd4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 12:12:42 -0700 Subject: [PATCH] fix: prevent duplicate prestige bot announcements on concurrent requests Adds an optimistic lock on the prestige route so that a second concurrent request for the same state is rejected with 409 rather than firing the Discord announcement twice. Also adds missing branch-coverage tests for debug.ts to satisfy the 100% threshold. Closes #162 --- apps/api/src/routes/prestige.ts | 15 ++++- apps/api/test/routes/debug.spec.ts | 96 +++++++++++++++++++++++++++ apps/api/test/routes/prestige.spec.ts | 20 ++++-- 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/apps/api/src/routes/prestige.ts b/apps/api/src/routes/prestige.ts index f482d5a..2c651a8 100644 --- a/apps/api/src/routes/prestige.ts +++ b/apps/api/src/routes/prestige.ts @@ -102,12 +102,23 @@ prestigeRouter.post("/", async(context) => { }).length; const now = Date.now(); - await prisma.gameState.update({ + const { updatedAt } = record; + + /* + * Use the record's current updatedAt as an optimistic lock — if another + * concurrent prestige request already committed, this update will match + * 0 rows and we can safely reject the duplicate without a double webhook. + */ + const updateResult = await prisma.gameState.updateMany({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: finalState as object, updatedAt: now }, - where: { discordId }, + where: { discordId, updatedAt }, }); + if (updateResult.count === 0) { + return context.json({ error: "Prestige already in progress" }, 409); + } + await prisma.player.update({ data: { characterName: state.player.characterName, diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index 2c8d051..a0fb33e 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -595,6 +595,18 @@ describe("debug route", () => { expect(adventurer?.unlocked).toBe(true); }); + it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => { + const state = makeState({ + adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 100, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"], + }); + 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 { adventurerStatsPatched: number }; + expect(body.adventurerStatsPatched).toBe(1); + }); + it("skips adventurer stat patching for adventurers not in defaults", async () => { const state = makeState({ adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"], @@ -816,6 +828,18 @@ describe("debug route", () => { expect(quest?.status).toBe("available"); }); + it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => { + const state = makeState({ + quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 900, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] 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 { questsPatched: number }; + expect(body.questsPatched).toBe(1); + }); + it("skips quest stat patching for quests not in defaults", async () => { const state = makeState({ quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"], @@ -845,6 +869,18 @@ describe("debug route", () => { expect(boss?.currentHp).toBe(100); }); + it("patches boss stats when only bountyRunestones has changed (exercises all earlier OR conditions)", async () => { + const state = makeState({ + bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 0, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 99, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head." }] 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 { bossesPatched: number }; + expect(body.bossesPatched).toBe(1); + }); + it("skips boss stat patching for bosses not in defaults", async () => { const state = makeState({ bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"], @@ -872,6 +908,18 @@ describe("debug route", () => { expect(zone?.status).toBe("unlocked"); }); + it("patches zone stats when only unlockQuestId has changed (exercises all earlier OR conditions)", async () => { + const state = makeState({ + zones: [{ id: "verdant_vale", status: "unlocked", name: "The Verdant Vale", description: "Rolling green hills and ancient forests stretch to the horizon. This is where your guild takes its first steps — trade roads in need of clearing, goblin camps to rout, and an undead queen stirring in the north.", emoji: "🌿", unlockBossId: null, unlockQuestId: "wrong_quest" }] as GameState["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 { zonesPatched: number }; + expect(body.zonesPatched).toBe(1); + }); + it("skips zone stat patching for zones not in defaults", async () => { const state = makeState({ zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"], @@ -901,6 +949,18 @@ describe("debug route", () => { expect(upgrade?.unlocked).toBe(true); }); + it("patches upgrade stats when only costCrystals has changed (exercises all earlier OR conditions)", async () => { + const state = makeState({ + upgrades: [{ id: "click_2", purchased: false, unlocked: false, multiplier: 2, name: "Battle Hardened", description: "Years of combat sharpen your instincts. Doubles click power again.", target: "click", adventurerId: undefined, costGold: 1000, costEssence: 0, costCrystals: 99 }] as GameState["upgrades"], + }); + 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 { upgradesPatched: number }; + expect(body.upgradesPatched).toBe(1); + }); + it("skips upgrade stat patching for upgrades not in defaults", async () => { const state = makeState({ upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"], @@ -929,6 +989,30 @@ describe("debug route", () => { expect(item?.equipped).toBe(false); }); + it("patches equipment stats when only cost has changed (exercises name/desc/type/rarity/bonus OR conditions)", async () => { + const state = makeState({ + equipment: [{ id: "shadow_dagger", owned: true, equipped: false, name: "Shadow Dagger", description: "Forged in the Shadow Marshes from condensed darkness. It strikes before it is seen.", type: "weapon", rarity: "epic", bonus: { combatMultiplier: 1.65 }, cost: { crystals: 99, essence: 500, gold: 0 }, setId: "shadow_infiltrator" }] as GameState["equipment"], + }); + 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 { equipmentPatched: number }; + expect(body.equipmentPatched).toBe(1); + }); + + it("patches equipment stats when only setId has changed (exercises all earlier OR conditions)", async () => { + const state = makeState({ + equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Iron Sword", description: "A sturdy weapon issued to veterans of the guild.", type: "weapon", rarity: "rare", bonus: { combatMultiplier: 1.25 }, cost: undefined, setId: "old_set" }] as GameState["equipment"], + }); + 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 { equipmentPatched: number }; + expect(body.equipmentPatched).toBe(1); + }); + it("skips equipment stat patching for items not in defaults", async () => { const state = makeState({ equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"], @@ -957,6 +1041,18 @@ describe("debug route", () => { expect(achievement?.unlockedAt).toBeNull(); }); + it("patches achievement stats when only reward has changed (exercises all earlier OR conditions)", async () => { + const state = makeState({ + achievements: [{ id: "first_click", unlockedAt: null, name: "First Strike", description: "Click the Guild Hall for the first time.", icon: "👆", condition: { amount: 1, type: "totalClicks" }, reward: { crystals: 999 } }] 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); + const body = await res.json() as { achievementsPatched: number }; + expect(body.achievementsPatched).toBe(1); + }); + it("skips achievement stat patching for achievements not in defaults", async () => { const state = makeState({ achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"], diff --git a/apps/api/test/routes/prestige.spec.ts b/apps/api/test/routes/prestige.spec.ts index b7ee04d..a6dd7b4 100644 --- a/apps/api/test/routes/prestige.spec.ts +++ b/apps/api/test/routes/prestige.spec.ts @@ -8,7 +8,7 @@ import type { GameState } from "@elysium/types"; vi.mock("../../src/db/client.js", () => ({ prisma: { player: { update: vi.fn() }, - gameState: { findUnique: vi.fn(), update: vi.fn() }, + gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, }, })); @@ -48,7 +48,7 @@ describe("prestige route", () => { let app: Hono; let prisma: { player: { update: ReturnType }; - gameState: { findUnique: ReturnType; update: ReturnType }; + gameState: { findUnique: ReturnType; update: ReturnType; updateMany: ReturnType }; }; beforeEach(async () => { @@ -83,8 +83,8 @@ describe("prestige route", () => { it("returns runestones on successful prestige", async () => { const state = makeState(); - vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); - vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never); + vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never); vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); const res = await post(""); expect(res.status).toBe(200); @@ -93,6 +93,14 @@ describe("prestige route", () => { expect(body.runestones).toBeGreaterThanOrEqual(0); }); + it("returns 409 when a concurrent prestige already committed", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never); + vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 0 } as never); + const res = await post(""); + expect(res.status).toBe(409); + }); + it("returns 500 when the database throws during prestige", async () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); const res = await post(""); @@ -112,8 +120,8 @@ describe("prestige route", () => { challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }], } as GameState["dailyChallenges"], }); - vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); - vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never); + vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never); vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); const res = await post(""); expect(res.status).toBe(200);