diff --git a/.env.example b/.env.example index d845080..0ed48c3 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ STELLAR_KEY=your_stellar_key_here # Server Configuration PORT=3000 API_KEY=the_secret_api_key_here +REDIS_URL=redis://localhost:6379 # API Keys (optional - some rate sources may require) OPENEXCHANGE_RATES_API_KEY=your_api_key_here diff --git a/package-lock.json b/package-lock.json index 3546d32..eacb028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "helmet": "^8.1.0", "morgan": "^1.10.1", "pg": "^8.20.0", + "redis": "^5.11.0", "socket.io": "^4.8.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", @@ -1315,6 +1316,74 @@ } } }, + "node_modules/@redis/bloom": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", + "integrity": "sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", + "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.11.0.tgz", + "integrity": "sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/search": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.11.0.tgz", + "integrity": "sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.11.0.tgz", + "integrity": "sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -2329,6 +2398,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2708,19 +2786,6 @@ "node": ">= 0.6" } }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -2734,6 +2799,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -4013,15 +4091,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -5032,6 +5101,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", + "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.11.0", + "@redis/client": "5.11.0", + "@redis/json": "5.11.0", + "@redis/search": "5.11.0", + "@redis/time-series": "5.11.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/remeda": { "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", diff --git a/package.json b/package.json index 7ffe26a..678c071 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "helmet": "^8.1.0", "morgan": "^1.10.1", "pg": "^8.20.0", + "redis": "^5.11.0", "socket.io": "^4.8.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", diff --git a/src/index.ts b/src/index.ts index 8ba52c7..7342659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import priceUpdatesRouter from "./routes/priceUpdates"; import assetsRouter from "./routes/assets"; import statusRouter from "./routes/status"; import prisma from "./lib/prisma"; +import { disconnectRedis } from "./lib/redis"; import { initSocket } from "./lib/socket"; import { SorobanEventListener } from "./services/sorobanEventListener"; import { specs } from "./lib/swagger"; @@ -288,7 +289,7 @@ app.use( err: Error, req: express.Request, res: express.Response, - next: express.NextFunction, + _next: express.NextFunction, ) => { console.error("Unhandled error:", err); res.status(500).json({ @@ -329,7 +330,7 @@ const closeHttpServer = (): Promise => }); }); -const shutdown = async (signal: NodeJS.Signals): Promise => { +const shutdown = async (signal: "SIGINT" | "SIGTERM"): Promise => { if (isShuttingDown) { console.log( `Shutdown already in progress. Received duplicate ${signal} signal.`, @@ -351,6 +352,9 @@ const shutdown = async (signal: NodeJS.Signals): Promise => { await prisma.$disconnect(); console.log("Database connections closed cleanly."); + await disconnectRedis(); + console.log("Redis connections closed cleanly."); + process.exit(0); } catch (error) { console.error("Graceful shutdown failed:", error); diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..13578d9 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,51 @@ +import { createClient, type RedisClientType } from "redis"; +import dotenv from "dotenv"; + +dotenv.config(); + +const redisUrl = process.env.REDIS_URL; + +let redisClient: RedisClientType | null = null; + +if (redisUrl) { + redisClient = createClient({ + url: redisUrl, + socket: { + connectTimeout: 3000, + reconnectStrategy: (retries) => Math.min(retries * 50, 1000), + }, + }); + + redisClient.on("error", (error) => { + console.error("[Redis] Client error:", error); + }); + + redisClient.on("connect", () => { + console.info("[Redis] Connected"); + }); + + redisClient.on("reconnecting", () => { + console.warn("[Redis] Reconnecting..."); + }); + + void redisClient.connect().catch((error) => { + console.error( + "[Redis] Failed to connect. Continuing without Redis cache:", + error, + ); + }); +} else { + console.info("[Redis] REDIS_URL not set. Redis caching is disabled."); +} + +export function getRedisClient(): RedisClientType | null { + return redisClient; +} + +export async function disconnectRedis(): Promise { + if (!redisClient || !redisClient.isOpen) { + return; + } + + await redisClient.quit(); +} diff --git a/src/services/marketRate/marketRateService.ts b/src/services/marketRate/marketRateService.ts index caa40b7..6701045 100644 --- a/src/services/marketRate/marketRateService.ts +++ b/src/services/marketRate/marketRateService.ts @@ -11,6 +11,8 @@ import { StellarService } from "../stellarService"; import { multiSigService } from "../multiSigService"; import { getIO } from "../../lib/socket"; import prisma from "../../lib/prisma"; +import { getRedisClient } from "../../lib/redis"; +import type { RedisClientType } from "redis"; import dotenv from "dotenv"; import { normalizeDateToUTC } from "../../utils/timeUtils"; @@ -22,13 +24,10 @@ import { priceReviewService } from "../priceReviewService"; export class MarketRateService { private fetchers: Map = new Map(); private cache: Map = new Map(); - private latestPricesCache: { - response: AggregatedFetcherResponse; - expiry: Date; - } | null = null; private stellarService: StellarService; private readonly CACHE_DURATION_MS = 30000; // 30 seconds - private readonly LATEST_PRICES_CACHE_DURATION_MS = 10000; // 10 seconds + private readonly LATEST_PRICES_REDIS_KEY = "market-rates:latest:v1"; + private readonly LATEST_PRICES_REDIS_TTL_SECONDS = 5; private multiSigEnabled: boolean; private remoteOracleServers: string[] = []; @@ -49,7 +48,7 @@ export class MarketRateService { if (this.multiSigEnabled) { console.info( - `[MarketRateService] Multi-Sig mode ENABLED with ${this.remoteOracleServers.length} remote servers` + `[MarketRateService] Multi-Sig mode ENABLED with ${this.remoteOracleServers.length} remote servers`, ); } @@ -97,17 +96,22 @@ export class MarketRateService { : normalizedCurrency; try { const clientAny = prisma as any; - if (clientAny?.errorLog && typeof clientAny.errorLog.create === "function") { - clientAny.errorLog.create({ - data: { - providerName, - errorMessage: - fetchError instanceof Error - ? fetchError.message - : JSON.stringify(fetchError), - occurredAt: new Date(), - }, - }).catch(() => {}); + if ( + clientAny?.errorLog && + typeof clientAny.errorLog.create === "function" + ) { + clientAny.errorLog + .create({ + data: { + providerName, + errorMessage: + fetchError instanceof Error + ? fetchError.message + : JSON.stringify(fetchError), + occurredAt: new Date(), + }, + }) + .catch(() => {}); } } catch { // swallow @@ -131,7 +135,8 @@ export class MarketRateService { ? normalizeDateToUTC(rate.comparisonTimestamp) : undefined, }; - const reviewAssessment = await priceReviewService.assessRate(normalizedRate); + const reviewAssessment = + await priceReviewService.assessRate(normalizedRate); const enrichedRate: MarketRate = { ...normalizedRate, manualReviewRequired: reviewAssessment.manualReviewRequired, @@ -158,27 +163,30 @@ export class MarketRateService { if (this.multiSigEnabled) { // Multi-sig workflow: create request and collect signatures console.info( - `[MarketRateService] Starting multi-sig workflow for ${normalizedCurrency} rate ${rate.rate}` + `[MarketRateService] Starting multi-sig workflow for ${normalizedCurrency} rate ${rate.rate}`, ); - const signatureRequest = await multiSigService.createMultiSigRequest( - reviewAssessment.reviewRecordId, - normalizedCurrency, - rate.rate, - rate.source, - memoId - ); + const signatureRequest = + await multiSigService.createMultiSigRequest( + reviewAssessment.reviewRecordId, + normalizedCurrency, + rate.rate, + rate.source, + memoId, + ); // Sign locally first try { - await multiSigService.signMultiSigPrice(signatureRequest.multiSigPriceId); + await multiSigService.signMultiSigPrice( + signatureRequest.multiSigPriceId, + ); console.info( - `[MarketRateService] Local signature added for multi-sig request ${signatureRequest.multiSigPriceId}` + `[MarketRateService] Local signature added for multi-sig request ${signatureRequest.multiSigPriceId}`, ); } catch (error) { console.error( `[MarketRateService] Failed to sign locally:`, - error + error, ); } @@ -186,11 +194,11 @@ export class MarketRateService { // (non-blocking - don't wait for completion) this.requestRemoteSignaturesAsync( signatureRequest.multiSigPriceId, - memoId + memoId, ).catch((err) => { console.error( `[MarketRateService] Error requesting remote signatures:`, - err + err, ); }); @@ -204,26 +212,26 @@ export class MarketRateService { const txHash = await this.stellarService.submitPriceUpdate( normalizedCurrency, rate.rate, - memoId + memoId, ); await priceReviewService.markContractSubmitted( reviewAssessment.reviewRecordId, memoId, - txHash + txHash, ); console.info( - `[MarketRateService] Single-sig price update submitted for ${normalizedCurrency}` + `[MarketRateService] Single-sig price update submitted for ${normalizedCurrency}`, ); } } catch (stellarError) { console.error( "Failed to submit price update to Stellar network:", - stellarError + stellarError, ); } } else { console.warn( - `Manual review required for ${normalizedCurrency} rate ${rate.rate}. Skipping contract submission.` + `Manual review required for ${normalizedCurrency} rate ${rate.rate}. Skipping contract submission.`, ); } @@ -310,46 +318,151 @@ export class MarketRateService { return Array.from(this.fetchers.keys()); } - async getLatestPrices(): Promise { - const cachedLatestPrices = this.latestPricesCache; - if (cachedLatestPrices && cachedLatestPrices.expiry > new Date()) { - return cachedLatestPrices.response; + protected getLatestPricesCacheClient(): Pick< + RedisClientType, + "get" | "setEx" | "del" + > | null { + const redisClient = getRedisClient(); + if (!redisClient || !redisClient.isReady) { + return null; } - const results = await this.getAllRates(); + return redisClient; + } - const successfulRates = results - .filter((result) => result.success && result.data) - .map((result) => result.data as MarketRate); + protected async fetchLatestPricesFromDatabase(): Promise { + const rows = await prisma.priceHistory.findMany({ + where: { + currency: { + in: this.getSupportedCurrencies(), + }, + }, + distinct: ["currency"], + orderBy: [{ currency: "asc" }, { timestamp: "desc" }], + }); + + return rows.map( + (row: { + currency: string; + rate: number | string; + timestamp: Date; + source: string; + }) => ({ + currency: row.currency, + rate: Number(row.rate), + timestamp: normalizeDateToUTC(row.timestamp), + source: row.source, + }), + ); + } - const errorMessages = results - .filter((result) => !result.success) - .map((result) => result.error) - .filter((error): error is string => !!error); + private parseLatestPricesCache( + cachedPayload: string, + ): AggregatedFetcherResponse | null { + try { + const parsed = JSON.parse(cachedPayload) as { + success?: boolean; + error?: string; + errors?: string[]; + data?: Array< + MarketRate & { timestamp: string; comparisonTimestamp?: string } + >; + }; - const allSuccessful = - successfulRates.length > 0 && errorMessages.length === 0; + if (typeof parsed.success !== "boolean") { + return null; + } - const response = { - success: allSuccessful, - data: successfulRates, - ...(errorMessages.length > 0 && { error: errorMessages[0] }), - ...(errorMessages.length > 0 && { errors: errorMessages }), - }; + const hydratedRates = Array.isArray(parsed.data) + ? parsed.data.map((rate) => ({ + ...rate, + timestamp: new Date(rate.timestamp), + ...(rate.comparisonTimestamp && { + comparisonTimestamp: new Date(rate.comparisonTimestamp), + }), + })) + : undefined; - if (response.success) { - this.latestPricesCache = { - response, - expiry: new Date(Date.now() + this.LATEST_PRICES_CACHE_DURATION_MS), + return { + success: parsed.success, + ...(hydratedRates && { data: hydratedRates }), + ...(parsed.error && { error: parsed.error }), + ...(parsed.errors && { errors: parsed.errors }), }; + } catch { + return null; } + } + + async getLatestPrices(): Promise { + const cacheClient = this.getLatestPricesCacheClient(); - return response; + if (cacheClient) { + try { + const cachedPayload = await cacheClient.get( + this.LATEST_PRICES_REDIS_KEY, + ); + if (cachedPayload) { + const cachedResponse = this.parseLatestPricesCache(cachedPayload); + if (cachedResponse) { + return cachedResponse; + } + } + } catch (error) { + console.warn("Failed to read latest prices from Redis cache:", error); + } + } + + try { + const latestRates = await this.fetchLatestPricesFromDatabase(); + + if (latestRates.length === 0) { + return { + success: false, + error: "No latest prices available", + }; + } + + const response: AggregatedFetcherResponse = { + success: true, + data: latestRates, + }; + + if (cacheClient) { + try { + await cacheClient.setEx( + this.LATEST_PRICES_REDIS_KEY, + this.LATEST_PRICES_REDIS_TTL_SECONDS, + JSON.stringify(response), + ); + } catch (error) { + console.warn("Failed to write latest prices to Redis cache:", error); + } + } + + return response; + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to fetch latest prices from database", + }; + } } clearCache(): void { this.cache.clear(); - this.latestPricesCache = null; + + const cacheClient = this.getLatestPricesCacheClient(); + if (!cacheClient) { + return; + } + + void cacheClient.del(this.LATEST_PRICES_REDIS_KEY).catch((error) => { + console.warn("Failed to clear latest prices Redis cache:", error); + }); } async getPendingReviews() { @@ -361,7 +474,8 @@ export class MarketRateService { reviewedBy?: string, reviewNotes?: string, ) { - const pendingReview = await priceReviewService.getPendingReviewById(reviewId); + const pendingReview = + await priceReviewService.getPendingReviewById(reviewId); if (!pendingReview) { throw new Error(`Pending review ${reviewId} was not found`); } @@ -447,15 +561,15 @@ export class MarketRateService { */ private async requestRemoteSignaturesAsync( multiSigPriceId: number, - memoId: string + _memoId: string, ): Promise { console.info( - `[MarketRateService] Requesting signatures from ${this.remoteOracleServers.length} remote servers for multi-sig ${multiSigPriceId}` + `[MarketRateService] Requesting signatures from ${this.remoteOracleServers.length} remote servers for multi-sig ${multiSigPriceId}`, ); // Request signatures from all remote servers in parallel const signatureRequests = this.remoteOracleServers.map((serverUrl) => - multiSigService.requestRemoteSignature(multiSigPriceId, serverUrl) + multiSigService.requestRemoteSignature(multiSigPriceId, serverUrl), ); const results = await Promise.allSettled(signatureRequests); @@ -465,20 +579,19 @@ export class MarketRateService { if (result.status === "fulfilled") { if (result.value.success) { console.info( - `[MarketRateService] ✅ Signature request sent to ${this.remoteOracleServers[index]}` + `[MarketRateService] ✅ Signature request sent to ${this.remoteOracleServers[index]}`, ); } else { console.warn( - `[MarketRateService] ⚠️ Signature request failed for ${this.remoteOracleServers[index]}: ${result.value.error}` + `[MarketRateService] ⚠️ Signature request failed for ${this.remoteOracleServers[index]}: ${result.value.error}`, ); } } else { console.error( `[MarketRateService] ❌ Error requesting signature from ${this.remoteOracleServers[index]}:`, - result.reason + result.reason, ); } }); } } - diff --git a/test/responseCaching.test.ts b/test/responseCaching.test.ts index 1a7384c..69ce37e 100644 --- a/test/responseCaching.test.ts +++ b/test/responseCaching.test.ts @@ -1,5 +1,5 @@ import { MarketRateService } from "../src/services/marketRate"; -import { AggregatedFetcherResponse, MarketRate } from "../src/services/marketRate/types"; +import { MarketRate } from "../src/services/marketRate"; function assert(condition: boolean, message: string): void { if (!condition) { @@ -7,91 +7,100 @@ function assert(condition: boolean, message: string): void { } } -class TestMarketRateService extends MarketRateService { - public latestPricesCalls = 0; - - override async getAllRates(): Promise< - Array<{ success: boolean; data?: MarketRate; error?: string }> - > { - this.latestPricesCalls += 1; +async function run(): Promise { + const service = Object.create(MarketRateService.prototype) as any; + service.databaseCalls = 0; + service.redisData = new Map(); + service.cache = new Map(); + service.LATEST_PRICES_REDIS_KEY = "market-rates:latest:v1"; + service.LATEST_PRICES_REDIS_TTL_SECONDS = 5; + service.getLatestPricesCacheClient = () => ({ + get: async (key: string): Promise => { + const entry = service.redisData.get(key); + if (!entry || entry.expiresAt <= Date.now()) { + service.redisData.delete(key); + return null; + } + return entry.value; + }, + setEx: async ( + key: string, + ttlSeconds: number, + value: string, + ): Promise => { + service.redisData.set(key, { + value, + expiresAt: Date.now() + ttlSeconds * 1000, + }); + }, + del: async (key: string): Promise => { + service.redisData.delete(key); + }, + }); + service.fetchLatestPricesFromDatabase = async (): Promise => { + service.databaseCalls += 1; return [ { - success: true, - data: { - currency: "KES", - rate: 150, - timestamp: new Date("2026-03-27T12:00:00.000Z"), - source: "test", - }, + currency: "KES", + rate: 150, + timestamp: new Date("2026-03-27T12:00:00.000Z"), + source: "test", }, { - success: true, - data: { - currency: "GHS", - rate: 15, - timestamp: new Date("2026-03-27T12:00:00.000Z"), - source: "test", - }, + currency: "GHS", + rate: 15, + timestamp: new Date("2026-03-27T12:00:00.000Z"), + source: "test", }, ]; - } -} - -async function run(): Promise { - const service = Object.assign( - Object.create(TestMarketRateService.prototype) as TestMarketRateService & { - cache: Map; - latestPricesCache: { response: AggregatedFetcherResponse; expiry: Date } | null; - LATEST_PRICES_CACHE_DURATION_MS: number; - }, - { - latestPricesCalls: 0, - cache: new Map(), - latestPricesCache: null, - LATEST_PRICES_CACHE_DURATION_MS: 10_000, - }, - ); + }; const firstResponse = await service.getLatestPrices(); const secondResponse = await service.getLatestPrices(); assert(firstResponse.success, "first latest-prices response should succeed"); - assert(secondResponse.success, "second latest-prices response should succeed"); assert( - service.latestPricesCalls === 1, - `expected cached latest prices to reuse the first response, got ${service.latestPricesCalls} fetches`, + secondResponse.success, + "second latest-prices response should succeed", + ); + assert( + service.databaseCalls === 1, + `expected Redis cache hit to avoid a second database query, got ${service.databaseCalls} queries`, + ); + assert( + firstResponse !== secondResponse, + "expected Redis cache hit to return an equivalent payload, not the same object reference", + ); + assert( + firstResponse.data?.[0]?.timestamp instanceof Date, + "expected first response timestamps to be Date objects", ); assert( - firstResponse === secondResponse, - "expected cached latest prices to return the same response object within the TTL", + secondResponse.data?.[0]?.timestamp instanceof Date, + "expected Redis response timestamps to be Date objects after hydration", ); - ( - service as unknown as { - latestPricesCache: { response: AggregatedFetcherResponse; expiry: Date } | null; - } - ).latestPricesCache = { - response: secondResponse, - expiry: new Date(Date.now() - 1000), - }; + await new Promise((resolve) => setTimeout(resolve, 5100)); const thirdResponse = await service.getLatestPrices(); assert(thirdResponse.success, "third latest-prices response should succeed"); assert( - service.latestPricesCalls === 2, - `expected an expired cache entry to trigger a refresh, got ${service.latestPricesCalls} fetches`, + service.databaseCalls === 2, + `expected TTL expiration to trigger a refresh, got ${service.databaseCalls} queries`, ); service.clearCache(); + const fourthResponse = await service.getLatestPrices(); + assert( - ( - service as unknown as { - latestPricesCache: { response: AggregatedFetcherResponse; expiry: Date } | null; - } - ).latestPricesCache === null, - "expected clearCache to remove the latest-prices cache entry", + fourthResponse.success, + "fourth latest-prices response should succeed after manual cache clear", + ); + assert( + service.databaseCalls === 3, + `expected clearCache to force a fresh query, got ${service.databaseCalls} queries`, ); console.log("responseCaching.test.ts passed");