Initial Commit
This commit is contained in:
198
openspec/specs/currency-provider-virtual/spec.md
Normal file
198
openspec/specs/currency-provider-virtual/spec.md
Normal file
@@ -0,0 +1,198 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user