diff --git a/src/services/fxQuorumEvaluator.test.ts b/src/services/fxQuorumEvaluator.test.ts index 7f0ed835..f2bc07ca 100644 --- a/src/services/fxQuorumEvaluator.test.ts +++ b/src/services/fxQuorumEvaluator.test.ts @@ -13,6 +13,7 @@ * - config validation (k, tolerance, reference) * - allowReducedQuorum flag semantics * - metric emissions (evaluated / passed / failed / in-consensus / divergence) + * - METRIC_QUORUM_EVALUATED failure-path regression (config validation + empty result) * - pager invoked with divergent rates; pager exceptions never crash caller * - FxQuorumAlerting: pages ops and writes audit event (incl. divergent rates) * - assess() is non-throwing; evaluate() throws FxQuorumFailedError @@ -29,6 +30,7 @@ import { FxQuorumFailedError, FxQuorumAlerting, METRIC_QUORUM_EVALUATED, + METRIC_QUORUM_EVALUATED, METRIC_QUORUM_PASSED, METRIC_QUORUM_FAILED, METRIC_QUORUM_IN_CONSENSUS, @@ -105,6 +107,99 @@ describe('FxQuorumEvaluator construction', () => { }); }); +// ─── METRIC_QUORUM_EVALUATED failure handling (regression) ──────────────────── + +describe('FxQuorumEvaluator: METRIC_QUORUM_EVALUATED failure handling', () => { + it('does not emit METRIC_QUORUM_EVALUATED when construction fails on invalid k', () => { + const metrics = makeMetrics(); + expect(() => new FxQuorumEvaluator({ k: 0, tolerance: 0.01 }, { metrics })).toThrow(/k must be an integer/); + const prom = metrics.exportPrometheus(); + expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(0); + }); + + it('does not emit METRIC_QUORUM_EVALUATED when construction fails on invalid tolerance', () => { + const metrics = makeMetrics(); + expect(() => new FxQuorumEvaluator({ k: 2, tolerance: -1 }, { metrics })).toThrow(/tolerance/); + const prom = metrics.exportPrometheus(); + expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(0); + }); + + it('does not emit METRIC_QUORUM_EVALUATED when construction fails on invalid reference', () => { + const metrics = makeMetrics(); + expect(() => new FxQuorumEvaluator({ k: 2, tolerance: 0.01, reference: 'bogus' as any }, { metrics })).toThrow(/reference/); + const prom = metrics.exportPrometheus(); + expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(0); + }); + + it('emits METRIC_QUORUM_EVALUATED on the empty-result (total outage) failure path', () => { + const metrics = makeMetrics(); + const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics }); + expect(() => e.evaluate('USD/EUR', [ + { providerId: 'a', rate: null }, + { providerId: 'b', rate: null }, + ])).toThrow(FxQuorumFailedError); + const prom = metrics.exportPrometheus(); + // The evaluation was attempted, so the evaluated counter must be observable + // even though the run failed and no consensus rate was produced. + expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBeGreaterThanOrEqual(1); + expect(countMetric(prom, METRIC_QUORUM_FAILED)).toBeGreaterThanOrEqual(1); + expect(countMetric(prom, METRIC_QUORUM_PASSED)).toBe(0); + }); + + it('emits METRIC_QUORUM_EVALUATED on the divergence failure path', () => { + const metrics = makeMetrics(); + const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics }); + expect(() => e.evaluate('USD/EUR', [ + { providerId: 'a', rate: makeRate('1.0000') }, + { providerId: 'b', rate: makeRate('1.2000') }, + ])).toThrow(FxQuorumFailedError); + const prom = metrics.exportPrometheus(); + expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBeGreaterThanOrEqual(1); + expect(countMetric(prom, METRIC_QUORUM_FAILED)).toBeGreaterThanOrEqual(1); + }); + + it('emits METRIC_QUORUM_EVALUATED exactly once per successful evaluate() call', () => { + const metrics = makeMetrics(); + const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics }); + e.evaluate('USD/EUR', [ + { providerId: 'a', rate: makeRate('1.0000') }, + { providerId: 'b', rate: makeRate('1.0001') }, + ]); + const prom = metrics.exportPrometheus(); + expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(1); + expect(countMetric(prom, METRIC_QUORUM_PASSED)).toBe(1); + expect(countMetric(prom, METRIC_QUORUM_FAILED)).toBe(0); + }); + + it('emits METRIC_QUORUM_EVALUATED exactly once per failed evaluate() call', () => { + const metrics = makeMetrics(); + const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics }); + expect(() => e.evaluate('USD/EUR', [ + { providerId: 'a', rate: makeRate('1.0000') }, + { providerId: 'b', rate: makeRate('1.2000') }, + ])).toThrow(FxQuorumFailedError); + const prom = metrics.exportPrometheus(); + expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(1); + expect(countMetric(prom, METRIC_QUORUM_FAILED)).toBe(1); + expect(countMetric(prom, METRIC_QUORUM_PASSED)).toBe(0); + }); + + it('assess() on the empty-result path reports agreed=false without emitting a passed counter', () => { + const metrics = makeMetrics(); + const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics }); + const r = e.assess('USD/EUR', [ + { providerId: 'a', rate: null }, + { providerId: 'b', rate: null }, + ]); + expect(r.agreed).toBe(false); + expect(r.valid).toBe(0); + expect(r.inConsensus).toBe(0); + expect(r.total).toBe(2); + const prom = metrics.exportPrometheus(); + expect(countMetric(prom, METRIC_QUORUM_PASSED)).toBe(0); + }); +}); + // ─── Quorum satisfied ───────────────────────────────────────────────────────── describe('FxQuorumEvaluator: quorum satisfied', () => { diff --git a/src/services/fxQuorumEvaluator.ts b/src/services/fxQuorumEvaluator.ts index 7f018fab..c9d838ef 100644 --- a/src/services/fxQuorumEvaluator.ts +++ b/src/services/fxQuorumEvaluator.ts @@ -61,6 +61,10 @@ * @module services/fxQuorumEvaluator */ +// Regression coverage for METRIC_QUORUM_EVALUATED failure handling lives in +// `src/services/__tests__/fxQuorumEvaluator.test.ts`. The constructor guards +// below are part of the public contract and are asserted there. + import { Decimal } from '../lib/decimal'; import { AppError, ErrorCode } from '../lib/errors'; import { Logger } from '../lib/logger'; @@ -236,6 +240,8 @@ export class FxQuorumEvaluator { } = {}, ) { if (!Number.isInteger(config.k) || config.k < 1) { + // Contract: k must be an integer >= 1. Thrown before any state is set so + // a misconfigured evaluator can never be constructed. throw new Error('FxQuorumEvaluator: k must be an integer >= 1'); } if ( @@ -243,6 +249,8 @@ export class FxQuorumEvaluator { !Number.isFinite(config.tolerance) || config.tolerance < 0 ) { + // Contract: tolerance must be a finite number >= 0. NaN/Infinity and + // negative values are rejected deterministically. throw new Error('FxQuorumEvaluator: tolerance must be a finite number >= 0'); } if ( @@ -250,12 +258,17 @@ export class FxQuorumEvaluator { config.reference !== 'median' && config.reference !== 'mean' ) { + // Contract: reference must be 'median' or 'mean' when provided. Any other + // value (including null/undefined-like strings) is rejected. throw new Error("FxQuorumEvaluator: reference must be 'median' or 'mean'"); } this.k = config.k; this.tolerance = config.tolerance; this.reference = config.reference ?? 'median'; + // minValidProviders defaults to k; allowReducedQuorum defaults to true. + // Both are surfaced via getConfig() so tests can assert the resolved + // defaults without reaching into private state. this.minValidProviders = config.minValidProviders ?? config.k; this.allowReducedQuorum = config.allowReducedQuorum ?? true; } @@ -374,6 +387,9 @@ export class FxQuorumEvaluator { const result = this.assess(pair, inputs); const { metrics, logger, pager } = this.options; + // METRIC_QUORUM_EVALUATED is emitted exactly once per evaluate() call, + // before either the success or failure branch, so the counter is + // observable and deterministic regardless of outcome. metrics?.incrementCounter(METRIC_QUORUM_EVALUATED, { pair: sanitizePair(pair) }); if (result.agreed && result.consensusRate) { @@ -384,6 +400,9 @@ export class FxQuorumEvaluator { return result.consensusRate; } + // ── Failure path: emit, log, page, then block the run. ── + // The failure branch must always increment METRIC_QUORUM_FAILED, set the + // in-consensus and divergence gauges, log, page (best-effort), and throw. // ── Failure path: emit, log, page, then block the run. ── metrics?.incrementCounter(METRIC_QUORUM_FAILED, { pair: result.pair }); metrics?.setGauge(METRIC_QUORUM_IN_CONSENSUS, result.inConsensus, { @@ -406,6 +425,8 @@ export class FxQuorumEvaluator { divergent: result.divergent, }); + // Pager is best-effort: a throwing or rejecting pager must never mask the + // FxQuorumFailedError that the pipeline relies on to block the run. if (pager) { try { const paged = pager(result); @@ -420,12 +441,16 @@ export class FxQuorumEvaluator { } } + // Always throw the operational 503 after emitting metrics/logs/pages so + // callers observe a deterministic error contract. throw new FxQuorumFailedError(result); } /** Exposed for tests / review: the parsed, validated config. */ getConfig(): Readonly { return { + // Return the resolved values (defaults applied) so tests can assert the + // effective configuration rather than the raw input. k: this.k, tolerance: this.tolerance, reference: this.reference,