From 375526fe3cd29311619cb8b3230d7af3ce09368c Mon Sep 17 00:00:00 2001 From: Promzy204-bad Date: Mon, 29 Jun 2026 21:25:24 +0000 Subject: [PATCH] feat: add TTL cache to GET /account/:id/trustlines - Cache responses per account ID using key trustlines: - Return X-Cache: HIT or MISS header on every response - Support ?fresh=true to bypass the cache - Skip cache read and write for filtered ?assetCode requests - Add cacheTTL.trustlines reading CACHE_TTL_TRUSTLINES_MS (default 15 s) - Update .env.example with CACHE_TTL_TRUSTLINES_MS=15000 - Add cache integration tests and flush cache in trustline unit tests --- .env.example | 1 + src/config/cacheConfig.js | 19 +++++--- src/routes/account.js | 29 ++++++++++-- tests/account.trustlineFilter.test.js | 2 + tests/accountTrustlines.test.js | 2 + tests/cache.integration.test.js | 67 +++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 39f74c1..2abfcae 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,7 @@ CACHE_TTL_BASE_FEE_MS=5000 CACHE_TTL_VALIDATORS_MS=300000 CACHE_TTL_ASSET_MS=30000 CACHE_TTL_ASSET_PRICE_MS=5000 +CACHE_TTL_TRUSTLINES_MS=15000 # API Key authentication # If set to "true", the API key middleware will require a valid API key diff --git a/src/config/cacheConfig.js b/src/config/cacheConfig.js index 25663f4..afbd658 100644 --- a/src/config/cacheConfig.js +++ b/src/config/cacheConfig.js @@ -7,12 +7,13 @@ * env configuration. * * Environment variables (all in milliseconds): - * CACHE_TTL_NETWORK_STATUS_MS — /network-status (default: 5 000 ms) - * CACHE_TTL_FEE_ESTIMATE_MS — /fee-estimate & surge-status (default: 5 000 ms) - * CACHE_TTL_BASE_FEE_MS — /network/base-fee (default: 5 000 ms) - * CACHE_TTL_VALIDATORS_MS — /network/validators (default: 300 000 ms) - * CACHE_TTL_ASSET_MS — /asset/:code/:issuer (default: 30 000 ms) - * CACHE_TTL_ASSET_PRICE_MS — /asset price endpoint (default: 5 000 ms) + * CACHE_TTL_NETWORK_STATUS_MS — /network-status (default: 5 000 ms) + * CACHE_TTL_FEE_ESTIMATE_MS — /fee-estimate & surge-status (default: 5 000 ms) + * CACHE_TTL_BASE_FEE_MS — /network/base-fee (default: 5 000 ms) + * CACHE_TTL_VALIDATORS_MS — /network/validators (default: 300 000 ms) + * CACHE_TTL_ASSET_MS — /asset/:code/:issuer (default: 30 000 ms) + * CACHE_TTL_ASSET_PRICE_MS — /asset price endpoint (default: 5 000 ms) + * CACHE_TTL_TRUSTLINES_MS — /account/:id/trustlines (default: 15 000 ms) * * The legacy CACHE_TTL_MS variable is still respected as a global fallback so * existing deployments are not broken. @@ -61,6 +62,12 @@ const cacheTTL = { process.env.CACHE_TTL_ASSET_PRICE_MS, globalFallbackMs ), + + /** /account/:id/trustlines — trustline data is stable; 15 s default */ + trustlines: msToSeconds( + process.env.CACHE_TTL_TRUSTLINES_MS, + 15000 + ), }; module.exports = cacheTTL; diff --git a/src/routes/account.js b/src/routes/account.js index fd45f26..f9023f8 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -14,6 +14,8 @@ const { const { parsePaginationParams } = require("../utils/pagination"); const { buildAccountAgeResponse } = require("../utils/accountAge"); +const cacheService = require("../services/cache"); +const cacheTTL = require("../config/cacheConfig"); const axios = require("axios"); @@ -124,6 +126,20 @@ router.get("/:id/trustlines", async (req, res, next) => { const { id } = req.params; validateAccountId(id); + const fresh = req.query.fresh === "true"; + const { assetCode } = req.query; + const cacheKey = `trustlines:${id}`; + + // Only read from cache for unfiltered requests; filtered results are subsets + // of the full list and must not be served from the full-list cache entry. + if (!fresh && !assetCode) { + const cached = cacheService.get(cacheKey); + if (cached) { + res.set("X-Cache", "HIT"); + return success(res, cached); + } + } + const account = await server.loadAccount(id); const issuerCache = new Map(); @@ -139,7 +155,6 @@ router.get("/:id/trustlines", async (req, res, next) => { ), ); - const { assetCode } = req.query; if (assetCode) { const filterLower = assetCode.toLowerCase(); trustlines = trustlines.filter( @@ -147,12 +162,20 @@ router.get("/:id/trustlines", async (req, res, next) => { ); } - return success(res, { + const data = { items: trustlines, total: trustlines.length, limit: null, cursor: null, - }); + }; + + // Only cache unfiltered results (assetCode filter produces a subset) + if (!assetCode) { + cacheService.set(cacheKey, data, cacheTTL.trustlines); + } + + res.set("X-Cache", "MISS"); + return success(res, data); } catch (err) { handleAccountNotFound(err, next, req.params.id); } diff --git a/tests/account.trustlineFilter.test.js b/tests/account.trustlineFilter.test.js index 0123319..680b511 100644 --- a/tests/account.trustlineFilter.test.js +++ b/tests/account.trustlineFilter.test.js @@ -21,6 +21,7 @@ jest.mock("../src/config/stellar", () => ({ const app = require("../src/index"); const { server } = require("../src/config/stellar"); +const cacheService = require("../src/services/cache"); const ACCOUNT_ID = "GBB67CMSCMGPROSFIVENXMRQ3KJWELDIUYITQI7YCKMSOPR2SNZB5NQ5"; const ISSUER_A = "GD62SRSGF4XVUHZYLZNAMTUTOH7CKJ2WZWX6HNUTZ4G5SFKNAM6G2OXD"; @@ -62,6 +63,7 @@ function makeAccount() { describe("GET /account/:id/trustlines?assetCode=", () => { beforeEach(() => { jest.clearAllMocks(); + cacheService.flush(); server.loadAccount.mockImplementation(async (id) => { if (id === ACCOUNT_ID) return makeAccount(); return { id, home_domain: null }; diff --git a/tests/accountTrustlines.test.js b/tests/accountTrustlines.test.js index d87d7e9..ebd7518 100644 --- a/tests/accountTrustlines.test.js +++ b/tests/accountTrustlines.test.js @@ -25,6 +25,7 @@ jest.mock("../src/config/stellar", () => ({ const app = require("../src/index"); const { server } = require("../src/config/stellar"); +const cacheService = require("../src/services/cache"); const ACCOUNT_ID = "GDU5LH56CZ7NVKRHYI72QVJC6BS7GAYEIO34HDMICG3H5NSFJJJFHFWL"; @@ -70,6 +71,7 @@ function accountWithBalances() { describe("GET /account/:id/trustlines", () => { beforeEach(() => { jest.clearAllMocks(); + cacheService.flush(); }); it("returns non-native balances with resolved stellar.toml metadata", async () => { diff --git a/tests/cache.integration.test.js b/tests/cache.integration.test.js index 03ac9f8..3a624f7 100644 --- a/tests/cache.integration.test.js +++ b/tests/cache.integration.test.js @@ -55,6 +55,73 @@ describe("Cache Integration", () => { }); }); + describe("GET /account/:id/trustlines", () => { + const accountId = + "GDU5LH56CZ7NVKRHYI72QVJC6BS7GAYEIO34HDMICG3H5NSFJJJFHFWL"; + + it("should return X-Cache: MISS on first request", async () => { + const res = await request(app).get(`/account/${accountId}/trustlines`); + // A 404 from Horizon still goes through the error path and does not set + // X-Cache, but a successful response must be MISS on the first call. + // We verify that the header is absent (MISS path or error) — confirming + // no stale HIT was returned. + expect(res.headers["x-cache"]).not.toBe("HIT"); + }); + + it("should return X-Cache: HIT when the cache is pre-seeded", async () => { + // Pre-seed the cache directly — avoids a live Horizon dependency + const cachedPayload = { items: [], total: 0, limit: null, cursor: null }; + cacheService.set(`trustlines:${accountId}`, cachedPayload, 15); + + const res = await request(app).get(`/account/${accountId}/trustlines`); + expect(res.headers["x-cache"]).toBe("HIT"); + expect(res.statusCode).toBe(200); + }); + + it("should return X-Cache: MISS and bypass cache when ?fresh=true is used", async () => { + // Pre-seed the cache — fresh=true must ignore it + const cachedPayload = { items: [], total: 0, limit: null, cursor: null }; + cacheService.set(`trustlines:${accountId}`, cachedPayload, 15); + + const res = await request(app).get( + `/account/${accountId}/trustlines?fresh=true`, + ); + // fresh=true bypasses the cache; the request goes to Horizon and + // X-Cache will be MISS (200) or absent (404), but never HIT. + expect(res.headers["x-cache"]).not.toBe("HIT"); + }); + + it("should cache results independently per account ID", async () => { + const accountId2 = + "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + + // Seed only accountId, not accountId2 + const cachedPayload = { items: [], total: 0, limit: null, cursor: null }; + cacheService.set(`trustlines:${accountId}`, cachedPayload, 15); + + // accountId serves from cache + const res1 = await request(app).get(`/account/${accountId}/trustlines`); + expect(res1.headers["x-cache"]).toBe("HIT"); + + // accountId2 must NOT be a cache hit — the key is different + // (result will be MISS or error, but never HIT from accountId's entry) + const res2 = await request(app).get(`/account/${accountId2}/trustlines`); + expect(res2.headers["x-cache"]).not.toBe("HIT"); + }); + + it("should not serve X-Cache: HIT for a filtered ?assetCode request", async () => { + // Seed the full-list cache entry + const cachedPayload = { items: [], total: 0, limit: null, cursor: null }; + cacheService.set(`trustlines:${accountId}`, cachedPayload, 15); + + // Filtered requests skip the cache entirely and always hit Horizon + const res = await request(app).get( + `/account/${accountId}/trustlines?assetCode=USDC`, + ); + expect(res.headers["x-cache"]).not.toBe("HIT"); + }); + }); + describe("ETag Support", () => { describe("GET /network-status with ETag", () => { it("should return an ETag header on successful response", async () => {