Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency-provider-fixer (currency-provider-fixer)
**Package**: `@suaveplan/currency-provider-fixer`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> No exceptions.
## ADDED Requirements
### Requirement: Provider identity flags
The package SHALL export a `FixerProvider` class that implements the
`CurrencyProvider` contract from `@suaveplan/currency` and exposes the stable
identity flags `name = "fixer"`, `supportsFiat = true`, and
`supportsCrypto = false`.
#### Scenario: identity flags are stable
- **WHEN** a caller constructs `new FixerProvider({ apiKey: "x" })`
- **THEN** `provider.name === "fixer"`, `provider.supportsFiat === true`,
and `provider.supportsCrypto === false`
**Verified by:** `src/fixer/fixer.test.ts::"exposes the stable identity flags name/supportsFiat/supportsCrypto"`
---
### Requirement: API key required at construction
The constructor SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.MISSING_CONFIG` when `config.apiKey` is the empty string,
before any network request is issued.
#### Scenario: empty api key is rejected at construction
- **WHEN** a caller invokes `new FixerProvider({ apiKey: "" })`
- **THEN** a `CurrencyProviderError` is thrown synchronously with
`code === CURRENCY_PROVIDER.MISSING_CONFIG` and `providerName === "fixer"`
**Verified by:** `src/fixer/fixer.test.ts::"raises MISSING_CONFIG when apiKey is empty at construction"`
---
### Requirement: Default base URL
The provider SHALL default the request base URL to
`https://data.fixer.io/api` and SHALL allow the caller to override it via
`config.baseUrl`.
#### Scenario: default base URL used when none provided
- **WHEN** `new FixerProvider({ apiKey: "k", fetch }).fetchRates()` is called
- **THEN** the recorded `fetch` URL starts with `https://data.fixer.io/api/latest?`
**Verified by:** `src/fixer/fixer.test.ts::"defaults the request base URL to data.fixer.io when no override is provided"`
#### Scenario: custom base URL is honoured
- **WHEN** `new FixerProvider({ apiKey: "k", baseUrl: "https://proxy/api", fetch }).fetchRates()` is called
- **THEN** the recorded `fetch` URL starts with `https://proxy/api/latest?`
**Verified by:** `src/fixer/fixer.test.ts::"honours a custom baseUrl override"`
---
### Requirement: Query construction
`fetchRates(options?)` SHALL build the request query with `access_key` set
to the configured api key, `base` defaulting to `EUR` when
`options.base` is unset, and `symbols` set to a comma-joined list when
`options.targets` is a non-empty array. When `options.targets` is omitted or
empty, no `symbols` parameter SHALL be added.
#### Scenario: default base EUR and access_key are forwarded
- **WHEN** `fetchRates()` is called without options
- **THEN** the URL query contains `access_key=k` and `base=EUR` and does
not contain `symbols`
**Verified by:** `src/fixer/fixer.test.ts::"defaults base to EUR and forwards access_key, omits symbols when targets is unset"`
#### Scenario: targets list is comma-joined into symbols
- **WHEN** `fetchRates({ base: "USD", targets: ["EUR","GBP"] })` is called
- **THEN** the URL query contains `base=USD` and `symbols=EUR%2CGBP`
**Verified by:** `src/fixer/fixer.test.ts::"forwards a custom base and comma-joins targets into the symbols param"`
---
### Requirement: Injectable FetchLike
The provider SHALL honour the optional `config.fetch` (`FetchLike` from
`@suaveplan/types`) and use it for every outbound request. When omitted, the
provider SHALL fall back to `globalThis.fetch`.
#### Scenario: custom fetch is invoked
- **WHEN** a recording `FetchLike` is passed via `config.fetch` and
`fetchRates()` is called
- **THEN** the recorded fetch closure receives exactly one call for that
invocation
**Verified by:** `src/fixer/fixer.test.ts::"invokes the injected FetchLike instead of globalThis.fetch"`
---
### Requirement: Successful envelope projection
The provider SHALL resolve `fetchRates` with `{ base, rates, timestamp:
data.timestamp * 1000 }` when the upstream responds with
`{ success: true, base, rates, timestamp }`. The upstream `timestamp` is
Unix seconds; the returned `timestamp` SHALL be milliseconds since epoch.
#### Scenario: successful envelope is returned with millisecond timestamp
- **WHEN** the upstream returns `{ success: true, base: "EUR", rates: { GBP: 0.85 }, timestamp: 1_700_000_000 }` with HTTP 200
- **THEN** `fetchRates` resolves to `{ base: "EUR", rates: { GBP: 0.85 }, timestamp: 1_700_000_000_000 }`
**Verified by:** `src/fixer/fixer.test.ts::"returns the canonical RateFetchResult with the timestamp converted from seconds to milliseconds"`
---
### Requirement: UNSUPPORTED_BASE mapping
The provider SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.UNSUPPORTED_BASE`, `providerName === "fixer"`, and the
upstream `error.info` carried on `upstreamMessage` whenever the upstream
responds with `{ success: false, error: { code: 105 } }`
(`base_currency_access_restricted` — free tier rejecting a non-EUR base).
#### Scenario: free-tier base restriction maps to UNSUPPORTED_BASE
- **WHEN** the upstream returns `{ success: false, error: { code: 105, type: "base_currency_access_restricted", info: "Access Restricted" } }`
- **THEN** `fetchRates({ base: "USD" })` rejects with
`code === CURRENCY_PROVIDER.UNSUPPORTED_BASE` and
`upstreamMessage === "Access Restricted"`
**Verified by:** `src/fixer/fixer.test.ts::"maps upstream error.code 105 to UNSUPPORTED_BASE"`
---
### Requirement: UNKNOWN_CURRENCY mapping
The provider SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.UNKNOWN_CURRENCY` and propagate the upstream
`error.info` on `upstreamMessage` whenever the upstream responds with
`{ success: false, error: { code: 201 } }` (`invalid_base_currency`) or
`{ ..., error: { code: 202 } }` (`invalid_currency_codes`).
#### Scenario: invalid base currency maps to UNKNOWN_CURRENCY
- **WHEN** the upstream returns `{ success: false, error: { code: 201, info: "invalid base" } }`
- **THEN** `fetchRates({ base: "ZZZ" })` rejects with
`code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` and
`upstreamMessage === "invalid base"`
**Verified by:** `src/fixer/fixer.test.ts::"maps upstream error.code 201 to UNKNOWN_CURRENCY"`
#### Scenario: invalid symbol codes maps to UNKNOWN_CURRENCY
- **WHEN** the upstream returns `{ success: false, error: { code: 202, info: "invalid symbols" } }`
- **THEN** `fetchRates({ targets: ["ZZZ"] })` rejects with
`code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` and
`upstreamMessage === "invalid symbols"`
**Verified by:** `src/fixer/fixer.test.ts::"maps upstream error.code 202 to UNKNOWN_CURRENCY"`
---
### Requirement: UPSTREAM_ERROR fallback
The provider SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.UPSTREAM_ERROR`, `providerName === "fixer"`, and
`upstreamMessage` populated with the upstream `error.info` (or the string
`"<unknown>"` when absent) whenever the upstream responds with
`{ success: false }` and any code that is not one of the discriminated
`105` / `201` / `202` codes — including when the `error` field is missing
entirely.
#### Scenario: unknown upstream error code maps to UPSTREAM_ERROR
- **WHEN** the upstream returns `{ success: false, error: { code: 999, info: "rate limit exceeded" } }`
- **THEN** `fetchRates()` rejects with
`code === CURRENCY_PROVIDER.UPSTREAM_ERROR` and
`upstreamMessage === "rate limit exceeded"`
**Verified by:** `src/fixer/fixer.test.ts::"maps any other success:false code to UPSTREAM_ERROR with upstreamMessage"`
#### Scenario: missing error field still maps to UPSTREAM_ERROR
- **WHEN** the upstream returns `{ success: false }` with no `error` field
- **THEN** `fetchRates()` rejects with
`code === CURRENCY_PROVIDER.UPSTREAM_ERROR` and
`upstreamMessage === "<unknown>"`
**Verified by:** `src/fixer/fixer.test.ts::"maps success:false with no error field to UPSTREAM_ERROR with <unknown>"`
---
### Requirement: PARSE_ERROR on malformed JSON
The provider SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.PARSE_ERROR` and `providerName === "fixer"` whenever
the upstream body cannot be parsed as JSON, when the parsed value is not
a non-null object (for example `null`, a string, a number, or an array),
or when a `success: true` envelope is missing any required field.
#### Scenario: invalid JSON body raises PARSE_ERROR
- **WHEN** the upstream returns the literal body `"<html>"` with HTTP 200
- **THEN** `fetchRates()` rejects with `code === CURRENCY_PROVIDER.PARSE_ERROR`
**Verified by:** `src/fixer/fixer.test.ts::"raises PARSE_ERROR when the response body is not valid JSON"`
#### Scenario: non-object JSON raises PARSE_ERROR
- **WHEN** the upstream returns the literal body `"null"` with HTTP 200
- **THEN** `fetchRates()` rejects with `code === CURRENCY_PROVIDER.PARSE_ERROR`
**Verified by:** `src/fixer/fixer.test.ts::"raises PARSE_ERROR when the parsed JSON is null or a non-object"`
#### Scenario: success:true envelope with missing required fields raises PARSE_ERROR
- **WHEN** the upstream returns `{ success: true }` with `base` / `rates` /
`timestamp` absent
- **THEN** `fetchRates()` rejects with `code === CURRENCY_PROVIDER.PARSE_ERROR`
**Verified by:** `src/fixer/fixer.test.ts::"raises PARSE_ERROR when a success envelope is missing required fields"`
---
### Requirement: HTTP_ERROR via slim-core httpRequest
The provider SHALL delegate HTTP transport to the slim core's `httpRequest`
helper so that non-2xx responses raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.HTTP_ERROR`, `providerName === "fixer"`, and `status`
populated to the upstream HTTP status code.
#### Scenario: 5xx upstream raises HTTP_ERROR
- **WHEN** the upstream returns HTTP 503 with body `"Service Unavailable"`
- **THEN** `fetchRates()` rejects with
`code === CURRENCY_PROVIDER.HTTP_ERROR`, `status === 503`, and
`providerName === "fixer"`
**Verified by:** `src/fixer/fixer.test.ts::"raises HTTP_ERROR with status and providerName on a non-2xx response"`
---
### Requirement: Caller AbortSignal propagation
When `options.signal` is supplied to `fetchRates`, the provider SHALL
forward it through `httpRequest` so the underlying fetch is cancelled when
the caller aborts.
#### Scenario: aborting the caller signal rejects the fetchRates promise
- **WHEN** `fetchRates({ signal })` is called with a signal whose controller
is aborted before the upstream resolves
- **THEN** the returned promise rejects with the caller's abort reason
**Verified by:** `src/fixer/fixer.test.ts::"forwards the caller AbortSignal to the underlying fetch"`
---
### Requirement: No auto-register top-level side effect
The package SHALL NOT register an instance of `FixerProvider` with any
global registry at import time. Importing the package SHALL be free of
side effects and SHALL leave `"sideEffects": false` honoured for
tree-shaking.
#### Scenario: import has no observable side effect
- **WHEN** the package is imported via `await import("@suaveplan/currency-provider-fixer")`
- **THEN** no `CurrencyProviderRegistry` instance is constructed or
mutated by the module, and the only public surface is the `FixerProvider`
class
**Verified by:** `src/fixer/fixer.test.ts::"has no side effect at import time and exposes only FixerProvider"`
---
### Requirement: Universal-tier purity
The package SHALL be universal-tier — it SHALL NOT import any
`node:` / `bun:` / DOM-only module, and SHALL run unchanged under Node 18+,
Bun, modern browsers, and Web Workers.
#### Scenario: no banned imports in source
- **WHEN** the source tree under `src/` is scanned
- **THEN** zero matches are found for `from "node:`, `from "bun:`,
`from "fs"`, `from "path"`, or any DOM-only global
**Verified by:** `src/fixer/fixer.test.ts::"imports only universal-tier modules — no node:, bun:, fs, or DOM-only globals"`