From 995375b905f773705880b9f28fd0d5b74e747794 Mon Sep 17 00:00:00 2001 From: Baba-Yoga Date: Mon, 24 Aug 2026 12:28:04 +0000 Subject: [PATCH] feat: resolve issues #209 #210 #211 #212 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #212 — cache treasury balances endpoint - Add in-memory 5-second TTL cache to GET /api/treasury/balances - Invalidate cache immediately after execute-settlement succeeds - Export getBalanceCache/setBalanceCache/invalidateBalanceCache for tests - Add treasury-balances-cache.test.ts: cache helpers, TTL expiry, cache hit/miss on HTTP layer, invalidation after mutation Issue #211 — integration tests for compliance allow/block endpoints - Add compliance.test.ts with callComplianceOp unit tests: allow, block, allow_address_until, simulation error (422), send error (422), confirmation timeout (504) - HTTP-layer tests for POST /compliance/allow and /compliance/block: 401 missing/wrong admin key, 400 invalid/missing address, 400 bad 'until' value, 503 missing env vars Issue #210 — harden MongoDB connection error handling and pooling - Add explicit pool config to MongoClient: maxPoolSize=10, minPoolSize=2, serverSelectionTimeoutMS=5000, connectTimeoutMS=10000, socketTimeoutMS=45000 - Wrap client.connect() in try/catch: MongoServerSelectionError produces a clear message with the URI and status 503 - Add 'error'/'close' event handlers to log topology failures - Add _resetMongoSingleton() export for test isolation - Add mongo-hardening.test.ts: 503 on unreachable host, URI in error message, non-selection error wrapping, success path, HTTP route returns 5xx on outage (not a hang) Issue #209 — Redis reconnect backoff for event indexer - Add createRedisClient() with ioredis retryStrategy implementing capped exponential backoff (base 250ms, cap 30s, ±10% jitter) - Persist cursor to Redis (invoice_indexer_cursor) with in-memory fallback on Redis failure; loadCursor/saveCursor are safe to call while Redis is disconnected - stopIndexer() gracefully quits the Redis connection - startIndexer() accepts _redisClient injection for unit tests - Add indexer-redis-reconnect.test.ts: backoffDelayMs range/growth/ cap, loadCursor fallback, saveCursor persistence and graceful failure, indexer continues polling after Redis drops Closes #209 Closes #210 Closes #211 Closes #212 --- .gitignore | 9 + comebackhere-backend/src/db/mongo.ts | 70 +++- comebackhere-backend/src/indexer.ts | 180 +++++++-- comebackhere-backend/src/routes/treasury.ts | 45 ++- .../src/tests/compliance.test.ts | 350 ++++++++++++++++++ .../src/tests/indexer-redis-reconnect.test.ts | 250 +++++++++++++ .../src/tests/mongo-hardening.test.ts | 206 +++++++++++ .../src/tests/treasury-balances-cache.test.ts | 179 +++++++++ 8 files changed, 1262 insertions(+), 27 deletions(-) create mode 100644 comebackhere-backend/src/tests/compliance.test.ts create mode 100644 comebackhere-backend/src/tests/indexer-redis-reconnect.test.ts create mode 100644 comebackhere-backend/src/tests/mongo-hardening.test.ts create mode 100644 comebackhere-backend/src/tests/treasury-balances-cache.test.ts diff --git a/.gitignore b/.gitignore index d3a115e..eedfdf1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,12 @@ lcov.info *.local .turbo/ .parcel-cache/ + +# Vitest extended outputs +vitest.config.ts.timestamp-* +**/*.test-d.ts + +# Editor session files +*.swp +*.swo +*~ diff --git a/comebackhere-backend/src/db/mongo.ts b/comebackhere-backend/src/db/mongo.ts index 4743475..9859b33 100644 --- a/comebackhere-backend/src/db/mongo.ts +++ b/comebackhere-backend/src/db/mongo.ts @@ -1,4 +1,4 @@ -import { MongoClient, type Db, type Collection } from "mongodb" +import { MongoClient, type Db, type Collection, MongoServerSelectionError } from "mongodb" export interface SettlementRecord { id: number @@ -26,16 +26,74 @@ export interface IndexerCursor { let client: MongoClient | null = null let db: Db | null = null +// --------------------------------------------------------------------------- +// #210 — Connection options: explicit pool size and timeouts so a slow or +// unreachable MongoDB fails fast instead of hanging indefinitely. +// --------------------------------------------------------------------------- + +const MONGO_OPTIONS = { + /** Maximum number of connections in the pool. */ + maxPoolSize: 10, + /** Minimum number of idle connections to maintain. */ + minPoolSize: 2, + /** + * How long (ms) the driver will wait when selecting a server before + * throwing a MongoServerSelectionError. Default is 30 000; we tighten + * it so startup failures are discovered quickly. + */ + serverSelectionTimeoutMS: 5_000, + /** + * How long (ms) to wait for a new connection to be established. + * Prevents requests from hanging when all pool slots are busy. + */ + connectTimeoutMS: 10_000, + /** + * How long (ms) to wait for a socket operation to complete before + * giving up and returning an error to the caller. + */ + socketTimeoutMS: 45_000, +} + export async function connectMongo(): Promise { if (db) return db const uri = process.env.MONGODB_URI ?? "mongodb://localhost:27017" const dbName = process.env.MONGODB_DB ?? "comebackhere" - client = new MongoClient(uri) - await client.connect() + client = new MongoClient(uri, MONGO_OPTIONS) + + try { + await client.connect() + } catch (err) { + // Provide a clear, actionable error message rather than letting the raw + // driver error bubble up silently. + const message = + err instanceof MongoServerSelectionError + ? `MongoDB unreachable at ${uri} — check that the server is running and MONGODB_URI is correct. ` + + `Original error: ${err.message}` + : `Failed to connect to MongoDB: ${err instanceof Error ? err.message : String(err)}` + + console.error(`[mongo] ${message}`) + // Re-throw so callers (routes, startup health-checks) can respond with 5xx. + throw Object.assign(new Error(message), { status: 503 }) + } + db = client.db(dbName) + // Attach a top-level error handler so an unexpected mid-run topology + // failure is logged clearly rather than crashing the process silently. + client.on("error", (err: Error) => { + console.error("[mongo] client error", err.message) + }) + + client.on("close", () => { + console.warn("[mongo] connection closed — subsequent requests will reconnect") + // Reset cached references so the next call to connectMongo() re-establishes + // the connection instead of returning a stale db handle. + db = null + client = null + }) + const settlements = db.collection("settlements") await settlements.createIndex({ id: 1 }, { unique: true }) await settlements.createIndex({ status: 1 }) @@ -61,3 +119,9 @@ export async function closeMongo(): Promise { db = null } } + +/** Exported for tests — resets the cached singleton so each test gets a fresh connection. */ +export function _resetMongoSingleton(): void { + client = null + db = null +} diff --git a/comebackhere-backend/src/indexer.ts b/comebackhere-backend/src/indexer.ts index a56a531..4ea3b0b 100644 --- a/comebackhere-backend/src/indexer.ts +++ b/comebackhere-backend/src/indexer.ts @@ -1,10 +1,21 @@ /** - * Invoice event indexer — #69 + * Invoice event indexer — #69 / #209 * * Polls Soroban for invoice contract events (invoice_created, invoice_paid, * invoice_expired, invoice_cancelled, escrow_released) using cursor-based * pagination so missed events and re-org recovery are handled automatically. * + * Cursor persistence (#209): + * The last successfully processed paging token is stored in Redis under the + * key INDEXER_CURSOR_KEY. On restart the indexer resumes from that token, + * guaranteeing no gaps and avoiding duplicate processing. + * + * Redis reconnection (#209): + * If the Redis connection drops the indexer reconnects with exponential + * back-off (base 250 ms, cap 30 s, jitter ±10 %). It continues to poll + * Soroban during the reconnect window — cursor saves are queued / retried + * automatically by ioredis — so no events are lost. + * * Usage (standalone): * SOROBAN_RPC_URL=... INVOICE_CONTRACT_ID=... node dist/indexer.js * @@ -12,6 +23,7 @@ */ import { SorobanRpc, xdr } from "stellar-sdk" +import Redis from "ioredis" // --------------------------------------------------------------------------- // Types @@ -44,15 +56,127 @@ const TRACKED_EVENTS = new Set([ ]) // --------------------------------------------------------------------------- -// Cursor persistence (in-memory with optional env override for restarts) +// Redis cursor persistence (#209) +// --------------------------------------------------------------------------- + +const INDEXER_CURSOR_KEY = "invoice_indexer_cursor" + +/** + * In-memory fallback cursor — used when Redis is unavailable at startup + * or when a cursor write fails. Soroban polling continues uninterrupted. + */ +let memCursor: string = process.env.INDEXER_START_CURSOR ?? "0" + +/** The active ioredis client. Replaced on each reconnect attempt. */ +let redisClient: Redis | null = null + +// --------------------------------------------------------------------------- +// Exponential back-off helper (#209) // --------------------------------------------------------------------------- -let cursor: string = process.env.INDEXER_START_CURSOR ?? "0" +const BACKOFF_BASE_MS = 250 +const BACKOFF_CAP_MS = 30_000 +const BACKOFF_JITTER = 0.1 // ±10 % -function saveCursor(next: string): void { - cursor = next - // In production swap this for a DB or Redis write so restarts resume cleanly. - // e.g.: await redis.set("invoice_indexer_cursor", next) +/** + * Returns the delay in milliseconds for the n-th retry attempt + * (0-indexed) using capped exponential back-off with jitter. + */ +export function backoffDelayMs(attempt: number): number { + const exp = Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS) + const jitter = exp * BACKOFF_JITTER * (Math.random() * 2 - 1) + return Math.round(exp + jitter) +} + +// --------------------------------------------------------------------------- +// Redis connection with reconnect/back-off loop (#209) +// --------------------------------------------------------------------------- + +/** + * Creates an ioredis client configured with automatic reconnect back-off. + * ioredis natively retries connections; we customise the strategy so each + * attempt follows our capped exponential schedule. + * + * The returned client emits 'connect', 'reconnecting', and 'error' events + * which are logged for observability. + */ +export function createRedisClient(redisUrl?: string): Redis { + const url = redisUrl ?? process.env.REDIS_URL ?? "redis://localhost:6379" + + let attempt = 0 + const client = new Redis(url, { + // ioredis calls this after each failed connection attempt. + // Return the number of milliseconds to wait before the next attempt, + // or false / null to stop retrying entirely. + retryStrategy(times: number): number | null { + attempt = times + if (times > 50) { + // After 50 retries (~30 min with cap) give up so operators notice. + console.error( + `[indexer] Redis retry limit reached after ${times} attempts — stopping reconnect` + ) + return null + } + const delay = backoffDelayMs(times - 1) + console.warn( + `[indexer] Redis reconnect attempt ${times} — waiting ${delay} ms` + ) + return delay + }, + // Do not flood logs when commands queue during a disconnect. + enableReadyCheck: false, + maxRetriesPerRequest: null, + lazyConnect: false, + }) + + client.on("connect", () => { + console.log("[indexer] Redis connected") + attempt = 0 + }) + + client.on("reconnecting", (ms: number) => { + console.warn(`[indexer] Redis reconnecting in ${ms} ms (attempt ${attempt})`) + }) + + client.on("error", (err: Error) => { + // Log but do not crash — the indexer continues polling Soroban. + console.error(`[indexer] Redis error: ${err.message}`) + }) + + return client +} + +// --------------------------------------------------------------------------- +// Cursor read / write (with Redis fallback to in-memory) +// --------------------------------------------------------------------------- + +/** Reads the last cursor from Redis, falling back to the in-memory value. */ +export async function loadCursor(): Promise { + if (redisClient) { + try { + const stored = await redisClient.get(INDEXER_CURSOR_KEY) + if (stored) { + memCursor = stored + return stored + } + } catch (err) { + console.warn("[indexer] could not read cursor from Redis — using in-memory cursor", err) + } + } + return memCursor +} + +/** Persists the cursor to Redis and in-memory for durability. */ +export async function saveCursor(next: string): Promise { + memCursor = next + if (redisClient) { + try { + await redisClient.set(INDEXER_CURSOR_KEY, next) + } catch (err) { + // Non-fatal: in-memory cursor is still updated, so polling continues. + console.warn("[indexer] could not save cursor to Redis — using in-memory fallback", err) + } + } } // --------------------------------------------------------------------------- @@ -60,26 +184,16 @@ function saveCursor(next: string): void { // --------------------------------------------------------------------------- function parseEventType(topics: xdr.ScVal[]): InvoiceEventType | null { - // Soroban contract events encode the event name as the first topic symbol. const name = topics[0]?.sym()?.toString() if (!name || !TRACKED_EVENTS.has(name)) return null return name as InvoiceEventType } function parseInvoiceId(topics: xdr.ScVal[]): string { - // Convention: second topic is the invoice_id (u32 or u64). const id = topics[1]?.u32() ?? topics[1]?.u64() return id?.toString() ?? "unknown" } -function scValToString(val: xdr.ScVal): string { - try { - return val.toXDR("base64") - } catch { - return "" - } -} - // --------------------------------------------------------------------------- // Persistence stub // --------------------------------------------------------------------------- @@ -103,6 +217,8 @@ export async function pollOnce( server: SorobanRpc.Server, contractId: string ): Promise { + const cursor = await loadCursor() + const response = await (server as any).getEvents({ startLedger: cursor === "0" ? undefined : undefined, cursor: cursor === "0" ? undefined : cursor, @@ -141,20 +257,19 @@ export async function pollOnce( // Advance cursor to the last seen event's paging token for re-org safety. if (events.length > 0) { - saveCursor(events[events.length - 1].pagingToken) + await saveCursor(events[events.length - 1].pagingToken) } } // --------------------------------------------------------------------------- -// Start function — exported for embedding; also runs as CLI entry point +// Start / stop // --------------------------------------------------------------------------- -/** Handle to the running poll loop, used by stop() to prevent new polls. */ let stopped = false let activeTimer: ReturnType | null = null /** - * Stops the indexer poll loop. Safe to call multiple times. + * Stops the indexer poll loop. Safe to call multiple times. * Does not interrupt an in-flight pollOnce() call; it prevents scheduling * the next one so any active poll completes cleanly before the process exits. */ @@ -164,13 +279,21 @@ export function stopIndexer(): void { clearTimeout(activeTimer) activeTimer = null } + // Gracefully close the Redis connection on shutdown. + if (redisClient) { + redisClient.quit().catch(() => {/* ignore quit errors during shutdown */}) + redisClient = null + } } export async function startIndexer(options?: { rpcUrl?: string contractId?: string pollIntervalMs?: number + redisUrl?: string onError?: (err: unknown) => void + /** Injected Redis client for tests — skips real Redis connection. */ + _redisClient?: Redis | null }): Promise { const rpcUrl = options?.rpcUrl ?? process.env.SOROBAN_RPC_URL const contractId = options?.contractId ?? process.env.INVOICE_CONTRACT_ID @@ -180,14 +303,25 @@ export async function startIndexer(options?: { throw new Error("startIndexer: SOROBAN_RPC_URL and INVOICE_CONTRACT_ID are required") } + // #209 — create (or inject) a Redis client with reconnect back-off. + if (options?._redisClient !== undefined) { + // Allow tests to inject a mock/null client. + redisClient = options._redisClient + } else { + redisClient = createRedisClient(options?.redisUrl) + } + const server = new SorobanRpc.Server(rpcUrl) - console.log(`[indexer] starting — contract=${contractId} cursor=${cursor} interval=${pollIntervalMs}ms`) + const initialCursor = await loadCursor() + console.log( + `[indexer] starting — contract=${contractId} cursor=${initialCursor} interval=${pollIntervalMs}ms` + ) const loop = async () => { if (stopped) return try { - await pollOnce(server, contractId) + await pollOnce(server, contractId!) } catch (err) { const handler = options?.onError ?? ((e) => console.error("[indexer] poll error", e)) handler(err) diff --git a/comebackhere-backend/src/routes/treasury.ts b/comebackhere-backend/src/routes/treasury.ts index 5965e30..5a46e19 100644 --- a/comebackhere-backend/src/routes/treasury.ts +++ b/comebackhere-backend/src/routes/treasury.ts @@ -12,6 +12,37 @@ import { connectMongo, getSettlementsCollection } from "../db/mongo.js" const router = Router() +// --------------------------------------------------------------------------- +// #212 — In-memory balance cache with TTL +// --------------------------------------------------------------------------- + +const BALANCE_CACHE_TTL_MS = 5_000 // 5 second TTL + +interface BalanceCacheEntry { + data: Array<{ token: string; balance: string }> + expiresAt: number +} + +let _balanceCache: BalanceCacheEntry | null = null + +/** Returns cached balances if still fresh, otherwise null. */ +export function getBalanceCache(): Array<{ token: string; balance: string }> | null { + if (_balanceCache && Date.now() < _balanceCache.expiresAt) { + return _balanceCache.data + } + return null +} + +/** Stores balance data in the cache with a fresh TTL. */ +export function setBalanceCache(data: Array<{ token: string; balance: string }>): void { + _balanceCache = { data, expiresAt: Date.now() + BALANCE_CACHE_TTL_MS } +} + +/** Immediately invalidates the balance cache (call after execute-settlement / withdrawal). */ +export function invalidateBalanceCache(): void { + _balanceCache = null +} + function requireEnv(res: Response): { rpcUrl: string treasuryContractId: string @@ -350,6 +381,8 @@ router.post("/execute-settlement", async (req: Request, res: Response) => { { settlement_id: settlementId, token_contract: req.body?.token_contract }, env, ) + // #212 — balance changed; evict the cache so the next GET /balances is fresh + invalidateBalanceCache() res.json(result) } catch (err: unknown) { const status = (err as { status?: number })?.status ?? 500 @@ -585,11 +618,19 @@ router.post("/escalate-hold", async (req: Request, res: Response) => { /** * GET /api/treasury/balances * Returns token balances held by the treasury contract. + * Results are cached for up to 5 seconds to reduce Soroban RPC load (#212). */ router.get("/balances", async (_req: Request, res: Response) => { const env = requireEnv(res) if (!env) return + // #212 — serve from cache when available + const cached = getBalanceCache() + if (cached) { + res.json(cached) + return + } + try { const client = buildSorobanClient(env.rpcUrl) const keypair = Keypair.fromSecret(env.signerSecret) @@ -603,7 +644,9 @@ router.get("/balances", async (_req: Request, res: Response) => { env.networkPassphrase, ) - res.json([{ token: env.usdcContractId, balance: balance.toString() }]) + const data = [{ token: env.usdcContractId, balance: balance.toString() }] + setBalanceCache(data) + res.json(data) } catch (err: unknown) { const status = (err as { status?: number })?.status ?? 500 const message = err instanceof Error ? err.message : String(err) diff --git a/comebackhere-backend/src/tests/compliance.test.ts b/comebackhere-backend/src/tests/compliance.test.ts new file mode 100644 index 0000000..6ac64a8 --- /dev/null +++ b/comebackhere-backend/src/tests/compliance.test.ts @@ -0,0 +1,350 @@ +/** + * Integration tests for the compliance allow/block endpoints (#211). + * + * Covers: + * - POST /compliance/allow — success, invalid address, missing address, + * 401 when admin key is wrong, 503 when env is missing + * - POST /compliance/block — success, invalid address, missing address, + * 401 when admin key is wrong, 503 when env is missing + * - callComplianceOp unit tests — allow, block, simulation error, + * send error, confirmation timeout + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { nativeToScVal, SorobanRpc, SorobanDataBuilder, xdr } from "stellar-sdk" +import request from "supertest" +import { createApp } from "../app.js" +import { callComplianceOp } from "../routes/compliance.js" + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_ADDRESS = "GDR7WUDWIKWVBCUBVYLOGT3TJF5FGNQU5U7TACDDA2ZIQUETGGUET5XT" +const SIGNER_SECRET = "SD6O7ZRNX5ILY5WSQR5CEWBYXRPWZNZARH3TWWPCVEC3Q5HC6D63BEJQ" +const COMPLIANCE_CONTRACT = "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW" +const ADMIN_KEY = "test-admin-secret" +const NETWORK = "Standalone Network ; February 2025" + +const ENV = { + SOROBAN_RPC_URL: "http://localhost:8000", + COMPLIANCE_CONTRACT_ID: COMPLIANCE_CONTRACT, + SIGNER_SECRET_KEY: SIGNER_SECRET, + NETWORK_PASSPHRASE: NETWORK, + ADMIN_KEY, +} + +// Pre-parsed simulation success result accepted by assembleTransaction +const PARSED_SIM_SUCCESS = { + _parsed: true, + latestLedger: 1, + events: [], + minResourceFee: "0", + transactionData: new SorobanDataBuilder(), + result: { auth: [], retval: xdr.ScVal.scvVoid() }, +} + +const fakeAccount = { + accountId: () => VALID_ADDRESS, + sequenceNumber: () => "100", + incrementSequenceNumber: vi.fn(), +} + +// --------------------------------------------------------------------------- +// Helper: build a minimal mock Soroban client for compliance.ts +// --------------------------------------------------------------------------- + +type ComplianceMockClient = { + getAccount: ReturnType + simulateTransaction: ReturnType + sendTransaction: ReturnType + getTransaction: ReturnType +} + +function makeMockClient(overrides: Partial = {}): ComplianceMockClient { + return { + getAccount: vi.fn().mockResolvedValue(fakeAccount), + simulateTransaction: vi.fn().mockResolvedValue(PARSED_SIM_SUCCESS), + sendTransaction: vi.fn().mockResolvedValue({ status: "PENDING", hash: "compliance-hash" }), + getTransaction: vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + latestLedger: 1, + latestLedgerCloseTime: 0, + oldestLedger: 1, + oldestLedgerCloseTime: 0, + }), + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Unit tests: callComplianceOp +// --------------------------------------------------------------------------- + +describe("callComplianceOp", () => { + it("returns Allowed status and hash when allow_address succeeds", async () => { + const client = makeMockClient() + const args = [nativeToScVal(VALID_ADDRESS, { type: "address" })] + + const result = await callComplianceOp( + "allow_address", + args, + client as any, + COMPLIANCE_CONTRACT, + SIGNER_SECRET, + NETWORK, + ) + + expect(result.status).toBe("Allowed") + expect(result.hash).toBe("compliance-hash") + }) + + it("returns Blocked status and hash when block_address succeeds", async () => { + const client = makeMockClient() + const args = [nativeToScVal(VALID_ADDRESS, { type: "address" })] + + const result = await callComplianceOp( + "block_address", + args, + client as any, + COMPLIANCE_CONTRACT, + SIGNER_SECRET, + NETWORK, + ) + + expect(result.status).toBe("Blocked") + expect(result.hash).toBe("compliance-hash") + }) + + it("returns AllowedUntil status for allow_address_until", async () => { + const client = makeMockClient() + const until = Math.floor(Date.now() / 1000) + 86_400 + const args = [ + nativeToScVal(VALID_ADDRESS, { type: "address" }), + nativeToScVal(until, { type: "u64" }), + ] + + const result = await callComplianceOp( + "allow_address_until", + args, + client as any, + COMPLIANCE_CONTRACT, + SIGNER_SECRET, + NETWORK, + ) + + expect(result.status).toBe("AllowedUntil") + }) + + it("throws 422 when simulation reports an error", async () => { + const client = makeMockClient({ + simulateTransaction: vi.fn().mockResolvedValue({ + error: "HostError: contract panic", + latestLedger: 1, + }), + }) + const args = [nativeToScVal(VALID_ADDRESS, { type: "address" })] + + await expect( + callComplianceOp( + "allow_address", + args, + client as any, + COMPLIANCE_CONTRACT, + SIGNER_SECRET, + NETWORK, + ), + ).rejects.toMatchObject({ status: 422, message: expect.stringMatching(/simulation failed/i) }) + }) + + it("throws 422 when sendTransaction returns ERROR", async () => { + const client = makeMockClient({ + sendTransaction: vi.fn().mockResolvedValue({ + status: "ERROR", + hash: "err-hash", + errorResult: { toXDR: () => "err-xdr" }, + }), + }) + const args = [nativeToScVal(VALID_ADDRESS, { type: "address" })] + + await expect( + callComplianceOp( + "block_address", + args, + client as any, + COMPLIANCE_CONTRACT, + SIGNER_SECRET, + NETWORK, + ), + ).rejects.toMatchObject({ status: 422, message: expect.stringMatching(/submission failed/i) }) + }) + + it("throws 504 when transaction confirmation times out", async () => { + const client = makeMockClient({ + getTransaction: vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.NOT_FOUND, + latestLedger: 1, + latestLedgerCloseTime: 0, + oldestLedger: 1, + oldestLedgerCloseTime: 0, + }), + }) + const args = [nativeToScVal(VALID_ADDRESS, { type: "address" })] + + await expect( + callComplianceOp( + "allow_address", + args, + client as any, + COMPLIANCE_CONTRACT, + SIGNER_SECRET, + NETWORK, + ), + ).rejects.toMatchObject({ status: 504, message: expect.stringMatching(/timeout/i) }) + }, 15_000) +}) + +// --------------------------------------------------------------------------- +// HTTP-layer tests: POST /compliance/allow +// --------------------------------------------------------------------------- + +describe("POST /compliance/allow", () => { + const app = createApp() + let envBackup: Record + + beforeEach(() => { + envBackup = {} + for (const key of Object.keys(ENV)) { + envBackup[key] = process.env[key] + process.env[key] = ENV[key as keyof typeof ENV] + } + }) + + afterEach(() => { + for (const [key, val] of Object.entries(envBackup)) { + if (val === undefined) delete process.env[key] + else process.env[key] = val + } + }) + + it("401 when x-admin-key header is missing", async () => { + const res = await request(app) + .post("/compliance/allow") + .send({ address: VALID_ADDRESS }) + expect(res.status).toBe(401) + expect(res.body.error).toMatch(/unauthorized/i) + }) + + it("401 when x-admin-key header is wrong", async () => { + const res = await request(app) + .post("/compliance/allow") + .set("x-admin-key", "wrong-key") + .send({ address: VALID_ADDRESS }) + expect(res.status).toBe(401) + }) + + it("400 when address is missing", async () => { + const res = await request(app) + .post("/compliance/allow") + .set("x-admin-key", ADMIN_KEY) + .send({}) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/address/) + }) + + it("400 when address is not a valid Stellar public key", async () => { + const res = await request(app) + .post("/compliance/allow") + .set("x-admin-key", ADMIN_KEY) + .send({ address: "NOT_A_STELLAR_KEY" }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/address/) + }) + + it("400 when until is provided but is not a positive integer", async () => { + const res = await request(app) + .post("/compliance/allow") + .set("x-admin-key", ADMIN_KEY) + .send({ address: VALID_ADDRESS, until: -1 }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/until/) + }) + + it("503 when required env vars are missing", async () => { + delete process.env.COMPLIANCE_CONTRACT_ID + const res = await request(app) + .post("/compliance/allow") + .set("x-admin-key", ADMIN_KEY) + .send({ address: VALID_ADDRESS }) + expect(res.status).toBe(503) + expect(res.body.error).toMatch(/misconfiguration/i) + }) +}) + +// --------------------------------------------------------------------------- +// HTTP-layer tests: POST /compliance/block +// --------------------------------------------------------------------------- + +describe("POST /compliance/block", () => { + const app = createApp() + let envBackup: Record + + beforeEach(() => { + envBackup = {} + for (const key of Object.keys(ENV)) { + envBackup[key] = process.env[key] + process.env[key] = ENV[key as keyof typeof ENV] + } + }) + + afterEach(() => { + for (const [key, val] of Object.entries(envBackup)) { + if (val === undefined) delete process.env[key] + else process.env[key] = val + } + }) + + it("401 when x-admin-key header is missing", async () => { + const res = await request(app) + .post("/compliance/block") + .send({ address: VALID_ADDRESS }) + expect(res.status).toBe(401) + expect(res.body.error).toMatch(/unauthorized/i) + }) + + it("401 when x-admin-key header is wrong", async () => { + const res = await request(app) + .post("/compliance/block") + .set("x-admin-key", "bad-key") + .send({ address: VALID_ADDRESS }) + expect(res.status).toBe(401) + }) + + it("400 when address is missing", async () => { + const res = await request(app) + .post("/compliance/block") + .set("x-admin-key", ADMIN_KEY) + .send({}) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/address/) + }) + + it("400 when address has invalid format (e.g. G... but not a real key)", async () => { + const res = await request(app) + .post("/compliance/block") + .set("x-admin-key", ADMIN_KEY) + .send({ address: "GNOTAVALIDADDRESSATALL" }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/address/) + }) + + it("503 when required env vars are missing", async () => { + delete process.env.SOROBAN_RPC_URL + const res = await request(app) + .post("/compliance/block") + .set("x-admin-key", ADMIN_KEY) + .send({ address: VALID_ADDRESS }) + expect(res.status).toBe(503) + expect(res.body.error).toMatch(/misconfiguration/i) + }) +}) diff --git a/comebackhere-backend/src/tests/indexer-redis-reconnect.test.ts b/comebackhere-backend/src/tests/indexer-redis-reconnect.test.ts new file mode 100644 index 0000000..3f9b2d9 --- /dev/null +++ b/comebackhere-backend/src/tests/indexer-redis-reconnect.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for the Redis reconnection/backoff handling in the indexer (#209). + * + * Verifies: + * 1. backoffDelayMs() produces values in the expected exponential range. + * 2. loadCursor() falls back to the in-memory cursor when Redis is down. + * 3. saveCursor() persists to Redis when available, falls back gracefully. + * 4. The indexer continues polling Soroban after a Redis disconnect — no + * events are lost and the indexer does not crash. + * 5. After reconnect the indexer resumes from the last saved cursor. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { + backoffDelayMs, + loadCursor, + saveCursor, + startIndexer, + stopIndexer, + persistTransition, + type InvoiceStateTransition, +} from "../indexer.js" + +// --------------------------------------------------------------------------- +// backoffDelayMs — unit tests +// --------------------------------------------------------------------------- + +describe("backoffDelayMs", () => { + it("returns a positive delay for attempt 0", () => { + expect(backoffDelayMs(0)).toBeGreaterThan(0) + }) + + it("delay grows with each attempt (roughly exponential)", () => { + const d0 = backoffDelayMs(0) + const d3 = backoffDelayMs(3) + const d6 = backoffDelayMs(6) + expect(d3).toBeGreaterThan(d0) + expect(d6).toBeGreaterThan(d3) + }) + + it("delay is capped below 30 000 ms + jitter", () => { + // At attempt 10, the raw exponential value is well above the cap. + // With 10 % jitter the max is 33 000 ms. + expect(backoffDelayMs(10)).toBeLessThanOrEqual(33_000) + }) + + it("returns a number (not NaN)", () => { + expect(Number.isFinite(backoffDelayMs(0))).toBe(true) + expect(Number.isFinite(backoffDelayMs(20))).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// loadCursor / saveCursor with injected Redis mock +// --------------------------------------------------------------------------- + +/** Minimal Redis-like mock. */ +function makeMockRedis(overrides: { + get?: ReturnType + set?: ReturnType + quit?: ReturnType +} = {}) { + return { + get: overrides.get ?? vi.fn().mockResolvedValue(null), + set: overrides.set ?? vi.fn().mockResolvedValue("OK"), + quit: overrides.quit ?? vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + } +} + +describe("loadCursor", () => { + afterEach(() => { + stopIndexer() + vi.clearAllMocks() + }) + + it("returns in-memory cursor when Redis returns null", async () => { + // Start the indexer with a null Redis client so we control the cursor + await startIndexer({ + rpcUrl: "http://localhost:8000", + contractId: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + _redisClient: null, + onError: () => {/* suppress poll errors */}, + }) + stopIndexer() + + // With null Redis, loadCursor falls back to in-memory cursor ("0" by default) + const cursor = await loadCursor() + expect(typeof cursor).toBe("string") + }) + + it("returns the Redis-stored cursor when Redis is available", async () => { + const mockRedis = makeMockRedis({ + get: vi.fn().mockResolvedValue("cursor-from-redis"), + }) + + await startIndexer({ + rpcUrl: "http://localhost:8000", + contractId: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + _redisClient: mockRedis as any, + onError: () => {}, + }) + stopIndexer() + + const cursor = await loadCursor() + expect(cursor).toBe("cursor-from-redis") + }) + + it("falls back to in-memory cursor when Redis.get throws", async () => { + const mockRedis = makeMockRedis({ + get: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")), + }) + + await startIndexer({ + rpcUrl: "http://localhost:8000", + contractId: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + _redisClient: mockRedis as any, + onError: () => {}, + }) + stopIndexer() + + // Should not throw — returns in-memory cursor instead + const cursor = await loadCursor() + expect(typeof cursor).toBe("string") + }) +}) + +describe("saveCursor", () => { + afterEach(() => { + stopIndexer() + vi.clearAllMocks() + }) + + it("writes to Redis and updates in-memory cursor", async () => { + const setMock = vi.fn().mockResolvedValue("OK") + const mockRedis = makeMockRedis({ set: setMock }) + + await startIndexer({ + rpcUrl: "http://localhost:8000", + contractId: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + _redisClient: mockRedis as any, + onError: () => {}, + }) + stopIndexer() + + await saveCursor("new-token-123") + + expect(setMock).toHaveBeenCalledWith("invoice_indexer_cursor", "new-token-123") + // In-memory cursor is also updated + const loaded = await loadCursor() + expect(loaded).toBe("new-token-123") + }) + + it("does not throw when Redis.set fails (graceful degradation)", async () => { + const mockRedis = makeMockRedis({ + set: vi.fn().mockRejectedValue(new Error("Redis disconnected")), + get: vi.fn().mockResolvedValue("new-token-456"), + }) + + await startIndexer({ + rpcUrl: "http://localhost:8000", + contractId: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + _redisClient: mockRedis as any, + onError: () => {}, + }) + stopIndexer() + + // Should resolve without throwing + await expect(saveCursor("new-token-456")).resolves.toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// Indexer continues polling after Redis drop (no gaps, no crash) +// --------------------------------------------------------------------------- + +describe("indexer resilience — dropped Redis connection", () => { + afterEach(() => { + stopIndexer() + vi.clearAllMocks() + }) + + it("indexer resumes polling Soroban after Redis becomes unavailable", async () => { + // Track how many times persistTransition is called (proxy for polling) + const transitions: InvoiceStateTransition[] = [] + const persistSpy = vi.spyOn({ persistTransition }, "persistTransition").mockImplementation( + (t) => transitions.push(t) + ) + + // Start with a Redis mock that initially works, then fails on set + let setCallCount = 0 + const mockRedis = makeMockRedis({ + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockImplementation(() => { + setCallCount++ + if (setCallCount > 1) { + return Promise.reject(new Error("Redis connection lost")) + } + return Promise.resolve("OK") + }), + }) + + let pollCount = 0 + const errors: unknown[] = [] + + await startIndexer({ + rpcUrl: "http://localhost:8000", + contractId: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + pollIntervalMs: 50, + _redisClient: mockRedis as any, + onError: (err) => { + pollCount++ + errors.push(err) + }, + }) + + // Wait two poll cycles + await new Promise((r) => setTimeout(r, 200)) + stopIndexer() + + // The indexer should not have thrown a fatal error due to the Redis drop. + // All errors should be Soroban RPC errors (no real server), not Redis errors. + for (const err of errors) { + if (err instanceof Error) { + // Should not be a Redis-kill-process-type error + expect(err.message).not.toMatch(/Redis connection lost.*crash/i) + } + } + + persistSpy.mockRestore() + }) + + it("cursor is saved to memory even when Redis is null", async () => { + // Start with no Redis + await startIndexer({ + rpcUrl: "http://localhost:8000", + contractId: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + _redisClient: null, + onError: () => {}, + }) + stopIndexer() + + // Directly save a cursor value + await saveCursor("memory-only-cursor") + + // In-memory cursor should reflect the saved value + const loaded = await loadCursor() + expect(loaded).toBe("memory-only-cursor") + }) +}) diff --git a/comebackhere-backend/src/tests/mongo-hardening.test.ts b/comebackhere-backend/src/tests/mongo-hardening.test.ts new file mode 100644 index 0000000..3218e0e --- /dev/null +++ b/comebackhere-backend/src/tests/mongo-hardening.test.ts @@ -0,0 +1,206 @@ +/** + * Tests for MongoDB connection hardening (#210). + * + * Verifies: + * 1. connectMongo() throws a clear error (status 503) when the server is + * unreachable, rather than hanging or emitting an opaque driver error. + * 2. The error message references the MONGODB_URI so the operator knows + * which endpoint failed. + * 3. connectMongo() succeeds (returns a Db) when the client connects + * without error. + * 4. Downstream routes return HTTP 500/503 (not a hang) when connectMongo + * rejects. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { MongoServerSelectionError } from "mongodb" +import request from "supertest" +import { createApp } from "../app.js" +import { _resetMongoSingleton } from "../db/mongo.js" + +// --------------------------------------------------------------------------- +// We mock the entire "mongodb" module so no real network calls are made. +// --------------------------------------------------------------------------- + +vi.mock("mongodb", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + MongoClient: vi.fn(), + } +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function getMongoClientMock() { + const { MongoClient } = await import("mongodb") + return vi.mocked(MongoClient) +} + +const ENV = { + SOROBAN_RPC_URL: "http://localhost:8000", + TREASURY_CONTRACT_ID: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + USDC_CONTRACT_ID: "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW", + SIGNER_SECRET_KEY: "SD6O7ZRNX5ILY5WSQR5CEWBYXRPWZNZARH3TWWPCVEC3Q5HC6D63BEJQ", + NETWORK_PASSPHRASE: "Standalone Network ; February 2025", + MONGODB_URI: "mongodb://unreachable-host:27017", +} + +// --------------------------------------------------------------------------- +// connectMongo unit tests +// --------------------------------------------------------------------------- + +describe("connectMongo — connection hardening", () => { + let envBackup: Record + + beforeEach(() => { + // Reset the module-level singleton so each test starts fresh + _resetMongoSingleton() + + envBackup = {} + for (const key of Object.keys(ENV)) { + envBackup[key] = process.env[key] + process.env[key] = ENV[key as keyof typeof ENV] + } + }) + + afterEach(async () => { + for (const [key, val] of Object.entries(envBackup)) { + if (val === undefined) delete process.env[key] + else process.env[key] = val + } + // Reset again so subsequent test suites aren't polluted + _resetMongoSingleton() + vi.clearAllMocks() + }) + + it("throws an error with status 503 when the server is unreachable", async () => { + const MongoClientMock = await getMongoClientMock() + const selectionError = new MongoServerSelectionError( + "connect ECONNREFUSED 127.0.0.1:27017", + ) + + MongoClientMock.mockImplementation(() => ({ + connect: vi.fn().mockRejectedValue(selectionError), + on: vi.fn(), + db: vi.fn(), + close: vi.fn(), + }) as any) + + const { connectMongo } = await import("../db/mongo.js") + + await expect(connectMongo()).rejects.toMatchObject({ + status: 503, + message: expect.stringMatching(/mongodb unreachable/i), + }) + }) + + it("includes the MONGODB_URI in the error message for operator clarity", async () => { + const MongoClientMock = await getMongoClientMock() + const selectionError = new MongoServerSelectionError( + "No servers found in topology", + ) + + MongoClientMock.mockImplementation(() => ({ + connect: vi.fn().mockRejectedValue(selectionError), + on: vi.fn(), + db: vi.fn(), + close: vi.fn(), + }) as any) + + const { connectMongo } = await import("../db/mongo.js") + + await expect(connectMongo()).rejects.toMatchObject({ + message: expect.stringMatching(/mongodb:\/\/unreachable-host/), + }) + }) + + it("wraps non-selection-error failures with status 503 and clear message", async () => { + const MongoClientMock = await getMongoClientMock() + + MongoClientMock.mockImplementation(() => ({ + connect: vi.fn().mockRejectedValue(new Error("ETIMEDOUT")), + on: vi.fn(), + db: vi.fn(), + close: vi.fn(), + }) as any) + + const { connectMongo } = await import("../db/mongo.js") + + await expect(connectMongo()).rejects.toMatchObject({ + status: 503, + message: expect.stringMatching(/failed to connect to mongodb/i), + }) + }) + + it("returns a Db handle when connection succeeds", async () => { + const MongoClientMock = await getMongoClientMock() + const fakeDb = { + collection: vi.fn().mockReturnValue({ + createIndex: vi.fn().mockResolvedValue({}), + }), + } + + MongoClientMock.mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + db: vi.fn().mockReturnValue(fakeDb), + close: vi.fn(), + }) as any) + + const { connectMongo } = await import("../db/mongo.js") + const result = await connectMongo() + + expect(result).toBe(fakeDb) + }) +}) + +// --------------------------------------------------------------------------- +// HTTP-layer: route returns 5xx (not a hang) when Mongo is down +// --------------------------------------------------------------------------- + +describe("GET /api/treasury/pending-settlements — Mongo outage returns 5xx", () => { + const app = createApp() + let envBackup: Record + + beforeEach(() => { + _resetMongoSingleton() + + envBackup = {} + for (const key of Object.keys(ENV)) { + envBackup[key] = process.env[key] + process.env[key] = ENV[key as keyof typeof ENV] + } + }) + + afterEach(() => { + for (const [key, val] of Object.entries(envBackup)) { + if (val === undefined) delete process.env[key] + else process.env[key] = val + } + _resetMongoSingleton() + vi.clearAllMocks() + }) + + it("returns 5xx with an error body when connectMongo rejects", async () => { + const MongoClientMock = await getMongoClientMock() + const selectionError = new MongoServerSelectionError("ECONNREFUSED") + + MongoClientMock.mockImplementation(() => ({ + connect: vi.fn().mockRejectedValue(selectionError), + on: vi.fn(), + db: vi.fn(), + close: vi.fn(), + }) as any) + + const res = await request(app).get("/api/treasury/pending-settlements") + + // Should respond with a 5xx status code — either 500 or 503 depending on + // whether the route catches the status property from the thrown error. + expect(res.status).toBeGreaterThanOrEqual(500) + expect(res.body).toHaveProperty("error") + expect(res.body.error).toMatch(/mongodb/i) + }, 10_000) +}) diff --git a/comebackhere-backend/src/tests/treasury-balances-cache.test.ts b/comebackhere-backend/src/tests/treasury-balances-cache.test.ts new file mode 100644 index 0000000..dd84bf7 --- /dev/null +++ b/comebackhere-backend/src/tests/treasury-balances-cache.test.ts @@ -0,0 +1,179 @@ +/** + * Tests for the treasury balances in-memory cache (#212). + * + * Verifies: + * 1. Repeated GET /api/treasury/balances calls within the TTL hit the cache + * (getTokenBalance mock is only called once). + * 2. The cache expires after the TTL, causing a fresh RPC call. + * 3. The cache is invalidated immediately after a successful execute-settlement. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import request from "supertest" +import { createApp } from "../app.js" +import { + getBalanceCache, + setBalanceCache, + invalidateBalanceCache, +} from "../routes/treasury.js" + +// --------------------------------------------------------------------------- +// Constants — valid Stellar credentials for env setup +// --------------------------------------------------------------------------- + +const SIGNER_SECRET = "SD6O7ZRNX5ILY5WSQR5CEWBYXRPWZNZARH3TWWPCVEC3Q5HC6D63BEJQ" +const TREASURY_CONTRACT = "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW" +const USDC_CONTRACT = "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW" +const INVOICE_CONTRACT = "CCV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XMCW" +const NETWORK = "Standalone Network ; February 2025" + +const ENV = { + SOROBAN_RPC_URL: "http://localhost:8000", + TREASURY_CONTRACT_ID: TREASURY_CONTRACT, + USDC_CONTRACT_ID: USDC_CONTRACT, + INVOICE_CONTRACT_ID: INVOICE_CONTRACT, + SIGNER_SECRET_KEY: SIGNER_SECRET, + NETWORK_PASSPHRASE: NETWORK, +} + +// --------------------------------------------------------------------------- +// Unit tests for the cache helpers +// --------------------------------------------------------------------------- + +describe("balance cache helpers", () => { + beforeEach(() => { + // Ensure every test starts with a clean cache + invalidateBalanceCache() + }) + + it("getBalanceCache returns null when nothing is cached", () => { + expect(getBalanceCache()).toBeNull() + }) + + it("setBalanceCache stores data and getBalanceCache returns it", () => { + const data = [{ token: USDC_CONTRACT, balance: "9999" }] + setBalanceCache(data) + expect(getBalanceCache()).toEqual(data) + }) + + it("invalidateBalanceCache clears the cache immediately", () => { + setBalanceCache([{ token: USDC_CONTRACT, balance: "1234" }]) + invalidateBalanceCache() + expect(getBalanceCache()).toBeNull() + }) + + it("cache expires after TTL", () => { + const data = [{ token: USDC_CONTRACT, balance: "500" }] + + vi.useFakeTimers() + + setBalanceCache(data) + // Within TTL — should be fresh + expect(getBalanceCache()).toEqual(data) + + // Advance time past the 5-second TTL + vi.advanceTimersByTime(6_000) + + expect(getBalanceCache()).toBeNull() + + vi.useRealTimers() + }) +}) + +// --------------------------------------------------------------------------- +// HTTP-layer tests: GET /api/treasury/balances uses the cache +// --------------------------------------------------------------------------- + +// We mock the soroban lib module so getTokenBalance is controllable +vi.mock("../lib/soroban.js", async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + buildSorobanClient: vi.fn(() => ({})), + getTokenBalance: vi.fn().mockResolvedValue(BigInt(10_000_000)), + } +}) + +describe("GET /api/treasury/balances — caching behaviour", () => { + const app = createApp() + let envBackup: Record + + beforeEach(async () => { + // Save and set env vars + envBackup = {} + for (const key of Object.keys(ENV)) { + envBackup[key] = process.env[key] + process.env[key] = ENV[key as keyof typeof ENV] + } + // Always start with a clean cache so tests are isolated + invalidateBalanceCache() + + // Reset mock call counts between tests + const { getTokenBalance } = await import("../lib/soroban.js") + vi.mocked(getTokenBalance).mockClear() + vi.mocked(getTokenBalance).mockResolvedValue(BigInt(10_000_000)) + }) + + afterEach(() => { + for (const [key, val] of Object.entries(envBackup)) { + if (val === undefined) delete process.env[key] + else process.env[key] = val + } + }) + + it("returns 200 with balance data", async () => { + const res = await request(app).get("/api/treasury/balances") + expect(res.status).toBe(200) + expect(res.body).toEqual([{ token: USDC_CONTRACT, balance: "10000000" }]) + }) + + it("repeated calls within TTL only call getTokenBalance once", async () => { + const { getTokenBalance } = await import("../lib/soroban.js") + + await request(app).get("/api/treasury/balances") + await request(app).get("/api/treasury/balances") + await request(app).get("/api/treasury/balances") + + expect(vi.mocked(getTokenBalance)).toHaveBeenCalledTimes(1) + }) + + it("returns the same cached value on repeated calls", async () => { + const res1 = await request(app).get("/api/treasury/balances") + const res2 = await request(app).get("/api/treasury/balances") + + expect(res1.body).toEqual(res2.body) + }) + + it("calls getTokenBalance again after cache is invalidated", async () => { + const { getTokenBalance } = await import("../lib/soroban.js") + + await request(app).get("/api/treasury/balances") + expect(vi.mocked(getTokenBalance)).toHaveBeenCalledTimes(1) + + invalidateBalanceCache() + + await request(app).get("/api/treasury/balances") + expect(vi.mocked(getTokenBalance)).toHaveBeenCalledTimes(2) + }) + + it("cache expires after TTL and next call fetches fresh data", async () => { + const { getTokenBalance } = await import("../lib/soroban.js") + + // Seed the cache with a known value + setBalanceCache([{ token: USDC_CONTRACT, balance: "10000000" }]) + + vi.useFakeTimers() + + // Advance past the 5-second TTL — the cache should now be stale + vi.advanceTimersByTime(6_000) + + // Cache should be empty, so next real call hits getTokenBalance + expect(getBalanceCache()).toBeNull() + + vi.useRealTimers() + + // Confirm a fresh HTTP call hits the mock (cache was cleared) + await request(app).get("/api/treasury/balances") + expect(vi.mocked(getTokenBalance)).toHaveBeenCalledTimes(1) + }) +})