199 lines
9.0 KiB
Markdown
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 -->
|