Initial Commit
This commit is contained in:
201
openspec/specs/currency-provider-ecb/spec.md
Normal file
201
openspec/specs/currency-provider-ecb/spec.md
Normal file
@@ -0,0 +1,201 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"` AND `provider.supportsFiat === true` AND `provider.supportsCrypto === false`
|
||||
|
||||
#### Scenario: custom baseUrl override
|
||||
|
||||
- **WHEN** a caller constructs `new EcbProvider({ baseUrl: "https://proxy/ecb" })` and calls `fetchRates()` with an injected `fetch`
|
||||
- **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"` AND `rates.USD === 1.0870` AND `rates.GBP === 0.8358` AND `rates.JPY === 162.48` AND `rates.CHF === 0.9443` AND `timestamp` is 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"` AND `rates.EUR` is within 1e-4 of `1 / 1.0870` AND `rates.GBP` is within 1e-4 of `0.8358 / 1.0870` AND `rates.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 `rates` contains exactly two keys, `USD` and `GBP`, AND `rates.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 `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` AND whose `providerName === "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 `fetch` resolves with `new Response("", { status: 503, statusText: "Down" })`
|
||||
- **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.HTTP_ERROR` AND `status === 503` AND `providerName === "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 `fetch` resolves with `new Response("<garbage/>", { status: 200 })`
|
||||
- **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.PARSE_ERROR` AND `providerName === "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 contain `XYZ`
|
||||
- **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.UNSUPPORTED_BASE` AND `providerName === "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 `fetch` returns a Promise that rejects on `signal.abort()` and the caller calls `controller.abort(new Error("cancelled"))` after issuing `fetchRates({ 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 and `fetchRates()` is called
|
||||
- **THEN** `recordedFetch` is invoked exactly once with the resolved URL AND `globalThis.fetch` is 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.ts` and `src/index.ts` are read
|
||||
- **THEN** neither file contains the substring `export *` (other than `export * as` namespace 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user