9.0 KiB
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.mdon 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
VirtualCurrencyProvideris 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 thensetRate("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 toremoveRate("USD", "GOLD") - THEN the first call returns
true, the second returnsfalse, andhasRate("USD", "GOLD") === false
Scenario: clear empties the table
- WHEN several rates are set and
clear()is called - THEN
listCurrencies()returns[]andhasRate(...)returnsfalsefor 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
fromvalues"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 ratessetRate("USD", "GOLD", 100),setRate("USD", "GEMS", 500), andfetchRates()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, andSILVERunder"USD", andfetchRates({ base: "USD", targets: ["GOLD"] })is called - THEN the resolved
ratescontains 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 withsetRate("GOLD", "GEMS", 5)andsetRate("GOLD", "USD", 0.01) - THEN the resolved
result.base === "GOLD"and the resolvedratescontains 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 andfetchRates({ base: "USD", targets: ["MISSING"] })is called - THEN the resolved
ratesis{}(notargetsmatched, the call does not throw —MISSINGsimply 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) hasfetchRates({ base: "NOPE" })called on it - THEN the promise rejects with a
CurrencyProviderErrorwhosecode === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"and whoseproviderName === "virtual-currency"
Scenario: throws UNKNOWN_CURRENCY when the default base is empty
- WHEN the provider was constructed with
defaultBase: "USD"and noUSD-rooted rates have ever been set, andfetchRates()(no options) is called - THEN the promise rejects with
CurrencyProviderErrorwhosecode === "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()wherefakeClockis aFakeClock({ startTime: 1_700_000_000_000 }), andfetchRates()is called twice withfakeClock.advance(1000)between them - THEN the first result's
timestamp === 1_700_000_000_000and the second result'stimestamp === 1_700_000_001_000
Scenario: default clock falls back to Date.now
- WHEN the provider is constructed without a
clockandfetchRates()is called - THEN the returned
timestampis a finite number whose value is within ±1000 ms ofDate.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"