9.3 KiB
Capability Spec Delta: currency-provider-ecb (add-currency-provider-ecb)
Package: @suaveplan/currency-provider-ecb
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time.
ADDED Requirements
Requirement: EcbProvider implements CurrencyProvider with stable identity
The package SHALL export EcbProvider, a class implementing CurrencyProvider
from @suaveplan/currency with name = "ecb", supportsFiat = true, and
supportsCrypto = false. The constructor SHALL accept an optional
NoKeyConfig (from @suaveplan/currency-providers) carrying baseUrl and
fetch overrides; no API key is required.
Scenario: identity flags
- WHEN a caller constructs
new EcbProvider()with no config - THEN
provider.name === "ecb"ANDprovider.supportsFiat === trueANDprovider.supportsCrypto === false
Scenario: custom baseUrl override
- WHEN a caller constructs
new EcbProvider({ baseUrl: "https://proxy/ecb" })and callsfetchRates()with an injectedfetch - THEN the injected fetch is invoked with
"https://proxy/ecb"
Verified by: src/ecb/ecb.test.ts::"exposes the documented identity flags" AND src/ecb/ecb.test.ts::"calls the custom baseUrl when provided"
Requirement: fetchRates returns EUR-based rates by default
The package SHALL fetch the ECB daily XML feed through httpRequest() from
@suaveplan/currency-providers, parse the EUR-quoted currency table via the
regex /currency='([A-Z]+)'\s+rate='([\d.]+)'/g, and return a
RateFetchResult whose base is "EUR" when the caller passes no base
option or explicitly passes "EUR". The returned rates map SHALL contain
every currency present in the feed, keyed by ISO-4217 code with numeric
exchange rates parsed via Number.parseFloat. The timestamp SHALL be the
wall-clock millisecond value at fetch completion.
Scenario: default base resolves to EUR
- WHEN
fetchRates()is called with no options against a feed containing USD=1.0870, GBP=0.8358, JPY=162.48, CHF=0.9443 - THEN the result has
base === "EUR"ANDrates.USD === 1.0870ANDrates.GBP === 0.8358ANDrates.JPY === 162.48ANDrates.CHF === 0.9443ANDtimestampis a finite positive number
Scenario: explicit EUR base
- WHEN
fetchRates({ base: "EUR" })is called against the same feed - THEN the result equals the default-base result
Verified by: src/ecb/ecb.test.ts::"returns EUR-based rates when no base is specified" AND src/ecb/ecb.test.ts::"returns EUR-based rates when EUR is explicitly requested"
Requirement: fetchRates converts to a non-EUR base mathematically
When the caller requests a non-EUR base, the package SHALL look up that
base in the EUR-quoted rate map and synthesise the target view by computing
result.EUR = 1 / baseRate and result.<other> = <eurRate> / baseRate for
every other currency. The requested base SHALL be absent from the returned
rates map (a currency does not quote itself).
Scenario: USD base derived from EUR feed
- WHEN
fetchRates({ base: "USD" })is called against a feed where USD=1.0870 and GBP=0.8358 (EUR-quoted) - THEN the result has
base === "USD"ANDrates.EURis within 1e-4 of1 / 1.0870ANDrates.GBPis within 1e-4 of0.8358 / 1.0870ANDrates.USD === undefined
Verified by: src/ecb/ecb.test.ts::"converts to a non-EUR base by inverting through the EUR feed"
Requirement: targets filter restricts the returned rate map
When options.targets is provided, the package SHALL return only the
currencies present in targets AND in the resolved rate map. Currencies
listed in targets that are absent from the feed SHALL be silently dropped
UNLESS every requested target is absent, in which case the package SHALL
raise CurrencyProviderError(UNKNOWN_CURRENCY).
Scenario: targets intersect the feed
- WHEN
fetchRates({ base: "EUR", targets: ["USD", "GBP"] })is called against a feed containing USD, GBP, JPY, CHF - THEN the result
ratescontains exactly two keys,USDandGBP, ANDrates.JPY === undefined
Scenario: all requested targets absent
- WHEN
fetchRates({ base: "EUR", targets: ["ZZZ", "QQQ"] })is called against a feed containing only USD, GBP, JPY, CHF - THEN the call rejects with
CurrencyProviderErrorwhosecode === CURRENCY_PROVIDER.UNKNOWN_CURRENCYAND whoseproviderName === "ecb"
Verified by: src/ecb/ecb.test.ts::"restricts the returned rates to the requested targets" AND src/ecb/ecb.test.ts::"raises UNKNOWN_CURRENCY when every requested target is absent"
Requirement: HTTP failures surface as typed CurrencyProviderError(HTTP_ERROR)
The package SHALL route every HTTP call through httpRequest() from
@suaveplan/currency-providers. When the upstream returns a non-2xx
response, the call SHALL reject with CurrencyProviderError whose code
is CURRENCY_PROVIDER.HTTP_ERROR, whose providerName === "ecb", and whose
status matches the HTTP status code. The package SHALL NOT hand-roll a
fetch() call site or a !response.ok block.
Scenario: 5xx response
- WHEN the injected
fetchresolves withnew Response("", { status: 503, statusText: "Down" }) - THEN the call rejects with
CurrencyProviderErrorwhosecode === CURRENCY_PROVIDER.HTTP_ERRORANDstatus === 503ANDproviderName === "ecb"
Verified by: src/ecb/ecb.test.ts::"raises HTTP_ERROR on a 5xx upstream response"
Requirement: PARSE_ERROR raised for an unparseable feed body
When the upstream returns 2xx but the body contains zero matches for the
currency-rate regex, the package SHALL raise
CurrencyProviderError(PARSE_ERROR). The package SHALL NOT cache or return
an empty rate map silently.
Scenario: empty body
- WHEN the injected
fetchresolves withnew Response("<garbage/>", { status: 200 }) - THEN the call rejects with
CurrencyProviderErrorwhosecode === CURRENCY_PROVIDER.PARSE_ERRORANDproviderName === "ecb"
Verified by: src/ecb/ecb.test.ts::"raises PARSE_ERROR when the upstream payload has no currency tags"
Requirement: UNSUPPORTED_BASE raised when the requested base is missing from the feed
When the caller requests a non-EUR base that is not present in the
EUR-quoted feed (e.g. "XYZ"), the package SHALL raise
CurrencyProviderError(UNSUPPORTED_BASE).
Scenario: unknown non-EUR base
- WHEN
fetchRates({ base: "XYZ" })is called against a feed that does not containXYZ - THEN the call rejects with
CurrencyProviderErrorwhosecode === CURRENCY_PROVIDER.UNSUPPORTED_BASEANDproviderName === "ecb"
Verified by: src/ecb/ecb.test.ts::"raises UNSUPPORTED_BASE when the requested base is missing from the feed"
Requirement: AbortSignal from RateFetchOptions is forwarded to httpRequest
When options.signal is set, the package SHALL forward that signal to
httpRequest({ signal }). The slim-core helper composes it with any timeout
signal via AbortSignal.any; aborting the caller signal SHALL abort the
in-flight upstream fetch.
Scenario: caller aborts the call
- WHEN the injected
fetchreturns a Promise that rejects onsignal.abort()and the caller callscontroller.abort(new Error("cancelled"))after issuingfetchRates({ signal: controller.signal }) - THEN the call rejects with an Error whose message contains
"cancelled"
Verified by: src/ecb/ecb.test.ts::"forwards the caller AbortSignal to the upstream fetch"
Requirement: Injectable FetchLike for deterministic tests and proxying
The constructor SHALL accept NoKeyConfig.fetch: FetchLike (imported from
@suaveplan/types) and use it for every HTTP call. When fetch is not
provided, the package SHALL fall back to globalThis.fetch. The package
SHALL NOT capture or close over globalThis.fetch at module-load time —
the fallback resolves at call time so test setups that replace
globalThis.fetch after construction still work.
Scenario: explicit fetch wins over globalThis.fetch
- WHEN
new EcbProvider({ fetch: recordedFetch })is instantiated andfetchRates()is called - THEN
recordedFetchis invoked exactly once with the resolved URL ANDglobalThis.fetchis never invoked
Verified by: src/ecb/ecb.test.ts::"uses the injected fetch when provided"
Requirement: Universal-tier purity and zero side effects
The package SHALL be importable in browsers, Web Workers, Node.js, and Bun
without any environment-specific shim. The package SHALL declare
"sideEffects": false in package.json and SHALL NOT register itself with
any registry at module-load time. The package SHALL ship explicit named
re-exports in every barrel (no export *).
Scenario: package.json declares sideEffects false
- WHEN the package.json is read at build time
- THEN
pkg.sideEffects === false
Scenario: barrel uses explicit named exports
- WHEN the contents of
src/ecb/index.tsandsrc/index.tsare read - THEN neither file contains the substring
export *(other thanexport * asnamespace forms, of which there are none in this package)
Verified by: src/ecb/ecb.test.ts::"exposes the documented identity flags" AND tests/package-shape.test.ts::"declares sideEffects false and explicit-named barrels"