From 38a96ea9d456155ea809ad88330a85fe55a910bc Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 29 Aug 2026 11:52:04 -0700 Subject: [PATCH] fix(claim): do not report accept when decision persist fails Accept waited for the clawtributors role PUT, then recorded the decision with a log-only catch. A database failure still DMed the claimant, announced the grant, and replied Claim accepted while the stored claim stayed pending. Await recordClaimDecision on the success path. On persist failure, reply with an error and skip the DM, announcement, and accepted reply. Signed-off-by: Sebastien Tardif --- src/server/claimServer.ts | 30 ++++++-- tests/claimAcceptPersist.test.ts | 126 +++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 tests/claimAcceptPersist.test.ts diff --git a/src/server/claimServer.ts b/src/server/claimServer.ts index 1f06be5..580b3d1 100644 --- a/src/server/claimServer.ts +++ b/src/server/claimServer.ts @@ -200,14 +200,30 @@ class ClaimReviewAcceptButton extends Button { return } - await recordClaimDecision({ - userId, - guildId, - status: "accepted", - decidedById: interaction.user?.id - }).catch((error) => { + try { + await recordClaimDecision({ + userId, + guildId, + status: "accepted", + decidedById: interaction.user?.id + }) + } catch (error) { console.error("Failed to record accepted claim:", error) - }) + await interaction.reply({ + components: [ + new Container( + [ + new TextDisplay("### Could not record claim"), + new TextDisplay( + "The clawtributors role was added, but the claim decision could not be saved. Ask a moderator to retry or update the claim record." + ) + ], + { accentColor: "#f85149" } + ) + ] + }) + return + } const user = await interaction.client.fetchUser(userId).catch(() => null) await user?.send({ diff --git a/tests/claimAcceptPersist.test.ts b/tests/claimAcceptPersist.test.ts new file mode 100644 index 0000000..fdb7ebb --- /dev/null +++ b/tests/claimAcceptPersist.test.ts @@ -0,0 +1,126 @@ +import { type ButtonInteraction, serializePayload } from "@buape/carbon" +import { describe, expect, it, spyOn } from "bun:test" +import * as claimRequests from "../src/data/claimRequests.js" +import { setRuntimeEnv } from "../src/runtime/env.js" +import { claimReviewComponents } from "../src/server/claimServer.js" + +const flattenComponents = (component: unknown): Record[] => { + if (!component || typeof component !== "object") { + return [] + } + + const record = component as Record + const children = Array.isArray(record.components) + ? record.components.flatMap(flattenComponents) + : [] + + return [record, ...children] +} + +const replyText = (replies: unknown[]) => + replies + .flatMap((reply) => flattenComponents(serializePayload(reply))) + .map((component) => component.content) + .filter((content): content is string => typeof content === "string") + .join("\n") + +const runAccept = async () => { + const replies: unknown[] = [] + const dms: unknown[] = [] + const announcements: unknown[] = [] + const patches: unknown[] = [] + const acceptButton = claimReviewComponents[0] + const interaction = { + user: { id: "reviewer-1" }, + message: { + id: "review-message-1", + channelId: "review-channel-1", + rawData: { components: [] } + }, + reply: async (payload: unknown) => { + replies.push(payload) + }, + client: { + fetchUser: async () => ({ + send: async (payload: unknown) => { + dms.push(payload) + } + }), + fetchChannel: async () => ({ + send: async (payload: unknown) => { + announcements.push(payload) + } + }), + rest: { + patch: async (...args: unknown[]) => { + patches.push(args) + } + } + } + } as unknown as ButtonInteraction + + await acceptButton.run(interaction, { + userId: "suser-1", + guildId: "sguild-1" + }) + + return { replies, dms, announcements, patches, text: replyText(replies) } +} + +describe("claim review accept persist", () => { + it("does not report accepted when decision persist fails", async () => { + setRuntimeEnv({ DISCORD_BOT_TOKEN: "test-token" } as Env) + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + async () => new Response(null, { status: 204 }) + ) + const persistSpy = spyOn( + claimRequests, + "recordClaimDecision" + ).mockImplementation(async () => { + throw new Error("database unavailable") + }) + const consoleError = spyOn(console, "error").mockImplementation(() => {}) + + try { + const result = await runAccept() + + expect(persistSpy).toHaveBeenCalledTimes(1) + expect(result.replies).toHaveLength(1) + expect(result.text.toLowerCase()).not.toContain("claim accepted") + expect(result.text.toLowerCase()).not.toContain("has been given the role") + expect(result.dms).toHaveLength(0) + expect(result.announcements).toHaveLength(0) + expect(result.patches).toHaveLength(0) + expect(result.text).toContain("Could not record claim") + } finally { + consoleError.mockRestore() + persistSpy.mockRestore() + fetchSpy.mockRestore() + } + }) + + it("reports accepted after the decision is recorded", async () => { + setRuntimeEnv({ DISCORD_BOT_TOKEN: "test-token" } as Env) + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + async () => new Response(null, { status: 204 }) + ) + const persistSpy = spyOn( + claimRequests, + "recordClaimDecision" + ).mockImplementation(async () => {}) + const consoleError = spyOn(console, "error").mockImplementation(() => {}) + + try { + const result = await runAccept() + + expect(persistSpy).toHaveBeenCalledTimes(1) + expect(result.text.toLowerCase()).toContain("claim accepted") + expect(result.dms).toHaveLength(1) + expect(result.announcements).toHaveLength(1) + } finally { + consoleError.mockRestore() + persistSpy.mockRestore() + fetchSpy.mockRestore() + } + }) +})