diff --git a/extensions/discord/src/monitor/questions.test.ts b/extensions/discord/src/monitor/questions.test.ts index c77e22ae0a52..e176b2f05c70 100644 --- a/extensions/discord/src/monitor/questions.test.ts +++ b/extensions/discord/src/monitor/questions.test.ts @@ -1,6 +1,15 @@ // Discord question component feedback tests. +import { InteractionResponseType, MessageFlags } from "discord-api-types/v10"; import { describe, expect, it, vi } from "vitest"; -import type { ButtonInteraction } from "../internal/discord.js"; +import { + createInteraction as createDiscordInteraction, + type ButtonInteraction, +} from "../internal/discord.js"; +import { + attachRestMock, + createInternalComponentInteractionPayload, + createInternalTestClient, +} from "../internal/test-builders.test-support.js"; import { createDiscordQuestionButton } from "./questions.js"; type InteractionHarness = { @@ -91,4 +100,99 @@ describe("Discord question button", () => { ephemeral: true, }); }); + + it.each( + [ + { + name: "submitted answer", + result: { + status: "answered" as const, + questionId: "target", + optionValue: "Production", + }, + expectedText: "Answer submitted.", + }, + { + name: "already answered question", + result: { status: "already-terminal" as const, reason: "already-terminal" as const }, + expectedText: "This question was already answered.", + }, + { + name: "question resolution failure", + error: new Error("question service unavailable"), + expectedText: "Could not submit this answer.", + }, + ].flatMap((outcome) => [ + { ...outcome, acknowledged: false }, + { ...outcome, acknowledged: true }, + ]), + )("delivers $name feedback when acknowledgement is $acknowledged", async (testCase) => { + const client = createInternalTestClient(); + const post = vi.fn(async () => undefined); + if (!testCase.acknowledged) { + post.mockRejectedValueOnce(new Error("temporary connection reset")); + } + attachRestMock(client, { post }); + const interaction = createDiscordInteraction( + client, + createInternalComponentInteractionPayload({ + id: "interaction-1", + token: "interaction-token", + user: { + id: "user-1", + username: "Alice", + discriminator: "0", + avatar: null, + global_name: null, + }, + }), + ) as ButtonInteraction; + const resolveQuestion = vi.fn(async () => { + if (testCase.error) { + throw testCase.error; + } + return testCase.result; + }); + const button = createDiscordQuestionButton({ + cfg: {} as never, + accountId: "default", + authorizeQuestion: vi.fn(async () => true), + resolveQuestion: resolveQuestion as never, + }); + + await button.run(interaction, { + id: "ask_0123456789abcdef0123456789abcdef", + i: "1", + }); + + expect(resolveQuestion).toHaveBeenCalledOnce(); + expect(post).toHaveBeenNthCalledWith( + 1, + "/interactions/interaction-1/interaction-token/callback", + { + body: { type: InteractionResponseType.DeferredMessageUpdate }, + }, + ); + if (testCase.acknowledged) { + expect(post).toHaveBeenNthCalledWith( + 2, + "/webhooks/app1/interaction-token", + { body: { content: testCase.expectedText, flags: MessageFlags.Ephemeral } }, + undefined, + ); + expect(interaction.responseState).toBe("deferred-update"); + } else { + expect(post).toHaveBeenNthCalledWith( + 2, + "/interactions/interaction-1/interaction-token/callback", + { + body: { + type: InteractionResponseType.ChannelMessageWithSource, + data: { content: testCase.expectedText, flags: MessageFlags.Ephemeral }, + }, + }, + ); + expect(interaction.responseState).toBe("replied"); + } + }); }); diff --git a/extensions/discord/src/monitor/questions.ts b/extensions/discord/src/monitor/questions.ts index c6abedebe4ed..90636ee31ea0 100644 --- a/extensions/discord/src/monitor/questions.ts +++ b/extensions/discord/src/monitor/questions.ts @@ -41,31 +41,28 @@ class QuestionButton extends Button { try { await interaction.acknowledge(); } catch {} - let result: Awaited>; + let content: string; try { - result = await this.ctx.resolveQuestion({ + const result = await this.ctx.resolveQuestion({ cfg: this.ctx.cfg, questionId: callback.questionId, optionIndex: callback.optionIndex, senderId: interaction.userId, clientDisplayName: `Discord question (${this.ctx.accountId})`, }); + content = + result.status === "answered" ? "Answer submitted." : "This question was already answered."; } catch { - try { - await interaction.followUp({ content: "Could not submit this answer.", ephemeral: true }); - } catch {} - return; + content = "Could not submit this answer."; } try { - await interaction.followUp({ - content: - result.status === "answered" - ? "Answer submitted." - : "This question was already answered.", - ephemeral: true, - }); + const feedback = { content, ephemeral: true }; + // A rejected acknowledgement leaves the initial callback available, not the webhook. + await (interaction.responseState === "unacknowledged" + ? interaction.reply(feedback) + : interaction.followUp(feedback)); } catch { - // Gateway state already committed; receipt delivery is best-effort. + // Gateway state may already be committed; receipt delivery is best-effort. } } }