"Fetch rates now" failed on the deployed site with nothing but a CORS error. api.frankfurter.app now answers every request with a 301 to api.frankfurter.dev/v1, and a redirect response must itself carry Access-Control-Allow-Origin for a browser to follow it cross-origin. That 301 carries no CORS headers at all, so the request died before the redirect could be taken. Measured rather than guessed, with an Origin header set: api.frankfurter.app -> 301, no access-control-* headers api.frankfurter.dev -> 200, access-control-allow-origin: * The response shape is unchanged, so parseFxResponse needed no edit. Nothing was wrong at build time, which is why this went unnoticed: a server-side fetch follows the 301 freely, so the baked rates were always correct. Only the browser path was broken. Two changes, because a new default alone fixes only some datasets: - DEFAULT_FX_API_URL points at .dev/v1, with a test that fails if the redirecting host ever comes back. - A 3 -> 4 migration drops settings.fxApiUrl when it holds exactly the dead default, which is the case for anyone who edited that field. It is DROPPED rather than rewritten, so the dataset follows whatever the current default is and the next move of the service needs no second migration for the same user; a URL the owner chose is left strictly alone. No stored figure changes, so it cannot alter a number in the budget. Verified by clicking the button in a real browser: "Updated 2 rate(s)", USD 6.456195 and EUR 7.474959 both stored with source "api" — and the USD figure differs from the baked constant, so it is a genuine live fetch. Two migration tests asserted the chain ends at 3; they now assert SCHEMA_VERSION, since what they mean is "all the way through". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
62 lines
2.9 KiB
TypeScript
62 lines
2.9 KiB
TypeScript
import { test, expect, describe } from "bun:test";
|
|
import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./fxApi.ts";
|
|
|
|
/** What the caller asked the service for, and so what it will accept back. */
|
|
const ALLOWED = ["DKK", "USD", "EUR"];
|
|
|
|
describe("buildFxUrl", () => {
|
|
test("substitutes the base and target placeholders", () => {
|
|
expect(buildFxUrl(DEFAULT_FX_API_URL, "DKK", ["USD", "EUR"])).toBe(
|
|
"https://api.frankfurter.dev/v1/latest?from=DKK&to=USD,EUR",
|
|
);
|
|
});
|
|
|
|
// Regression. api.frankfurter.app answers every request with a 301 to
|
|
// api.frankfurter.dev/v1, and a redirect response must itself carry
|
|
// Access-Control-Allow-Origin for a browser to follow it cross-origin. That
|
|
// 301 carries no CORS headers at all, so the fetch was blocked before the
|
|
// redirect could be taken: "Fetch rates now" failed on the deployed site
|
|
// with a bare CORS error and no indication that the endpoint had moved.
|
|
test("does not point at the host that only redirects", () => {
|
|
expect(DEFAULT_FX_API_URL).not.toContain("frankfurter.app");
|
|
});
|
|
});
|
|
|
|
describe("parseFxResponse", () => {
|
|
test("inverts the rates into base units per one unit", () => {
|
|
// The API returns how many target units 1 base unit buys; we store the
|
|
// inverse, so 1 EUR = 7.46 DKK becomes baseUnitsPerOne 7.46.
|
|
const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK", ALLOWED);
|
|
expect(rates.find((r) => r.currency === "EUR")!.baseUnitsPerOne).toBeCloseTo(7.46, 2);
|
|
expect(rates.every((r) => r.source === "api")).toBe(true);
|
|
expect(rates.every((r) => r.updatedAt !== "")).toBe(true);
|
|
});
|
|
|
|
test("skips the base currency's own row if the API returns one", () => {
|
|
const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK", ALLOWED);
|
|
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
|
|
});
|
|
|
|
test("ignores currencies this dataset does not define", () => {
|
|
const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK", ALLOWED);
|
|
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
|
|
});
|
|
|
|
test("rejects a response with no rates object", () => {
|
|
expect(() => parseFxResponse({}, "DKK", ALLOWED)).toThrow(/rates/);
|
|
expect(() => parseFxResponse("nope", "DKK", ALLOWED)).toThrow(/rates/);
|
|
});
|
|
|
|
test("rejects a zero or negative rate rather than storing an infinity", () => {
|
|
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK", ALLOWED)).toThrow(/EUR/);
|
|
});
|
|
});
|
|
|
|
test("an explicit updatedAt is used instead of the clock", () => {
|
|
// The build bakes rates in with the BUILD date, not the date the browser
|
|
// happens to run parseFxResponse.
|
|
const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", ALLOWED, "2026-09-01");
|
|
expect(rates.map((r) => r.updatedAt)).toEqual(["2026-09-01", "2026-09-01"]);
|
|
expect(rates.every((r) => r.source === "api")).toBe(true);
|
|
});
|