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,222 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency-provider-coinmarketcap (add-currency-provider-coinmarketcap)
**Package**: `@suaveplan/currency-provider-coinmarketcap`
**Status**: ACTIVE
> Every requirement below carries a `**Verified by:**` line at authoring time.
> No exceptions.
## ADDED Requirements
### Requirement: CoinMarketCapProvider — class implementing CurrencyProvider
The package SHALL export `CoinMarketCapProvider`, a class implementing
`CurrencyProvider` from `@suaveplan/currency` with stable identity fields
`name = "coinmarketcap"`, `supportsCrypto = true`, and `supportsFiat = false`.
The constructor SHALL accept an `ApiKeyConfig` (from
`@suaveplan/currency-providers`) carrying a non-empty `apiKey`, an optional
`baseUrl` (default `https://pro-api.coinmarketcap.com/v1`), and an optional
injectable `FetchLike` (default `globalThis.fetch`).
#### Scenario: Identity fields are populated
- **WHEN** a `CoinMarketCapProvider` is constructed with a valid `apiKey`
- **THEN** `provider.name === "coinmarketcap"`, `provider.supportsCrypto === true`,
`provider.supportsFiat === false`, and `provider.supportsVirtual` is undefined.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"exposes stable identity fields"`
---
### Requirement: Constructor rejects missing or empty API key
The package SHALL raise `CurrencyProviderError(MISSING_CONFIG)` from the
constructor when `config.apiKey` is the empty string or contains only
whitespace. The error SHALL carry `providerName: "coinmarketcap"`.
#### Scenario: Empty apiKey is rejected at construction time
- **WHEN** `new CoinMarketCapProvider({ apiKey: "" })` is called
- **THEN** a `CurrencyProviderError` is thrown synchronously with
`code === "CURRENCY_PROVIDER.MISSING_CONFIG"` and
`providerName === "coinmarketcap"`.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey is empty"` AND `src/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey is whitespace"`
---
### Requirement: fetchRates issues an authenticated quotes/latest request
`fetchRates(options?)` SHALL call
`GET <baseUrl>/cryptocurrency/quotes/latest?symbol=<targets-csv>&convert=<base>`
through the shared `httpRequest()` helper from
`@suaveplan/currency-providers`. The request SHALL carry the headers
`X-CMC_PRO_API_KEY: <apiKey>` and `Accept: application/json`. When
`options.targets` is omitted, the request SHALL query a default symbol set
covering the top crypto-by-market-cap (`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`).
When `options.base` is omitted, the request SHALL convert into `USD`.
`options.signal` SHALL be forwarded to `httpRequest()` so caller cancellation
propagates to the underlying fetch.
#### Scenario: Builds the canonical URL and headers
- **WHEN** `fetchRates({ base: "EUR", targets: ["BTC", "ETH"] })` is called
- **THEN** the injected fetch receives a `GET` to
`https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH&convert=EUR`
with `X-CMC_PRO_API_KEY` and `Accept: application/json` headers.
#### Scenario: Defaults targets to the top-10 symbol set and base to USD
- **WHEN** `fetchRates()` is called with no options
- **THEN** the request URL contains `symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`
and `convert=USD`.
#### Scenario: Forwards caller AbortSignal
- **WHEN** the caller aborts the signal passed via `options.signal`
- **THEN** the underlying fetch rejects with the abort reason and
`fetchRates` rejects.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"issues an authenticated GET to /cryptocurrency/quotes/latest"` AND `src/coinmarketcap/coinmarketcap.test.ts::"defaults to USD + top-10 symbols when no options are given"` AND `src/coinmarketcap/coinmarketcap.test.ts::"forwards the caller AbortSignal to the injected fetch"`
---
### Requirement: Non-2xx HTTP responses surface as HTTP_ERROR
The package SHALL surface non-2xx responses as
`CurrencyProviderError(HTTP_ERROR)` (raised by the shared `httpRequest()`
helper, with `status` populated and `providerName: "coinmarketcap"`).
#### Scenario: 503 maps to HTTP_ERROR with status
- **WHEN** the upstream returns `503 Service Unavailable`
- **THEN** `fetchRates()` rejects with `CurrencyProviderError` whose
`code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, and
`providerName === "coinmarketcap"`.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"surfaces non-2xx responses as HTTP_ERROR"`
---
### Requirement: Malformed JSON or missing data map raises PARSE_ERROR
The package SHALL raise `CurrencyProviderError(PARSE_ERROR)` when the
upstream payload is not valid JSON, when the parsed value is not an object,
or when the `data` field is missing / not an object. The error SHALL carry
`providerName: "coinmarketcap"`.
#### Scenario: Invalid JSON body raises PARSE_ERROR
- **WHEN** the upstream returns a 200 with body `"not-json"`
- **THEN** `fetchRates()` rejects with `CurrencyProviderError`,
`code === "CURRENCY_PROVIDER.PARSE_ERROR"`.
#### Scenario: Missing data field raises PARSE_ERROR
- **WHEN** the upstream returns `{ "status": { "error_code": null } }`
- **THEN** `fetchRates()` rejects with `CurrencyProviderError`,
`code === "CURRENCY_PROVIDER.PARSE_ERROR"`.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises PARSE_ERROR when the body is not valid JSON"` AND `src/coinmarketcap/coinmarketcap.test.ts::"raises PARSE_ERROR when the data field is missing"`
---
### Requirement: Upstream-reported errors surface as UPSTREAM_ERROR
When the upstream payload carries a non-null, non-zero `status.error_code`,
the package SHALL raise `CurrencyProviderError(UPSTREAM_ERROR)` carrying the
upstream `status.error_message` (when present) on the `upstreamMessage`
discriminant and `providerName: "coinmarketcap"`.
#### Scenario: Upstream error_code populated
- **WHEN** the upstream returns
`{ "status": { "error_code": 1006, "error_message": "Insufficient credits" } }`
- **THEN** `fetchRates()` rejects with `CurrencyProviderError`,
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`, and
`upstreamMessage === "Insufficient credits"`.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises UPSTREAM_ERROR when status.error_code is populated"`
---
### Requirement: No resolved symbols raises UNKNOWN_CURRENCY
When the upstream payload contains a valid `data` map but none of the
requested target symbols resolve to a quote in the configured base
currency, the package SHALL raise
`CurrencyProviderError(UNKNOWN_CURRENCY)` carrying
`providerName: "coinmarketcap"`.
#### Scenario: Data map empty for requested symbols
- **WHEN** the upstream returns `{ "data": {} }` for the requested symbols
- **THEN** `fetchRates()` rejects with `CurrencyProviderError`,
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises UNKNOWN_CURRENCY when no requested symbol resolved"`
---
### Requirement: Resolved rates are inverted to base-per-unit form
CoinMarketCap returns `1 crypto = X fiat`; the package SHALL invert each
returned price into `1 fiat = (1 / price) crypto` and build the
`RateFetchResult.rates` map keyed by symbol. The result's `base` SHALL
match the requested `base`; `timestamp` SHALL be set to `Date.now()` at
parse time.
#### Scenario: Returned price is inverted
- **WHEN** the upstream returns
`{ "data": { "BTC": { "quote": { "USD": { "price": 50000 } } } } }`
for `fetchRates({ base: "USD", targets: ["BTC"] })`
- **THEN** the result has `base === "USD"`, `rates.BTC === 1 / 50000`,
and a numeric `timestamp`.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"inverts price into base-per-unit rate"`
---
### Requirement: Custom baseUrl and injected fetch are honoured
The package SHALL honour the optional `baseUrl` and `fetch` fields of
`ApiKeyConfig`. When `baseUrl` is set, the request URL SHALL use it as
prefix. When `fetch` is set, that closure SHALL be used in place of
`globalThis.fetch`. When `fetch` is omitted, `globalThis.fetch` SHALL be
the fallback.
#### Scenario: Custom baseUrl prefixes the request
- **WHEN** the provider is constructed with `baseUrl: "https://sandbox-api.coinmarketcap.com/v1"`
- **THEN** the request URL starts with `https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest`.
#### Scenario: Omitted fetch falls back to globalThis.fetch
- **WHEN** the provider is constructed without a `fetch` field and
`globalThis.fetch` is temporarily replaced with a recording stub
- **THEN** the recording stub receives the call.
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"honours a custom baseUrl"` AND `src/coinmarketcap/coinmarketcap.test.ts::"falls back to globalThis.fetch when fetch is not injected"`
---
### Requirement: Universal-tier purity and packaging
The package SHALL be universal-tier with `sideEffects: false`, no
`node:` / `bun:` / DOM imports in production source, explicit named
barrels (no `export *` in non-root `index.ts`), test files importing
runner primitives only from `@suaveplan/testing/runner`, and 100/100/100/100
istanbul coverage thresholds. Public subpath exports SHALL include `.`
and `./coinmarketcap/coinmarketcap`.
#### Scenario: Subpath exports tree-shake the provider class
- **WHEN** a consumer imports
`from "@suaveplan/currency-provider-coinmarketcap/coinmarketcap/coinmarketcap"`
- **THEN** the import resolves to the provider module and the rest of
the package tree-shakes away.
**Verified by:** `package.json::exports` AND the §13 pre-ship gate (`bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-coinmarketcap` + `bunx turbo test:coverage`).