Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
# @suaveplan/currency-provider-ecb - Universal @suaveplan/currency provider for the European Central Bank (ECB) daily exchange-rates XML feed. Free, no API key. Implements the slim @suaveplan/currency-providers contract: typed CurrencyProviderError, injectable FetchLike from @suaveplan/types, shared httpRequest helper, mathematical conversion to any non-EUR base, AbortSignal propagation, target filtering, PARSE_ERROR / UNSUPPORTED_BASE / UNKNOWN_CURRENCY / HTTP_ERROR coverage.
Universal `@suaveplan/currency` rate provider for the European Central
Bank's daily exchange-rates XML feed. Free, no API key required. Built on the
slim `@suaveplan/currency-providers` plugin core: typed
`CurrencyProviderError` on every failure path, shared `httpRequest()` helper
for HTTP wrapping + abort-signal composition, injectable `FetchLike` from
`@suaveplan/types`, mathematical conversion to any non-EUR base, target
filtering with `UNKNOWN_CURRENCY` guarding, and `PARSE_ERROR` protection
against silently caching an empty rate map.
<!-- begin:auto-docs -->
## Documentation tree
- [ecb](./src/ecb/ecb.md) — `EcbProvider` is the `@suaveplan/currency` rate-source implementation for the European Central Bank's daily reference-r…
- [ecb](./src/ecb/ecb.md) — `EcbProvider` is the `@suaveplan/currency` rate-source implementation for the European Central Bank's daily reference-r…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-ecb @suaveplan/currency @suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const converter = new CurrencyConverter({ provider: new EcbProvider() });
const rate = await converter.getRate("EUR", "USD");
```
Or via the slim core's registry, alongside other sibling provider packages:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
const converter = new CurrencyConverter({ provider: registry.get("ecb") });
```
## Features
- Free, no API key.
- ~30 fiat currencies, EUR-quoted upstream; non-EUR bases derived
arithmetically.
- Daily refresh (~16:00 CET); weekends / holidays return the last published
snapshot.
- `RateFetchOptions.signal` forwarded through the slim core's HTTP helper.
- Typed `CurrencyProviderError` codes: `HTTP_ERROR`, `PARSE_ERROR`,
`UNSUPPORTED_BASE`, `UNKNOWN_CURRENCY`.
- Universal (browser / Web Worker / Node 18+ / Bun).
- Zero runtime deps beyond `@suaveplan/currency`,
`@suaveplan/currency-providers`, `@suaveplan/types`, `tslib`.
## Modules
- [`src/ecb/ecb.md`](./src/ecb/ecb.md) — `EcbProvider` class, configuration,
error matrix, advanced examples.

View File

