diff --git a/__tests__/message-handler.test.ts b/__tests__/message-handler.test.ts index 862a6ae..6da73e3 100644 --- a/__tests__/message-handler.test.ts +++ b/__tests__/message-handler.test.ts @@ -28,6 +28,8 @@ describe("onMessage", () => { "identify", "sync", "syncBuyback", + "syncBuybackDetail", + "syncBuybackPricing", "verify-write", "verify-remove", ], @@ -134,6 +136,172 @@ describe("onMessage", () => { ); }); + it("fetches a buy-back detail page by pledge id", async () => { + const sendResponse = vi.fn(); + const getToken = vi.fn().mockResolvedValue("test-token"); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + status: 200, + text: vi.fn().mockResolvedValue("detail"), + } as any); + + await onMessage( + JSON.stringify({ action: "syncBuybackDetail", id: "1000001" }), + sendResponse, + getToken, + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result).toEqual({ + code: 200, + action: "syncBuybackDetail", + id: "1000001", + payload: "detail", + }); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://robertsspaceindustries.com/pledge/buyback/1000001", + expect.objectContaining({ method: "GET" }) + ); + }); + + it.each([["../../account/settings"], ["1000001?x=1"], [""], [undefined]])( + "refuses a buy-back detail id of %s", + async (id) => { + const sendResponse = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await onMessage( + JSON.stringify({ action: "syncBuybackDetail", id }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(400); + expect(fetchSpy).not.toHaveBeenCalled(); + } + ); + + it("answers a buy-back detail request that fails", async () => { + const sendResponse = vi.fn(); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("offline")); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await onMessage( + JSON.stringify({ action: "syncBuybackDetail", id: "1000001" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result).toEqual({ + code: 500, + action: "syncBuybackDetail", + id: "1000001", + error: "Buy-back detail failed", + }); + }); + + it("reads the account's store pricing after asking for a store token", async () => { + const sendResponse = vi.fn(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: 1, data: "token" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + data: { + app: { + pricing: { + currencyCode: "EUR", + exchangeRate: 8800, + taxRate: 1900, + isTaxInclusive: true, + }, + }, + }, + }, + ]), + { status: 200 } + ) + ); + + await onMessage( + JSON.stringify({ action: "syncBuybackPricing" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result).toEqual({ + code: 200, + action: "syncBuybackPricing", + payload: { + currencyCode: "EUR", + exchangeRate: 8800, + taxRate: 1900, + isTaxInclusive: true, + }, + }); + expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([ + "https://robertsspaceindustries.com/api/account/v2/setAuthToken", + "https://robertsspaceindustries.com/pledge-store/api/upgrade/v2/graphql", + ]); + }); + + // Without a store token RSI prices in USD whatever the account uses, so + // reading on would convert other currencies with the wrong rate. + it("reads no pricing when RSI refuses the store token", async () => { + const sendResponse = vi.fn(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(JSON.stringify({ success: 0 }), { status: 200 }) + ); + + await onMessage( + JSON.stringify({ action: "syncBuybackPricing" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(502); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("fails store pricing RSI answers without its rates", async () => { + const sendResponse = vi.fn(); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: 1 }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([{ data: { app: { pricing: { currencyCode: "EUR" } } } }]), + { status: 200 } + ) + ); + + await onMessage( + JSON.stringify({ action: "syncBuybackPricing" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(502); + }); + it("responds to syncBuyback action without token", async () => { const sendResponse = vi.fn(); const getToken = vi.fn().mockResolvedValue(null); diff --git a/lib/message-handler.ts b/lib/message-handler.ts index 33ae4f3..12f5f4f 100644 --- a/lib/message-handler.ts +++ b/lib/message-handler.ts @@ -2,6 +2,9 @@ import { identify, fetchPledges, fetchBuybacks, + fetchBuybackDetail, + setStoreAuthToken, + fetchStorePricing, fetchCitizenPage, updateBio, } from "./rsi"; @@ -21,6 +24,8 @@ export const SUPPORTED_ACTIONS = [ "identify", "sync", "syncBuyback", + "syncBuybackDetail", + "syncBuybackPricing", "verify-write", "verify-remove", ] as const; @@ -93,6 +98,55 @@ async function verifyBio( return { code: 200, action, payload: { handle, changed } }; } +// The page asking can be any script on a FleetYards origin, so it only ever +// names a pledge by its numeric id and never a URL of its own. +const PLEDGE_ID_PATTERN = /^\d{1,12}$/; + +type StorePricing = { + currencyCode: string; + exchangeRate: number; + taxRate: number; + isTaxInclusive: boolean; +}; + +function isStorePricing(value: any): value is StorePricing { + return ( + typeof value?.currencyCode === "string" && + Number.isFinite(value?.exchangeRate) && + Number.isFinite(value?.taxRate) && + typeof value?.isTaxInclusive === "boolean" + ); +} + +// The currency, exchange rate and tax RSI converts the account's prices with, +// so FleetYards can turn a buy-back price back into RSI's own USD figure. +async function storePricing(token: string) { + const auth = await setStoreAuthToken(token); + if (!auth.ok || !(await reportsSuccess(auth))) { + return { code: auth.ok ? 502 : auth.status, error: "Store token failed" }; + } + + const response = await fetchStorePricing(token); + if (!response.ok) { + return { code: response.status, error: "Store pricing failed" }; + } + + const results: any = await response.json().catch(() => undefined); + const pricing = Array.isArray(results) + ? results[0]?.data?.app?.pricing + : undefined; + if (!isStorePricing(pricing)) { + return { code: 502, error: "Store pricing unreadable" }; + } + + const { currencyCode, exchangeRate, taxRate, isTaxInclusive } = pricing; + + return { + code: 200, + payload: { currencyCode, exchangeRate, taxRate, isTaxInclusive }, + }; +} + export async function onMessage( rawMessage: string, sendResponse: SendResponse, @@ -159,6 +213,48 @@ export async function onMessage( }) ); } + } else if (message?.action == "syncBuybackDetail") { + const id = String(message.id ?? ""); + const token = await getToken(); + + if (!PLEDGE_ID_PATTERN.test(id)) { + sendResponse( + JSON.stringify({ code: 400, action: message.action, error: "Invalid pledge id" }) + ); + } else if (!token) { + sendResponse( + JSON.stringify({ code: 401, action: message.action, id, error: "No RSI session" }) + ); + } else { + const result = await fetchBuybackDetail(token, id) + .then(async (response) => ({ + code: response.status, + payload: await response.text(), + })) + .catch((error) => { + console.error("FY Sync: Buy-back detail failed", error); + + return { code: 500, error: "Buy-back detail failed" }; + }); + + sendResponse(JSON.stringify({ action: message.action, id, ...result })); + } + } else if (message?.action == "syncBuybackPricing") { + const token = await getToken(); + + if (!token) { + sendResponse( + JSON.stringify({ code: 401, action: message.action, error: "No RSI session" }) + ); + } else { + const result = await storePricing(token).catch((error) => { + console.error("FY Sync: Store pricing failed", error); + + return { code: 500, error: "Store pricing failed" }; + }); + + sendResponse(JSON.stringify({ action: message.action, ...result })); + } } else if (message?.action == "sync" || message?.action == "syncBuyback") { const token = await getToken(); if (!token) { diff --git a/lib/rsi.ts b/lib/rsi.ts index be65882..e3e0aae 100644 --- a/lib/rsi.ts +++ b/lib/rsi.ts @@ -54,6 +54,43 @@ export function fetchBuybacks(token: string, page = 1) { return fetchAccountPage("buy-back-pledges", token, page); } +export function fetchBuybackDetail(token: string, id: string) { + return fetch(`${RSI_BASE_URL}/pledge/buyback/${id}`, { + method: "GET", + headers: { + ...HTML_PAGE_HEADERS, + "X-Rsi-Token": token, + }, + credentials: "include", + }); +} + +// The store prices in the account's currency once it has a store token, which +// RSI's own pages ask for the same way before they show a price. Without one +// it answers in USD whatever the account uses. +export function setStoreAuthToken(token: string) { + return fetchRSIApi({ + url: `${RSI_BASE_URL}/api/account/v2/setAuthToken`, + payload: {}, + rsiToken: token, + }); +} + +export function fetchStorePricing(token: string) { + return fetchRSIApi({ + url: `${RSI_BASE_URL}/pledge-store/api/upgrade/v2/graphql`, + payload: [ + { + operationName: "pricing", + variables: {}, + query: + "query pricing { app { pricing { currencyCode exchangeRate taxRate isTaxInclusive } } }", + }, + ], + rsiToken: token, + }); +} + export function fetchCitizenPage(handle: string) { return fetch(`${RSI_BASE_URL}/en/citizens/${encodeURIComponent(handle)}`, { method: "GET",