From 63e3cf732270ebfb95afaab47b4e874ab9373abe Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 20 May 2025 13:54:57 -0500 Subject: [PATCH 1/2] fix(realtime): handle unexpected rate values --- .../exchanges/currencybeacon/index.ts | 24 +-- .../exchanges/exchangeratehost/index.ts | 31 ++-- .../exchanges/free-currency-rates/index.ts | 24 +-- .../src/services/exchanges/yadio/index.ts | 27 +-- realtime/src/utils/index.ts | 35 ++++ realtime/test/unit/utils/index.spec.ts | 171 +++++++++++++++++- 6 files changed, 237 insertions(+), 75 deletions(-) diff --git a/realtime/src/services/exchanges/currencybeacon/index.ts b/realtime/src/services/exchanges/currencybeacon/index.ts index 228a321..ed5613d 100644 --- a/realtime/src/services/exchanges/currencybeacon/index.ts +++ b/realtime/src/services/exchanges/currencybeacon/index.ts @@ -7,11 +7,14 @@ import { UnknownExchangeServiceError, InvalidExchangeConfigError, } from "@domain/exchanges" +import { CacheKeys } from "@domain/cache" import { toPrice, toSeconds, toTimestamp } from "@domain/primitives" + import { LocalCacheService } from "@services/cache" -import { CacheKeys } from "@domain/cache" import { baseLogger } from "@services/logger" +import { cleanRatesObject, isRatesObjectValid } from "@utils" + const mutex = new Mutex() export const CurrencyBeaconExchangeService = async ({ base, @@ -68,8 +71,9 @@ export const CurrencyBeaconExchangeService = async ({ }, }, ) - const rates = data?.response?.rates - if (status >= 400 || !isRatesObjectValid(rates)) { + const rates = cleanRatesObject(data?.response?.rates) + + if (status >= 400 || !isRatesObjectValid(rates)) { await LocalCacheService().set({ key: cacheKeyStatus, value: status, @@ -96,20 +100,6 @@ export const CurrencyBeaconExchangeService = async ({ } } -const isRatesObjectValid = (rates: unknown): rates is CurrencyBeaconRates => { - if (!rates || typeof rates !== "object") return false - - let keyCount = 0 - for (const key in rates) { - if (typeof key !== "string" || typeof rates[key] !== "number") { - return false - } - keyCount++ - } - - return !!keyCount -} - const tickerFromRaw = ({ rate, timestamp, diff --git a/realtime/src/services/exchanges/exchangeratehost/index.ts b/realtime/src/services/exchanges/exchangeratehost/index.ts index 9193572..ca97839 100644 --- a/realtime/src/services/exchanges/exchangeratehost/index.ts +++ b/realtime/src/services/exchanges/exchangeratehost/index.ts @@ -7,11 +7,14 @@ import { UnknownExchangeServiceError, InvalidExchangeConfigError, } from "@domain/exchanges" +import { CacheKeys } from "@domain/cache" import { toPrice, toSeconds, toTimestamp } from "@domain/primitives" + import { LocalCacheService } from "@services/cache" -import { CacheKeys } from "@domain/cache" import { baseLogger } from "@services/logger" +import { cleanRatesObject, isRatesObjectValid } from "@utils" + const mutex = new Mutex() export const ExchangeRateHostService = async ({ base, @@ -69,7 +72,13 @@ export const ExchangeRateHostService = async ({ ) const { success, quotes, timestamp } = data - if (!success || status >= 400 || !isRatesObjectValid(quotes)) { + const rates = cleanRatesObject(quotes) + + if ( + !success || + status >= 400 || + !isRatesObjectValid(rates) + ) { await LocalCacheService().set({ key: cacheKeyStatus, value: status, @@ -80,11 +89,11 @@ export const ExchangeRateHostService = async ({ await LocalCacheService().set({ key: cacheKey, - value: quotes, + value: rates, ttlSecs: toSeconds(cacheTtlSecs > 0 ? cacheTtlSecs : 300), }) - return tickerFromRaw({ rate: quotes[symbol], timestamp }) + return tickerFromRaw({ rate: rates[symbol], timestamp }) } catch (error) { baseLogger.error({ error }, "ExchangeRateHost unknown error") return new UnknownExchangeServiceError(error.message || error) @@ -96,20 +105,6 @@ export const ExchangeRateHostService = async ({ } } -const isRatesObjectValid = (rates: unknown): rates is ExchangeRateHostRates => { - if (!rates || typeof rates !== "object") return false - - let keyCount = 0 - for (const key in rates) { - if (typeof key !== "string" || typeof rates[key] !== "number") { - return false - } - keyCount++ - } - - return !!keyCount -} - const tickerFromRaw = ({ rate, timestamp, diff --git a/realtime/src/services/exchanges/free-currency-rates/index.ts b/realtime/src/services/exchanges/free-currency-rates/index.ts index d5aae2b..164c431 100644 --- a/realtime/src/services/exchanges/free-currency-rates/index.ts +++ b/realtime/src/services/exchanges/free-currency-rates/index.ts @@ -7,11 +7,14 @@ import { UnknownExchangeServiceError, InvalidExchangeConfigError, } from "@domain/exchanges" +import { CacheKeys } from "@domain/cache" import { toPrice, toSeconds, toTimestamp } from "@domain/primitives" + import { LocalCacheService } from "@services/cache" -import { CacheKeys } from "@domain/cache" import { baseLogger } from "@services/logger" +import { cleanRatesObject, isRatesObjectValid } from "@utils" + const mutex = new Mutex() export const FreeCurrencyRatesExchangeService = async ({ base, @@ -110,24 +113,11 @@ const getWithFallback = async ({ for (const url of urls) { try { const { status, data } = await axios.get(url, params) - const rates = data ? data[baseCurrency] : undefined - if (status === 200 && isRatesObjectValid(rates)) return rates + const rates = cleanRatesObject(data ? data[baseCurrency] : undefined) + if (status === 200 && isRatesObjectValid(rates)) + return rates } catch {} } return new UnknownExchangeServiceError(`Invalid response.`) } - -const isRatesObjectValid = (rates: unknown): rates is FreeCurrencyRatesRates => { - if (!rates || typeof rates !== "object") return false - - let keyCount = 0 - for (const key in rates) { - if (typeof key !== "string" || typeof rates[key] !== "number") { - return false - } - keyCount++ - } - - return !!keyCount -} diff --git a/realtime/src/services/exchanges/yadio/index.ts b/realtime/src/services/exchanges/yadio/index.ts index fe47d93..fd08fec 100644 --- a/realtime/src/services/exchanges/yadio/index.ts +++ b/realtime/src/services/exchanges/yadio/index.ts @@ -8,11 +8,14 @@ import { InvalidExchangeConfigError, InvalidExchangeResponseError, } from "@domain/exchanges" +import { CacheKeys } from "@domain/cache" import { toPrice, toSeconds, toTimestamp } from "@domain/primitives" + import { LocalCacheService } from "@services/cache" -import { CacheKeys } from "@domain/cache" import { baseLogger } from "@services/logger" +import { cleanRatesObject, isRatesObjectValid } from "@utils" + const mutex = new Mutex() export const YadioExchangeService = async ({ base, @@ -51,10 +54,14 @@ export const YadioExchangeService = async ({ }, ) - const rates = data && data[base] - if (status >= 400 || !isRatesObjectValid(rates)) + const rawRates = data && data[base] + if (status >= 400 || !rawRates) return new InvalidExchangeResponseError(`Invalid response. Error ${status}`) + const rates = cleanRatesObject(rawRates) + if (!isRatesObjectValid(rates)) + return new InvalidExchangeResponseError(`No valid rates found in response`) + await LocalCacheService().set({ key: cacheKey, value: rates, @@ -73,20 +80,6 @@ export const YadioExchangeService = async ({ } } -const isRatesObjectValid = (rates: unknown): rates is YadioRates => { - if (!rates || typeof rates !== "object") return false - - let keyCount = 0 - for (const key in rates) { - if (typeof key !== "string" || typeof rates[key] !== "number") { - return false - } - keyCount++ - } - - return !!keyCount -} - const tickerFromRaw = ({ rate, timestamp, diff --git a/realtime/src/utils/index.ts b/realtime/src/utils/index.ts index abebadd..9eefac8 100644 --- a/realtime/src/utils/index.ts +++ b/realtime/src/utils/index.ts @@ -17,3 +17,38 @@ export const round = (number: number): number => +(Math.round(Number(number + "e+2")) + "e-2") export const unixTimestamp = (date: Date) => Math.floor(date.getTime() / 1000) + +export const isRatesObjectValid = (rates: unknown): rates is T => { + if (!rates || typeof rates !== "object" || Array.isArray(rates)) return false + + let keyCount = 0 + for (const key in rates) { + const value = (rates as Record)[key] + if (typeof key !== "string" || typeof value !== "number") { + return false + } + keyCount++ + } + + return !!keyCount +} + +export const cleanRatesObject = (rates: unknown): RatesObject => { + if (!rates || typeof rates !== "object") return {} + + return Object.entries(rates).reduce((cleaned, [key, value]) => { + // Handle string conversion + if (typeof value === "string") { + const parsed = parseFloat(value) + if (!isNaN(parsed) && isFinite(parsed)) { + cleaned[key] = parsed + } + } + // Handle direct number values + else if (typeof value === "number" && !isNaN(value) && isFinite(value)) { + cleaned[key] = value + } + + return cleaned + }, {} as RatesObject) +} diff --git a/realtime/test/unit/utils/index.spec.ts b/realtime/test/unit/utils/index.spec.ts index 511e9e1..1e57718 100644 --- a/realtime/test/unit/utils/index.spec.ts +++ b/realtime/test/unit/utils/index.spec.ts @@ -1,8 +1,167 @@ -import { median } from "@utils" +import { + isDefined, + median, + assertUnreachable, + round, + unixTimestamp, + isRatesObjectValid, + cleanRatesObject, +} from "@utils" -it("median test", () => { - expect(median([2, 1, 5])).toBe(2) - expect(median([2, 1])).toBe(1.5) - expect(median([2, 1, undefined])).toBe(1.5) - expect(median([2, 1, undefined, 5])).toBe(2) +describe("isDefined", () => { + it("should return true for defined values", () => { + expect(isDefined([])).toBe(true) + expect(isDefined({})).toBe(true) + }) + + it("should return false for false values", () => { + expect(isDefined(undefined)).toBe(false) + expect(isDefined(0)).toBe(false) + expect(isDefined("")).toBe(false) + expect(isDefined(false)).toBe(false) + expect(isDefined(null)).toBe(false) + }) +}) + +describe("median", () => { + it("should calculate median correctly for odd-length arrays", () => { + expect(median([2, 1, 5])).toBe(2) + expect(median([7, 3, 1, 9, 5])).toBe(5) + }) + + it("should calculate median correctly for even-length arrays", () => { + expect(median([2, 1])).toBe(1.5) + expect(median([1, 3, 5, 7])).toBe(4) + }) + + it("should handle arrays with undefined values", () => { + expect(median([2, 1, undefined])).toBe(1.5) + expect(median([2, 1, undefined, 5])).toBe(2) + expect(median([undefined, undefined, 5])).toBe(5) + }) + + it("should handle empty arrays", () => { + expect(median([])).toBeNaN() // Empty array should return NaN + }) + + it("should handle arrays with only undefined values", () => { + expect(median([undefined, undefined])).toBeNaN() + }) +}) + +describe("assertUnreachable", () => { + it("should throw an error with the correct message", () => { + expect(() => { + assertUnreachable("test" as never) + }).toThrow("This should never compile with test") + }) +}) + +describe("round", () => { + it("should round numbers to 2 decimal places", () => { + expect(round(1.234)).toBe(1.23) + expect(round(1.235)).toBe(1.24) // Tests normal rounding behavior + expect(round(1.5)).toBe(1.5) + }) + + it("should handle integer values", () => { + expect(round(5)).toBe(5) + expect(round(0)).toBe(0) + }) + + it("should handle negative values", () => { + expect(round(-1.234)).toBe(-1.23) + expect(round(-1.237)).toBe(-1.24) + }) +}) + +describe("unixTimestamp", () => { + it("should convert Date objects to Unix timestamps", () => { + // January 1, 1970, 00:00:00 UTC is 0 in Unix time + expect(unixTimestamp(new Date(0))).toBe(0) + + // Create a specific date for testing + const testDate = new Date("2023-01-01T00:00:00Z") + const expectedTimestamp = Math.floor(testDate.getTime() / 1000) + expect(unixTimestamp(testDate)).toBe(expectedTimestamp) + }) +}) + +describe("isRatesObjectValid", () => { + it("should return true for valid rates objects", () => { + const validRates = { + USD: 1, + EUR: 0.85, + GBP: 0.72, + } + expect(isRatesObjectValid(validRates)).toBe(true) + }) + + it("should return false for objects with non-number values", () => { + const invalidRates = { + USD: 1, + EUR: "0.85", + GBP: 0.72, + } + expect(isRatesObjectValid(invalidRates)).toBe(false) + }) + + it("should return false for empty objects", () => { + expect(isRatesObjectValid({})).toBe(false) + }) + + it("should return false for non-object values", () => { + expect(isRatesObjectValid(null)).toBe(false) + expect(isRatesObjectValid(undefined)).toBe(false) + expect(isRatesObjectValid("rates")).toBe(false) + expect(isRatesObjectValid(123)).toBe(false) + expect(isRatesObjectValid([1, 2, 3])).toBe(false) + }) +}) + +describe("cleanRatesObject", () => { + it("should convert string numbers to actual numbers", () => { + const mixedRates = { + USD: 1, + EUR: "0.85", + GBP: 0.72, + } + + const expected = { + USD: 1, + EUR: 0.85, + GBP: 0.72, + } + + expect(cleanRatesObject(mixedRates)).toEqual(expected) + }) + + it("should filter out non-numeric values", () => { + const dirtyRates = { + USD: 1, + EUR: "invalid", + GBP: NaN, + JPY: Infinity, + PEN: null, + CAD: 1.3, + } + + const expected = { + USD: 1, + CAD: 1.3, + } + + expect(cleanRatesObject(dirtyRates)).toEqual(expected) + }) + + it("should return an empty object for non-object inputs", () => { + expect(cleanRatesObject(null)).toEqual({}) + expect(cleanRatesObject(undefined)).toEqual({}) + expect(cleanRatesObject("rates")).toEqual({}) + expect(cleanRatesObject(123)).toEqual({}) + }) + + it("should handle empty objects", () => { + expect(cleanRatesObject({})).toEqual({}) + }) }) From 1591b0c0b8a83701f08fd3eaf0f27b2def7afab1 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 20 May 2025 13:58:06 -0500 Subject: [PATCH 2/2] fix: lint issues --- realtime/src/utils/index.types.d.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 realtime/src/utils/index.types.d.ts diff --git a/realtime/src/utils/index.types.d.ts b/realtime/src/utils/index.types.d.ts new file mode 100644 index 0000000..21401c7 --- /dev/null +++ b/realtime/src/utils/index.types.d.ts @@ -0,0 +1 @@ +type RatesObject = { [key: string]: number }