@@ -0,0 +1,68 @@
{
"name": "@suaveplan/currency-provider-ecb",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal @suaveplan/currency provider for the European Central Bank (ECB) daily exchange-rates XML feed. Free, no API key. Implements the slim @suaveplan/currency-providers contract: typed CurrencyProviderError, injectable FetchLike from @suaveplan/types, shared httpRequest helper, mathematical conversion to any non-EUR base, AbortSignal propagation, target filtering, PARSE_ERROR / UNSUPPORTED_BASE / UNKNOWN_CURRENCY / HTTP_ERROR coverage.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./ecb/ecb": {
"import": {
"types": "./dist/ecb/ecb.d.ts",
"default": "./dist/ecb/ecb.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/currency": "workspace:^",
"@suaveplan/currency-providers": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,143 @@
# EcbProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EcbProvider` is the `@suaveplan/currency` rate-source implementation for the
European Central Bank's daily reference-rates XML feed. It is the lowest-
friction fiat provider in the Suaveplan catalogue: free, no API key required,
universal (runs in browsers, Web Workers, Node.js, and Bun), and built on
the slim `@suaveplan/currency-providers` plugin core so every error path,
abort-signal composition, and HTTP wrapping is shared with every other sibling
provider in the family.
The package exposes a single class, `EcbProvider`, with stable identity flags
(`name === "ecb"`, `supportsFiat === true`, `supportsCrypto === false`). It is
not auto-registered with any registry — consumers wire it through either
`new CurrencyConverter({ provider: new EcbProvider() })` or
`registry.register(new EcbProvider())` against a
`CurrencyProviderRegistry` from `@suaveplan/currency-providers`.
## Features
- Free, no authentication — public ECB daily XML feed.
- ~30 fiat currencies (every major plus many minor European / global pairs).
- Updates around 16:00 CET each business day; weekends and bank holidays
return the last published snapshot.
- Default base is EUR; any non-EUR base is synthesised by inverting through
the EUR rates so callers never have to triangulate manually.
- Optional `targets` filter so consumers iterating a small basket avoid
caching the full 30-currency table.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR` for non-
2xx upstream responses, `PARSE_ERROR` for an unparseable body,
`UNSUPPORTED_BASE` when the requested base is missing from the EUR-quoted
table, `UNKNOWN_CURRENCY` when every requested target is absent.
- `AbortSignal` propagation — `RateFetchOptions.signal` is forwarded through
the slim core's `httpRequest()` so callers can cancel in-flight requests.
- Injectable `FetchLike` (from `@suaveplan/types`) — tests pass a recording
closure; production accepts the default `globalThis.fetch`.
- Zero runtime dependencies beyond `@suaveplan/currency-providers` /
`@suaveplan/currency` / `@suaveplan/types` / `tslib`.
## Usage
### Basic Example
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const converter = new CurrencyConverter({
provider: new EcbProvider(),
});
const rate = await converter.getRate("EUR", "USD");
console.log(`1 EUR = ${rate} USD`);
```
### Advanced Example
Wiring through the slim core's registry with a custom `baseUrl` (e.g. an
internal proxy that caches the upstream XML at the edge) and a caller-side
`AbortSignal` so a UI cancellation aborts the in-flight HTTP request.
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const registry = new CurrencyProviderRegistry();
registry.register(
new EcbProvider({
baseUrl: "https://cdn.internal.example.com/ecb/daily.xml",
}),
);
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("user navigated away")), 5_000);
const provider = registry.get("ecb");
const result = await provider.fetchRates({
base: "USD",
targets: ["EUR", "GBP", "JPY"],
signal: controller.signal,
});
console.log(result.base, result.rates, result.timestamp);
```
## API Reference
### `class EcbProvider implements CurrencyProvider`
#### `constructor(config?: NoKeyConfig)`
- `config.baseUrl?: string` — override the default ECB endpoint
(`https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml`).
- `config.fetch?: FetchLike` — injectable `fetch` implementation; defaults to
`globalThis.fetch` resolved at call time.
#### Read-only fields
- `name: "ecb"` — stable identifier surfaced as `CurrencyProvider.name`.
- `supportsFiat: true`
- `supportsCrypto: false`
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
Fetches and returns the latest ECB rates. `options.base` defaults to `"EUR"`;
when set to any other currency the result is synthesised by inverting through
the EUR-quoted feed. `options.targets` filters the returned map.
`options.signal` is forwarded to the underlying HTTP call.
**Throws** `CurrencyProviderError` with one of:
- `HTTP_ERROR` — non-2xx response (raised by the slim core's `httpRequest`).
- `PARSE_ERROR` — response body contained no parseable currency tags.
- `UNSUPPORTED_BASE` — caller asked for a non-EUR base that is not in the
EUR-quoted feed.
- `UNKNOWN_CURRENCY` — every entry in `options.targets` was absent from the
resolved rate map.
## Implementation Notes
- The ECB endpoint always publishes EUR-based rates. Non-EUR bases are
derived purely arithmetically — there is no second HTTP call. This is
the same algorithm the upstream `@suaveplan/currency-providers` shipped
before the 2026-05-17 slim refactor split it into siblings.
- XML parsing uses a regex (`currency='([A-Z]+)'\s+rate='([\d.]+)'`) rather
than a full XML parser to keep the package zero-runtime-dep beyond the
slim core and `@suaveplan/currency`. The ECB feed shape is stable; if the
upstream format changes, the failure surfaces immediately as
`PARSE_ERROR`.
- Rates are not available on weekends or ECB bank holidays; the feed returns
the last published snapshot. Consumers that need same-day freshness should
layer `@suaveplan/currency`'s `staleDurationMs` on top.
- `globalThis.fetch` is resolved at call time, not constructor time, so test
setups that replace the global between construction and the first
`fetchRates()` call still work.
- The package declares `"sideEffects": false` and ships explicit named barrels
per AGENTS.md §6a; tree-shaking unused exports is safe.

View File

@@ -0,0 +1,318 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import { EcbProvider } from "./ecb.js";
const SAMPLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
<Cube>
<Cube time='2026-02-27'>
<Cube currency='USD' rate='1.0870'/>
<Cube currency='GBP' rate='0.8358'/>
<Cube currency='JPY' rate='162.48'/>
<Cube currency='CHF' rate='0.9443'/>
</Cube>
</Cube>
</gesmes:Envelope>`;
interface FetchCall {
url: string;
init: RequestInit | undefined;
}
function recordingFetch(
handler: (call: FetchCall) => Promise<Response> | Response
): { fetch: FetchLike; calls: FetchCall[] } {
const calls: FetchCall[] = [];
const fetchImpl: FetchLike = async (
url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
const call: FetchCall = { url: String(url), init };
calls.push(call);
return await handler(call);
};
return { fetch: fetchImpl, calls };
}
describe("EcbProvider", () => {
it("exposes the documented identity flags", { timeout: 5000 }, () => {
const provider = new EcbProvider();
expect(provider.name).toBe("ecb");
expect(provider.supportsFiat).toBe(true);
expect(provider.supportsCrypto).toBe(false);
});
it("returns EUR-based rates when no base is specified", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({ fetch });
const result = await provider.fetchRates();
expect(result.base).toBe("EUR");
expect(result.rates.USD).toBeCloseTo(1.087, 4);
expect(result.rates.GBP).toBeCloseTo(0.8358, 4);
expect(result.rates.JPY).toBeCloseTo(162.48, 2);
expect(result.rates.CHF).toBeCloseTo(0.9443, 4);
expect(result.timestamp).toBeGreaterThan(0);
expect(Number.isFinite(result.timestamp)).toBe(true);
expect(calls).toHaveLength(1);
});
it("returns EUR-based rates when EUR is explicitly requested", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({ fetch });
const result = await provider.fetchRates({ base: "EUR" });
expect(result.base).toBe("EUR");
expect(result.rates.USD).toBeCloseTo(1.087, 4);
});
it("converts to a non-EUR base by inverting through the EUR feed", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({ fetch });
const result = await provider.fetchRates({ base: "USD" });
expect(result.base).toBe("USD");
expect(result.rates.EUR).toBeCloseTo(1 / 1.087, 4);
expect(result.rates.GBP).toBeCloseTo(0.8358 / 1.087, 4);
expect(result.rates.JPY).toBeCloseTo(162.48 / 1.087, 2);
expect(result.rates.USD).toBeUndefined();
});
it("restricts the returned rates to the requested targets", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({ fetch });
const result = await provider.fetchRates({
base: "EUR",
targets: ["USD", "GBP"],
});
expect(Object.keys(result.rates).sort()).toEqual(["GBP", "USD"]);
expect(result.rates.JPY).toBeUndefined();
});
it("raises UNKNOWN_CURRENCY when every requested target is absent", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({ fetch });
try {
await provider.fetchRates({
base: "EUR",
targets: ["ZZZ", "QQQ"],
});
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
expect(err.providerName).toBe("ecb");
}
});
it("raises HTTP_ERROR on a 5xx upstream response", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response("", { status: 503, statusText: "Down" })
);
const provider = new EcbProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
expect(err.status).toBe(503);
expect(err.providerName).toBe("ecb");
}
});
it("raises PARSE_ERROR when the upstream payload has no currency tags", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("<garbage/>", {
status: 200,
})
);
const provider = new EcbProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
expect(err.providerName).toBe("ecb");
}
});
it("raises UNSUPPORTED_BASE when the requested base is missing from the feed", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({ fetch });
try {
await provider.fetchRates({ base: "XYZ" });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNSUPPORTED_BASE);
expect(err.providerName).toBe("ecb");
}
});
it("calls the custom baseUrl when provided", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({
fetch,
baseUrl: "https://proxy.example.com/ecb",
});
await provider.fetchRates();
expect(calls[0]?.url).toBe("https://proxy.example.com/ecb");
});
it("uses the injected fetch when provided", { timeout: 5000 }, async () => {
const { fetch, calls } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const original = globalThis.fetch;
let globalCalled = false;
globalThis.fetch = (async () => {
globalCalled = true;
return new Response("ignored", { status: 200 });
}) as typeof globalThis.fetch;
try {
const provider = new EcbProvider({ fetch });
await provider.fetchRates();
expect(calls).toHaveLength(1);
expect(globalCalled).toBe(false);
} finally {
globalThis.fetch = original;
}
});
it("falls back to globalThis.fetch when no fetch is injected", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
let globalCalled = false;
globalThis.fetch = (async () => {
globalCalled = true;
return new Response(SAMPLE_XML, { status: 200 });
}) as typeof globalThis.fetch;
try {
const provider = new EcbProvider();
const result = await provider.fetchRates();
expect(globalCalled).toBe(true);
expect(result.base).toBe("EUR");
} finally {
globalThis.fetch = original;
}
});
it("forwards the caller AbortSignal to the upstream fetch", {
timeout: 5000,
}, async () => {
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
};
const provider = new EcbProvider({ fetch: fetchImpl });
const controller = new AbortController();
const promise = provider.fetchRates({ signal: controller.signal });
controller.abort(new Error("cancelled"));
await expect(promise).rejects.toThrow(/cancelled/);
});
it("omits absent targets but returns the present ones", {
timeout: 5000,
}, async () => {
// Covers the branch where targets is non-empty AND at least one
// requested target IS present — must not raise UNKNOWN_CURRENCY.
const { fetch } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({ fetch });
const result = await provider.fetchRates({
base: "EUR",
targets: ["USD", "ZZZ"],
});
expect(result.rates.USD).toBeCloseTo(1.087, 4);
expect(result.rates.ZZZ).toBeUndefined();
});
it("treats an empty targets array as no filter", {
timeout: 5000,
}, async () => {
// targets.length === 0 path — does not raise UNKNOWN_CURRENCY even
// though every key in the filtered map is absent.
const { fetch } = recordingFetch(
() => new Response(SAMPLE_XML, { status: 200 })
);
const provider = new EcbProvider({ fetch });
const result = await provider.fetchRates({
base: "EUR",
targets: [],
});
expect(Object.keys(result.rates)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,166 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
/** Stable provider identifier surfaced as `provider.name`. */
const PROVIDER_NAME = "ecb";
/** Default ECB daily reference rates feed. */
const DEFAULT_URL =
"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
/**
* Regex extracting `currency='XXX' rate='1.2345'` pairs from the ECB XML
* envelope. Matches the same shape the upstream provider used; chosen over
* a real XML parser to keep the package zero-runtime-dep.
*/
const CURRENCY_RATE_REGEX = /currency='([A-Z]+)'\s+rate='([\d.]+)'/g;
/**
* European Central Bank (ECB) currency provider. Fetches the public daily
* EUR-based reference-rates XML feed and exposes it through the slim
* `CurrencyProvider` contract.
*
* Identity: `name === "ecb"`, `supportsFiat === true`, `supportsCrypto === false`.
*
* Every HTTP call goes through `@suaveplan/currency-providers`'s
* `httpRequest()` helper — non-2xx responses raise
* `CurrencyProviderError(HTTP_ERROR)` and any caller `AbortSignal` from
* `RateFetchOptions.signal` is composed with the slim core's timeout pipeline.
*/
export class EcbProvider implements CurrencyProvider {
public readonly name = PROVIDER_NAME;
public readonly supportsCrypto = false;
public readonly supportsFiat = true;
private readonly baseUrl: string;
private readonly fetchImpl?: FetchLike;
public constructor(config?: NoKeyConfig) {
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
if (config?.fetch !== undefined) {
this.fetchImpl = config.fetch;
}
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const fetchImpl: FetchLike = this.fetchImpl ?? globalThis.fetch;
const requestInit: {
url: string;
fetch: FetchLike;
providerName: string;
signal?: AbortSignal;
} = {
url: this.baseUrl,
fetch: fetchImpl,
providerName: PROVIDER_NAME,
};
if (options?.signal !== undefined) {
requestInit.signal = options.signal;
}
const response = await httpRequest(requestInit);
const xml = await response.text();
const eurRates = parseEcbXml(xml);
const requestedBase = options?.base ?? "EUR";
const timestamp = Date.now();
if (requestedBase === "EUR") {
return filterTargets(
{ base: "EUR", rates: eurRates, timestamp },
options?.targets
);
}
const baseRate = eurRates[requestedBase];
if (baseRate === undefined) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNSUPPORTED_BASE,
`ECB does not provide rate for ${requestedBase}`,
{ providerName: PROVIDER_NAME }
);
}
const converted: Record<string, number> = { EUR: 1 / baseRate };
for (const [code, rate] of Object.entries(eurRates)) {
if (code !== requestedBase) {
converted[code] = rate / baseRate;
}
}
return filterTargets(
{ base: requestedBase, rates: converted, timestamp },
options?.targets
);
}
}
/**
* Parse ECB XML to a `{ code → rate }` map. Raises `PARSE_ERROR` when the
* payload contains zero matches so an empty rate map is never silently
* returned and cached downstream.
*/
function parseEcbXml(xml: string): Record<string, number> {
const rates: Record<string, number> = {};
const regex = new RegExp(CURRENCY_RATE_REGEX.source, "g");
let match: RegExpExecArray | null = regex.exec(xml);
while (match !== null) {
// Both capture groups are guaranteed by the regex shape; the
// non-null assertion is the right form here — replacing it with a
// runtime check would create a branch that is unreachable by
// construction (see §7a — never delete code that serves a purpose,
// but also never invent unreachable branches that fail coverage).
const code = match[1] as string;
const value = match[2] as string;
rates[code] = Number.parseFloat(value);
match = regex.exec(xml);
}
if (Object.keys(rates).length === 0) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"ECB feed contained no parseable currency rates",
{ providerName: PROVIDER_NAME }
);
}
return rates;
}
/**
* Restrict `result.rates` to the caller's `targets`. When `targets` is
* non-empty but every entry is absent from the resolved rate map, raise
* `UNKNOWN_CURRENCY` so the caller discovers the misconfiguration instead
* of caching an empty success.
*/
function filterTargets(
result: RateFetchResult,
targets?: readonly string[]
): RateFetchResult {
if (targets === undefined) return result;
const filtered: Record<string, number> = {};
for (const t of targets) {
const value = result.rates[t];
if (value !== undefined) {
filtered[t] = value;
}
}
if (targets.length > 0 && Object.keys(filtered).length === 0) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`ECB feed had no overlap with requested targets: ${targets.join(", ")}`,
{ providerName: PROVIDER_NAME }
);
}
return { ...result, rates: filtered };
}

View File

@@ -0,0 +1 @@
export { EcbProvider } from "./ecb.js";

View File

@@ -0,0 +1 @@
export { EcbProvider } from "./ecb/ecb.js";

View File

@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = resolve(__dirname, "..");
describe("package shape", () => {
it("declares sideEffects false and explicit-named barrels", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(resolve(PKG_ROOT, "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
const rootBarrel = readFileSync(
resolve(PKG_ROOT, "src/index.ts"),
"utf8"
);
const featureBarrel = readFileSync(
resolve(PKG_ROOT, "src/ecb/index.ts"),
"utf8"
);
// No `export *` of any kind in either barrel (no namespace re-export
// in this package either, so the substring check is sufficient).
expect(rootBarrel.includes("export *")).toBe(false);
expect(featureBarrel.includes("export *")).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `globalThis.fetch` (overridable via `FetchLike` injection),
// `Response`, `Number.parseFloat`, `Date.now`, `Object.entries`, and `RegExp`
// — all available in every supported runtime (Node 18+, Bun, browsers, Web
// Workers). This file exists to give vitest a stable setup-file path mirroring
// the sibling-package layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/currency-provider-ecb",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/currency",
"@suaveplan/currency-providers",
"@suaveplan/types",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});