diff --git a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx index 84e16961d9..c2035ba71a 100644 --- a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx +++ b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx @@ -4,6 +4,7 @@ import WaveDropActionsAddReaction from "@/components/waves/drops/WaveDropActions import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; import { ApiDropType } from "@/generated/models/ApiDropType"; +import * as commonApi from "@/services/api/common-api"; const applyOptimisticDropUpdateMock = jest.fn(() => ({ rollback: jest.fn() })); const setToastMock = jest.fn(); @@ -47,6 +48,25 @@ jest.mock("@/services/api/common-api", () => ({ commonApiPost: jest.fn(() => Promise.resolve({})), })); +jest.mock("@sentry/nextjs", () => ({ + __esModule: true, + addBreadcrumb: jest.fn(), + withScope: jest.fn((callback: (scope: any) => void) => { + const scope = { + setLevel: jest.fn(), + setFingerprint: jest.fn(), + setTag: jest.fn(), + setExtras: jest.fn(), + }; + callback(scope); + }), + captureException: jest.fn(), +})); + +jest.mock("@/services/websocket/useWebSocketMessage", () => ({ + useWebsocketStatus: jest.fn(() => "connected"), +})); + jest.mock("@/components/mobile-wrapper-dialog/MobileWrapperDialog", () => ({ __esModule: true, default: (props: any) => mobileWrapperDialogMock(props), @@ -173,6 +193,24 @@ describe("WaveDropActionsAddReaction", () => { }); }); + it("shows the structured API error message when adding a reaction fails", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + new Error("Unauthorized") + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /add reaction/i })); + fireEvent.click(await screen.findByText(/select emoji/i)); + + await waitFor(() => { + expect(setToastMock).toHaveBeenCalledWith({ + message: "Unauthorized", + type: "error", + }); + }); + }); + it("opens and closes picker on mobile button click", async () => { render(); const button = screen.getByRole("button", { name: /add reaction/i }); diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index 6e650feab5..433e8a41c4 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -28,6 +28,25 @@ jest.mock("@/services/api/common-api", () => ({ commonApiDelete: jest.fn(), })); +jest.mock("@sentry/nextjs", () => ({ + __esModule: true, + addBreadcrumb: jest.fn(), + withScope: jest.fn((callback: (scope: any) => void) => { + const scope = { + setLevel: jest.fn(), + setFingerprint: jest.fn(), + setTag: jest.fn(), + setExtras: jest.fn(), + }; + callback(scope); + }), + captureException: jest.fn(), +})); + +jest.mock("@/services/websocket/useWebSocketMessage", () => ({ + useWebsocketStatus: jest.fn(() => "connected"), +})); + jest.mock("@/hooks/useIsTouchDevice", () => ({ __esModule: true, default: jest.fn(() => false), @@ -48,6 +67,29 @@ jest.mock("@/hooks/useLongPressInteraction", () => ({ const mockUseEmoji = useEmoji as jest.Mock; const mockUseAuth = useAuth as jest.Mock; +const setToastMock = jest.fn(); +const createStructuredReactionError = ({ + body, + message = "technical error", + status, + statusText, +}: { + body?: unknown; + message?: string; + status?: number; + statusText?: string; +}): Error & { + status?: number; + response: { body?: unknown; status?: number; statusText?: string }; +} => + Object.assign(new Error(message), { + ...(status !== undefined ? { status } : {}), + response: { + ...(body !== undefined ? { body } : {}), + ...(status !== undefined ? { status } : {}), + ...(statusText !== undefined ? { statusText } : {}), + }, + }); type NativeEmojiMock = { skins: Array<{ native: string }> }; @@ -95,7 +137,7 @@ describe("WaveDropReactions", () => { jest.clearAllMocks(); mockUseAuth.mockReturnValue({ connectedProfile: { id: "profile-1", handle: "alice" }, - setToast: jest.fn(), + setToast: setToastMock, }); getMyStreamMock().mockReturnValue({ applyOptimisticDropUpdate: jest.fn(() => ({ rollback: jest.fn() })), @@ -260,6 +302,7 @@ describe("WaveDropReactions", () => { expect(commonApi.commonApiPost).toHaveBeenCalledWith({ endpoint: "drops/test-drop/reaction", body: { reaction: ":gm:" }, + errorMode: "structured", }); // Click button again to decrement @@ -269,6 +312,99 @@ describe("WaveDropReactions", () => { }); expect(commonApi.commonApiDelete).toHaveBeenCalledWith({ endpoint: "drops/test-drop/reaction", + errorMode: "structured", + }); + }); + + it("shows the structured API error message when a chip reaction fails", async () => { + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], + }, + ], + () => null + ) + ); + + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + body: JSON.stringify({ message: "Unauthorized" }), + message: "unexpected raw error", + }) + ); + + render( + + ); + + fireEvent.click(screen.getAllByRole("button")[0]); + + await waitFor(() => { + expect(setToastMock).toHaveBeenCalledWith({ + message: "Unauthorized", + type: "error", + }); + }); + }); + + it("shows the safe status-text message when a chip reaction gets an empty structured response", async () => { + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], + }, + ], + () => null + ) + ); + + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + body: " ", + message: " ", + status: 404, + statusText: "Not Found", + }) + ); + + render( + + ); + + fireEvent.click(screen.getAllByRole("button")[0]); + + await waitFor(() => { + expect(setToastMock).toHaveBeenCalledWith({ + message: "Not Found", + type: "error", + }); }); }); diff --git a/__tests__/components/waves/drops/reaction-utils.test.ts b/__tests__/components/waves/drops/reaction-utils.test.ts new file mode 100644 index 0000000000..49098ffc63 --- /dev/null +++ b/__tests__/components/waves/drops/reaction-utils.test.ts @@ -0,0 +1,281 @@ +import { getReactionErrorMessage } from "@/components/waves/drops/reaction-utils"; + +const createStructuredReactionError = ({ + body, + message = "technical error", + status, + statusText, +}: { + body?: unknown; + message?: string; + status?: number; + statusText?: string; +}): Error & { + status?: number; + response: { body?: unknown; status?: number; statusText?: string }; +} => + Object.assign(new Error(message), { + ...(status !== undefined ? { status } : {}), + response: { + ...(body !== undefined ? { body } : {}), + ...(status !== undefined ? { status } : {}), + ...(statusText !== undefined ? { statusText } : {}), + }, + }); + +describe("getReactionErrorMessage", () => { + it("surfaces the error field from structured API errors", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ error: "Rate limited" }), + message: "unexpected raw error", + status: 429, + }), + "Error adding reaction" + ) + ).toBe("Rate limited"); + }); + + it("surfaces the message field from structured API errors", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ message: "Unauthorized" }), + message: "unexpected raw error", + status: 401, + }), + "Error adding reaction" + ) + ).toBe("Unauthorized"); + }); + + it("surfaces the first details message from structured API errors", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ + details: [{ message: "Reaction not allowed" }], + }), + message: "unexpected raw error", + }), + "Error adding reaction" + ) + ).toBe("Reaction not allowed"); + }); + + it("skips blank details messages and uses the first later valid one", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ + details: [ + { message: " " }, + { message: " Reaction not allowed " }, + ], + }), + message: "unexpected raw error", + }), + "Error adding reaction" + ) + ).toBe("Reaction not allowed"); + }); + + it("skips malformed detail entries before finding a valid message", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ + details: [null, "bad", {}, { message: "Retry later" }], + }), + message: "unexpected raw error", + }), + "Error adding reaction" + ) + ).toBe("Retry later"); + }); + + it("falls back when no detail entry contains a usable message", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ + details: [null, { message: " " }, { message: 123 }], + }), + message: "Bad Request", + status: 400, + statusText: "Bad Request", + }), + "Error adding reaction" + ) + ).toBe("Bad Request"); + }); + + it("falls back for non-JSON structured error bodies", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: "Bad Gateway", + message: "Bad Gateway", + status: 502, + }), + "Error adding reaction" + ) + ).toBe("Error adding reaction"); + }); + + it("maps unauthorized status when the structured body is missing", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + message: "Something went wrong", + status: 401, + }), + "Error adding reaction" + ) + ).toBe("Unauthorized"); + }); + + it("maps unauthorized status when response is null", () => { + expect( + getReactionErrorMessage( + Object.assign(new Error("Something went wrong"), { + status: 401, + response: null, + }), + "Error adding reaction" + ) + ).toBe("Unauthorized"); + }); + + it("maps rate-limit status when the structured body is blank", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: " ", + message: " ", + status: 429, + statusText: "Too Many Requests", + }), + "Error adding reaction" + ) + ).toBe("Too Many Requests"); + }); + + it("surfaces the structured status text when the structured body is missing", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + message: "Service Unavailable", + status: 503, + statusText: "Service Unavailable", + }), + "Error adding reaction" + ) + ).toBe("Service Unavailable"); + }); + + it("surfaces the structured status text when the structured body is blank", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: " ", + message: " ", + status: 404, + statusText: "Not Found", + }), + "Error adding reaction" + ) + ).toBe("Not Found"); + }); + + it("surfaces the structured status text when the parsed json body has no known fields", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ foo: "bar" }), + message: "Bad Request", + status: 400, + statusText: "Bad Request", + }), + "Error adding reaction" + ) + ).toBe("Bad Request"); + }); + + it("surfaces the structured status text when parsed json known fields are blank", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: JSON.stringify({ error: " " }), + message: "Bad Request", + status: 400, + statusText: "Bad Request", + }), + "Error adding reaction" + ) + ).toBe("Bad Request"); + }); + + it("surfaces the structured status text when an object body has no known fields", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: { foo: "bar" }, + message: "Bad Request", + status: 400, + statusText: "Bad Request", + }), + "Error adding reaction" + ) + ).toBe("Bad Request"); + }); + + it("falls back to the structured error message when status text is unavailable", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: " ", + message: "Service Unavailable", + status: 503, + }), + "Error adding reaction" + ) + ).toBe("Service Unavailable"); + }); + + it("falls back to the structured error message when response is missing", () => { + expect( + getReactionErrorMessage( + Object.assign(new Error("Service Unavailable"), { + status: 503, + }), + "Error adding reaction" + ) + ).toBe("Service Unavailable"); + }); + + it("falls back for generic network errors", () => { + expect( + getReactionErrorMessage( + new Error( + "Network request failed. Please check your connection and try again. (https://api.test.6529.io/api/drops/drop-1/reaction)" + ), + "Error adding reaction" + ) + ).toBe("Error adding reaction"); + }); + + it("does not use the raw error message when an unsafe body is present", () => { + expect( + getReactionErrorMessage( + createStructuredReactionError({ + body: "Bad Gateway", + message: "Unauthorized", + status: 401, + }), + "Error adding reaction" + ) + ).toBe("Error adding reaction"); + }); +}); diff --git a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts index 01af864ec0..3dc61d0da0 100644 --- a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts +++ b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts @@ -157,7 +157,7 @@ it("calculates rolling end date correctly", () => { expect(body.voting.period.max).toBe(60); // last decision before 65 }); -it("sets winning thresholds for approve waves", () => { +it("keeps winning_threshold for approve waves", () => { const config: any = { overview: { type: ApiWaveType.Approve, name: "A" }, groups: { @@ -196,7 +196,7 @@ it("sets winning thresholds for approve waves", () => { }, }, outcomes: [], - approval: { threshold: 3, thresholdTimeMs: null }, + approval: { threshold: 3, thresholdTimeMs: 60000 }, }; const drop: any = { parts: [], @@ -205,5 +205,56 @@ it("sets winning thresholds for approve waves", () => { metadata: [], }; const body = getCreateNewWaveBody({ drop, picture: null, config }); - expect(body.wave.winning_thresholds).toEqual({ min: 3, max: 3 }); + expect(body.wave.winning_threshold).toBe(config.approval.threshold); +}); + +it("sets winning_threshold to null for non-approve waves", () => { + const config: any = { + overview: { type: ApiWaveType.Rank, name: "R" }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 1, + votingStartDate: 2, + endDate: null, + firstDecisionTime: 2, + subsequentDecisions: [], + isRolling: false, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [], + submissionStrategy: null, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: false, + }, + chat: { enabled: false }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: false, + averagingInterval: 5, + averagingIntervalUnit: "minutes", + }, + }, + outcomes: [], + approval: { threshold: 3, thresholdTimeMs: 60000 }, + }; + const drop: any = { + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], + }; + const body = getCreateNewWaveBody({ drop, picture: null, config }); + expect(body.wave.winning_threshold).toBeNull(); }); diff --git a/__tests__/helpers/waves/create-wave.helpers.test.ts b/__tests__/helpers/waves/create-wave.helpers.test.ts index d422a5f73b..ca6399628f 100644 --- a/__tests__/helpers/waves/create-wave.helpers.test.ts +++ b/__tests__/helpers/waves/create-wave.helpers.test.ts @@ -1,14 +1,72 @@ import { + getCreateNewWaveBody, getCreateWaveNextStep, getCreateWavePreviousStep, calculateLastDecisionTime, } from "@/helpers/waves/create-wave.helpers"; +import { ApiWaveOutcomeCredit } from "@/generated/models/ApiWaveOutcomeCredit"; +import { ApiWaveOutcomeSubType } from "@/generated/models/ApiWaveOutcomeSubType"; +import { ApiWaveOutcomeType } from "@/generated/models/ApiWaveOutcomeType"; import { ApiWaveParticipationIdentitySubmissionAllowDuplicates } from "@/generated/models/ApiWaveParticipationIdentitySubmissionAllowDuplicates"; import { ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted } from "@/generated/models/ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted"; import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { ApiWaveMetadataType } from "@/generated/models/ApiWaveMetadataType"; -import { CreateWaveStep } from "@/types/waves.types"; +import { + CreateWaveOutcomeConfigWinnersCreditValueType, + CreateWaveOutcomeType, + CreateWaveStep, +} from "@/types/waves.types"; + +const createBaseConfig = (waveType: ApiWaveType) => + ({ + overview: { type: waveType, name: "W", image: null }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 1, + votingStartDate: 2, + endDate: 10, + firstDecisionTime: 2, + subsequentDecisions: [], + isRolling: false, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [], + submissionStrategy: null, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: false, + }, + chat: { enabled: true }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: false, + averagingInterval: 5, + averagingIntervalUnit: "minutes", + }, + }, + outcomes: [], + approval: { threshold: null, thresholdTimeMs: null }, + }) as any; + +const createDrop = () => + ({ + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], + }) as any; describe("create-wave.helpers", () => { describe("getCreateWaveNextStep", () => { @@ -78,9 +136,6 @@ describe("create-wave.helpers", () => { describe("getCreateNewWaveBody", () => { it("converts config into request body", () => { - const { - getCreateNewWaveBody, - } = require("@/helpers/waves/create-wave.helpers"); const config = { overview: { type: ApiWaveType.Chat, name: "W", image: null }, groups: { @@ -138,9 +193,6 @@ describe("create-wave.helpers", () => { }); it("includes identity submission strategy when configured", () => { - const { - getCreateNewWaveBody, - } = require("@/helpers/waves/create-wave.helpers"); const config = { overview: { type: ApiWaveType.Rank, name: "W", image: null }, groups: { @@ -200,5 +252,192 @@ describe("create-wave.helpers", () => { config.drops.submissionStrategy ); }); + + it("keeps manual approve outcomes without max winners and filters invalid credit amounts", () => { + const config = createBaseConfig(ApiWaveType.Approve); + config.outcomes = [ + { + type: CreateWaveOutcomeType.MANUAL, + title: "Manual action", + credit: null, + category: null, + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: 0, + category: "gold", + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: Number.NaN, + category: "gold", + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: 15, + category: "gold", + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: null, + category: null, + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: 0, + category: null, + maxWinners: null, + winnersConfig: null, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: 25, + category: null, + maxWinners: null, + winnersConfig: null, + }, + ] as any; + + const res = getCreateNewWaveBody({ + drop: createDrop(), + picture: null, + config, + }); + + expect(res.outcomes).toEqual([ + { + type: ApiWaveOutcomeType.Manual, + description: "Manual action", + }, + { + type: ApiWaveOutcomeType.Automatic, + subtype: ApiWaveOutcomeSubType.CreditDistribution, + description: "", + credit: ApiWaveOutcomeCredit.Rep, + rep_category: "gold", + amount: 15, + }, + { + type: ApiWaveOutcomeType.Automatic, + subtype: ApiWaveOutcomeSubType.CreditDistribution, + description: "", + credit: ApiWaveOutcomeCredit.Cic, + amount: 25, + }, + ]); + }); + + it("filters rank outcomes with missing or non-positive total amounts", () => { + const config = createBaseConfig(ApiWaveType.Rank); + config.outcomes = [ + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: null, + category: "gold", + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: 0, + winners: [{ value: 10 }], + }, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: null, + category: "gold", + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: Number.NaN, + winners: [{ value: 10 }], + }, + }, + { + type: CreateWaveOutcomeType.REP, + title: null, + credit: null, + category: "gold", + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: 10, + winners: [{ value: 10 }], + }, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: null, + category: null, + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: null, + winners: [{ value: 20 }], + }, + }, + { + type: CreateWaveOutcomeType.NIC, + title: null, + credit: null, + category: null, + maxWinners: 1, + winnersConfig: { + creditValueType: + CreateWaveOutcomeConfigWinnersCreditValueType.ABSOLUTE_VALUE, + totalAmount: 20, + winners: [{ value: 20 }], + }, + }, + ] as any; + + const res = getCreateNewWaveBody({ + drop: createDrop(), + picture: null, + config, + }); + + expect(res.outcomes).toEqual([ + { + type: ApiWaveOutcomeType.Automatic, + subtype: ApiWaveOutcomeSubType.CreditDistribution, + description: "Rep distribution", + credit: ApiWaveOutcomeCredit.Rep, + rep_category: "gold", + amount: 10, + distribution: [{ amount: 10, description: null }], + }, + { + type: ApiWaveOutcomeType.Automatic, + subtype: ApiWaveOutcomeSubType.CreditDistribution, + description: "NIC distribution", + credit: ApiWaveOutcomeCredit.Cic, + amount: 20, + distribution: [{ amount: 20, description: null }], + }, + ]); + }); }); }); diff --git a/__tests__/hooks/useDropReaction.test.ts b/__tests__/hooks/useDropReaction.test.ts new file mode 100644 index 0000000000..59da6a00fd --- /dev/null +++ b/__tests__/hooks/useDropReaction.test.ts @@ -0,0 +1,267 @@ +import { useDropReaction } from "@/hooks/drops/useDropReaction"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { DropSize } from "@/helpers/waves/drop.helpers"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { useAuth } from "@/components/auth/Auth"; +import { useMyStream } from "@/contexts/wave/MyStreamContext"; +import * as commonApi from "@/services/api/common-api"; +import * as dropReactionMonitoring from "@/utils/monitoring/dropReactionMonitoring"; +import { act, renderHook } from "@testing-library/react"; + +const setToastMock = jest.fn(); +const rollbackMock = jest.fn(); +const applyOptimisticDropUpdateMock = jest.fn(() => ({ + rollback: rollbackMock, +})); + +jest.mock("@/components/auth/Auth", () => ({ + useAuth: jest.fn(), +})); + +jest.mock("@/contexts/wave/MyStreamContext", () => ({ + useMyStream: jest.fn(), +})); + +jest.mock("@/services/api/common-api", () => ({ + commonApiPost: jest.fn(), + commonApiDelete: jest.fn(), +})); + +jest.mock("@/helpers/reactions/reactionHistory", () => ({ + recordReaction: jest.fn(), +})); + +jest.mock("@/services/websocket/useWebSocketMessage", () => ({ + useWebsocketStatus: jest.fn(() => "connected"), +})); + +jest.mock("@/utils/monitoring/dropReactionMonitoring", () => ({ + beginReactionMutation: jest.fn(() => ({ mutationId: "mutation-1" })), + deriveReactionAction: jest.fn(() => "add"), + recordReactionOptimisticApplied: jest.fn(), + recordReactionRequestFailed: jest.fn(), + recordReactionRequestSent: jest.fn(), + recordReactionRequestSucceeded: jest.fn(), + recordReactionRollbackApplied: jest.fn(), +})); + +const mockUseAuth = useAuth as jest.Mock; +const mockUseMyStream = useMyStream as jest.Mock; +const createStructuredReactionError = ({ + body, + message = "technical error", + status, +}: { + body?: unknown; + message?: string; + status?: number; +}): Error & { + status?: number; + response: { body?: unknown; status?: number }; +} => + Object.assign(new Error(message), { + ...(status !== undefined ? { status } : {}), + response: { + ...(body !== undefined ? { body } : {}), + ...(status !== undefined ? { status } : {}), + }, + }); + +const mockDrop = { + id: "drop-1", + wave: { id: "wave-1" }, + context_profile_context: { reaction: null }, + author: { handle: "author-handle" }, + parts: [], + metadata: [], + drop_type: ApiDropType.Standard, + serial_no: 1, + created_at: new Date().toISOString(), + reply_to: null, + wave_messages: [], + reactions: [], + type: DropSize.FULL, + stableKey: "drop-1", + stableHash: "hash-drop-1", +} as unknown as ExtendedDrop; + +describe("useDropReaction", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ + setToast: setToastMock, + connectedProfile: { + id: "identity-1", + handle: "user", + pfp: null, + banner1: null, + banner2: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: 0, + primary_wallet: "0xuser", + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + is_wave_creator: false, + artist_of_prevote_cards: [], + profile_wave_id: null, + }, + }); + mockUseMyStream.mockReturnValue({ + applyOptimisticDropUpdate: applyOptimisticDropUpdateMock, + }); + }); + + it("shows structured API error messages for quick react failures", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + body: JSON.stringify({ error: "Rate limited" }), + message: "unexpected raw error", + status: 429, + }) + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(commonApi.commonApiPost).toHaveBeenCalledWith({ + endpoint: "drops/drop-1/reaction", + body: { reaction: ":smile:" }, + errorMode: "structured", + }); + expect(setToastMock).toHaveBeenCalledWith({ + message: "Rate limited", + type: "error", + }); + }); + + it("does not treat a throwing onSuccess callback as a request failure", async () => { + const onSuccess = jest.fn(() => { + throw new Error("consumer callback failed"); + }); + (commonApi.commonApiPost as jest.Mock).mockResolvedValueOnce({}); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { + source: "quick-react", + onSuccess, + }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect( + dropReactionMonitoring.recordReactionRequestSucceeded + ).toHaveBeenCalledTimes(1); + expect( + dropReactionMonitoring.recordReactionRequestFailed + ).not.toHaveBeenCalled(); + expect( + dropReactionMonitoring.recordReactionRollbackApplied + ).not.toHaveBeenCalled(); + expect(setToastMock).not.toHaveBeenCalled(); + expect(rollbackMock).not.toHaveBeenCalled(); + }); + + it("falls back for unsafe structured quick react failures", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + body: "Bad Gateway", + message: "Bad Gateway", + status: 502, + }) + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(setToastMock).toHaveBeenCalledWith({ + message: "Error adding reaction", + type: "error", + }); + }); + + it("maps unauthorized status when the structured body is empty", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + message: "Something went wrong", + status: 401, + }) + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(setToastMock).toHaveBeenCalledWith({ + message: "Unauthorized", + type: "error", + }); + }); + + it("maps rate-limit status when the structured body is blank", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + body: " ", + message: " ", + status: 429, + }) + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(setToastMock).toHaveBeenCalledWith({ + message: "Too Many Requests", + type: "error", + }); + }); + + it("surfaces the safe status-text message when the structured body is missing", async () => { + (commonApi.commonApiPost as jest.Mock).mockRejectedValueOnce( + createStructuredReactionError({ + message: "Service Unavailable", + status: 503, + }) + ); + + const { result } = renderHook(() => + useDropReaction(mockDrop, { source: "quick-react" }) + ); + + await act(async () => { + await result.current.react(":smile:"); + }); + + expect(setToastMock).toHaveBeenCalledWith({ + message: "Service Unavailable", + type: "error", + }); + }); +}); diff --git a/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts index 971f7d91e6..5d3664977d 100644 --- a/__tests__/services/common-api.test.ts +++ b/__tests__/services/common-api.test.ts @@ -135,6 +135,7 @@ describe("commonApiPost", () => { response: { status: number; headers: Headers; + statusText?: string; body?: unknown; }; } | null = null; @@ -153,6 +154,7 @@ describe("commonApiPost", () => { response: { status: number; headers: Headers; + statusText?: string; body?: unknown; }; }; @@ -165,9 +167,204 @@ describe("commonApiPost", () => { expect(error?.headers.get("retry-after")).toBe("2"); expect(error?.response.status).toBe(429); expect(error?.response.headers).toBe(responseHeaders); + expect(error?.response.statusText).toBe("Too Many Requests"); expect(error?.response.body).toBe('{"error":"rate limited"}'); }); + it("falls back to statusText in structured errors when the response body is whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + text: async () => " ", + }); + + let error: { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("Service Unavailable"); + expect(error?.response.body).toBe(" "); + expect(error?.response.statusText).toBe("Service Unavailable"); + }); + + it("falls back to statusText when response body is whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + text: async () => " ", + }); + + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( + "Service Unavailable" + ); + }); + + it("falls back to statusText when parsed json fields are whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => JSON.stringify({ error: " " }), + }); + + let error: { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("Bad Request"); + expect(error?.response.body).toBe('{"error":" "}'); + expect(error?.response.statusText).toBe("Bad Request"); + }); + + it("prefers message when error field is whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => + JSON.stringify({ + error: " ", + message: "Name already exists", + }), + }); + + let error: { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("Name already exists"); + expect(error?.response.body).toBe( + '{"error":" ","message":"Name already exists"}' + ); + expect(error?.response.statusText).toBe("Bad Request"); + }); + + it("prefers details message when higher-priority fields are whitespace only", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + text: async () => + JSON.stringify({ + error: " ", + message: " ", + details: [{ message: "Not allowed to reorder this curation" }], + }), + }); + + let error: { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + response: { + status: number; + headers: Headers; + statusText?: string; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("Not allowed to reorder this curation"); + expect(error?.response.body).toBe( + '{"error":" ","message":" ","details":[{"message":"Not allowed to reorder this curation"}]}' + ); + expect(error?.response.statusText).toBe("Forbidden"); + }); + it("prefers message when error key is missing", async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/__tests__/utils/monitoring/dropReactionMonitoring.test.ts b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts new file mode 100644 index 0000000000..ff3522a96f --- /dev/null +++ b/__tests__/utils/monitoring/dropReactionMonitoring.test.ts @@ -0,0 +1,407 @@ +import * as Sentry from "@sentry/nextjs"; +import { + __resetDropReactionMonitoringForTests, + beginReactionMutation, + deriveReactionAction, + recordReactionOptimisticApplied, + recordReactionRealtimeReconciliation, + recordReactionRequestFailed, + recordReactionRequestSent, + recordReactionRequestSucceeded, + recordReactionRollbackApplied, +} from "@/utils/monitoring/dropReactionMonitoring"; +import { WebSocketStatus } from "@/services/websocket/WebSocketTypes"; + +jest.mock("@sentry/nextjs", () => ({ + __esModule: true, + addBreadcrumb: jest.fn(), + withScope: jest.fn((callback: (scope: any) => void) => { + const scope = { + setLevel: jest.fn(), + setFingerprint: jest.fn(), + setTag: jest.fn(), + setExtras: jest.fn(), + }; + callback(scope); + }), + captureException: jest.fn(), +})); + +describe("dropReactionMonitoring", () => { + const addBreadcrumbMock = Sentry.addBreadcrumb as jest.Mock; + const withScopeMock = Sentry.withScope as jest.Mock; + const captureExceptionMock = Sentry.captureException as jest.Mock; + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + __resetDropReactionMonitoringForTests(); + dateNowSpy = jest.spyOn(Date, "now").mockReturnValue(1_000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("derives reaction actions correctly", () => { + expect(deriveReactionAction(null, ":smile:")).toBe("add"); + expect(deriveReactionAction(":wave:", null)).toBe("remove"); + expect(deriveReactionAction(":wave:", ":smile:")).toBe("replace"); + }); + + it("records breadcrumbs for a successful reaction request", () => { + const mutation = beginReactionMutation({ + dropId: "drop-1", + waveId: "wave-1", + source: "quick-react", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + dateNowSpy.mockReturnValue(1_100); + recordReactionOptimisticApplied(mutation); + recordReactionRequestSent(mutation, { + endpoint: "drops/drop-1/reaction", + method: "POST", + }); + + dateNowSpy.mockReturnValue(1_250); + recordReactionRequestSucceeded(mutation); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.intent", + }) + ); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.optimistic_applied", + }) + ); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.request_sent", + }) + ); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.request_succeeded", + data: expect.objectContaining({ + latency_ms: 150, + }), + }) + ); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + + it("captures a classified failure event for auth errors", () => { + const mutation = beginReactionMutation({ + dropId: "drop-2", + waveId: "wave-1", + source: "picker", + action: "replace", + previousReaction: ":wave:", + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + recordReactionRequestSent(mutation, { + endpoint: "drops/drop-2/reaction", + method: "POST", + }); + + const error = Object.assign(new Error("Unauthorized"), { + status: 401, + }); + + dateNowSpy.mockReturnValue(1_200); + recordReactionRequestFailed(mutation, error); + recordReactionRollbackApplied(mutation); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.request_failed", + data: expect.objectContaining({ + status_code: 401, + error_kind: "auth", + }), + }) + ); + expect(withScopeMock).toHaveBeenCalled(); + expect(captureExceptionMock).toHaveBeenCalledWith(error); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.rollback_applied", + }) + ); + }); + + it("captures an out-of-order anomaly when an older mutation resolves last", () => { + const olderMutation = beginReactionMutation({ + dropId: "drop-3", + waveId: "wave-1", + source: "quick-react", + action: "add", + previousReaction: null, + intendedReaction: ":wave:", + optimisticReaction: ":wave:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + const newerMutation = beginReactionMutation({ + dropId: "drop-3", + waveId: "wave-1", + source: "quick-react", + action: "replace", + previousReaction: ":wave:", + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + void newerMutation; + + recordReactionRequestSent(olderMutation, { + endpoint: "drops/drop-3/reaction", + method: "POST", + }); + + dateNowSpy.mockReturnValue(1_350); + recordReactionRequestSucceeded(olderMutation); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.response_superseded", + data: expect.objectContaining({ + superseded_by_mutation_id: expect.any(String), + }), + }) + ); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Reaction response superseded by newer mutation", + }) + ); + }); + + it("captures reconciliation mismatch after success when failure timestamp is null", () => { + const mutation = beginReactionMutation({ + dropId: "drop-4", + waveId: "wave-1", + source: "chip", + action: "replace", + previousReaction: ":wave:", + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + recordReactionRequestSent(mutation, { + endpoint: "drops/drop-4/reaction", + method: "POST", + }); + + dateNowSpy.mockReturnValue(1_100); + recordReactionRequestSucceeded(mutation); + expect(mutation.apiFailedAt).toBeNull(); + + dateNowSpy.mockReturnValue(1_500); + recordReactionRealtimeReconciliation({ + drop: { + id: "drop-4", + wave: { id: "wave-1" }, + context_profile_context: { + reaction: ":wave:", + } as any, + }, + websocketStatus: WebSocketStatus.CONNECTED, + }); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.optimistic_reverted", + data: expect.objectContaining({ + server_reaction: ":wave:", + }), + }) + ); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Reaction optimistic state disagreed with canonical state", + }) + ); + }); + + it("adds a breadcrumb only when realtime reconciliation matches intent", () => { + const mutation = beginReactionMutation({ + dropId: "drop-5", + waveId: "wave-1", + source: "chip", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + recordReactionRequestSent(mutation, { + endpoint: "drops/drop-5/reaction", + method: "POST", + }); + + dateNowSpy.mockReturnValue(1_100); + recordReactionRequestSucceeded(mutation); + + dateNowSpy.mockReturnValue(1_300); + recordReactionRealtimeReconciliation({ + drop: { + id: "drop-5", + wave: { id: "wave-1" }, + context_profile_context: { + reaction: ":smile:", + } as any, + }, + websocketStatus: WebSocketStatus.CONNECTED, + }); + + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "reaction.realtime_reconciled", + }) + ); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + + it("resets the per-drop sequence when the last tracked mutation ages out", () => { + const firstMutation = beginReactionMutation({ + dropId: "drop-6", + waveId: "wave-1", + source: "picker", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + dateNowSpy.mockReturnValue(302_000); + const nextMutation = beginReactionMutation({ + dropId: "drop-6", + waveId: "wave-1", + source: "picker", + action: "replace", + previousReaction: ":smile:", + intendedReaction: ":wave:", + optimisticReaction: ":wave:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + expect(firstMutation.dropMutationSeq).toBe(1); + expect(nextMutation.dropMutationSeq).toBe(1); + }); + + it("keeps the per-drop sequence while a newer mutation is still tracked", () => { + const oldestMutation = beginReactionMutation({ + dropId: "drop-7", + waveId: "wave-1", + source: "picker", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + dateNowSpy.mockReturnValue(150_000); + const newerMutation = beginReactionMutation({ + dropId: "drop-7", + waveId: "wave-1", + source: "picker", + action: "replace", + previousReaction: ":smile:", + intendedReaction: ":wave:", + optimisticReaction: ":wave:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + dateNowSpy.mockReturnValue(302_000); + const latestMutation = beginReactionMutation({ + dropId: "drop-7", + waveId: "wave-1", + source: "picker", + action: "replace", + previousReaction: ":wave:", + intendedReaction: ":fire:", + optimisticReaction: ":fire:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + + expect(oldestMutation.dropMutationSeq).toBe(1); + expect(newerMutation.dropMutationSeq).toBe(2); + expect(latestMutation.dropMutationSeq).toBe(3); + }); + + it("dedupes repeated identical failure events within 60 seconds", () => { + const firstMutation = beginReactionMutation({ + dropId: "drop-8", + waveId: "wave-1", + source: "picker", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + recordReactionRequestSent(firstMutation, { + endpoint: "drops/drop-8/reaction", + method: "POST", + }); + + const secondMutation = beginReactionMutation({ + dropId: "drop-8", + waveId: "wave-1", + source: "picker", + action: "add", + previousReaction: null, + intendedReaction: ":smile:", + optimisticReaction: ":smile:", + profileId: "profile-1", + websocketStatus: WebSocketStatus.CONNECTED, + }); + recordReactionRequestSent(secondMutation, { + endpoint: "drops/drop-8/reaction", + method: "POST", + }); + + const networkError = new TypeError("Failed to fetch"); + + dateNowSpy.mockReturnValue(1_100); + recordReactionRequestFailed(firstMutation, networkError); + dateNowSpy.mockReturnValue(1_200); + recordReactionRequestFailed(secondMutation, networkError); + + expect(captureExceptionMock).toHaveBeenCalledTimes(2); + expect(captureExceptionMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + message: "Reaction response superseded by newer mutation", + }) + ); + expect(captureExceptionMock.mock.calls[1]?.[0]).toBe(networkError); + }); +}); diff --git a/components/waves/drops/WaveDropActionsAddReaction.tsx b/components/waves/drops/WaveDropActionsAddReaction.tsx index 1b9c3a57ef..2c2f704292 100644 --- a/components/waves/drops/WaveDropActionsAddReaction.tsx +++ b/components/waves/drops/WaveDropActionsAddReaction.tsx @@ -16,7 +16,10 @@ const WaveDropActionsAddReaction: React.FC<{ readonly onAddReaction?: (() => void) | undefined; readonly dialogZIndexClassName?: string | undefined; }> = ({ drop, isMobile = false, onAddReaction, dialogZIndexClassName }) => { - const { react, canReact } = useDropReaction(drop, onAddReaction); + const { react, canReact } = useDropReaction(drop, { + source: "picker", + onSuccess: onAddReaction, + }); const [showPicker, setShowPicker] = useState(false); const buttonRef = useRef(null); const pickerContainerRef = useRef(null); diff --git a/components/waves/drops/WaveDropActionsQuickReact.tsx b/components/waves/drops/WaveDropActionsQuickReact.tsx index ea00b74545..f7adac3a93 100644 --- a/components/waves/drops/WaveDropActionsQuickReact.tsx +++ b/components/waves/drops/WaveDropActionsQuickReact.tsx @@ -21,7 +21,10 @@ const WaveDropActionsQuickReact: React.FC<{ readonly isMobile?: boolean; readonly onReacted?: () => void; }> = ({ drop, isMobile = false, onReacted }) => { - const { react, canReact } = useDropReaction(drop, onReacted); + const { react, canReact } = useDropReaction(drop, { + source: "quick-react", + onSuccess: onReacted, + }); // Subscribe to localStorage changes (hydration-safe) const reactionSnapshot = useSyncExternalStore( diff --git a/components/waves/drops/WaveDropReactions.tsx b/components/waves/drops/WaveDropReactions.tsx index cc895e7019..aeadf28588 100644 --- a/components/waves/drops/WaveDropReactions.tsx +++ b/components/waves/drops/WaveDropReactions.tsx @@ -14,6 +14,7 @@ import { DropSize } from "@/helpers/waves/drop.helpers"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; import useLongPressInteraction from "@/hooks/useLongPressInteraction"; import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; +import { useWebsocketStatus } from "@/services/websocket/useWebSocketMessage"; import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; @@ -28,9 +29,19 @@ import { Tooltip } from "react-tooltip"; import { cloneReactionEntries, findReactionIndex, + getReactionErrorMessage, removeUserFromReactions, toProfileMin, } from "./reaction-utils"; +import { + beginReactionMutation, + deriveReactionAction, + recordReactionOptimisticApplied, + recordReactionRequestFailed, + recordReactionRequestSent, + recordReactionRequestSucceeded, + recordReactionRollbackApplied, +} from "@/utils/monitoring/dropReactionMonitoring"; import styles from "./WaveDropReactions.module.scss"; import WaveDropReactionsDetailDialog from "./WaveDropReactionsDetailDialog"; @@ -85,6 +96,7 @@ function WaveDropReaction({ const { setToast, connectedProfile } = useAuth(); const { emojiMap, findNativeEmoji } = useEmoji(); const { applyOptimisticDropUpdate } = useMyStream(); + const websocketStatus = useWebsocketStatus(); const rollbackRef = useRef<(() => void) | null>(null); const canReact = Boolean(connectedProfile?.handle); @@ -326,10 +338,27 @@ function WaveDropReaction({ return; } + const intendedReaction = selected ? null : reaction.reaction; + const mutation = beginReactionMutation({ + dropId: drop.id, + waveId, + source: "chip", + action: deriveReactionAction( + drop.context_profile_context?.reaction ?? null, + intendedReaction + ), + previousReaction: drop.context_profile_context?.reaction ?? null, + intendedReaction, + optimisticReaction: intendedReaction, + profileId: connectedProfile?.id ?? null, + websocketStatus, + }); + setSelected((s) => !s); setTotal((n) => Math.max(0, n + (selected ? -1 : 1))); applyOptimisticReactionChange(!selected); + recordReactionOptimisticApplied(mutation); if (!selected) { recordReaction(reaction.reaction); @@ -339,34 +368,53 @@ function WaveDropReaction({ const body = { reaction: reaction.reaction }; const endpoint = `drops/${drop.id}/reaction`; if (selected) { + recordReactionRequestSent(mutation, { + endpoint, + method: "DELETE", + }); await commonApiDelete({ endpoint, + errorMode: "structured", }); } else { + recordReactionRequestSent(mutation, { + endpoint, + method: "POST", + }); await commonApiPost({ endpoint, body, + errorMode: "structured", }); } + recordReactionRequestSucceeded(mutation); } catch (error) { - let msg = selected ? "Error removing reaction" : "Error adding reaction"; - if (typeof error === "string") msg = error; + recordReactionRequestFailed(mutation, error); + const msg = getReactionErrorMessage( + error, + selected ? "Error removing reaction" : "Error adding reaction" + ); setToast({ message: msg, type: "error" }); setSelected((s) => !s); setTotal((n) => Math.max(0, n + (selected ? 1 : -1))); rollbackRef.current?.(); + recordReactionRollbackApplied(mutation); rollbackRef.current = null; } rollbackRef.current = null; }, [ applyOptimisticReactionChange, canReact, + connectedProfile?.id, drop.id, + drop.context_profile_context?.reaction, longPressTriggered, reaction.reaction, selected, setToast, + waveId, + websocketStatus, ]); const tooltipProfiles = useMemo(() => { diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index 4610f0a2f1..ddfce438c6 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -109,3 +109,193 @@ export const toProfileMin = ( sub_classification: profile.sub_classification, }; }; + +type StructuredReactionError = Error & { + status?: unknown; + response?: { + status?: unknown; + statusText?: unknown; + body?: unknown; + } | null; +}; + +const isNonEmptyUnknownArray = (value: unknown): value is readonly unknown[] => + Array.isArray(value) && value.length > 0; + +const getDetailsFirstMessage = (details: unknown): string | null => { + if (!isNonEmptyUnknownArray(details)) { + return null; + } + + for (const detail of details) { + if (detail === null || typeof detail !== "object") { + continue; + } + + const message = (detail as { message?: unknown }).message; + if (typeof message !== "string") { + continue; + } + + const trimmedMessage = message.trim(); + if (trimmedMessage.length > 0) { + return trimmedMessage; + } + } + + return null; +}; + +// Reaction toasts should only surface messages from known API JSON fields. +const getStructuredReactionBodyMessage = (body: unknown): string | null => { + if (typeof body === "string") { + try { + return getStructuredReactionBodyMessage(JSON.parse(body) as unknown); + } catch { + return null; + } + } + + if (body === null || typeof body !== "object") { + return null; + } + + const bodyRecord = body as Record; + const errorMessage = bodyRecord["error"]; + if (typeof errorMessage === "string" && errorMessage.trim().length > 0) { + return errorMessage; + } + + const message = bodyRecord["message"]; + if (typeof message === "string" && message.trim().length > 0) { + return message; + } + + return getDetailsFirstMessage(bodyRecord["details"]); +}; + +const hasNoStructuredReactionBody = (body: unknown): boolean => { + if (body === null || body === undefined) { + return true; + } + + if (typeof body === "string") { + if (body.trim().length === 0) { + return true; + } + + try { + return hasNoStructuredReactionBody(JSON.parse(body) as unknown); + } catch { + return false; + } + } + + return getStructuredReactionBodyMessage(body) === null; +}; + +const getStructuredReactionStatus = ( + error: StructuredReactionError +): number | null => { + const directStatus = error.status; + if (typeof directStatus === "number") { + return directStatus; + } + + const responseStatus = error.response?.status; + if (typeof responseStatus === "number") { + return responseStatus; + } + + return null; +}; + +const getEmptyStructuredReactionStatusMessage = ( + status: number | null +): string | null => { + switch (status) { + case 401: + return "Unauthorized"; + case 429: + return "Too Many Requests"; + case null: + return null; + default: + return null; + } +}; + +const getEmptyStructuredReactionStatusText = ( + error: StructuredReactionError +): string | null => { + const statusText = error.response?.statusText; + if (typeof statusText !== "string") { + return null; + } + + const trimmedStatusText = statusText.trim(); + + if (trimmedStatusText.length === 0) { + return null; + } + + return trimmedStatusText; +}; + +const getEmptyStructuredReactionFallbackMessage = ( + error: StructuredReactionError +): string | null => { + const message = error.message; + if (typeof message !== "string") { + return null; + } + + const trimmedMessage = message.trim(); + + if (trimmedMessage.length === 0) { + return null; + } + + return trimmedMessage; +}; + +export const getReactionErrorMessage = ( + error: unknown, + fallback: string +): string => { + if (error !== null && typeof error === "object") { + const structuredError = error as StructuredReactionError; + const structuredResponse = structuredError.response; + if (structuredResponse !== null && structuredResponse !== undefined) { + const structuredBody = structuredResponse.body; + const safeMessage = getStructuredReactionBodyMessage(structuredBody); + if (safeMessage) { + return safeMessage; + } + + if (!hasNoStructuredReactionBody(structuredBody)) { + return fallback; + } + } + + const statusMessage = getEmptyStructuredReactionStatusMessage( + getStructuredReactionStatus(structuredError) + ); + if (statusMessage) { + return statusMessage; + } + + const statusText = getEmptyStructuredReactionStatusText(structuredError); + if (statusText) { + return statusText; + } + + const fallbackMessage = + getEmptyStructuredReactionFallbackMessage(structuredError); + if (fallbackMessage) { + return fallbackMessage; + } + } + + return fallback; +}; diff --git a/contexts/wave/hooks/useWaveRealtimeUpdater.ts b/contexts/wave/hooks/useWaveRealtimeUpdater.ts index 8c6f0d4fea..3ec3c58af8 100644 --- a/contexts/wave/hooks/useWaveRealtimeUpdater.ts +++ b/contexts/wave/hooks/useWaveRealtimeUpdater.ts @@ -14,6 +14,8 @@ import { useCallback, useContext, useEffect, useRef } from "react"; import { useWaveEligibility } from "../WaveEligibilityContext"; import type { WaveDataStoreUpdater } from "./types"; import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { WebSocketStatus } from "@/services/websocket/WebSocketTypes"; +import { recordReactionRealtimeReconciliation } from "@/utils/monitoring/dropReactionMonitoring"; interface UseWaveRealtimeUpdaterProps extends WaveDataStoreUpdater { readonly activeWaveId: string | null; @@ -183,6 +185,16 @@ export function useWaveRealtimeUpdater({ endpoint: `drops/${drop.id}`, }); if (apiDrop) { + if (type === ProcessIncomingDropType.DROP_REACTION_UPDATE) { + recordReactionRealtimeReconciliation({ + drop: { + id: apiDrop.id, + wave: { id: apiDrop.wave.id }, + context_profile_context: apiDrop.context_profile_context, + }, + websocketStatus: WebSocketStatus.CONNECTED, + }); + } updateData({ key: waveId, drops: [ diff --git a/generated/models/ApiCreateWaveConfig.ts b/generated/models/ApiCreateWaveConfig.ts index fa0489df2c..1dcd6b8c62 100644 --- a/generated/models/ApiCreateWaveConfig.ts +++ b/generated/models/ApiCreateWaveConfig.ts @@ -12,16 +12,18 @@ */ import { ApiCreateNewWaveScope } from '../models/ApiCreateNewWaveScope'; -import { ApiIntRange } from '../models/ApiIntRange'; import { ApiWaveDecisionsStrategy } from '../models/ApiWaveDecisionsStrategy'; import { ApiWaveType } from '../models/ApiWaveType'; import { HttpFile } from '../http/http'; export class ApiCreateWaveConfig { 'type': ApiWaveType; - 'winning_thresholds': ApiIntRange | null; /** - * This amount of top rated drops will win. Must be set if and only if type is RANK + * Single positive threshold a drop must reach to win. Must be set if and only if type is APPROVE + */ + 'winning_threshold': number | null; + /** + * Total number of APPROVE decisions this wave may produce. Null means unlimited. Must be set if and only if type is APPROVE */ 'max_winners': number | null; /** @@ -44,10 +46,10 @@ export class ApiCreateWaveConfig { "format": "" }, { - "name": "winning_thresholds", - "baseName": "winning_thresholds", - "type": "ApiIntRange", - "format": "" + "name": "winning_threshold", + "baseName": "winning_threshold", + "type": "number", + "format": "int64" }, { "name": "max_winners", diff --git a/generated/models/ApiWaveConfig.ts b/generated/models/ApiWaveConfig.ts index 6c784f6c35..42f08796dc 100644 --- a/generated/models/ApiWaveConfig.ts +++ b/generated/models/ApiWaveConfig.ts @@ -11,7 +11,6 @@ * Do not edit the class manually. */ -import { ApiIntRange } from '../models/ApiIntRange'; import { ApiWaveDecisionsStrategy } from '../models/ApiWaveDecisionsStrategy'; import { ApiWaveScope } from '../models/ApiWaveScope'; import { ApiWaveType } from '../models/ApiWaveType'; @@ -19,9 +18,12 @@ import { HttpFile } from '../http/http'; export class ApiWaveConfig { 'type': ApiWaveType; - 'winning_thresholds': ApiIntRange | null; /** - * This amount of top rated drops will win. Must be set if and only if type is RANK + * Single positive threshold a drop must reach to win. Null for non-APPROVE waves + */ + 'winning_threshold': number | null; + /** + * Total number of APPROVE decisions this wave may produce. Null means unlimited. Null for non-APPROVE waves */ 'max_winners': number | null; /** @@ -33,6 +35,9 @@ export class ApiWaveConfig { 'decisions_strategy': ApiWaveDecisionsStrategy | null; 'next_decision_time': number | null; 'admin_drop_deletion_enabled': boolean; + 'total_no_of_decisions': number | null; + 'no_of_decisions_done': number | null; + 'no_of_decisions_left': number | null; static readonly discriminator: string | undefined = undefined; @@ -46,10 +51,10 @@ export class ApiWaveConfig { "format": "" }, { - "name": "winning_thresholds", - "baseName": "winning_thresholds", - "type": "ApiIntRange", - "format": "" + "name": "winning_threshold", + "baseName": "winning_threshold", + "type": "number", + "format": "int64" }, { "name": "max_winners", @@ -92,6 +97,24 @@ export class ApiWaveConfig { "baseName": "admin_drop_deletion_enabled", "type": "boolean", "format": "" + }, + { + "name": "total_no_of_decisions", + "baseName": "total_no_of_decisions", + "type": "number", + "format": "int64" + }, + { + "name": "no_of_decisions_done", + "baseName": "no_of_decisions_done", + "type": "number", + "format": "int64" + }, + { + "name": "no_of_decisions_left", + "baseName": "no_of_decisions_left", + "type": "number", + "format": "int64" } ]; static getAttributeTypeMap() { diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index c35e4520ff..330cbeaebe 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -526,7 +526,7 @@ import { ApiUploadsPage } from '../models/ApiUploadsPage'; import { ApiWallet } from '../models/ApiWallet'; import { ApiWave } from '../models/ApiWave'; import { ApiWaveChatConfig } from '../models/ApiWaveChatConfig'; -import { ApiWaveConfig } from '../models/ApiWaveConfig'; +import { ApiWaveConfig } from '../models/ApiWaveConfig'; import { ApiWaveContributorOverview } from '../models/ApiWaveContributorOverview'; import { ApiWaveCreditScope } from '../models/ApiWaveCreditScope'; import { ApiWaveCreditType } from '../models/ApiWaveCreditType'; diff --git a/helpers/waves/create-wave.helpers.ts b/helpers/waves/create-wave.helpers.ts index 6811dfce1e..8bf1ced170 100644 --- a/helpers/waves/create-wave.helpers.ts +++ b/helpers/waves/create-wave.helpers.ts @@ -1,6 +1,5 @@ import type { ApiCreateNewWave } from "@/generated/models/ApiCreateNewWave"; import type { ApiCreateWaveDropRequest } from "@/generated/models/ApiCreateWaveDropRequest"; -import type { ApiIntRange } from "@/generated/models/ApiIntRange"; import { ApiWaveCreditScope } from "@/generated/models/ApiWaveCreditScope"; import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; import { ApiWaveOutcomeCredit } from "@/generated/models/ApiWaveOutcomeCredit"; @@ -44,6 +43,11 @@ const getTimeWeightedLockMs = ( return Math.max(MIN_MS, Math.min(MAX_MS, ms)); }; +const isPositiveFiniteNumber = ( + value: number | null | undefined +): value is number => + typeof value === "number" && Number.isFinite(value) && value > 0; + export const getCreateWaveNextStep = ({ step, waveType, @@ -122,27 +126,6 @@ export const getCreateWavePreviousStep = ({ } }; -const getWinningThreshold = ({ - config, -}: { - readonly config: CreateWaveConfig; -}): ApiIntRange | null => { - const waveType = config.overview.type; - switch (waveType) { - case ApiWaveType.Approve: - return { - min: config.approval.threshold, - max: config.approval.threshold, - }; - case ApiWaveType.Rank: - case ApiWaveType.Chat: - return null; - default: - assertUnreachable(waveType); - return null; - } -}; - const getRankOutcomes = ({ config, }: { @@ -150,15 +133,17 @@ const getRankOutcomes = ({ }): ApiCreateWaveOutcome[] => { const outcomes: ApiCreateWaveOutcome[] = []; for (const outcome of config.outcomes) { + const winnersConfig = outcome.winnersConfig; + if ( outcome.type === CreateWaveOutcomeType.MANUAL && outcome.title && - outcome.winnersConfig + winnersConfig ) { outcomes.push({ type: ApiWaveOutcomeType.Manual, description: outcome.title, - distribution: outcome.winnersConfig.winners.map((winner) => ({ + distribution: winnersConfig.winners.map((winner) => ({ amount: winner.value, description: outcome.title, })), @@ -166,7 +151,8 @@ const getRankOutcomes = ({ } else if ( outcome.type === CreateWaveOutcomeType.REP && outcome.category && - outcome.winnersConfig?.totalAmount + winnersConfig && + isPositiveFiniteNumber(winnersConfig.totalAmount) ) { outcomes.push({ type: ApiWaveOutcomeType.Automatic, @@ -174,23 +160,24 @@ const getRankOutcomes = ({ description: "Rep distribution", credit: ApiWaveOutcomeCredit.Rep, rep_category: outcome.category, - amount: outcome.winnersConfig.totalAmount, - distribution: outcome.winnersConfig.winners.map((winner) => ({ + amount: winnersConfig.totalAmount, + distribution: winnersConfig.winners.map((winner) => ({ amount: winner.value, description: null, })), }); } else if ( outcome.type === CreateWaveOutcomeType.NIC && - outcome.winnersConfig?.totalAmount + winnersConfig && + isPositiveFiniteNumber(winnersConfig.totalAmount) ) { outcomes.push({ type: ApiWaveOutcomeType.Automatic, subtype: ApiWaveOutcomeSubType.CreditDistribution, description: "NIC distribution", credit: ApiWaveOutcomeCredit.Cic, - amount: outcome.winnersConfig.totalAmount, - distribution: outcome.winnersConfig.winners.map((winner) => ({ + amount: winnersConfig.totalAmount, + distribution: winnersConfig.winners.map((winner) => ({ amount: winner.value, description: null, })), @@ -207,11 +194,7 @@ const getApproveOutcomes = ({ }): ApiCreateWaveOutcome[] => { const outcomes: ApiCreateWaveOutcome[] = []; for (const outcome of config.outcomes) { - if ( - outcome.type === CreateWaveOutcomeType.MANUAL && - outcome.title && - outcome.maxWinners - ) { + if (outcome.type === CreateWaveOutcomeType.MANUAL && outcome.title) { outcomes.push({ type: ApiWaveOutcomeType.Manual, description: outcome.title, @@ -219,7 +202,7 @@ const getApproveOutcomes = ({ } else if ( outcome.type === CreateWaveOutcomeType.REP && outcome.category && - outcome.credit + isPositiveFiniteNumber(outcome.credit) ) { outcomes.push({ type: ApiWaveOutcomeType.Automatic, @@ -229,7 +212,10 @@ const getApproveOutcomes = ({ rep_category: outcome.category, amount: outcome.credit, }); - } else if (outcome.type === CreateWaveOutcomeType.NIC && outcome.credit) { + } else if ( + outcome.type === CreateWaveOutcomeType.NIC && + isPositiveFiniteNumber(outcome.credit) + ) { outcomes.push({ type: ApiWaveOutcomeType.Automatic, subtype: ApiWaveOutcomeSubType.CreditDistribution, @@ -335,23 +321,17 @@ const calculateEndDate = (dates: CreateWaveDatesConfig): number => { ); } - // If isRolling is true, we need to calculate the last decision time - if (dates.isRolling) { - // Need an end date for rolling waves - if (typeof dates.endDate !== "number") { - throw new Error("End date must be explicitly set when isRolling is true"); - } - - // Calculate the last decision time that will occur before the user-specified end date - return calculateLastDecisionTime( - dates.firstDecisionTime, - dates.subsequentDecisions, - dates.endDate - ); + // If we reach this point, isRolling is true and we need to calculate the last decision time + if (typeof dates.endDate !== "number") { + throw new Error("End date must be explicitly set when isRolling is true"); } - // This should never happen if all cases are covered - return dates.endDate ?? dates.firstDecisionTime; + // Calculate the last decision time that will occur before the user-specified end date + return calculateLastDecisionTime( + dates.firstDecisionTime, + dates.subsequentDecisions, + dates.endDate + ); }; export const getCreateNewWaveBody = ({ @@ -423,7 +403,10 @@ export const getCreateNewWaveBody = ({ wave: { admin_drop_deletion_enabled: config.drops.adminCanDeleteDrops, type: config.overview.type, - winning_thresholds: getWinningThreshold({ config }), + winning_threshold: + config.overview.type === ApiWaveType.Approve + ? config.approval.threshold + : null, // TODO - should be in outcomes max_winners: null, time_lock_ms: diff --git a/helpers/waves/waves.helpers.ts b/helpers/waves/waves.helpers.ts index 94f32b0d15..b1125279e6 100644 --- a/helpers/waves/waves.helpers.ts +++ b/helpers/waves/waves.helpers.ts @@ -63,10 +63,7 @@ export const convertWaveToUpdateWave = ( wave: { admin_drop_deletion_enabled: wave.wave.admin_drop_deletion_enabled, type: wave.wave.type, - winning_thresholds: - wave.wave.winning_thresholds?.max || wave.wave.winning_thresholds?.min - ? wave.wave.winning_thresholds - : null, + winning_threshold: wave.wave.winning_threshold, max_winners: wave.wave.max_winners, time_lock_ms: wave.wave.time_lock_ms, admin_group: { diff --git a/hooks/drops/useDropReaction.ts b/hooks/drops/useDropReaction.ts index ac93d7a880..c920afc48f 100644 --- a/hooks/drops/useDropReaction.ts +++ b/hooks/drops/useDropReaction.ts @@ -9,26 +9,46 @@ import { recordReaction } from "@/helpers/reactions/reactionHistory"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; +import { useWebsocketStatus } from "@/services/websocket/useWebSocketMessage"; import { useCallback, useRef } from "react"; import { cloneReactionEntries, findReactionIndex, + getReactionErrorMessage, removeUserFromReactions, toProfileMin, } from "@/components/waves/drops/reaction-utils"; +import { + beginReactionMutation, + deriveReactionAction, + recordReactionOptimisticApplied, + recordReactionRequestFailed, + recordReactionRequestSent, + recordReactionRequestSucceeded, + recordReactionRollbackApplied, + type ReactionSource, +} from "@/utils/monitoring/dropReactionMonitoring"; interface UseDropReactionResult { readonly react: (reactionCode: string) => Promise; readonly canReact: boolean; } +interface UseDropReactionOptions { + readonly source?: ReactionSource | undefined; + readonly onSuccess?: (() => void) | undefined; +} + export function useDropReaction( drop: ExtendedDrop, - onSuccess?: () => void + options?: UseDropReactionOptions ): UseDropReactionResult { const { setToast, connectedProfile } = useAuth(); const { applyOptimisticDropUpdate } = useMyStream(); + const websocketStatus = useWebsocketStatus(); const rollbackRef = useRef<(() => void) | null>(null); + const source = options?.source ?? "picker"; + const onSuccess = options?.onSuccess; const canReact = !drop.id.startsWith("temp-"); @@ -120,47 +140,89 @@ export function useDropReaction( if (!canReact) return; const isRemoving = reactionCode === contextProfileContext?.reaction; + const intendedReaction = isRemoving ? null : reactionCode; + const mutation = beginReactionMutation({ + dropId, + waveId, + source, + action: deriveReactionAction( + contextProfileContext?.reaction ?? null, + intendedReaction + ), + previousReaction: contextProfileContext?.reaction ?? null, + intendedReaction, + optimisticReaction: intendedReaction, + profileId: connectedProfile?.id ?? null, + websocketStatus, + }); rollbackRef.current?.(); - rollbackRef.current = applyOptimisticReaction( - isRemoving ? null : reactionCode - ); + rollbackRef.current = applyOptimisticReaction(intendedReaction); + recordReactionOptimisticApplied(mutation); if (!isRemoving) { recordReaction(reactionCode); } + let succeeded = false; + try { const endpoint = `drops/${drop.id}/reaction`; if (isRemoving) { - await commonApiDelete({ endpoint }); + recordReactionRequestSent(mutation, { + endpoint, + method: "DELETE", + }); + await commonApiDelete({ + endpoint, + errorMode: "structured", + }); } else { + recordReactionRequestSent(mutation, { + endpoint, + method: "POST", + }); await commonApiPost({ endpoint, body: { reaction: reactionCode }, + errorMode: "structured", }); } + recordReactionRequestSucceeded(mutation); rollbackRef.current = null; - onSuccess?.(); + succeeded = true; } catch (error) { - let errorMessage = isRemoving - ? "Error removing reaction" - : "Error adding reaction"; - if (typeof error === "string") { - errorMessage = error; - } + recordReactionRequestFailed(mutation, error); + const errorMessage = getReactionErrorMessage( + error, + isRemoving ? "Error removing reaction" : "Error adding reaction" + ); setToast({ message: errorMessage, type: "error" }); rollbackRef.current?.(); + recordReactionRollbackApplied(mutation); rollbackRef.current = null; } + + if (succeeded) { + try { + onSuccess?.(); + } catch { + // Ignore consumer callback errors so a successful request stays successful. + } + } }, [ canReact, applyOptimisticReaction, + connectedProfile?.id, contextProfileContext?.reaction, drop.id, + dropId, setToast, onSuccess, + source, + waveId, + websocketStatus, ] ); diff --git a/openapi.yaml b/openapi.yaml index fcf0a0fdb9..db3811155b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8274,7 +8274,7 @@ components: required: - type - time_lock_ms - - winning_thresholds + - winning_threshold - max_winners - admin_group - decisions_strategy @@ -8282,17 +8282,18 @@ components: properties: type: $ref: "#/components/schemas/ApiWaveType" - winning_thresholds: + winning_threshold: description: >- - Drops which rate tally ends up in this range will win. Must be set - if and only if type is APPROVE - anyOf: - - type: "null" - - $ref: "#/components/schemas/ApiIntRange" + Single positive threshold a drop must reach to win. Must be set if + and only if type is APPROVE + type: integer + format: int64 + minimum: 1 + nullable: true max_winners: description: >- - This amount of top rated drops will win. Must be set if and only if - type is RANK + Total number of APPROVE decisions this wave may produce. Null means + unlimited. Must be set if and only if type is APPROVE type: integer format: int64 minimum: 1 @@ -11639,27 +11640,31 @@ components: required: - type - time_lock_ms - - winning_thresholds + - winning_threshold - max_winners - admin_group - authenticated_user_eligible_for_admin - decisions_strategy - next_decision_time - admin_drop_deletion_enabled + - total_no_of_decisions + - no_of_decisions_done + - no_of_decisions_left properties: type: $ref: "#/components/schemas/ApiWaveType" - winning_thresholds: + winning_threshold: description: >- - Drops which rate tally ends up in this range will win. Must be set - if and only if type is APPROVE - anyOf: - - type: "null" - - $ref: "#/components/schemas/ApiIntRange" + Single positive threshold a drop must reach to win. Null for + non-APPROVE waves + type: integer + format: int64 + minimum: 1 + nullable: true max_winners: description: >- - This amount of top rated drops will win. Must be set if and only if - type is RANK + Total number of APPROVE decisions this wave may produce. Null means + unlimited. Null for non-APPROVE waves type: integer format: int64 minimum: 1 @@ -11687,6 +11692,21 @@ components: nullable: true admin_drop_deletion_enabled: type: boolean + total_no_of_decisions: + type: integer + format: int64 + minimum: 1 + nullable: true + no_of_decisions_done: + type: integer + format: int64 + minimum: 0 + nullable: true + no_of_decisions_left: + type: integer + format: int64 + minimum: 0 + nullable: true ApiWaveContributorOverview: required: - contributor_identity diff --git a/services/api/common-api.ts b/services/api/common-api.ts index 51fcc54f16..25447a50c9 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -9,6 +9,7 @@ type StructuredApiError = Error & { response: { status: number; headers: Headers; + statusText?: string; body?: unknown; }; }; @@ -59,15 +60,30 @@ const normalizeHeaders = (value: unknown): Headers => { } }; +const getUsableErrorMessage = ( + message: string, + fallbackMessage: string +): string => (message.trim().length > 0 ? message : fallbackMessage); + +const getUsableErrorField = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + + return value.trim().length > 0 ? value : undefined; +}; + const createStructuredApiError = ({ message, status, headers, + statusText, body, }: { message: string; status: number; headers: Headers; + statusText?: string; body?: unknown; }): StructuredApiError => { const error = new Error(message) as StructuredApiError; @@ -77,6 +93,7 @@ const createStructuredApiError = ({ error.response = { status, headers, + ...(statusText !== undefined ? { statusText } : {}), ...(body !== undefined ? { body } : {}), }; return error; @@ -86,7 +103,10 @@ const handleApiError = async ( res: Response, errorMode: ApiErrorMode ): Promise => { - const fallbackErrorMessage = res.statusText || "Something went wrong"; + const fallbackErrorMessage = getUsableErrorMessage( + res.statusText, + "Something went wrong" + ); let errorMessage = fallbackErrorMessage; let errorBody: unknown = undefined; @@ -112,13 +132,19 @@ const handleApiError = async ( typeof (bodyDetails[0] as { message?: unknown }).message === "string" ? (bodyDetails[0] as { message: string }).message : undefined; - - if (typeof bodyError === "string") { - errorMessage = bodyError; - } else if (typeof bodyMessage === "string") { - errorMessage = bodyMessage; - } else if (typeof detailsFirstMessage === "string") { - errorMessage = detailsFirstMessage; + const structuredErrorMessage = + getUsableErrorField(bodyError) ?? + getUsableErrorField(bodyMessage) ?? + getUsableErrorField(detailsFirstMessage); + const hasStructuredErrorField = + typeof bodyError === "string" || + typeof bodyMessage === "string" || + typeof detailsFirstMessage === "string"; + + if (structuredErrorMessage !== undefined) { + errorMessage = structuredErrorMessage; + } else if (hasStructuredErrorField) { + errorMessage = fallbackErrorMessage; } else { errorMessage = rawContent; } @@ -130,16 +156,22 @@ const handleApiError = async ( errorMessage = fallbackErrorMessage; } + const normalizedErrorMessage = getUsableErrorMessage( + errorMessage, + fallbackErrorMessage + ); + if (errorMode === "structured") { throw createStructuredApiError({ - message: errorMessage, + message: normalizedErrorMessage, status: res.status, headers: normalizeHeaders((res as { headers?: unknown }).headers), + statusText: res.statusText, body: errorBody, }); } - return Promise.reject(errorMessage); + return Promise.reject(normalizedErrorMessage); }; const executeApiRequest = async ( @@ -388,6 +420,7 @@ export const commonApiPostWithoutBodyAndResponse = async (param: { export const commonApiDelete = async (param: { endpoint: string; headers?: Record | undefined; + errorMode?: ApiErrorMode | undefined; }): Promise => { const url = buildUrl(param.endpoint); @@ -397,7 +430,8 @@ export const commonApiDelete = async (param: { getHeaders(param.headers), undefined, undefined, - false + false, + param.errorMode ?? "legacy-string" ); }; diff --git a/utils/monitoring/dropReactionMonitoring.ts b/utils/monitoring/dropReactionMonitoring.ts new file mode 100644 index 0000000000..197e4d08e6 --- /dev/null +++ b/utils/monitoring/dropReactionMonitoring.ts @@ -0,0 +1,657 @@ +"use client"; + +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { WebSocketStatus } from "@/services/websocket/WebSocketTypes"; +import * as Sentry from "@sentry/nextjs"; + +const RECONCILIATION_WINDOW_MS = 15_000; +const DEDUPE_WINDOW_MS = 60_000; +const MUTATION_RETENTION_MS = 5 * 60_000; +const REACTION_FEATURE = "drop-reaction"; +const REACTION_REQUEST_OPERATION = "reaction-request"; +const REACTION_ANOMALY_OPERATION = "reaction-anomaly"; +const ANOMALY_OUT_OF_ORDER = "out-of-order"; +const ANOMALY_OPTIMISTIC_REVERTED = "optimistic-reverted"; + +export type ReactionSource = "quick-react" | "picker" | "chip"; +type ReactionAction = "add" | "remove" | "replace"; +type ReactionErrorKind = + | "network" + | "auth" + | "rate-limit" + | "server" + | "endpoint-contract"; + +interface ReactionMutationContext { + readonly mutationId: string; + readonly dropMutationSeq: number; + readonly dropId: string; + readonly waveId: string; + readonly source: ReactionSource; + readonly action: ReactionAction; + readonly previousReaction: string | null; + readonly intendedReaction: string | null; + readonly optimisticReaction: string | null; + readonly profileId: string | null; + readonly startedAt: number; + readonly pathname: string | null; + readonly visibilityState: string | null; + readonly online: boolean | null; + readonly websocketStatus: WebSocketStatus | null; + endpoint?: string | null; + method?: string | null; + requestSentAt: number | null; + apiSucceededAt: number | null; + apiFailedAt: number | null; + failureCaptured?: boolean; + supersededByMutationId?: string | null; +} + +const latestMutationIdByDrop = new Map(); +const dropMutationSeqByDrop = new Map(); +const mutationContextById = new Map(); +const dedupeEventAtByKey = new Map(); + +function pruneState(now: number): void { + for (const [key, timestamp] of dedupeEventAtByKey.entries()) { + if (now - timestamp > DEDUPE_WINDOW_MS) { + dedupeEventAtByKey.delete(key); + } + } + + for (const [mutationId, context] of mutationContextById.entries()) { + if (now - context.startedAt <= MUTATION_RETENTION_MS) { + continue; + } + + mutationContextById.delete(mutationId); + if (latestMutationIdByDrop.get(context.dropId) === mutationId) { + latestMutationIdByDrop.delete(context.dropId); + dropMutationSeqByDrop.delete(context.dropId); + } + } +} + +function shouldCaptureEvent(key: string, now: number): boolean { + const lastCapturedAt = dedupeEventAtByKey.get(key); + if ( + typeof lastCapturedAt === "number" && + now - lastCapturedAt < DEDUPE_WINDOW_MS + ) { + return false; + } + + dedupeEventAtByKey.set(key, now); + return true; +} + +function createMutationId(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + + return `reaction-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function getCurrentPathname(): string | null { + if (typeof window === "undefined") { + return null; + } + return window.location.pathname || null; +} + +function getVisibilityState(): string | null { + if (typeof document === "undefined") { + return null; + } + return document.visibilityState; +} + +function getOnlineStatus(): boolean | null { + if (typeof navigator === "undefined") { + return null; + } + return navigator.onLine; +} + +function toNullableReaction(value: string | null | undefined): string | null { + return typeof value === "string" ? value : null; +} + +function toWebsocketStatus( + value: WebSocketStatus | string | null | undefined +): WebSocketStatus | null { + if ( + value === WebSocketStatus.CONNECTED || + value === WebSocketStatus.CONNECTING || + value === WebSocketStatus.DISCONNECTED + ) { + return value; + } + + return null; +} + +function addReactionBreadcrumb( + message: string, + context: ReactionMutationContext, + data: Record = {}, + level: "info" | "warning" | "error" = "info" +): void { + Sentry.addBreadcrumb({ + category: "reactions", + level, + message, + data: { + mutation_id: context.mutationId, + drop_mutation_seq: context.dropMutationSeq, + drop_id: context.dropId, + wave_id: context.waveId, + source: context.source, + action: context.action, + previous_reaction: context.previousReaction, + intended_reaction: context.intendedReaction, + optimistic_reaction: context.optimisticReaction, + profile_id: context.profileId ?? undefined, + pathname: context.pathname ?? undefined, + visibility_state: context.visibilityState ?? undefined, + online: context.online ?? undefined, + websocket_status: context.websocketStatus ?? undefined, + endpoint: context.endpoint ?? undefined, + method: context.method ?? undefined, + ...data, + }, + }); +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error) { + const typedError = error as { + message?: unknown; + error?: unknown; + }; + if (typeof typedError.message === "string") { + return typedError.message; + } + if (typeof typedError.error === "string") { + return typedError.error; + } + } + return String(error); +} + +function toCaptureExceptionInput(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(toErrorMessage(error)); +} + +function parseStatusCode(status: unknown): number | null { + if (typeof status === "number" && Number.isFinite(status)) { + return status; + } + + if (typeof status === "string") { + const parsed = Number.parseInt(status, 10); + return Number.isNaN(parsed) ? null : parsed; + } + + return null; +} + +function extractErrorStatusCode(error: unknown): number | null { + if (error === null || typeof error !== "object") { + return null; + } + + const typedError = error as { + status?: unknown; + code?: unknown; + response?: { + status?: unknown; + }; + cause?: { + status?: unknown; + code?: unknown; + response?: { + status?: unknown; + }; + }; + }; + + return ( + parseStatusCode(typedError.status) ?? + parseStatusCode(typedError.response?.status) ?? + parseStatusCode(typedError.code) ?? + parseStatusCode(typedError.cause?.status) ?? + parseStatusCode(typedError.cause?.response?.status) ?? + parseStatusCode(typedError.cause?.code) + ); +} + +function isNetworkError(error: unknown): boolean { + if (error instanceof TypeError) { + return true; + } + + const normalizedMessage = toErrorMessage(error).toLowerCase(); + return ( + normalizedMessage.includes("failed to fetch") || + normalizedMessage.includes("load failed") || + normalizedMessage.includes("networkerror") || + normalizedMessage.includes("network error") || + normalizedMessage.includes("network request failed") + ); +} + +function classifyReactionError(error: unknown): { + statusCode: number | null; + errorKind: ReactionErrorKind; +} { + const statusCode = extractErrorStatusCode(error); + + if (statusCode === 401 || statusCode === 403) { + return { statusCode, errorKind: "auth" }; + } + + if (statusCode === 429) { + return { statusCode, errorKind: "rate-limit" }; + } + + if (statusCode === 404 || statusCode === 405) { + return { statusCode, errorKind: "endpoint-contract" }; + } + + if (typeof statusCode === "number" && statusCode >= 500) { + return { statusCode, errorKind: "server" }; + } + + if (isNetworkError(error)) { + return { statusCode, errorKind: "network" }; + } + + return { statusCode, errorKind: "server" }; +} + +function captureReactionEvent({ + error, + level, + fingerprint, + tags, + extra, +}: { + error: Error; + level: "warning" | "error"; + fingerprint: string[]; + tags: Record; + extra: Record; +}): void { + Sentry.withScope((scope) => { + scope.setLevel(level); + scope.setFingerprint(fingerprint); + Object.entries(tags).forEach(([key, value]) => { + scope.setTag(key, value); + }); + scope.setExtras(extra); + Sentry.captureException(error); + }); +} + +function maybeCaptureSupersededResponse( + context: ReactionMutationContext, + now: number +): void { + const latestMutationId = latestMutationIdByDrop.get(context.dropId); + if (!latestMutationId || latestMutationId === context.mutationId) { + return; + } + + context.supersededByMutationId = latestMutationId; + addReactionBreadcrumb( + "reaction.response_superseded", + context, + { + superseded_by_mutation_id: latestMutationId, + time_since_mutation_ms: now - context.startedAt, + }, + "warning" + ); + + const dedupeKey = [ + ANOMALY_OUT_OF_ORDER, + context.dropId, + context.source, + context.action, + latestMutationId, + ].join(":"); + if (!shouldCaptureEvent(dedupeKey, now)) { + return; + } + + captureReactionEvent({ + error: new Error("Reaction response superseded by newer mutation"), + level: "warning", + fingerprint: [REACTION_FEATURE, ANOMALY_OUT_OF_ORDER], + tags: { + feature: REACTION_FEATURE, + operation: REACTION_ANOMALY_OPERATION, + anomaly_kind: ANOMALY_OUT_OF_ORDER, + source: context.source, + action: context.action, + }, + extra: { + mutation_id: context.mutationId, + drop_mutation_seq: context.dropMutationSeq, + drop_id: context.dropId, + wave_id: context.waveId, + previous_reaction: context.previousReaction, + intended_reaction: context.intendedReaction, + optimistic_reaction: context.optimisticReaction, + profile_id: context.profileId ?? undefined, + pathname: context.pathname ?? undefined, + visibility_state: context.visibilityState ?? undefined, + online: context.online ?? undefined, + websocket_status: context.websocketStatus ?? undefined, + endpoint: context.endpoint ?? undefined, + method: context.method ?? undefined, + superseded_by_mutation_id: latestMutationId, + time_since_mutation_ms: now - context.startedAt, + anomaly_kind: ANOMALY_OUT_OF_ORDER, + }, + }); +} + +export function deriveReactionAction( + previousReaction: string | null | undefined, + intendedReaction: string | null | undefined +): ReactionAction { + const previous = toNullableReaction(previousReaction); + const intended = toNullableReaction(intendedReaction); + + if (intended === null) { + return "remove"; + } + + if (previous === null) { + return "add"; + } + + return "replace"; +} + +export function beginReactionMutation(params: { + dropId: string; + waveId: string; + source: ReactionSource; + action: ReactionAction; + previousReaction: string | null | undefined; + intendedReaction: string | null | undefined; + optimisticReaction: string | null | undefined; + profileId: string | null | undefined; + websocketStatus?: WebSocketStatus | string | null; +}): ReactionMutationContext { + const now = Date.now(); + pruneState(now); + + const nextSeq = (dropMutationSeqByDrop.get(params.dropId) ?? 0) + 1; + dropMutationSeqByDrop.set(params.dropId, nextSeq); + + const context: ReactionMutationContext = { + mutationId: createMutationId(), + dropMutationSeq: nextSeq, + dropId: params.dropId, + waveId: params.waveId, + source: params.source, + action: params.action, + previousReaction: toNullableReaction(params.previousReaction), + intendedReaction: toNullableReaction(params.intendedReaction), + optimisticReaction: toNullableReaction(params.optimisticReaction), + profileId: params.profileId ?? null, + startedAt: now, + pathname: getCurrentPathname(), + visibilityState: getVisibilityState(), + online: getOnlineStatus(), + websocketStatus: toWebsocketStatus(params.websocketStatus), + requestSentAt: null, + apiSucceededAt: null, + apiFailedAt: null, + }; + + latestMutationIdByDrop.set(params.dropId, context.mutationId); + mutationContextById.set(context.mutationId, context); + + addReactionBreadcrumb("reaction.intent", context); + + return context; +} + +export function recordReactionOptimisticApplied( + context: ReactionMutationContext +): void { + addReactionBreadcrumb("reaction.optimistic_applied", context); +} + +export function recordReactionRequestSent( + context: ReactionMutationContext, + params: { + endpoint: string; + method: "POST" | "DELETE"; + } +): void { + context.endpoint = params.endpoint; + context.method = params.method; + context.requestSentAt = Date.now(); + + addReactionBreadcrumb("reaction.request_sent", context); +} + +export function recordReactionRequestSucceeded( + context: ReactionMutationContext +): void { + const now = Date.now(); + context.apiSucceededAt = now; + + maybeCaptureSupersededResponse(context, now); + + addReactionBreadcrumb("reaction.request_succeeded", context, { + latency_ms: now - (context.requestSentAt ?? context.startedAt), + }); +} + +export function recordReactionRequestFailed( + context: ReactionMutationContext, + error: unknown +): void { + const now = Date.now(); + context.apiFailedAt = now; + + maybeCaptureSupersededResponse(context, now); + + const { statusCode, errorKind } = classifyReactionError(error); + const latencyMs = now - (context.requestSentAt ?? context.startedAt); + const errorMessage = toErrorMessage(error); + + addReactionBreadcrumb( + "reaction.request_failed", + context, + { + status_code: statusCode ?? undefined, + latency_ms: latencyMs, + error_kind: errorKind, + error_message: errorMessage, + }, + "warning" + ); + + const dedupeKey = [ + "failure", + errorKind, + context.dropId, + context.source, + context.action, + statusCode ?? "na", + ].join(":"); + + if (!shouldCaptureEvent(dedupeKey, now)) { + context.failureCaptured = true; + return; + } + + captureReactionEvent({ + error: toCaptureExceptionInput(error), + level: errorKind === "server" ? "error" : "warning", + fingerprint: [REACTION_FEATURE, errorKind], + tags: { + feature: REACTION_FEATURE, + operation: REACTION_REQUEST_OPERATION, + source: context.source, + action: context.action, + error_kind: errorKind, + }, + extra: { + mutation_id: context.mutationId, + drop_mutation_seq: context.dropMutationSeq, + drop_id: context.dropId, + wave_id: context.waveId, + previous_reaction: context.previousReaction, + intended_reaction: context.intendedReaction, + optimistic_reaction: context.optimisticReaction, + profile_id: context.profileId ?? undefined, + pathname: context.pathname ?? undefined, + visibility_state: context.visibilityState ?? undefined, + online: context.online ?? undefined, + websocket_status: context.websocketStatus ?? undefined, + endpoint: context.endpoint ?? undefined, + method: context.method ?? undefined, + status_code: statusCode ?? undefined, + latency_ms: latencyMs, + error_kind: errorKind, + error_message: errorMessage, + }, + }); + + context.failureCaptured = true; +} + +export function recordReactionRollbackApplied( + context: ReactionMutationContext +): void { + addReactionBreadcrumb("reaction.rollback_applied", context); +} + +export function recordReactionRealtimeReconciliation(params: { + drop: Pick & { + readonly wave: Pick; + readonly context_profile_context: ApiDrop["context_profile_context"]; + }; + websocketStatus?: WebSocketStatus | string | null; +}): void { + const now = Date.now(); + pruneState(now); + + const latestMutationId = latestMutationIdByDrop.get(params.drop.id); + if (!latestMutationId) { + return; + } + + const context = mutationContextById.get(latestMutationId); + if (!context || now - context.startedAt > RECONCILIATION_WINDOW_MS) { + return; + } + + const serverReaction = params.drop.context_profile_context?.reaction ?? null; + + if (serverReaction === context.intendedReaction) { + addReactionBreadcrumb("reaction.realtime_reconciled", context, { + reconciled_from: "ws_refetch", + server_reaction: serverReaction ?? undefined, + time_since_mutation_ms: now - context.startedAt, + websocket_status: toWebsocketStatus(params.websocketStatus) ?? undefined, + }); + return; + } + + if (context.apiFailedAt !== null || context.apiSucceededAt === null) { + return; + } + + addReactionBreadcrumb( + "reaction.optimistic_reverted", + context, + { + reconciled_from: "ws_refetch", + server_reaction: serverReaction ?? undefined, + time_since_mutation_ms: now - context.startedAt, + websocket_status: toWebsocketStatus(params.websocketStatus) ?? undefined, + }, + "warning" + ); + + const dedupeKey = [ + ANOMALY_OPTIMISTIC_REVERTED, + context.dropId, + context.source, + context.action, + context.intendedReaction ?? "null", + serverReaction ?? "null", + ].join(":"); + + if (!shouldCaptureEvent(dedupeKey, now)) { + return; + } + + captureReactionEvent({ + error: new Error( + "Reaction optimistic state disagreed with canonical state" + ), + level: "warning", + fingerprint: [REACTION_FEATURE, ANOMALY_OPTIMISTIC_REVERTED], + tags: { + feature: REACTION_FEATURE, + operation: REACTION_ANOMALY_OPERATION, + anomaly_kind: ANOMALY_OPTIMISTIC_REVERTED, + source: context.source, + action: context.action, + }, + extra: { + mutation_id: context.mutationId, + drop_mutation_seq: context.dropMutationSeq, + drop_id: context.dropId, + wave_id: context.waveId, + previous_reaction: context.previousReaction, + intended_reaction: context.intendedReaction, + optimistic_reaction: context.optimisticReaction, + server_reaction: serverReaction ?? undefined, + profile_id: context.profileId ?? undefined, + pathname: context.pathname ?? undefined, + visibility_state: context.visibilityState ?? undefined, + online: context.online ?? undefined, + websocket_status: + toWebsocketStatus(params.websocketStatus) ?? + context.websocketStatus ?? + undefined, + endpoint: context.endpoint ?? undefined, + method: context.method ?? undefined, + reconciled_from: "ws_refetch", + time_since_mutation_ms: now - context.startedAt, + anomaly_kind: ANOMALY_OPTIMISTIC_REVERTED, + }, + }); +} + +export function __resetDropReactionMonitoringForTests(): void { + latestMutationIdByDrop.clear(); + dropMutationSeqByDrop.clear(); + mutationContextById.clear(); + dedupeEventAtByKey.clear(); +}