Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/web/src/app/perks/components/perks-points-spin-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useActiveAccount } from "@/core/hooks/use-active-account";

import { PointsSpin, SPIN_VALUES } from "@/features/points";
import { success } from "@/features/shared";
import { error, success } from "@/features/shared";
import { Button, Modal, ModalBody, ModalFooter, ModalHeader, StyledTooltip } from "@/features/ui";
import { delay, getAccessToken } from "@/utils";
import { getGameStatusCheckQueryOptions, useGameClaim } from "@ecency/sdk";
Expand Down Expand Up @@ -39,7 +39,15 @@ export function PerksPointsSpinBanner() {
);

const claimGame = useCallback(async () => {
await claim();
// The claim rejects on any edge/proxy failure, and an uncaught rejection out of
// this click handler is what surfaced as ECENCY-NEXT-1FCJ. Surface it to the
// user instead, and do not run the success toast or the refetch on a failure.
try {
await claim();
} catch {
error(i18next.t("perks.spin-error"));
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. claimgame failure lacks regression test 📘 Rule violation ▣ Testability

The new rejected-claim branch displays an error and suppresses refetch/success behavior, but no
changed test exercises these observable outcomes. This leaves the unhandled-rejection fix without
direct regression coverage.
Agent Prompt
## Issue description
Add a regression test for the `claimGame` failure path introduced by this bug fix.

## Issue Context
The test should reject `claim()`, exercise the spin claim through the component UI, verify the translated error toast is shown, and verify the success toast and status refetch are not performed.

## Fix Focus Areas
- apps/web/src/app/perks/components/perks-points-spin-banner.tsx[41-53]
- apps/web/src/specs/app/perks/components/perks-points-spin-banner.spec.tsx[1-1]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 208fabd. Added apps/web/src/specs/app/perks/perks-points-spin-banner.spec.tsx: a rejected claim() asserts the perks.spin-error toast fires and that neither the success toast nor refetch runs, plus the success-path counterpart. Verified the failing-path test fails against the pre-fix component with expected "vi.fn()" to be called with arguments: [ 'perks.spin-error' ].

Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
await delay(1000);
refetch();
success(i18next.t("perks.spin-success"));
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -3198,6 +3198,7 @@
"spin-now": "Spin now",
"want-more-spins": "Want more spins?",
"spin-success": "Points has transferred to your account. Enjoy with Ecency!",
"spin-error": "Could not complete the spin. Please try again.",
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
"spins-left": "spins left",
"next-spin": "Next free spin",
"quests": {
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/specs/utils/speech.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,45 @@ function installSpeechSynthesisWithoutEventTarget(
};
}

/**
* iOS Brave wraps `getVoices()` and builds its fake voice from
* `Object.getPrototypeOf(voices[0])`, so an empty real list makes the SHIM itself
* throw (ECENCY-NEXT-1GMR) rather than returning a list with holes.
*/
function installSpeechSynthesisThatThrows(
ready?: Array<SpeechSynthesisVoice | undefined>
) {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
let voicesChangedHandler: (() => void) | undefined;
let current = ready;
const getVoices = vi.fn(() => {
if (!current) {
throw new TypeError(
"undefined is not an object (evaluating 'Object.getPrototypeOf(voice)')"
);
}
return current as SpeechSynthesisVoice[];
});

Object.defineProperty(window, "speechSynthesis", {
configurable: true,
value: {
getVoices,
addEventListener: vi.fn((_event: "voiceschanged", handler: () => void) => {
voicesChangedHandler = handler;
}),
removeEventListener: vi.fn()
}
});

return {
getVoices,
setVoices: (next: Array<SpeechSynthesisVoice | undefined>) => {
current = next;
},
emitVoicesChanged: () => voicesChangedHandler?.()
};
}

afterEach(() => {
if (originalSpeechSynthesisDescriptor) {
Object.defineProperty(
Expand Down Expand Up @@ -111,6 +150,18 @@ describe("getVoicesAsync", () => {
);
});

// Regression guard for ECENCY-NEXT-1GMR: the throw comes OUT of `getVoices()`
// itself, so no amount of filtering downstream can catch it.
it("waits for voiceschanged when getVoices() throws (iOS Brave)", async () => {
const speechSynthesis = installSpeechSynthesisThatThrows();

const voicesPromise = getVoicesAsync();
speechSynthesis.setVoices([undefined, voice]);
speechSynthesis.emitVoicesChanged();

await expect(voicesPromise).resolves.toEqual([voice]);
});

describe("when SpeechSynthesis is not an EventTarget (Safari <= 15)", () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down Expand Up @@ -139,6 +190,23 @@ describe("getVoicesAsync", () => {
await expect(voicesPromise).resolves.toEqual([]);
});

it("resolves empty rather than rejecting when getVoices() keeps throwing", async () => {
const getVoices = vi.fn(() => {
throw new TypeError(
"undefined is not an object (evaluating 'Object.getPrototypeOf(voice)')"
);
});
Object.defineProperty(window, "speechSynthesis", {
configurable: true,
value: { getVoices }
});

const voicesPromise = getVoicesAsync();
await vi.advanceTimersByTimeAsync(5000);

await expect(voicesPromise).resolves.toEqual([]);
});

// Regression guard: `onvoiceschanged` holds a single handler, so registering
// through it would let a second caller overwrite the first and strand it.
// Both callers here must be served.
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/utils/speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,22 @@ const FALLBACK_TIMEOUT_MS = 5000;

export function getVoicesAsync(): Promise<SpeechSynthesisVoice[]> {
return new Promise((resolve) => {
const readVoices = () =>
window.speechSynthesis.getVoices().filter(Boolean) as SpeechSynthesisVoice[];
// iOS Brave's fingerprint-farbling shim wraps `getVoices()` and builds its
// fake voice from `Object.getPrototypeOf(voices[0])`. On iOS the first call
// routinely returns an EMPTY list, so the shim dereferences `undefined` and
// throws out of `getVoices()` itself (ECENCY-NEXT-1GMR) before any filtering
// of ours runs. Treat a throwing voice list as an empty one: "no voices yet"
// is the state the listener and the poll below already wait through, so the
// caller gets an empty list instead of a rejected promise.
const readVoices = (): SpeechSynthesisVoice[] => {
try {
return window.speechSynthesis
.getVoices()
.filter(Boolean) as SpeechSynthesisVoice[];
} catch {
return [];
}
};

const voices = readVoices();
if (voices.length) {
Expand Down
103 changes: 103 additions & 0 deletions packages/sdk/src/modules/games/mutations/game-claim.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { gameClaimRequest } from "./game-claim";
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

function jsonResponse(data: unknown, status = 200) {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => "application/json; charset=utf-8" },
text: async () => JSON.stringify(data),
};
}

function htmlResponse(html: string, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => "text/html; charset=utf-8" },
text: async () => html,
};
}

describe("gameClaimRequest", () => {
// getBoundFetch() caches the bound fetch on first call, so reuse one stable
// mock and reset it per test.
const fetchMock = vi.fn();

beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("POSTs to /private-api/post-game with the code, game type and key", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ score: 50 }));

const result = await gameClaimRequest("hs-token", "spin", "spin-key");

const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/private-api/post-game");
expect((init as RequestInit).method).toBe("POST");
const body = JSON.parse((init as RequestInit).body as string);
expect(body).toMatchObject({
code: "hs-token",
game_type: "spin",
key: "spin-key",
});
expect(result).toEqual({ score: 50 });
});

// The 502 that produced ECENCY-NEXT-1FCJ: an nginx HTML page parsed as JSON
// threw a bare SyntaxError that named neither the endpoint nor the cause.
it("throws a stable, body-free error on an HTML gateway response (ECENCY-NEXT-1FCJ)", async () => {
fetchMock.mockResolvedValueOnce(
htmlResponse("<html><body><h1>502 Bad Gateway</h1></body></html>", 502)
);

const err = await gameClaimRequest("t", "spin", "k").then(
() => {
throw new Error("expected the 502 to reject");
},
(e: Error) => e
);

expect(err.message).toBe("[SDK][Games] – failed with status 502");
// The raw page must not leak into the message, else Sentry fragments the
// group across every distinct error page.
expect(err.message).not.toContain("<html>");
});

it("throws a descriptive error when a 2xx response is not JSON", async () => {
fetchMock.mockResolvedValueOnce(htmlResponse("<html>OK</html>", 200));

await expect(gameClaimRequest("t", "spin", "k")).rejects.toThrow(
/expected JSON but received "text\/html"/
);
});

it("throws a descriptive error when a 2xx JSON body is malformed", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => "application/json" },
text: async () => "not-json",
});

await expect(gameClaimRequest("t", "spin", "k")).rejects.toThrow(
/malformed JSON response/
);
});

it("folds a short JSON error body into the message on a non-2xx", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ message: "No spins left" }, 409)
);

