Files
2026-05-27 23:29:53 +01:00

12 KiB

Capability Spec Delta: currency-provider-fixer (add-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"