From e437e35523499e92648967379b92e1c8fcee17f4 Mon Sep 17 00:00:00 2001 From: prodbycorne <277280234+prodbycorne@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:30:57 +0100 Subject: [PATCH] Fix CI: resolve unmerged conflicts left across the backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main was left in a broken state where several PRs' merge conflicts were concatenated instead of resolved, breaking every CI run: - package.json / package-lock.json had duplicate keys and a dropped closing brace, so `npm ci` failed with EJSONPARSE / EUSAGE. - src/index.js had three conflicting "how do we start/stop the server" implementations spliced together (duplicate `server` declarations, a syntax error), reconciled into one: the async startServer()/shutdown() design, with indexerPoller folded back in since it was dropped from that path by the bad merge but is still used elsewhere. - src/routes/airdrops.js had misplaced requires stuck mid-function, a duplicate router/upload/validateWithCurrentLedger, a dead broken validateAirdropCreate() left over from a since-superseded manual validator, and two POST routes each registered twice with different middleware (merged into one registration using every needed middleware). - src/services/priceOracle.js's SOURCES array had 6 entries instead of 3 (two competing circuit-breaker designs for the same 3 sources); merged into 3 entries carrying both the generic CircuitBreaker used for fetch-with-failover and the per-source getCircuitState used by the /health endpoint's price_source_circuits. - src/config.js had duplicate databaseUrl/stellar.* keys where a raw process.env fallback silently overrode the envalid-validated value. - Several test files (test/auth.test.js, test/alerts-routes.test.js, test/circuitBreaker.test.js, test/health.test.js, test/prices.test.js, test/airdrops.test.js) had the same pattern: duplicate mock methods, orphan fragments, or two whole test files concatenated together, fixed by keeping the version that matches current source behavior and removing dead duplicates. Also found and fixed a real, independent routing bug surfaced once the suite could finally run: src/routes/indexer.js registered `GET /airdrops/:id/recipients`, the exact same path as the airdrops CRUD API's paginated recipients endpoint. Since indexerRouter is mounted first, it silently shadowed the real endpoint in the full app — the paginated recipients list was unreachable in production. Renamed the indexer's route to `GET /airdrops/:id/onchain-recipients` (updated its test to match) since it serves indexed on-chain claim data, a different concern from the off-chain uploaded recipient list. npm ci, `npx @redocly/cli lint openapi.yaml`, and `npm test` (337 tests across 37 suites) all pass locally, matching CI's exact steps. --- package-lock.json | 4 ++ package.json | 3 -- src/config.js | 5 +- src/index.js | 65 +++++------------------ src/routes/airdrops.js | 61 +++++----------------- src/routes/indexer.js | 2 +- src/services/priceOracle.js | 5 +- test/airdrops.test.js | 21 ++------ test/alerts-routes.test.js | 17 ------ test/auth.test.js | 15 ------ test/circuitBreaker.test.js | 19 ++++--- test/health.test.js | 11 ++-- test/indexerRoutes.test.js | 2 +- test/prices.test.js | 101 ++++++------------------------------ 14 files changed, 70 insertions(+), 261 deletions(-) diff --git a/package-lock.json b/package-lock.json index f5724fc..275f376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5697,6 +5697,8 @@ "license": "MIT", "engines": { "node": ">=8.0.0" + } + }, "node_modules/swagger-ui-dist": { "version": "5.32.8", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", @@ -6126,6 +6128,8 @@ "license": "MIT", "engines": { "node": ">=0.4" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/package.json b/package.json index 062089d..f8c832d 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,6 @@ "swagger-ui-express": "^5.0.1", "winston": "^3.14.0", "winston-daily-rotate-file": "^5.0.0", - "zod": "^4.4.3", - "winston-daily-rotate-file": "^5.0.0" - "winston-daily-rotate-file": "^5.0.0", "ws": "^8.21.0", "yamljs": "^0.3.0", "zod": "^4.4.3" diff --git a/src/config.js b/src/config.js index 149ba78..e5feffe 100644 --- a/src/config.js +++ b/src/config.js @@ -111,12 +111,9 @@ module.exports = { redis: { url: env.REDIS_URL, }, - databaseUrl: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/smartdrop', stellar: { - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org', - sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-rpc.mainnet.stellar.gateway.fm', - usdcIssuer: process.env.USDC_ISSUER || 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', horizonUrl: env.STELLAR_HORIZON_URL, + sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-rpc.mainnet.stellar.gateway.fm', usdcIssuer, }, indexer: { diff --git a/src/index.js b/src/index.js index 991bfd0..eddc8fa 100644 --- a/src/index.js +++ b/src/index.js @@ -27,11 +27,7 @@ const apiDocsRouter = require('./routes/apiDocs'); const priceWebSocket = require('./ws/priceWebSocket'); const app = express(); -let server = { - close(callback) { - if (callback) callback(); - }, -}; +let server; app.use(requestIdMiddleware); app.use(helmet()); @@ -114,40 +110,11 @@ app.use('/api-docs', apiDocsRouter); app.use(notFoundHandler); app.use(errorHandler); -const server = app.listen(config.port, () => { - logger.info(`SmartDrop backend running on port ${config.port}`); - priceRefreshJob.start(); - indexerPoller.start(); -}); - -let server; - -if (require.main === module) { - server = app.listen(config.port, () => { - logger.info(`SmartDrop backend running on port ${config.port}`); - priceRefreshJob.start(); - }); -process.on('SIGTERM', async () => { - logger.info('SIGTERM received, shutting down'); - priceRefreshJob.stop(); - indexerPoller.stop(); - server.close(); - await cache.disconnect(); - process.exit(0); -}); - -process.on('SIGINT', async () => { - logger.info('SIGINT received, shutting down'); - priceRefreshJob.stop(); - indexerPoller.stop(); - server.close(); - await cache.disconnect(); - process.exit(0); -}); function shutdown(signal) { return async () => { logger.info(`${signal} received, shutting down`); priceRefreshJob.stop(); + indexerPoller.stop(); webhookRetryWorker.stop(); airdropExpiryJob.stop(); require('./ws/PriceSubscriptionManager').stopHeartbeat(); @@ -157,16 +124,6 @@ function shutdown(signal) { }; } -if (require.main === module) { - startServer().catch((err) => { - logger.error('Startup failed', { error: err.message }); - process.exit(1); - }); - - process.on('SIGTERM', shutdown('SIGTERM')); - process.on('SIGINT', shutdown('SIGINT')); -} - async function startServer() { await warmCache(config.watchedAssets); @@ -174,6 +131,7 @@ async function startServer() { logger.info(`SmartDrop backend running on port ${config.port}`); priceWebSocket.attach(server); priceRefreshJob.start(); + indexerPoller.start(); webhookRetryWorker.start(); airdropExpiryJob.start(); }); @@ -182,12 +140,15 @@ async function startServer() { return server; } +if (require.main === module) { + startServer().catch((err) => { + logger.error('Startup failed', { error: err.message }); + process.exit(1); + }); + + process.on('SIGTERM', shutdown('SIGTERM')); + process.on('SIGINT', shutdown('SIGINT')); +} + module.exports = { app, server }; -module.exports = app; -module.exports.app = app; -module.exports.server = server || { - close(callback) { - if (callback) callback(); - }, -}; module.exports.startServer = startServer; diff --git a/src/routes/airdrops.js b/src/routes/airdrops.js index f5c1472..3d770e2 100644 --- a/src/routes/airdrops.js +++ b/src/routes/airdrops.js @@ -16,9 +16,15 @@ const { recipientsSchema, routeIdParamsSchema, } = require('../validation/schemas'); +const buildRateLimit = require('../middleware/rateLimit'); +const { StrKey } = require('stellar-sdk'); const router = express.Router(); -const upload = multer(); +const CSV_PARSE_CHUNK_BYTES = 64 * 1024; +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: config.airdrops.csvMaxBytes }, +}); const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); const validatePaginationQuery = validate(paginationQuerySchema, 'query'); const validateRecipientBody = validate(airdropRecipientsBodySchema); @@ -31,15 +37,9 @@ function validateWithCurrentLedger(schemaFactory) { } catch (err) { logger.error('Airdrop validation error', { error: err.message }); return next(err); -const buildRateLimit = require('../middleware/rateLimit'); -const { StrKey } = require('stellar-sdk'); - -const router = express.Router(); -const CSV_PARSE_CHUNK_BYTES = 64 * 1024; -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: config.airdrops.csvMaxBytes }, -}); + } + }; +} const createAirdropLimit = buildRateLimit({ windowSeconds: config.airdrops.rateLimit.windowSeconds, @@ -75,41 +75,6 @@ function isValidStellarAddress(address) { } } -function validateAirdropCreate(body, currentLedger) { - const { name, asset, asset_issuer, total_amount, expiry_ledger, recipients = [] } = body; - - if (!name || typeof name !== 'string') { - return 'name is required and must be a string'; - } - if (!asset || typeof asset !== 'string' || !/^[A-Z0-9]{1,12}$/i.test(asset)) { - return 'asset is required and must be 1-12 alphanumeric characters'; - } - if (!asset_issuer || !isValidStellarAddress(asset_issuer)) { - return 'asset_issuer is required and must be a valid Stellar address'; - } - if (typeof total_amount !== 'number' || total_amount <= 0) { - return 'total_amount is required and must be a positive number'; - } - if (typeof expiry_ledger !== 'number' || expiry_ledger <= currentLedger) { - return `expiry_ledger is required and must be greater than current ledger (${currentLedger})`; - } - if (recipients.length > config.airdrops.maxRecipients) { - return 'recipients cannot exceed 10,000'; - } - - const recipientSet = new Set(); - let sum = 0; - for (let i = 0; i < recipients.length; i++) { - const r = recipients[i]; - if (!r.address || !isValidStellarAddress(r.address)) { - return `recipient ${i}: invalid Stellar address`; - } - if (recipientSet.has(r.address)) { - return `recipient ${i}: duplicate address ${r.address}`; - } - }; -} - function parseRecipients(recipients, next) { const result = recipientsSchema.safeParse(recipients); if (!result.success) { @@ -147,8 +112,7 @@ async function parseCSV(buffer) { return results; } -router.post('/airdrops', validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => { -router.post('/airdrops', createAirdropLimit, async (req, res, next) => { +router.post('/airdrops', createAirdropLimit, validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => { try { const airdrop = await airdropsService.create(req.validated.body); return res.status(201).json(airdrop); @@ -221,8 +185,7 @@ router.post('/airdrops/:id/cancel', validateRouteIdParams, async (req, res, next } }); -router.post('/airdrops/:id/recipients', validateRouteIdParams, upload.single('file'), validateRecipientBody, async (req, res, next) => { -router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile, async (req, res, next) => { +router.post('/airdrops/:id/recipients', validateRouteIdParams, addRecipientsLimit, uploadRecipientsFile, validateRecipientBody, async (req, res, next) => { try { const airdrop = await airdropsService.get(req.params.id); if (!airdrop) { diff --git a/src/routes/indexer.js b/src/routes/indexer.js index 2c16300..22e0271 100644 --- a/src/routes/indexer.js +++ b/src/routes/indexer.js @@ -31,7 +31,7 @@ router.get('/airdrops/:id/status', async (req, res) => { } }); -router.get('/airdrops/:id/recipients', async (req, res) => { +router.get('/airdrops/:id/onchain-recipients', async (req, res) => { try { if (!isValidId(req.params.id)) { return res.status(400).json({ error: 'Invalid airdrop id' }); diff --git a/src/services/priceOracle.js b/src/services/priceOracle.js index 4bbea43..6147e9f 100644 --- a/src/services/priceOracle.js +++ b/src/services/priceOracle.js @@ -19,15 +19,14 @@ const SOURCES = [ name: 'coingecko', fetch: coingecko.fetchPrice, breaker: new CircuitBreaker('coingecko', breakerOptions), + getCircuitState: coingecko.getCircuitState, }, { name: 'coinmarketcap', fetch: coinmarketcap.fetchPrice, breaker: new CircuitBreaker('coinmarketcap', breakerOptions), + getCircuitState: coinmarketcap.getCircuitState, }, - { name: 'stellar_dex', fetch: stellarDex.fetchPrice }, - { name: 'coingecko', fetch: coingecko.fetchPrice, getCircuitState: coingecko.getCircuitState }, - { name: 'coinmarketcap', fetch: coinmarketcap.fetchPrice, getCircuitState: coinmarketcap.getCircuitState }, ]; /** diff --git a/test/airdrops.test.js b/test/airdrops.test.js index 932de22..9839baf 100644 --- a/test/airdrops.test.js +++ b/test/airdrops.test.js @@ -2,7 +2,6 @@ const mockStore = new Map(); const mockSets = new Map(); -const mockSortedSets = new Map(); const mockZSets = new Map(); const mockLists = new Map(); const mockCounters = new Map(); @@ -17,20 +16,6 @@ const mockRedis = { mockSets.get(key)?.delete(val); }), zadd: jest.fn(async (key, score, member) => { - if (!mockSortedSets.has(key)) mockSortedSets.set(key, new Map()); - mockSortedSets.get(key).set(member, score); - }), - zrem: jest.fn(async (key, member) => { - mockSortedSets.get(key)?.delete(member); - }), - zcard: jest.fn(async (key) => mockSortedSets.get(key)?.size || 0), - zrevrange: jest.fn(async (key, start, stop) => { - const sortedSet = mockSortedSets.get(key); - if (!sortedSet) return []; - const entries = Array.from(sortedSet.entries()).sort((a, b) => b[1] - a[1]); - const startIdx = start === -1 ? entries.length + start : start; - const stopIdx = stop === -1 ? entries.length + stop : stop; - return entries.slice(startIdx, stopIdx + 1).map(([member]) => member); if (!mockZSets.has(key)) mockZSets.set(key, new Map()); mockZSets.get(key).set(member, Number(score)); }), @@ -118,6 +103,9 @@ jest.mock('stellar-sdk', () => ({ StrKey: { isValidEd25519PublicKey: jest.fn((address) => address.startsWith('G') && address.length === 56), }, + SorobanRpc: { + Server: jest.fn(() => ({})), + }, })); const request = require('supertest'); @@ -127,14 +115,11 @@ let app; beforeAll(() => { app = require('../src/index').app; - const { app: importedApp } = require('../src/index'); - app = importedApp; }); beforeEach(() => { mockStore.clear(); mockSets.clear(); - mockSortedSets.clear(); mockZSets.clear(); mockLists.clear(); mockCounters.clear(); diff --git a/test/alerts-routes.test.js b/test/alerts-routes.test.js index fd83264..0ff9401 100644 --- a/test/alerts-routes.test.js +++ b/test/alerts-routes.test.js @@ -5,7 +5,6 @@ process.env.ADMIN_API_KEY = adminApiKey; const mockStore = new Map(); const mockSortedSets = new Map(); -const mockZSets = new Map(); const mockRedis = { smembers: jest.fn(async () => []), @@ -25,22 +24,6 @@ const mockRedis = { const stopIdx = stop === -1 ? entries.length + stop : stop; return entries.slice(startIdx, stopIdx + 1).map(([member]) => member); }), - if (!mockZSets.has(key)) mockZSets.set(key, new Map()); - mockZSets.get(key).set(member, Number(score)); - }), - zrem: jest.fn(async (key, ...members) => { - const z = mockZSets.get(key); - if (!z) return; - for (const m of members) z.delete(m); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const z = mockZSets.get(key); - if (!z) return []; - const sorted = [...z.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m); - const end = stop === -1 ? sorted.length : stop + 1; - return sorted.slice(start, end); - }), - zcard: jest.fn(async (key) => (mockZSets.get(key)?.size || 0)), }; jest.mock('../src/services/cache', () => ({ diff --git a/test/auth.test.js b/test/auth.test.js index 80346d0..c432e3e 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -7,7 +7,6 @@ const crypto = require('crypto'); const mockStore = new Map(); const mockSets = new Map(); const mockSortedSets = new Map(); -const mockZSets = new Map(); const mockRedis = { smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]), @@ -32,20 +31,6 @@ const mockRedis = { const startIdx = start === -1 ? entries.length + start : start; const stopIdx = stop === -1 ? entries.length + stop : stop; return entries.slice(startIdx, stopIdx + 1).map(([member]) => member); - if (!mockZSets.has(key)) mockZSets.set(key, new Map()); - mockZSets.get(key).set(member, Number(score)); - }), - zrem: jest.fn(async (key, ...members) => { - const z = mockZSets.get(key); - if (!z) return; - for (const m of members) z.delete(m); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const z = mockZSets.get(key); - if (!z) return []; - const sorted = [...z.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m); - const end = stop === -1 ? sorted.length : stop + 1; - return sorted.slice(start, end); }), }; diff --git a/test/circuitBreaker.test.js b/test/circuitBreaker.test.js index 817e495..5f6b425 100644 --- a/test/circuitBreaker.test.js +++ b/test/circuitBreaker.test.js @@ -1,5 +1,14 @@ 'use strict'; +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; + +jest.mock('../src/logger', () => mockLogger); + const { CircuitBreaker, STATES } = require('../src/utils/circuitBreaker'); function buildBreaker(options = {}) { @@ -85,14 +94,8 @@ describe('CircuitBreaker', () => { })).rejects.toThrow('rate limited'); expect(breaker.getState()).toBe(STATES.CLOSED); -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -}; - -jest.mock('../src/logger', () => mockLogger); + }); +}); function loadCircuitBreaker() { jest.resetModules(); diff --git a/test/health.test.js b/test/health.test.js index 6a0dd3d..9925e26 100644 --- a/test/health.test.js +++ b/test/health.test.js @@ -13,27 +13,30 @@ jest.mock('../src/services/priceOracle', () => ({ coinmarketcap: 'open', stellar_dex: 'half-open', })), + getSourceCircuitStates: jest.fn(() => [ + { source: 'coingecko', open: false, openUntil: null }, + { source: 'coinmarketcap', open: false, openUntil: null }, + ]), refreshAllCachedPrices: jest.fn(), })); jest.mock('../src/jobs/priceRefresh', () => ({ start: jest.fn(), stop: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), })); jest.mock('../src/jobs/webhookRetryWorker', () => ({ start: jest.fn(), stop: jest.fn(), + tick: jest.fn(), + getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), })); jest.mock('../src/ws/priceWebSocket', () => ({ attach: jest.fn(), })); -describe('health endpoint', () => { - test('exposes price source circuit states', async () => { - jest.resetModules(); - const { app } = require('../src/index'); // --------------------------------------------------------------------------- // Helpers – reset modules between tests so mocks are applied cleanly // --------------------------------------------------------------------------- diff --git a/test/indexerRoutes.test.js b/test/indexerRoutes.test.js index 673daaa..e933f8f 100644 --- a/test/indexerRoutes.test.js +++ b/test/indexerRoutes.test.js @@ -72,7 +72,7 @@ describe('indexer routes', () => { test('returns indexed recipients', async () => { mockGetAirdropRecipients.mockResolvedValue([{ recipient: 'GRECIPIENT', status: 'claimed' }]); - const res = await request(buildApp()).get('/api/v1/airdrops/drop-1/recipients'); + const res = await request(buildApp()).get('/api/v1/airdrops/drop-1/onchain-recipients'); expect(res.status).toBe(200); expect(res.body.recipients).toHaveLength(1); diff --git a/test/prices.test.js b/test/prices.test.js index e9604c7..f7175a5 100644 --- a/test/prices.test.js +++ b/test/prices.test.js @@ -8,6 +8,8 @@ jest.mock('../src/services/cache', () => ({ del: jest.fn(), disconnect: jest.fn(), isConnected: jest.fn(() => false), +})); + process.env.ADMIN_API_KEY = 'b'.repeat(64); const express = require('express'); @@ -28,20 +30,12 @@ jest.mock('../src/logger', () => ({ debug: jest.fn(), })); -jest.mock('../src/services/priceOracle', () => ({ - getPrice: jest.fn(), - fetchFreshPrice: jest.fn(), - refreshAllCachedPrices: jest.fn(), -})); - jest.mock('../src/services/apiKeys', () => ({ validateApiKey: jest.fn(), })); // --- Imports --- -const request = require('supertest'); -const { app } = require('../src'); const priceOracle = require('../src/services/priceOracle'); const apiKeys = require('../src/services/apiKeys'); @@ -65,43 +59,9 @@ const PRICE_STALE = { stale_warning: 'Price is 35.0 minutes old (threshold: 30 min)', }; -const PRICE_NULL = { - asset_code: 'UNKNOWN', - issuer: null, - price_usd: null, - source: 'unavailable', - fetched_at: '2024-01-01T00:00:00.000Z', - is_stale: true, - stale_warning: 'No price data available from any source', - sources_attempted: [], - redis_unavailable: false, -}; - // Valid 56-char Stellar address (G + 55 uppercase alphanumeric chars) const VALID_ISSUER = 'G' + 'A'.repeat(55); -// --- GET /api/v1/prices/:asset_code --- - -describe('GET /api/v1/prices/:asset_code', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('happy path — 200 with full response shape', async () => { - priceOracle.getPrice.mockResolvedValue(PRICE_HAPPY); - - const res = await request(app).get('/api/v1/prices/XLM'); - - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - asset_code: 'XLM', - issuer: null, - price_usd: expect.any(Number), - source: expect.any(String), - fetched_at: expect.any(String), - is_stale: false, - stale_warning: null, - sources_attempted: expect.any(Array), const pricesRouter = require('../src/routes/prices'); const logger = require('../src/logger'); const { errorHandler } = require('../src/middleware/errorHandler'); @@ -175,23 +135,6 @@ describe('price routes', () => { expect(res.body.stale_warning.length).toBeGreaterThan(0); }); - test('oracle throws — 500 with generic message, no internal details leaked', async () => { - priceOracle.getPrice.mockRejectedValue(new Error('DB connection failed')); - - const res = await request(app).get('/api/v1/prices/XLM'); - - expect(res.status).toBe(500); - expect(res.body).toMatchObject({ - error: 'Internal server error', - message: 'Failed to fetch price data', - }); - expect(res.body).not.toHaveProperty('stack'); - expect(JSON.stringify(res.body)).not.toContain('DB connection failed'); - }); - - // NOTE: issue #9 expects 200 when price_usd is null; the route actually returns 404. - test('unknown asset (price_usd: null) — 404 with error body', async () => { - priceOracle.getPrice.mockResolvedValue(PRICE_NULL); test('GET /prices/:asset_code preserves stale warnings from the oracle', async () => { mockGetPrice.mockResolvedValueOnce( priceResponse({ @@ -238,8 +181,8 @@ describe('price routes', () => { const res = await request(app).get('/api/v1/prices/UNKNOWN'); expect(res.status).toBe(404); - expect(res.body).toMatchObject({ - error: 'Price not available', + expect(res.body.error).toMatchObject({ + code: 'NOT_FOUND', message: expect.stringContaining('UNKNOWN'), }); }); @@ -260,20 +203,6 @@ describe('price routes', () => { expect(priceOracle.getPrice).toHaveBeenCalledWith('USDC', VALID_ISSUER); }); - test('invalid asset code (>12 chars) — 400', async () => { - const res = await request(app).get('/api/v1/prices/TOOLONGCODE123'); - - expect(res.status).toBe(400); - expect(res.body).toMatchObject({ error: 'Invalid asset code' }); - }); - - test('malformed issuer — 400', async () => { - const res = await request(app).get('/api/v1/prices/XLM?issuer=BADISSUER'); - - expect(res.status).toBe(400); - expect(res.body).toMatchObject({ error: 'Invalid issuer' }); - }); - test('Redis unavailable — 200 with redis_unavailable: true (graceful degradation)', async () => { priceOracle.getPrice.mockResolvedValue({ ...PRICE_HAPPY, redis_unavailable: true }); @@ -288,15 +217,21 @@ describe('price routes', () => { // --- GET /api/v1/prices/:asset_code/refresh --- describe('GET /api/v1/prices/:asset_code/refresh', () => { + let app; + beforeEach(() => { - jest.clearAllMocks(); + app = buildApp(); + mockGetPrice.mockReset(); + mockFetchFreshPrice.mockReset(); + apiKeys.validateApiKey.mockReset(); + logger.error.mockClear(); }); test('no Authorization header — 401', async () => { const res = await request(app).get('/api/v1/prices/XLM/refresh'); expect(res.status).toBe(401); - expect(res.body).toMatchObject({ error: 'Missing or invalid API key' }); + expect(res.body.error).toMatchObject({ code: 'UNAUTHORIZED', message: 'Missing or invalid API key' }); }); test('invalid API key — 401', async () => { @@ -307,7 +242,7 @@ describe('GET /api/v1/prices/:asset_code/refresh', () => { .set('Authorization', 'Bearer bad-key'); expect(res.status).toBe(401); - expect(res.body).toMatchObject({ error: 'Missing or invalid API key' }); + expect(res.body.error).toMatchObject({ code: 'UNAUTHORIZED', message: 'Missing or invalid API key' }); }); test('valid API key — 200 with full response shape', async () => { @@ -334,16 +269,9 @@ describe('GET /api/v1/prices/:asset_code/refresh', () => { .set('Authorization', 'Bearer valid-key'); expect(res.status).toBe(500); - expect(res.body).toMatchObject({ - error: 'Internal server error', - message: 'Failed to refresh price data', - }); + expect(res.body.error).toMatchObject({ code: 'INTERNAL_ERROR' }); expect(res.body).not.toHaveProperty('stack'); expect(JSON.stringify(res.body)).not.toContain('External source failed'); - expect(res.body.error).toMatchObject({ - code: 'NOT_FOUND', - message: 'No price data found for UNKNOWN', - }); }); test('GET /prices/:asset_code rejects invalid asset codes before oracle lookup', async () => { @@ -387,6 +315,7 @@ describe('GET /api/v1/prices/:asset_code/refresh', () => { }); test('GET /prices/:asset_code/refresh validates params and calls fresh oracle lookup', async () => { + apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); mockFetchFreshPrice.mockResolvedValueOnce( priceResponse({ asset_code: 'USDC',