await expect(gameClaimRequest("t", "spin", "k")).rejects.toThrow(
/failed with status 409: .*No spins left/
);
});
});
83 changes: 66 additions & 17 deletions packages/sdk/src/modules/games/mutations/game-claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,71 @@ import { useMutation } from "@tanstack/react-query";
import { GameClaim } from "../types";
import { useRecordActivity } from "@/modules/analytics/mutations";

/**
* POST a single game claim and return the parsed JSON body.
*
* A failed post-game comes back from the edge as an HTML gateway page (a 502 was
* the trail on ECENCY-NEXT-1FCJ), and `response.json()` on that throws a bare
* `SyntaxError` naming neither the endpoint nor the cause. Check the status and
* the content type first, then fail with a STABLE, low-cardinality message
* (content type + status, never the raw body) so these group as a single Sentry
* issue instead of fragmenting on every distinct error page.
*
* Exported for unit testing; the hook below wraps it.
*/
export async function gameClaimRequest(
code: string,
gameType: "spin",
key: string
): Promise<GameClaim> {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
const fetchApi = getBoundFetch();
const response = await fetchApi(
CONFIG.privateApiHost + "/private-api/post-game",
{
method: "POST",
body: JSON.stringify({
game_type: gameType,
code,
key,
}),
headers: {
"Content-Type": "application/json",
},
}
);

// Media types are case-insensitive; normalise once and reuse in every branch.
const contentType = (response.headers.get("content-type") ?? "")
.split(";")[0]
.trim()
.toLowerCase();
const body = await response.text();

if (!response.ok) {
// Only fold a short JSON error body into the message; an HTML gateway page
// (e.g. 502/503) would otherwise fragment the Sentry group per distinct page.
const detail =
body && contentType.includes("json") ? `: ${body.slice(0, 200)}` : "";
throw new Error(
`[SDK][Games] – failed with status ${response.status}${detail}`
);
}

if (!contentType.includes("json")) {
throw new Error(
`[SDK][Games] – expected JSON but received "${contentType || "empty"}" response (status ${response.status})`
);
}

try {
return JSON.parse(body) as GameClaim;
} catch {
throw new Error(
`[SDK][Games] – malformed JSON response (status ${response.status})`
);
}
}

export function useGameClaim(
username: string | undefined,
code: string | undefined,
Expand All @@ -21,23 +86,7 @@ export function useGameClaim(
throw new Error("[SDK][Games] – missing auth");
}

const fetchApi = getBoundFetch();
const response = await fetchApi(
CONFIG.privateApiHost + "/private-api/post-game",
{
method: "POST",
body: JSON.stringify({
game_type: gameType,
code,
key,
}),
headers: {
"Content-Type": "application/json",
},
}
);

return (await response.json()) as GameClaim;
return gameClaimRequest(code, gameType, key);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
},
onSuccess() {
recordActivity();
Expand Down
Loading