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

199 lines
9.0 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: currency-provider-virtual
**Package**: `@suaveplan/currency-provider-virtual`
**Status**: SHIPPED (2026-05-17 — `add-currency-provider-virtual`)
> Folded from
> `openspec/changes/add-currency-provider-virtual/specs/currency-provider-virtual/spec.md`
> on 2026-05-17. Every requirement carries its original
> `**Verified by:**` line citing the test that pins each scenario.
## Requirements
### Requirement: VirtualCurrencyProvider implements CurrencyProvider with virtual support
The package SHALL export a `VirtualCurrencyProvider` class that implements
the `CurrencyProvider` contract from `@suaveplan/currency`. The provider
SHALL expose `name === "virtual-currency"`, `supportsCrypto === false`,
`supportsFiat === false`, and `supportsVirtual === true`. The provider
SHALL NOT consume `FetchLike`, SHALL NOT issue HTTP requests, and SHALL
NOT depend on `@suaveplan/money` — it is a pure in-memory rate source.
#### Scenario: surfaces the expected identity flags
- **WHEN** a `VirtualCurrencyProvider` is constructed
- **THEN** `provider.name === "virtual-currency"`,
`provider.supportsCrypto === false`,
`provider.supportsFiat === false`,
`provider.supportsVirtual === true`
#### Scenario: package has no FetchLike consumption
- **WHEN** `grep -rn "FetchLike\|globalThis.fetch\|fetch(" src/` is run
- **THEN** the output contains zero matches (no HTTP code paths exist)
**Verified by:** `src/provider/provider.test.ts::"surfaces the expected identity flags"` AND `src/provider/provider.test.ts::"has no FetchLike or fetch references in production source"`
---
### Requirement: Programmable rate-table API
The provider SHALL expose a programmable rate table via `setRate(from,
to, rate)`, `removeRate(from, to): boolean`, `clear(): void`,
`hasRate(from, to): boolean`, `getRate(from, to): number | undefined`,
and `listCurrencies(): readonly string[]`. `setRate` SHALL be upsert —
calling it twice for the same `(from, to)` pair replaces the previous
rate without throwing. `removeRate` SHALL return `true` when a pair was
removed and `false` otherwise, and SHALL NOT throw on unknown pairs.
`clear` SHALL empty the entire rate table. `listCurrencies` SHALL return
a sorted snapshot (lexicographic) of every currency that appears as a
`from` in the table.
#### Scenario: setRate upserts rates
- **WHEN** `setRate("USD", "GOLD", 100)` is called and then
`setRate("USD", "GOLD", 200)` is called
- **THEN** `getRate("USD", "GOLD") === 200` (the second call replaced
the first without throwing)
#### Scenario: removeRate is idempotent
- **WHEN** `setRate("USD", "GOLD", 100)` is followed by two calls to
`removeRate("USD", "GOLD")`
- **THEN** the first call returns `true`, the second returns `false`,
and `hasRate("USD", "GOLD") === false`
#### Scenario: clear empties the table
- **WHEN** several rates are set and `clear()` is called
- **THEN** `listCurrencies()` returns `[]` and `hasRate(...)` returns
`false` for every previously-known pair
#### Scenario: hasRate and getRate report pair state
- **WHEN** `setRate("USD", "GOLD", 100)` is called
- **THEN** `hasRate("USD", "GOLD") === true`,
`hasRate("USD", "GEMS") === false`,
`getRate("USD", "GOLD") === 100`,
`getRate("USD", "MISSING") === undefined`
#### Scenario: listCurrencies returns a sorted snapshot
- **WHEN** rates are set with `from` values `"USD"`, `"GOLD"`, `"EUR"`
in any order
- **THEN** `listCurrencies()` returns `["EUR", "GOLD", "USD"]`
(lexicographic order, regardless of insertion order)
**Verified by:** `src/provider/provider.test.ts::"setRate upserts an existing pair without throwing"` AND `src/provider/provider.test.ts::"removeRate returns true on first call and false on the second"` AND `src/provider/provider.test.ts::"clear empties the rate table"` AND `src/provider/provider.test.ts::"hasRate and getRate report pair state correctly"` AND `src/provider/provider.test.ts::"listCurrencies returns a lexicographic snapshot"`
---
### Requirement: fetchRates returns the rates for the requested base
The provider SHALL implement `fetchRates(options?: RateFetchOptions):
Promise<RateFetchResult>`. The returned `result.base` SHALL be
`options?.base` when provided, otherwise the provider's configured
`defaultBase`. The returned `result.rates` SHALL contain exactly the
rates stored under that base in the rate table, optionally filtered by
`options?.targets` (an inclusion list — only matching targets appear).
`result.timestamp` SHALL be the value returned by the injected `Clock`
at the moment of the call. The provider SHALL NOT triangulate — it
returns only the rates explicitly stored under the requested base.
#### Scenario: returns all rates for the default base
- **WHEN** the provider is constructed with `defaultBase: "USD"` and
rates `setRate("USD", "GOLD", 100)`, `setRate("USD", "GEMS", 500)`,
and `fetchRates()` is called
- **THEN** the promise resolves to a result with `base: "USD"`,
`rates: { GOLD: 100, GEMS: 500 }`
#### Scenario: filters by targets
- **WHEN** the provider holds rates for `GOLD`, `GEMS`, and `SILVER`
under `"USD"`, and `fetchRates({ base: "USD", targets: ["GOLD"] })`
is called
- **THEN** the resolved `rates` contains exactly `{ GOLD: 100 }` (the
other targets are absent)
#### Scenario: honours an explicit non-default base
- **WHEN** `fetchRates({ base: "GOLD" })` is called against a provider
that has rates set with `setRate("GOLD", "GEMS", 5)` and
`setRate("GOLD", "USD", 0.01)`
- **THEN** the resolved `result.base === "GOLD"` and the resolved
`rates` contains exactly `{ GEMS: 5, USD: 0.01 }`
#### Scenario: returns an empty rate map when the base has no targets
- **WHEN** `setRate("USD", "GOLD", 100)` is called and
`fetchRates({ base: "USD", targets: ["MISSING"] })` is called
- **THEN** the resolved `rates` is `{}` (no `targets` matched, the call
does not throw — `MISSING` simply isn't in the inclusion list)
**Verified by:** `src/provider/provider.test.ts::"returns all rates for the default base"` AND `src/provider/provider.test.ts::"filters returned rates by the targets inclusion list"` AND `src/provider/provider.test.ts::"honours an explicit non-default base"` AND `src/provider/provider.test.ts::"returns an empty rate map when the targets filter excludes every stored target"`
---
### Requirement: UNKNOWN_CURRENCY raised for missing base
The provider SHALL throw
`CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` when
`fetchRates({ base })` is called for a `base` that is not present in
the rate table (and the table is therefore unable to answer for that
base). The thrown error SHALL carry `providerName === "virtual-currency"`.
The provider SHALL NOT throw a raw `Error`, and SHALL NOT triangulate
to satisfy an unknown base — the registry can be empty, and an unknown
base is a definitive "no answer" path.
#### Scenario: throws CurrencyProviderError(UNKNOWN_CURRENCY) when base is missing
- **WHEN** a fresh `VirtualCurrencyProvider` (empty table) has
`fetchRates({ base: "NOPE" })` called on it
- **THEN** the promise rejects with a `CurrencyProviderError` whose
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"` and whose
`providerName === "virtual-currency"`
#### Scenario: throws UNKNOWN_CURRENCY when the default base is empty
- **WHEN** the provider was constructed with `defaultBase: "USD"` and
no `USD`-rooted rates have ever been set, and `fetchRates()` (no
options) is called
- **THEN** the promise rejects with `CurrencyProviderError` whose
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`
**Verified by:** `src/provider/provider.test.ts::"throws CurrencyProviderError(UNKNOWN_CURRENCY) when the requested base is missing"` AND `src/provider/provider.test.ts::"throws UNKNOWN_CURRENCY when the default base has no stored rates"`
---
### Requirement: Clock injection for deterministic timestamps
The provider constructor SHALL accept an optional `clock: Clock`
(`@suaveplan/types`'s `Clock = () => number`) and SHALL use it to
populate `RateFetchResult.timestamp` on every `fetchRates` call. When
no `clock` is provided the provider SHALL default to
`() => Date.now()`. The provider SHALL call the clock at the moment of
the `fetchRates` invocation, not at construction.
#### Scenario: FakeClock-driven timestamps are deterministic across calls
- **WHEN** the provider is constructed with `clock: () => fakeClock.now()`
where `fakeClock` is a `FakeClock({ startTime: 1_700_000_000_000 })`,
and `fetchRates()` is called twice with `fakeClock.advance(1000)`
between them
- **THEN** the first result's `timestamp === 1_700_000_000_000` and the
second result's `timestamp === 1_700_000_001_000`
#### Scenario: default clock falls back to Date.now
- **WHEN** the provider is constructed without a `clock` and
`fetchRates()` is called
- **THEN** the returned `timestamp` is a finite number whose value is
within ±1000 ms of `Date.now()` measured immediately around the call
**Verified by:** `src/provider/provider.test.ts::"FakeClock-driven timestamps are deterministic across calls"` AND `src/provider/provider.test.ts::"defaults to Date.now when no clock is injected"`
<!-- markdownlint-enable MD024 -->