Initial Commit
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# error-codes — typed error helpers for the virtual provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module surfaces the single `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code
|
||||
that `@suaveplan/currency-provider-virtual` raises, plus a tiny
|
||||
`makeUnknownCurrencyError(base)` builder that constructs the typed
|
||||
`CurrencyProviderError` with the canonical message format and
|
||||
`providerName: "virtual-currency"` discriminant pre-populated. The full
|
||||
8-code registry and the `CurrencyProviderError` class itself live in the
|
||||
`@suaveplan/currency-providers` slim core — this module never adds new
|
||||
codes, it only re-narrows the surface the virtual provider actually uses.
|
||||
|
||||
The `VIRTUAL_PROVIDER_NAME` constant is the stable identifier exposed as
|
||||
`provider.name` and stamped onto every emitted error's `providerName`
|
||||
discriminant, so consumer assertions can pin to a single source of truth.
|
||||
|
||||
## Features
|
||||
|
||||
- `VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` — re-export of the
|
||||
slim-core `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code (string-identical).
|
||||
- `VIRTUAL_PROVIDER_NAME` — `"virtual-currency"`.
|
||||
- `makeUnknownCurrencyError(base)` — returns a `CurrencyProviderError`
|
||||
with the canonical message and `providerName` discriminant set.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
makeUnknownCurrencyError,
|
||||
VIRTUAL_CURRENCY_PROVIDER_CODES,
|
||||
} from "@suaveplan/currency-provider-virtual/error-codes/error-codes";
|
||||
|
||||
const error = makeUnknownCurrencyError("NOPE");
|
||||
console.log(error.code); // "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"
|
||||
console.log(error.providerName); // "virtual-currency"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CurrencyProviderError } from "@suaveplan/currency-providers";
|
||||
import {
|
||||
makeUnknownCurrencyError,
|
||||
VIRTUAL_CURRENCY_PROVIDER_CODES,
|
||||
} from "@suaveplan/currency-provider-virtual/error-codes/error-codes";
|
||||
|
||||
function isUnknownVirtualCurrency(err: unknown): err is CurrencyProviderError {
|
||||
return (
|
||||
err instanceof CurrencyProviderError &&
|
||||
err.code === VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY &&
|
||||
err.providerName === "virtual-currency"
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
throw makeUnknownCurrencyError("GHOST");
|
||||
} catch (err) {
|
||||
if (isUnknownVirtualCurrency(err)) {
|
||||
// Fall back to the next provider in the chain.
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `VIRTUAL_CURRENCY_PROVIDER_CODES`
|
||||
|
||||
Frozen record. Contains exactly one key — `UNKNOWN_CURRENCY` — whose
|
||||
value is the namespaced string `"CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`
|
||||
imported from the slim core. Reference-equal with the slim-core
|
||||
constant; comparisons via `===` work as expected.
|
||||
|
||||
### `VIRTUAL_PROVIDER_NAME`
|
||||
|
||||
`"virtual-currency"`. Stable identifier used as `provider.name` and as
|
||||
the `providerName` discriminant on every emitted error.
|
||||
|
||||
### `makeUnknownCurrencyError(base: string): CurrencyProviderError`
|
||||
|
||||
Builds the canonical error for a missing-base lookup.
|
||||
|
||||
- `base` — the unknown base currency code; included verbatim in the
|
||||
message.
|
||||
- Returns a `CurrencyProviderError` with
|
||||
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`,
|
||||
`providerName === "virtual-currency"`, and message format
|
||||
`"VirtualCurrencyProvider: no rates stored for base \"<base>\""`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- This module owns zero codes — it re-exports one from the slim core.
|
||||
Adding a new code requires an OpenSpec change against
|
||||
`@suaveplan/currency-providers` (which holds the canonical registry),
|
||||
not against this package.
|
||||
- The helper is `const`-only — no top-level state, no I/O, no closures
|
||||
captured — so it remains tree-shake-friendly under
|
||||
`"sideEffects": false`.
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
makeUnknownCurrencyError,
|
||||
VIRTUAL_CURRENCY_PROVIDER_CODES,
|
||||
VIRTUAL_PROVIDER_NAME,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("VIRTUAL_CURRENCY_PROVIDER_CODES", () => {
|
||||
it("re-exports UNKNOWN_CURRENCY identical to the slim-core constant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY).toBe(
|
||||
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
|
||||
);
|
||||
expect(VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY).toBe(
|
||||
"CURRENCY_PROVIDER.UNKNOWN_CURRENCY"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VIRTUAL_PROVIDER_NAME", () => {
|
||||
it("is the canonical virtual-currency name", { timeout: 5000 }, () => {
|
||||
expect(VIRTUAL_PROVIDER_NAME).toBe("virtual-currency");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeUnknownCurrencyError", () => {
|
||||
it("builds a CurrencyProviderError with the typed code and discriminants", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = makeUnknownCurrencyError("GHOST");
|
||||
|
||||
expect(err).toBeInstanceOf(CurrencyProviderError);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err.code).toBe(VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
|
||||
expect(err.providerName).toBe(VIRTUAL_PROVIDER_NAME);
|
||||
expect(err.message).toBe(
|
||||
'VirtualCurrencyProvider: no rates stored for base "GHOST"'
|
||||
);
|
||||
expect(err.status).toBeUndefined();
|
||||
expect(err.upstreamMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes the base verbatim in the message — even unusual codes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = makeUnknownCurrencyError("USDC-GOLD");
|
||||
expect(err.message).toContain('"USDC-GOLD"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
|
||||
/**
|
||||
* The single `CURRENCY_PROVIDER_*` namespaced code this package raises, surfaced
|
||||
* locally for ergonomic access (so call sites do not have to import the full
|
||||
* 8-code registry from the slim core just to assert on `UNKNOWN_CURRENCY`).
|
||||
* The value is the same string constant produced by the slim core's
|
||||
* `defineCodes` call — references compare identically.
|
||||
*/
|
||||
export const VIRTUAL_CURRENCY_PROVIDER_CODES = {
|
||||
UNKNOWN_CURRENCY: CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
} as const;
|
||||
|
||||
/** Stable identifier used as `provider.name` and on every `providerName`
|
||||
* discriminant of an emitted `CurrencyProviderError`. */
|
||||
export const VIRTUAL_PROVIDER_NAME = "virtual-currency";
|
||||
|
||||
/**
|
||||
* Build the `CurrencyProviderError(UNKNOWN_CURRENCY)` raised by
|
||||
* `VirtualCurrencyProvider.fetchRates` when the requested base has no
|
||||
* rates in the table. Extracted into a tiny helper so the provider's call
|
||||
* site stays a one-liner and the error-construction path is covered by a
|
||||
* dedicated unit test rather than only the integration scenario.
|
||||
*/
|
||||
export function makeUnknownCurrencyError(base: string): CurrencyProviderError {
|
||||
return new CurrencyProviderError(
|
||||
VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
`VirtualCurrencyProvider: no rates stored for base "${base}"`,
|
||||
{ providerName: VIRTUAL_PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
makeUnknownCurrencyError,
|
||||
VIRTUAL_CURRENCY_PROVIDER_CODES,
|
||||
VIRTUAL_PROVIDER_NAME,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export { VirtualCurrencyProvider } from "./provider/provider.js";
|
||||
export type {
|
||||
VirtualCurrencyProviderConfig,
|
||||
VirtualRateEntry,
|
||||
} from "./provider/provider.types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { VirtualCurrencyProvider } from "./provider.js";
|
||||
export type {
|
||||
VirtualCurrencyProviderConfig,
|
||||
VirtualRateEntry,
|
||||
} from "./provider.types.js";
|
||||
@@ -0,0 +1,160 @@
|
||||
# provider — VirtualCurrencyProvider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`VirtualCurrencyProvider` is an in-memory, programmable rate-source
|
||||
implementation of `@suaveplan/currency`'s `CurrencyProvider` contract.
|
||||
It serves operator-defined virtual / game currencies (and any internal
|
||||
test rates) without ever issuing an HTTP request or consuming a
|
||||
`FetchLike`. Rates are stored as a nested `Map<from, Map<to, number>>`
|
||||
and mutated at runtime through `setRate`, `removeRate`, and `clear`.
|
||||
|
||||
The provider sets `supportsVirtual: true` and is the only sibling in
|
||||
the `@suaveplan/currency-provider-*` family that does not depend on the
|
||||
slim core's `httpRequest` helper. Use it for game economies, loyalty
|
||||
points, internal test fixtures, or any rate table you want to control
|
||||
deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- `setRate(from, to, rate)` — upsert a directed rate; replaces an
|
||||
existing pair without throwing.
|
||||
- `removeRate(from, to): boolean` — idempotent removal; returns whether
|
||||
anything was actually removed. Auto-cleans the inner map when the
|
||||
last rate for a `from` is dropped.
|
||||
- `clear()` — empty the table in one call.
|
||||
- `hasRate(from, to): boolean`, `getRate(from, to): number | undefined`,
|
||||
`listCurrencies(): readonly string[]` — read-only helpers.
|
||||
- `fetchRates(options?)` — returns the rates stored under
|
||||
`options.base` (default `defaultBase`), optionally filtered by an
|
||||
`options.targets` inclusion list. Throws
|
||||
`CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` when the
|
||||
requested base is not in the table.
|
||||
- Optional `Clock` injection for deterministic `timestamp` values.
|
||||
- Universal — zero browser / Node / DOM APIs, `sideEffects: false`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
|
||||
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
defaultBase: "USD",
|
||||
initialRates: [
|
||||
{ from: "USD", to: "GOLD", rate: 100 },
|
||||
{ from: "USD", to: "GEMS", rate: 500 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await provider.fetchRates();
|
||||
// { base: "USD", rates: { GOLD: 100, GEMS: 500 }, timestamp: <Date.now()> }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
|
||||
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
|
||||
import { CURRENCY_PROVIDER_CODES, CurrencyProviderError } from "@suaveplan/currency-providers";
|
||||
|
||||
// Deterministic timestamps + partial-graph rates (no triangulation).
|
||||
const fakeClock = new FakeClock({ startTime: 1_700_000_000_000 });
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
defaultBase: "GOLD",
|
||||
clock: () => fakeClock.now(),
|
||||
});
|
||||
|
||||
provider.setRate("GOLD", "GEMS", 5);
|
||||
provider.setRate("GOLD", "USD", 0.01);
|
||||
|
||||
const golden = await provider.fetchRates({ base: "GOLD", targets: ["GEMS"] });
|
||||
// { base: "GOLD", rates: { GEMS: 5 }, timestamp: 1_700_000_000_000 }
|
||||
|
||||
fakeClock.advance(1000);
|
||||
|
||||
const stale = await provider.fetchRates({ base: "GOLD" });
|
||||
// { base: "GOLD", rates: { GEMS: 5, USD: 0.01 }, timestamp: 1_700_000_001_000 }
|
||||
|
||||
// Operator updates a rate at runtime — next fetchRates picks it up.
|
||||
provider.setRate("GOLD", "GEMS", 6);
|
||||
|
||||
// Unknown base raises the typed error.
|
||||
try {
|
||||
await provider.fetchRates({ base: "GHOST" });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof CurrencyProviderError &&
|
||||
err.code === CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
|
||||
) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class VirtualCurrencyProvider implements CurrencyProvider`
|
||||
|
||||
Constructor: `new VirtualCurrencyProvider(config?: VirtualCurrencyProviderConfig)`.
|
||||
|
||||
Static identity:
|
||||
|
||||
- `name === "virtual-currency"`
|
||||
- `supportsCrypto === false`
|
||||
- `supportsFiat === false`
|
||||
- `supportsVirtual === true`
|
||||
|
||||
Methods:
|
||||
|
||||
- `setRate(from: string, to: string, rate: number): void` — upsert. No
|
||||
throw on duplicate pair.
|
||||
- `removeRate(from: string, to: string): boolean` — idempotent. Never
|
||||
throws.
|
||||
- `clear(): void` — empties the table.
|
||||
- `hasRate(from: string, to: string): boolean`
|
||||
- `getRate(from: string, to: string): number | undefined`
|
||||
- `listCurrencies(): readonly string[]` — sorted snapshot of every
|
||||
`from` key.
|
||||
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
|
||||
rejects with `CurrencyProviderError(UNKNOWN_CURRENCY)` when the
|
||||
requested base is not stored; otherwise resolves to the rate map for
|
||||
that base, filtered by `options.targets` when provided, with
|
||||
`timestamp = clock()`.
|
||||
|
||||
### `interface VirtualCurrencyProviderConfig`
|
||||
|
||||
- `defaultBase?: string` — base used when `fetchRates()` omits
|
||||
`options.base`. Defaults to `"USD"`.
|
||||
- `initialRates?: readonly VirtualRateEntry[]` — entries upserted at
|
||||
construction. Duplicates within the array are last-write-wins.
|
||||
- `clock?: Clock` — `() => number` (ms since epoch). Defaults to
|
||||
`() => Date.now()`.
|
||||
|
||||
### `interface VirtualRateEntry`
|
||||
|
||||
`{ from: string; to: string; rate: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The rate table is `Map<from, Map<to, number>>` — O(1) lookup,
|
||||
O(n) enumeration over the inner map. The flat alternative
|
||||
(`Map<"${from}->${to}", number>`) was rejected because currency codes
|
||||
can contain `-` (e.g. `USDC-GOLD`, `GAME-1-COIN`) which would create
|
||||
ambiguous keys.
|
||||
- `removeRate` deletes the empty inner map after the last pair under a
|
||||
`from` is removed, so `listCurrencies()` stays accurate without a
|
||||
separate compaction pass.
|
||||
- The `fetchRates` "no I/O" promise wrapping satisfies the
|
||||
`CurrencyProvider` async contract without introducing a microtask
|
||||
delay observable to callers; the work is purely synchronous.
|
||||
- `Clock` is invoked once per `fetchRates` call, at the moment of the
|
||||
call (not construction). This matches the wall-clock semantics
|
||||
every other provider in the family uses.
|
||||
- The provider is intentionally side-effect-free in its constructor
|
||||
(unlike the upstream form which called `@suaveplan/money`
|
||||
`registerCurrency`) — keeping `"sideEffects": false` honest and
|
||||
tree-shake safe.
|
||||
@@ -0,0 +1,275 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VirtualCurrencyProvider } from "./provider.js";
|
||||
|
||||
describe("VirtualCurrencyProvider — identity", () => {
|
||||
it("surfaces the expected identity flags", { timeout: 5000 }, () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
expect(provider.name).toBe("virtual-currency");
|
||||
expect(provider.supportsCrypto).toBe(false);
|
||||
expect(provider.supportsFiat).toBe(false);
|
||||
expect(provider.supportsVirtual).toBe(true);
|
||||
});
|
||||
|
||||
it("has no FetchLike or fetch references in production source", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Static check: the provider module never imports or invokes
|
||||
// FetchLike / globalThis.fetch / fetch(. Reading the file is enough —
|
||||
// the assertion is a structural guarantee, not a runtime trace.
|
||||
const sourceFiles = [
|
||||
join(__dirname, "provider.ts"),
|
||||
join(__dirname, "provider.types.ts"),
|
||||
join(__dirname, "index.ts"),
|
||||
];
|
||||
const offending: string[] = [];
|
||||
for (const path of sourceFiles) {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
// Strip block comments and line comments so the negative assertion
|
||||
// only catches real code references, not docstring mentions.
|
||||
const code = raw
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
if (/\bFetchLike\b|globalThis\.fetch|\bfetch\s*\(/.test(code)) {
|
||||
offending.push(path);
|
||||
}
|
||||
}
|
||||
expect(offending).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VirtualCurrencyProvider — programmable rate-table API", () => {
|
||||
it("setRate upserts an existing pair without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
provider.setRate("USD", "GOLD", 100);
|
||||
provider.setRate("USD", "GOLD", 200);
|
||||
expect(provider.getRate("USD", "GOLD")).toBe(200);
|
||||
});
|
||||
|
||||
it("removeRate returns true on first call and false on the second", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
provider.setRate("USD", "GOLD", 100);
|
||||
expect(provider.removeRate("USD", "GOLD")).toBe(true);
|
||||
expect(provider.removeRate("USD", "GOLD")).toBe(false);
|
||||
expect(provider.hasRate("USD", "GOLD")).toBe(false);
|
||||
});
|
||||
|
||||
it("removeRate is a no-op for an unknown from", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
expect(provider.removeRate("UNKNOWN", "GOLD")).toBe(false);
|
||||
});
|
||||
|
||||
it("removeRate keeps the from entry when other targets remain under it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
provider.setRate("USD", "GOLD", 100);
|
||||
provider.setRate("USD", "GEMS", 500);
|
||||
expect(provider.removeRate("USD", "GOLD")).toBe(true);
|
||||
// USD still has GEMS — it must stay in listCurrencies.
|
||||
expect(provider.listCurrencies()).toEqual(["USD"]);
|
||||
expect(provider.hasRate("USD", "GEMS")).toBe(true);
|
||||
});
|
||||
|
||||
it("removeRate cleans up empty inner maps so listCurrencies stays accurate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
provider.setRate("USD", "GOLD", 100);
|
||||
provider.removeRate("USD", "GOLD");
|
||||
expect(provider.listCurrencies()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clear empties the rate table", { timeout: 5000 }, () => {
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
initialRates: [
|
||||
{ from: "USD", to: "GOLD", rate: 100 },
|
||||
{ from: "EUR", to: "GEMS", rate: 50 },
|
||||
],
|
||||
});
|
||||
provider.clear();
|
||||
expect(provider.listCurrencies()).toEqual([]);
|
||||
expect(provider.hasRate("USD", "GOLD")).toBe(false);
|
||||
expect(provider.hasRate("EUR", "GEMS")).toBe(false);
|
||||
});
|
||||
|
||||
it("hasRate and getRate report pair state correctly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
provider.setRate("USD", "GOLD", 100);
|
||||
expect(provider.hasRate("USD", "GOLD")).toBe(true);
|
||||
expect(provider.hasRate("USD", "GEMS")).toBe(false);
|
||||
expect(provider.hasRate("EUR", "GOLD")).toBe(false);
|
||||
expect(provider.getRate("USD", "GOLD")).toBe(100);
|
||||
expect(provider.getRate("USD", "MISSING")).toBeUndefined();
|
||||
expect(provider.getRate("MISSING", "GOLD")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listCurrencies returns a lexicographic snapshot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
provider.setRate("USD", "GOLD", 100);
|
||||
provider.setRate("GOLD", "GEMS", 5);
|
||||
provider.setRate("EUR", "GOLD", 90);
|
||||
expect(provider.listCurrencies()).toEqual(["EUR", "GOLD", "USD"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VirtualCurrencyProvider — fetchRates", () => {
|
||||
it("returns all rates for the default base", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
defaultBase: "USD",
|
||||
initialRates: [
|
||||
{ from: "USD", to: "GOLD", rate: 100 },
|
||||
{ from: "USD", to: "GEMS", rate: 500 },
|
||||
],
|
||||
});
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.base).toBe("USD");
|
||||
expect(result.rates).toEqual({ GOLD: 100, GEMS: 500 });
|
||||
expect(typeof result.timestamp).toBe("number");
|
||||
});
|
||||
|
||||
it("uses the configured defaultBase fallback when no config supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// No defaultBase passed — implementation defaults to "USD".
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
initialRates: [{ from: "USD", to: "GOLD", rate: 100 }],
|
||||
});
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.base).toBe("USD");
|
||||
expect(result.rates).toEqual({ GOLD: 100 });
|
||||
});
|
||||
|
||||
it("filters returned rates by the targets inclusion list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
defaultBase: "USD",
|
||||
initialRates: [
|
||||
{ from: "USD", to: "GOLD", rate: 100 },
|
||||
{ from: "USD", to: "GEMS", rate: 500 },
|
||||
{ from: "USD", to: "SILVER", rate: 50 },
|
||||
],
|
||||
});
|
||||
const result = await provider.fetchRates({
|
||||
base: "USD",
|
||||
targets: ["GOLD"],
|
||||
});
|
||||
expect(result.rates).toEqual({ GOLD: 100 });
|
||||
});
|
||||
|
||||
it("honours an explicit non-default base", { timeout: 5000 }, async () => {
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
defaultBase: "USD",
|
||||
initialRates: [
|
||||
{ from: "GOLD", to: "GEMS", rate: 5 },
|
||||
{ from: "GOLD", to: "USD", rate: 0.01 },
|
||||
],
|
||||
});
|
||||
const result = await provider.fetchRates({ base: "GOLD" });
|
||||
expect(result.base).toBe("GOLD");
|
||||
expect(result.rates).toEqual({ GEMS: 5, USD: 0.01 });
|
||||
});
|
||||
|
||||
it("returns an empty rate map when the targets filter excludes every stored target", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
defaultBase: "USD",
|
||||
initialRates: [{ from: "USD", to: "GOLD", rate: 100 }],
|
||||
});
|
||||
const result = await provider.fetchRates({
|
||||
base: "USD",
|
||||
targets: ["MISSING"],
|
||||
});
|
||||
expect(result.rates).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VirtualCurrencyProvider — UNKNOWN_CURRENCY", () => {
|
||||
it("throws CurrencyProviderError(UNKNOWN_CURRENCY) when the requested base is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new VirtualCurrencyProvider();
|
||||
await expect(
|
||||
provider.fetchRates({ base: "NOPE" })
|
||||
).rejects.toBeInstanceOf(CurrencyProviderError);
|
||||
|
||||
try {
|
||||
await provider.fetchRates({ base: "NOPE" });
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyProviderError);
|
||||
const typed = err as CurrencyProviderError;
|
||||
expect(typed.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
|
||||
expect(typed.providerName).toBe("virtual-currency");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws UNKNOWN_CURRENCY when the default base has no stored rates", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new VirtualCurrencyProvider({ defaultBase: "USD" });
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyProviderError);
|
||||
const typed = err as CurrencyProviderError;
|
||||
expect(typed.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("VirtualCurrencyProvider — Clock injection", () => {
|
||||
it("FakeClock-driven timestamps are deterministic across calls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fakeClock = new FakeClock({ startTime: 1_700_000_000_000 });
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
defaultBase: "USD",
|
||||
initialRates: [{ from: "USD", to: "GOLD", rate: 100 }],
|
||||
clock: () => fakeClock.now(),
|
||||
});
|
||||
|
||||
const first = await provider.fetchRates();
|
||||
expect(first.timestamp).toBe(1_700_000_000_000);
|
||||
|
||||
fakeClock.advance(1000);
|
||||
|
||||
const second = await provider.fetchRates();
|
||||
expect(second.timestamp).toBe(1_700_000_001_000);
|
||||
});
|
||||
|
||||
it("defaults to Date.now when no clock is injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new VirtualCurrencyProvider({
|
||||
defaultBase: "USD",
|
||||
initialRates: [{ from: "USD", to: "GOLD", rate: 100 }],
|
||||
});
|
||||
const before = Date.now();
|
||||
const result = await provider.fetchRates();
|
||||
const after = Date.now();
|
||||
expect(result.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(result.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "@suaveplan/currency";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
makeUnknownCurrencyError,
|
||||
VIRTUAL_PROVIDER_NAME,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { VirtualCurrencyProviderConfig } from "./provider.types.js";
|
||||
|
||||
const DEFAULT_BASE = "USD";
|
||||
|
||||
/**
|
||||
* In-memory programmable virtual / game currency provider implementing
|
||||
* the `CurrencyProvider` contract from `@suaveplan/currency`. No HTTP,
|
||||
* no API key, no `FetchLike`. The rate table is a nested
|
||||
* `Map<from, Map<to, number>>` populated at construction and mutable at
|
||||
* runtime via `setRate` / `removeRate` / `clear`.
|
||||
*
|
||||
* Triangulation is deliberately absent — the provider returns only the
|
||||
* directed rates explicitly stored under the requested `base`. Callers
|
||||
* that need bidirectional or triangulated rates pre-populate them with
|
||||
* `setRate`, or wire the provider through `CurrencyConverter` which
|
||||
* triangulates from its cache.
|
||||
*/
|
||||
export class VirtualCurrencyProvider implements CurrencyProvider {
|
||||
public readonly name = VIRTUAL_PROVIDER_NAME;
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = false;
|
||||
public readonly supportsVirtual = true;
|
||||
|
||||
private readonly defaultBase: string;
|
||||
private readonly clock: Clock;
|
||||
private readonly table: Map<string, Map<string, number>> = new Map();
|
||||
|
||||
public constructor(config: VirtualCurrencyProviderConfig = {}) {
|
||||
this.defaultBase = config.defaultBase ?? DEFAULT_BASE;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
for (const entry of config.initialRates ?? []) {
|
||||
this.setRate(entry.from, entry.to, entry.rate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a directed rate. Calling twice for the same `(from, to)`
|
||||
* pair replaces the previous value without throwing.
|
||||
*/
|
||||
public setRate(from: string, to: string, rate: number): void {
|
||||
let inner = this.table.get(from);
|
||||
if (inner === undefined) {
|
||||
inner = new Map();
|
||||
this.table.set(from, inner);
|
||||
}
|
||||
inner.set(to, rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a directed rate. Returns `true` when a rate was removed,
|
||||
* `false` otherwise. Cleans up the inner map when the last rate for
|
||||
* a `from` is dropped, so `listCurrencies()` stays accurate. Never
|
||||
* throws.
|
||||
*/
|
||||
public removeRate(from: string, to: string): boolean {
|
||||
const inner = this.table.get(from);
|
||||
if (inner === undefined) return false;
|
||||
const removed = inner.delete(to);
|
||||
if (removed && inner.size === 0) {
|
||||
this.table.delete(from);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Empty the entire rate table. */
|
||||
public clear(): void {
|
||||
this.table.clear();
|
||||
}
|
||||
|
||||
/** Non-throwing probe — `true` when {@link getRate} would return a number. */
|
||||
public hasRate(from: string, to: string): boolean {
|
||||
return this.table.get(from)?.has(to) ?? false;
|
||||
}
|
||||
|
||||
/** Direct rate lookup, or `undefined` when the pair is unknown. */
|
||||
public getRate(from: string, to: string): number | undefined {
|
||||
return this.table.get(from)?.get(to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted snapshot of every currency that appears as a `from` in the
|
||||
* table. Lexicographic order — equal sets produce identical lists
|
||||
* regardless of insertion order.
|
||||
*/
|
||||
public listCurrencies(): readonly string[] {
|
||||
return [...this.table.keys()].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the rates stored under `options.base` (or the configured
|
||||
* `defaultBase`), filtered by `options.targets` when present. Throws
|
||||
* `CurrencyProviderError(UNKNOWN_CURRENCY)` when the requested base
|
||||
* has no rates stored. The promise wrapping satisfies the
|
||||
* `CurrencyProvider` contract; no I/O occurs.
|
||||
*/
|
||||
public fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
|
||||
const base = options?.base ?? this.defaultBase;
|
||||
const inner = this.table.get(base);
|
||||
if (inner === undefined) {
|
||||
return Promise.reject(makeUnknownCurrencyError(base));
|
||||
}
|
||||
const rates = filterRates(inner, options?.targets);
|
||||
return Promise.resolve({
|
||||
base,
|
||||
rates,
|
||||
timestamp: this.clock(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function filterRates(
|
||||
inner: ReadonlyMap<string, number>,
|
||||
targets: readonly string[] | undefined
|
||||
): Readonly<Record<string, number>> {
|
||||
const out: Record<string, number> = {};
|
||||
if (targets === undefined) {
|
||||
for (const [code, rate] of inner) {
|
||||
out[code] = rate;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const targetSet = new Set(targets);
|
||||
for (const [code, rate] of inner) {
|
||||
if (targetSet.has(code)) {
|
||||
out[code] = rate;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Initial rate-table entry accepted by the {@link VirtualCurrencyProvider}
|
||||
* constructor. The provider stores rates as directed `from -> to` pairs so
|
||||
* partial graphs (only some pairs known) and arbitrary virtual-virtual
|
||||
* conversions are first-class — there is no implicit shared base.
|
||||
*/
|
||||
export interface VirtualRateEntry {
|
||||
/** Source currency code. */
|
||||
readonly from: string;
|
||||
/** Target currency code. */
|
||||
readonly to: string;
|
||||
/** Conversion rate (1 unit of `from` = `rate` units of `to`). */
|
||||
readonly rate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the {@link VirtualCurrencyProvider} constructor.
|
||||
*
|
||||
* All fields are optional. A provider constructed with no config is a
|
||||
* valid (empty) instance — callers populate the rate table via
|
||||
* `setRate` and `fetchRates({ base })` will raise
|
||||
* `CurrencyProviderError(UNKNOWN_CURRENCY)` until a matching `from`
|
||||
* has been seeded.
|
||||
*/
|
||||
export interface VirtualCurrencyProviderConfig {
|
||||
/**
|
||||
* Default `base` used by `fetchRates()` when the caller omits
|
||||
* `options.base`. Defaults to `"USD"`.
|
||||
*/
|
||||
readonly defaultBase?: string;
|
||||
/**
|
||||
* Initial rate-table entries. Each entry is upserted via the same
|
||||
* code path as `setRate`, so duplicates within the array silently
|
||||
* replace earlier values (last-write-wins).
|
||||
*/
|
||||
readonly initialRates?: readonly VirtualRateEntry[];
|
||||
/**
|
||||
* Optional wall-clock source for `RateFetchResult.timestamp`. Defaults
|
||||
* to `() => Date.now()`. Tests inject a `FakeClock`-backed function
|
||||
* for deterministic timestamps.
|
||||
*/
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
Reference in New Issue
Block a user