Files
suaveplan/openspec/specs/currency-provider-cryptocompare/spec.md
2026-05-27 23:29:53 +01:00

154 lines
7.3 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency-provider-cryptocompare (currency-provider-cryptocompare)
**Package**: `@suaveplan/currency-provider-cryptocompare`
**Status**: ARCHIVED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: CryptoCompareProvider — public pricemulti rate fetch
The package SHALL export `CryptoCompareProvider` implementing
`CurrencyProvider` from `@suaveplan/currency` with `name === "cryptocompare"`,
`supportsCrypto === true`, `supportsFiat === false`. `fetchRates(options?)`
SHALL call
`GET <baseUrl>/pricemulti?fsyms=<targets>&tsyms=<base>` via the shared
`httpRequest` helper from `@suaveplan/currency-providers`, defaulting
`base` to `"USD"` and `targets` to the 15-symbol built-in list when
`options.targets` is omitted or empty. Returned `rates` SHALL be the
inverted spot price (`1 / price`) for every symbol whose quote in the
requested `base` is a finite positive number; non-finite and non-positive
quotes SHALL be skipped.
#### Scenario: defaults base to USD and inverts pricemulti payload
- **WHEN** `fetchRates()` is called against
`{ BTC: { USD: 67000 }, ETH: { USD: 3500 }, FAKE: { USD: 0 } }`
- **THEN** `result.base === "USD"`, `result.rates.BTC` ≈ `1 / 67000`,
`result.rates.ETH` ≈ `1 / 3500`, and `result.rates.FAKE` is absent.
#### Scenario: honours custom baseUrl and explicit targets
- **WHEN** `fetchRates({ base: "EUR", targets: ["BTC", "ETH"] })` is called
with `baseUrl: "https://staging.example.com/data"`
- **THEN** the request URL is exactly
`https://staging.example.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR`.
#### Scenario: empty targets array falls back to built-in defaults
- **WHEN** `fetchRates({ base: "USD", targets: [] })` is called
- **THEN** the request URL `fsyms` parameter starts with
`BTC,ETH,SOL,USDT`.
**Verified by:** `src/cryptocompare/cryptocompare.test.ts::"reports stable metadata identifying the provider"` AND `src/cryptocompare/cryptocompare.test.ts::"inverts pricemulti payload, skips zero prices, and defaults base to USD"` AND `src/cryptocompare/cryptocompare.test.ts::"honours custom baseUrl and explicit targets list"` AND `src/cryptocompare/cryptocompare.test.ts::"falls back to defaults when targets array is empty"`
---
### Requirement: Optional API key attached via Apikey authorization header
The package SHALL accept an **optional** `apiKey` field on the constructor
config. When `apiKey` is provided and non-whitespace, every outbound
request SHALL carry an `authorization: Apikey <key>` header per
CryptoCompare's documented Apikey auth scheme. When `apiKey` is omitted,
`undefined`, or whitespace-only, the request SHALL be issued with no
authorization header (keyless free-tier mode).
#### Scenario: keyless construction issues no authorization header
- **WHEN** `new CryptoCompareProvider({ fetch }).fetchRates(...)` runs
- **THEN** the injected fetch sees `init?.headers === undefined`.
#### Scenario: keyed construction issues authorization: Apikey <key>
- **WHEN** `new CryptoCompareProvider({ fetch, apiKey: "secret-key" }).fetchRates(...)`
runs
- **THEN** the injected fetch sees
`init.headers.authorization === "Apikey secret-key"`.
#### Scenario: whitespace-only apiKey is treated as absent
- **WHEN** `new CryptoCompareProvider({ fetch, apiKey: " " }).fetchRates(...)`
runs
- **THEN** the injected fetch sees `init?.headers === undefined`.
**Verified by:** `src/cryptocompare/cryptocompare.test.ts::"sends authorization header only when an apiKey is provided"` AND `src/cryptocompare/cryptocompare.test.ts::"treats whitespace-only apiKey as absent and omits the header"`
---
### Requirement: Typed-error mapping for HTTP, parse, upstream, and unknown-currency failures
The package SHALL surface every failure as `CurrencyProviderError` from
`@suaveplan/currency-providers` with `providerName === "cryptocompare"`.
Non-2xx responses SHALL raise `HTTP_ERROR` (via the shared `httpRequest`
helper, populating `status`). Invalid JSON bodies and non-object parsed
payloads SHALL raise `PARSE_ERROR`. Payloads carrying `Response: "Error"`
SHALL raise `UPSTREAM_ERROR` with `upstreamMessage` set to the upstream
`Message` field, or to `"unknown upstream error"` when `Message` is
absent. `fetchRates()` SHALL raise `UNKNOWN_CURRENCY` when no requested
symbol resolves to a finite positive quote in the configured `base`.
#### Scenario: non-2xx response raises HTTP_ERROR
- **WHEN** the injected fetch returns HTTP 502
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
and `status === 502`.
#### Scenario: invalid JSON body raises PARSE_ERROR
- **WHEN** the body is `"<<not json>>"` with `content-type: application/json`
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.PARSE_ERROR"`.
#### Scenario: non-object payload (e.g. JSON null) raises PARSE_ERROR
- **WHEN** the body is `"null"` parsed as JSON
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.PARSE_ERROR"`.
#### Scenario: upstream error payload raises UPSTREAM_ERROR
- **WHEN** the payload is
`{ Response: "Error", Message: "tsym XYZ is not available" }`
- **THEN** the call rejects with
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"` and `upstreamMessage`
contains `"tsym XYZ"`.
#### Scenario: upstream error payload without Message uses default text
- **WHEN** the payload is `{ Response: "Error" }`
- **THEN** the call rejects with
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"` and
`upstreamMessage === "unknown upstream error"`.
#### Scenario: empty result map raises UNKNOWN_CURRENCY
- **WHEN** `fetchRates({ base: "USD", targets: ["BTC"] })` is called and
the payload is `{}`
- **THEN** the call rejects with
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`.
#### Scenario: every quote non-finite or non-positive raises UNKNOWN_CURRENCY
- **WHEN** the payload contains only `0`, `NaN`, and negative quotes
- **THEN** the call rejects with
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`.
**Verified by:** `src/cryptocompare/cryptocompare.test.ts::"raises HTTP_ERROR on non-2xx response"` AND `src/cryptocompare/cryptocompare.test.ts::"raises PARSE_ERROR when the response body is not valid JSON"` AND `src/cryptocompare/cryptocompare.test.ts::"raises PARSE_ERROR when the parsed payload is not an object"` AND `src/cryptocompare/cryptocompare.test.ts::"raises UPSTREAM_ERROR when payload carries Response='Error' with Message"` AND `src/cryptocompare/cryptocompare.test.ts::"raises UPSTREAM_ERROR with default message when Message field is absent"` AND `src/cryptocompare/cryptocompare.test.ts::"raises UNKNOWN_CURRENCY when no symbols resolve to a quote"` AND `src/cryptocompare/cryptocompare.test.ts::"raises UNKNOWN_CURRENCY when every quote is non-finite or non-positive"`
---
### Requirement: Caller AbortSignal forwarded into shared httpRequest
The package SHALL forward `RateFetchOptions.signal` (when provided)
through the shared `httpRequest` helper so caller-initiated aborts
propagate to the underlying `FetchLike` call.
#### Scenario: caller signal reaches the injected fetch
- **WHEN** `fetchRates({ signal: controller.signal, ... })` is called with
a recording `FetchLike`
- **THEN** the recorded `init.signal` is exactly `controller.signal`.
**Verified by:** `src/cryptocompare/cryptocompare.test.ts::"forwards a caller AbortSignal through httpRequest"`