Files
2026-05-27 23:29:53 +01:00

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.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"