diff --git a/.gitignore b/.gitignore index e44d172..85f5e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,26 @@ coverage/ # Lock files — project uses pnpm; npm lock file is not committed package-lock.json + +# Editor and IDE files +.idea/ +*.swp +*.swo +*~ + +# OS generated files +Thumbs.db +Desktop.ini + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Temporary files +tmp/ +temp/ +.tmp/ diff --git a/src/modules/indexer/ledger-gap-detection.service.ts b/src/modules/indexer/ledger-gap-detection.service.ts index b10b88e..4e3c682 100644 --- a/src/modules/indexer/ledger-gap-detection.service.ts +++ b/src/modules/indexer/ledger-gap-detection.service.ts @@ -52,21 +52,16 @@ export async function detectLedgerGap(): Promise { const networkHead = await fetchStellarNetworkHead(); const gapSize = networkHead - indexedLedger.ledger; - if (gapSize > 10) { - // Gap threshold: 10 ledgers (~50 seconds at 5s/ledger) + if (gapSize > 50) { + // Gap threshold: 50 ledgers (~250 seconds at 5s/ledger) logger.warn( { - event: 'ledger_gap_detected', - lastProcessed: indexedLedger.ledger, - networkHead, - gapSize, - gapRange: { - start: indexedLedger.ledger + 1, - end: networkHead, - }, - updatedAt: indexedLedger.updatedAt.toISOString(), + latest_processed_ledger: indexedLedger.ledger, + latest_network_ledger: networkHead, + gap: gapSize, + detected_at: new Date().toISOString(), }, - `Ledger gap detected: ${gapSize} ledgers behind (${indexedLedger.ledger} → ${networkHead})` + `Indexer is behind by ${gapSize} ledgers (${indexedLedger.ledger} → ${networkHead})` ); return { diff --git a/src/modules/indexer/ledger-lag-warning.unit.test.ts b/src/modules/indexer/ledger-lag-warning.unit.test.ts new file mode 100644 index 0000000..09800dc --- /dev/null +++ b/src/modules/indexer/ledger-lag-warning.unit.test.ts @@ -0,0 +1,211 @@ +// src/modules/indexer/ledger-lag-warning.unit.test.ts +// Unit tests for #602 — structured warn log when indexer falls behind by > 50 ledgers. +// +// Uses jest mocks — no database required. + +import { detectLedgerGap } from './ledger-gap-detection.service'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + indexedLedger: { + findFirst: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + +// The service hard-codes the mock network head at 12_400. +// We test against that value. +const MOCK_NETWORK_HEAD = 12_400; + +const mockPrisma = prisma as unknown as { + indexedLedger: { findFirst: jest.Mock }; +}; + +const mockLogger = logger as unknown as { + warn: jest.Mock; + error: jest.Mock; +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeLedgerRecord(ledger: number) { + return { + id: 1, + ledger, + cursor: `${ledger}-000`, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('#602 indexer lag warning log', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── Warning emitted when gap > 50 ───────────────────────────────────────── + + it('emits a warn log when gap exceeds 50 ledgers', async () => { + // gap = 12_400 - 100 = 12_300 (> 50) + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(100) + ); + + await detectLedgerGap(); + + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + }); + + it('warn log contains latest_processed_ledger field', async () => { + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(100) + ); + + await detectLedgerGap(); + + const [logContext] = mockLogger.warn.mock.calls[0]; + expect(logContext).toHaveProperty('latest_processed_ledger', 100); + }); + + it('warn log contains latest_network_ledger field', async () => { + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(100) + ); + + await detectLedgerGap(); + + const [logContext] = mockLogger.warn.mock.calls[0]; + expect(logContext).toHaveProperty( + 'latest_network_ledger', + MOCK_NETWORK_HEAD + ); + }); + + it('warn log contains gap field with correct value', async () => { + const processedLedger = 100; + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(processedLedger) + ); + + await detectLedgerGap(); + + const [logContext] = mockLogger.warn.mock.calls[0]; + expect(logContext).toHaveProperty( + 'gap', + MOCK_NETWORK_HEAD - processedLedger + ); + }); + + it('warn log contains detected_at field as ISO string', async () => { + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(100) + ); + + await detectLedgerGap(); + + const [logContext] = mockLogger.warn.mock.calls[0]; + expect(logContext).toHaveProperty('detected_at'); + // Must be a valid ISO 8601 timestamp + expect(() => new Date(logContext.detected_at as string)).not.toThrow(); + expect(new Date(logContext.detected_at as string).toISOString()).toBe( + logContext.detected_at + ); + }); + + it('all four required fields are present in a single warn call', async () => { + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(100) + ); + + await detectLedgerGap(); + + const [logContext] = mockLogger.warn.mock.calls[0]; + expect(logContext).toHaveProperty('latest_processed_ledger'); + expect(logContext).toHaveProperty('latest_network_ledger'); + expect(logContext).toHaveProperty('gap'); + expect(logContext).toHaveProperty('detected_at'); + }); + + it('returns detected: true when gap > 50', async () => { + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(100) + ); + + const result = await detectLedgerGap(); + + expect(result.detected).toBe(true); + expect(result.gapSize).toBeGreaterThan(50); + }); + + // ── Warning NOT emitted when gap ≤ 50 ───────────────────────────────────── + + it('does not emit a warn log when gap is exactly 50', async () => { + // gap = 12_400 - 12_350 = 50 (not > 50) + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(MOCK_NETWORK_HEAD - 50) + ); + + await detectLedgerGap(); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('does not emit a warn log when gap is 0', async () => { + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(MOCK_NETWORK_HEAD) + ); + + await detectLedgerGap(); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('does not emit a warn log when gap is less than 50', async () => { + // gap = 12_400 - 12_360 = 40 (< 50) + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(MOCK_NETWORK_HEAD - 40) + ); + + await detectLedgerGap(); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('returns detected: false when gap is ≤ 50', async () => { + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(MOCK_NETWORK_HEAD - 30) + ); + + const result = await detectLedgerGap(); + + expect(result.detected).toBe(false); + }); + + // ── Warn emitted on every tick while gap persists ───────────────────────── + + it('emits a warn log on every call while gap persists', async () => { + mockPrisma.indexedLedger.findFirst.mockResolvedValue( + makeLedgerRecord(100) + ); + + await detectLedgerGap(); + await detectLedgerGap(); + await detectLedgerGap(); + + expect(mockLogger.warn).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/modules/wallets/fan-portfolio.integration.test.ts b/src/modules/wallets/fan-portfolio.integration.test.ts new file mode 100644 index 0000000..884adfc --- /dev/null +++ b/src/modules/wallets/fan-portfolio.integration.test.ts @@ -0,0 +1,313 @@ +// Integration test: fan portfolio endpoint aggregating holdings across multiple creators (#601) +// +// Scope: +// - Seeds a wallet with holdings in three different creators at different quantities +// - Calls the fan portfolio endpoint for that wallet +// - Asserts all three creators appear in the response with correct quantity and total_value +// - Asserts a creator not held by the wallet is absent from the response +// - Asserts the response includes a grand_total field summing all total_value entries +// +// Uses Jest mocks — no database required. + +import { httpGetWalletHoldings } from './wallet-holdings.controllers'; +import * as walletHoldingsService from './wallet-holdings.service'; +import { HoldingEntry } from './wallet-holdings.schemas'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const VALID_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function makeReq( + params: Record = {}, + query: Record = {} +): any { + return { params, query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +// Wallet holds keys across three creators +const HOLDING_CREATOR_A: HoldingEntry = { + creator_id: 'creator-alpha', + creator_handle: 'alpha', + key_count: '10', + current_price: '500', + total_value: '5000', // 10 * 500 +}; + +const HOLDING_CREATOR_B: HoldingEntry = { + creator_id: 'creator-beta', + creator_handle: 'beta', + key_count: '5', + current_price: '200', + total_value: '1000', // 5 * 200 +}; + +const HOLDING_CREATOR_C: HoldingEntry = { + creator_id: 'creator-gamma', + creator_handle: 'gamma', + key_count: '3', + current_price: '100', + total_value: '300', // 3 * 100 +}; + +// grand_total = 5000 + 1000 + 300 = 6300 +const EXPECTED_GRAND_TOTAL = '6300'; + +const HELD_CREATOR_ID_NOT_IN_RESPONSE = 'creator-not-held'; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('#601 fan portfolio endpoint — aggregated holdings', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── All three creators appear in response ───────────────────────────────── + + it('returns 200 with all three held creators in the response', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(3); + }); + + it('creator-alpha is present in the response', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const items: HoldingEntry[] = res.json.mock.calls[0][0].data.items; + const alpha = items.find(i => i.creator_id === 'creator-alpha'); + expect(alpha).toBeDefined(); + }); + + it('creator-beta is present in the response', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const items: HoldingEntry[] = res.json.mock.calls[0][0].data.items; + const beta = items.find(i => i.creator_id === 'creator-beta'); + expect(beta).toBeDefined(); + }); + + it('creator-gamma is present in the response', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const items: HoldingEntry[] = res.json.mock.calls[0][0].data.items; + const gamma = items.find(i => i.creator_id === 'creator-gamma'); + expect(gamma).toBeDefined(); + }); + + // ── quantity and total_value correct for each creator ───────────────────── + + it('creator-alpha has correct key_count and total_value', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const items: HoldingEntry[] = res.json.mock.calls[0][0].data.items; + const alpha = items.find(i => i.creator_id === 'creator-alpha')!; + expect(alpha.key_count).toBe('10'); + expect(alpha.total_value).toBe('5000'); + }); + + it('creator-beta has correct key_count and total_value', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const items: HoldingEntry[] = res.json.mock.calls[0][0].data.items; + const beta = items.find(i => i.creator_id === 'creator-beta')!; + expect(beta.key_count).toBe('5'); + expect(beta.total_value).toBe('1000'); + }); + + it('creator-gamma has correct key_count and total_value', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const items: HoldingEntry[] = res.json.mock.calls[0][0].data.items; + const gamma = items.find(i => i.creator_id === 'creator-gamma')!; + expect(gamma.key_count).toBe('3'); + expect(gamma.total_value).toBe('300'); + }); + + // ── Creator not held by wallet is absent ────────────────────────────────── + + it('a creator not held by the wallet is absent from the response', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const items: HoldingEntry[] = res.json.mock.calls[0][0].data.items; + const notHeld = items.find( + i => i.creator_id === HELD_CREATOR_ID_NOT_IN_RESPONSE + ); + expect(notHeld).toBeUndefined(); + }); + + // ── grand_total field ───────────────────────────────────────────────────── + + it('response includes a grand_total field', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('grand_total'); + }); + + it('grand_total sums all total_value entries correctly', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.grand_total).toBe(EXPECTED_GRAND_TOTAL); + }); + + it('grand_total is "0" when wallet has no holdings with total_value', async () => { + const holdingNoValue: HoldingEntry = { + creator_id: 'creator-no-price', + creator_handle: 'no-price', + key_count: '5', + current_price: null, + total_value: null, + }; + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[holdingNoValue], 1]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.grand_total).toBe('0'); + }); + + it('grand_total is "0" when there are no holdings', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.grand_total).toBe('0'); + }); + + // ── full response shape validation ──────────────────────────────────────── + + it('response data contains items, meta, and grand_total', async () => { + jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([ + [HOLDING_CREATOR_A, HOLDING_CREATOR_B, HOLDING_CREATOR_C], + 3, + ]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const data = res.json.mock.calls[0][0].data; + expect(data).toHaveProperty('items'); + expect(data).toHaveProperty('meta'); + expect(data).toHaveProperty('grand_total'); + }); +}); diff --git a/src/modules/wallets/wallet-activity-date-filter.integration.test.ts b/src/modules/wallets/wallet-activity-date-filter.integration.test.ts new file mode 100644 index 0000000..fcefbaa --- /dev/null +++ b/src/modules/wallets/wallet-activity-date-filter.integration.test.ts @@ -0,0 +1,325 @@ +// Integration test: trade history endpoint returns results filtered by date range (#599) +// +// Scope: +// - Seeds five trades: two before the range, one inside, two after +// - Calls fetchWalletActivity with from/to params enclosing only the middle trade +// - Asserts exactly one trade is returned matching the expected trade +// - Asserts trades outside the range are absent +// - Boundary trades (exactly on from and to) are included +// +// Uses Jest mocks — no database required. + +import { fetchWalletActivity } from './wallet-activity.service'; +import { prisma } from '../../utils/prisma.utils'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + activity: { + findMany: jest.fn(), + count: jest.fn(), + }, + creatorProfile: { + findMany: jest.fn(), + }, + }, +})); + +const mockPrisma = prisma as unknown as { + activity: { findMany: jest.Mock; count: jest.Mock }; + creatorProfile: { findMany: jest.Mock }; +}; + +const WALLET_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const CREATOR_ID = 'creator-date-filter-1'; + +// ── Trade fixtures ──────────────────────────────────────────────────────────── +// Two before the range +const TRADE_BEFORE_1 = { + id: 'trade-before-1', + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { amount: '1', price_at_trade: '100', fee_paid: '1', ledger_sequence: 1 }, + createdAt: new Date('2026-01-05T00:00:00Z'), +}; +const TRADE_BEFORE_2 = { + id: 'trade-before-2', + type: 'KEY_SOLD', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { amount: '2', price_at_trade: '110', fee_paid: '1', ledger_sequence: 2 }, + createdAt: new Date('2026-01-09T23:59:59Z'), +}; + +// One inside the range (boundary dates inclusive) +const TRADE_INSIDE = { + id: 'trade-inside', + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { amount: '3', price_at_trade: '150', fee_paid: '2', ledger_sequence: 3 }, + createdAt: new Date('2026-01-15T12:00:00Z'), +}; + +// Two after the range +const TRADE_AFTER_1 = { + id: 'trade-after-1', + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { amount: '4', price_at_trade: '200', fee_paid: '3', ledger_sequence: 4 }, + createdAt: new Date('2026-01-20T00:00:01Z'), +}; +const TRADE_AFTER_2 = { + id: 'trade-after-2', + type: 'KEY_SOLD', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { amount: '5', price_at_trade: '210', fee_paid: '3', ledger_sequence: 5 }, + createdAt: new Date('2026-01-25T00:00:00Z'), +}; + +// Boundary trades: exactly on from / to +const TRADE_ON_FROM = { + id: 'trade-on-from', + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { amount: '6', price_at_trade: '120', fee_paid: '1', ledger_sequence: 6 }, + createdAt: new Date('2026-01-10T00:00:00Z'), +}; +const TRADE_ON_TO = { + id: 'trade-on-to', + type: 'KEY_SOLD', + actor: WALLET_ADDRESS, + creatorId: CREATOR_ID, + payload: { amount: '7', price_at_trade: '180', fee_paid: '2', ledger_sequence: 7 }, + createdAt: new Date('2026-01-20T00:00:00Z'), +}; + +const CREATOR_PROFILES = [{ id: CREATOR_ID, handle: 'date-filter-creator' }]; + +// from / to that enclose only the inside trade +const FROM = '2026-01-10T00:00:00.000Z'; +const TO = '2026-01-20T00:00:00.000Z'; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('#599 trade history endpoint — date range filter', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.creatorProfile.findMany.mockResolvedValue(CREATOR_PROFILES); + }); + + // ── Only trades within the range returned ───────────────────────────────── + + it('returns exactly one trade when only the middle trade falls within the range', async () => { + // The service delegates filtering to Prisma; the mock simulates what + // the DB would return after applying the date filter. + mockPrisma.activity.findMany.mockResolvedValue([TRADE_INSIDE]); + mockPrisma.activity.count.mockResolvedValue(1); + + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + to: TO, + }); + + expect(total).toBe(1); + expect(items).toHaveLength(1); + expect(items[0].id).toBe(TRADE_INSIDE.id); + }); + + it('the returned trade matches the expected inside trade', async () => { + mockPrisma.activity.findMany.mockResolvedValue([TRADE_INSIDE]); + mockPrisma.activity.count.mockResolvedValue(1); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + to: TO, + }); + + expect(items[0]).toMatchObject({ + id: TRADE_INSIDE.id, + type: 'buy', + creator_id: CREATOR_ID, + amount: '3', + price_at_trade: '150', + }); + }); + + // ── Trades before `from` absent ─────────────────────────────────────────── + + it('does not include trades before the from date', async () => { + mockPrisma.activity.findMany.mockResolvedValue([TRADE_INSIDE]); + mockPrisma.activity.count.mockResolvedValue(1); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + to: TO, + }); + + const ids = items.map(i => i.id); + expect(ids).not.toContain(TRADE_BEFORE_1.id); + expect(ids).not.toContain(TRADE_BEFORE_2.id); + }); + + // ── Trades after `to` absent ────────────────────────────────────────────── + + it('does not include trades after the to date', async () => { + mockPrisma.activity.findMany.mockResolvedValue([TRADE_INSIDE]); + mockPrisma.activity.count.mockResolvedValue(1); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + to: TO, + }); + + const ids = items.map(i => i.id); + expect(ids).not.toContain(TRADE_AFTER_1.id); + expect(ids).not.toContain(TRADE_AFTER_2.id); + }); + + // ── Boundary dates are inclusive ────────────────────────────────────────── + + it('includes a trade that falls exactly on the from boundary', async () => { + mockPrisma.activity.findMany.mockResolvedValue([ + TRADE_ON_FROM, + TRADE_INSIDE, + ]); + mockPrisma.activity.count.mockResolvedValue(2); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + to: TO, + }); + + const ids = items.map(i => i.id); + expect(ids).toContain(TRADE_ON_FROM.id); + }); + + it('includes a trade that falls exactly on the to boundary', async () => { + mockPrisma.activity.findMany.mockResolvedValue([ + TRADE_INSIDE, + TRADE_ON_TO, + ]); + mockPrisma.activity.count.mockResolvedValue(2); + + const [items] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + to: TO, + }); + + const ids = items.map(i => i.id); + expect(ids).toContain(TRADE_ON_TO.id); + }); + + it('includes both boundary trades and the inside trade when all are within range', async () => { + mockPrisma.activity.findMany.mockResolvedValue([ + TRADE_ON_TO, + TRADE_INSIDE, + TRADE_ON_FROM, + ]); + mockPrisma.activity.count.mockResolvedValue(3); + + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + to: TO, + }); + + expect(total).toBe(3); + expect(items).toHaveLength(3); + }); + + // ── Service passes date filter to Prisma where clause ───────────────────── + + it('passes gte filter to prisma when from is provided', async () => { + mockPrisma.activity.findMany.mockResolvedValue([]); + mockPrisma.activity.count.mockResolvedValue(0); + + await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + }); + + const whereArg = mockPrisma.activity.findMany.mock.calls[0][0].where; + expect(whereArg.createdAt).toBeDefined(); + expect(whereArg.createdAt.gte).toEqual(new Date(FROM)); + }); + + it('passes lte filter to prisma when to is provided', async () => { + mockPrisma.activity.findMany.mockResolvedValue([]); + mockPrisma.activity.count.mockResolvedValue(0); + + await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + to: TO, + }); + + const whereArg = mockPrisma.activity.findMany.mock.calls[0][0].where; + expect(whereArg.createdAt).toBeDefined(); + expect(whereArg.createdAt.lte).toEqual(new Date(TO)); + }); + + it('passes both gte and lte filters to prisma when from and to are provided', async () => { + mockPrisma.activity.findMany.mockResolvedValue([]); + mockPrisma.activity.count.mockResolvedValue(0); + + await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: FROM, + to: TO, + }); + + const whereArg = mockPrisma.activity.findMany.mock.calls[0][0].where; + expect(whereArg.createdAt.gte).toEqual(new Date(FROM)); + expect(whereArg.createdAt.lte).toEqual(new Date(TO)); + }); + + it('does not add createdAt filter when neither from nor to is provided', async () => { + mockPrisma.activity.findMany.mockResolvedValue([]); + mockPrisma.activity.count.mockResolvedValue(0); + + await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + }); + + const whereArg = mockPrisma.activity.findMany.mock.calls[0][0].where; + expect(whereArg.createdAt).toBeUndefined(); + }); + + // ── Empty result within date range ──────────────────────────────────────── + + it('returns empty array when no trades fall within the range', async () => { + mockPrisma.activity.findMany.mockResolvedValue([]); + mockPrisma.activity.count.mockResolvedValue(0); + + const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { + limit: 20, + offset: 0, + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-30T23:59:59.000Z', + }); + + expect(items).toHaveLength(0); + expect(total).toBe(0); + }); +}); diff --git a/src/modules/wallets/wallet-activity.controllers.ts b/src/modules/wallets/wallet-activity.controllers.ts index 7e0d21e..33751f7 100644 --- a/src/modules/wallets/wallet-activity.controllers.ts +++ b/src/modules/wallets/wallet-activity.controllers.ts @@ -10,6 +10,7 @@ import { } from '../../utils/api-response.utils'; import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; import { logger } from '../../utils/logger.utils'; +import { maskSensitive } from '../../utils/mask-sensitive.utils'; export async function httpGetWalletActivity( req: Request, @@ -65,12 +66,12 @@ export async function httpGetWalletActivity( : address; logger.debug( - { + maskSensitive({ wallet_address: maskedAddress, result_count: items.length, query_duration_ms: Math.round(duration), filters_applied, - }, + }), 'Wallet activity feed query' ); diff --git a/src/modules/wallets/wallet-activity.schemas.ts b/src/modules/wallets/wallet-activity.schemas.ts index dbff3a3..260e1a3 100644 --- a/src/modules/wallets/wallet-activity.schemas.ts +++ b/src/modules/wallets/wallet-activity.schemas.ts @@ -28,6 +28,14 @@ export const WalletActivityQuerySchema = z type: z.enum(['buy', 'sell']).optional(), creator_id: z.string().optional(), cursor: z.string().optional(), + from: z + .string() + .datetime({ message: 'from must be an ISO 8601 datetime string' }) + .optional(), + to: z + .string() + .datetime({ message: 'to must be an ISO 8601 datetime string' }) + .optional(), }) .strict(); diff --git a/src/modules/wallets/wallet-activity.service.ts b/src/modules/wallets/wallet-activity.service.ts index 4500be4..b8709f1 100644 --- a/src/modules/wallets/wallet-activity.service.ts +++ b/src/modules/wallets/wallet-activity.service.ts @@ -25,7 +25,7 @@ export async function fetchWalletActivity( address: string, query: WalletActivityQueryType ): Promise<[WalletActivityItem[], number, string | null]> { - const { limit, offset, type, creator_id, cursor } = query; + const { limit, offset, type, creator_id, cursor, from, to } = query; // Map the public-facing type param to the internal ActivityType enum values. const typeFilter = @@ -40,6 +40,17 @@ export async function fetchWalletActivity( where.creatorId = creator_id; } + // Date range filter: `from` and `to` are inclusive boundaries. + if (from || to) { + where.createdAt = {}; + if (from) { + where.createdAt.gte = new Date(from); + } + if (to) { + where.createdAt.lte = new Date(to); + } + } + let prismaCursor: { id: string } | undefined; if (cursor) { try { diff --git a/src/modules/wallets/wallet-holdings.controllers.ts b/src/modules/wallets/wallet-holdings.controllers.ts index 9f600a3..47f146e 100644 --- a/src/modules/wallets/wallet-holdings.controllers.ts +++ b/src/modules/wallets/wallet-holdings.controllers.ts @@ -57,7 +57,14 @@ export async function httpGetWalletHoldings( total, }); - sendSuccess(res, { items, meta }); + // Sum all total_value entries for a portfolio grand total. + const grand_total = items + .reduce((sum, item) => { + return sum + (item.total_value !== null ? Number(item.total_value) : 0); + }, 0) + .toString(); + + sendSuccess(res, { items, meta, grand_total }); } catch (error) { next(error); } diff --git a/src/utils/mask-sensitive.utils.test.ts b/src/utils/mask-sensitive.utils.test.ts new file mode 100644 index 0000000..ae990a3 --- /dev/null +++ b/src/utils/mask-sensitive.utils.test.ts @@ -0,0 +1,191 @@ +/** + * Unit tests for maskSensitive helper (#600). + * + * Verifies: + * - All listed sensitive key names are masked to '[REDACTED]' + * - Non-sensitive keys are left unchanged + * - Nested objects are masked recursively + * - Original object is not mutated + */ + +import { maskSensitive } from './mask-sensitive.utils'; + +// ── Sensitive key masking ───────────────────────────────────────────────────── + +describe('maskSensitive — sensitive keys masked to [REDACTED]', () => { + it('masks "url" key', () => { + expect(maskSensitive({ url: 'https://secret.example.com' })).toEqual({ + url: '[REDACTED]', + }); + }); + + it('masks "callback" key', () => { + expect(maskSensitive({ callback: 'https://hooks.example.com/cb' })).toEqual({ + callback: '[REDACTED]', + }); + }); + + it('masks "address" key', () => { + expect( + maskSensitive({ address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }) + ).toEqual({ address: '[REDACTED]' }); + }); + + it('masks "webhook_url" key', () => { + expect(maskSensitive({ webhook_url: 'https://discord.com/api/webhooks/123/abc' })).toEqual({ + webhook_url: '[REDACTED]', + }); + }); + + it('masks "recipient" key', () => { + expect(maskSensitive({ recipient: 'user@example.com' })).toEqual({ + recipient: '[REDACTED]', + }); + }); + + it('masks a string value to [REDACTED]', () => { + const result = maskSensitive({ url: 'some-value' }); + expect(result.url).toBe('[REDACTED]'); + }); + + it('masks a numeric value to [REDACTED]', () => { + const result = maskSensitive({ address: 12345 } as any); + expect(result.address).toBe('[REDACTED]'); + }); + + it('masks a null value to [REDACTED]', () => { + const result = maskSensitive({ url: null } as any); + expect(result.url).toBe('[REDACTED]'); + }); +}); + +// ── Non-sensitive keys unchanged ────────────────────────────────────────────── + +describe('maskSensitive — non-sensitive keys unchanged', () => { + it('leaves a plain string field unchanged', () => { + const result = maskSensitive({ name: 'alice' }); + expect(result.name).toBe('alice'); + }); + + it('leaves a numeric field unchanged', () => { + const result = maskSensitive({ count: 42 }); + expect(result.count).toBe(42); + }); + + it('leaves a boolean field unchanged', () => { + const result = maskSensitive({ active: true }); + expect(result.active).toBe(true); + }); + + it('leaves null field value unchanged when key is not sensitive', () => { + const result = maskSensitive({ label: null } as any); + expect(result.label).toBeNull(); + }); + + it('returns the full object with only sensitive fields redacted', () => { + const result = maskSensitive({ + event: 'trade_completed', + url: 'https://secret.example.com', + creator_id: 'creator-1', + amount: 10, + }); + expect(result).toEqual({ + event: 'trade_completed', + url: '[REDACTED]', + creator_id: 'creator-1', + amount: 10, + }); + }); +}); + +// ── Nested objects masked recursively ───────────────────────────────────────── + +describe('maskSensitive — nested objects', () => { + it('masks a sensitive key inside a nested object', () => { + const result = maskSensitive({ + meta: { address: 'GXYZ', label: 'wallet' }, + }); + expect(result).toEqual({ + meta: { address: '[REDACTED]', label: 'wallet' }, + }); + }); + + it('masks sensitive keys at multiple levels of nesting', () => { + const result = maskSensitive({ + level1: { + level2: { + url: 'https://deep.example.com', + safe: 'keep-me', + }, + }, + }); + expect(result).toEqual({ + level1: { + level2: { + url: '[REDACTED]', + safe: 'keep-me', + }, + }, + }); + }); + + it('masks sensitive keys inside array elements', () => { + const result = maskSensitive({ + hooks: [ + { webhook_url: 'https://hook1.example.com', id: 'h1' }, + { webhook_url: 'https://hook2.example.com', id: 'h2' }, + ], + }); + expect(result).toEqual({ + hooks: [ + { webhook_url: '[REDACTED]', id: 'h1' }, + { webhook_url: '[REDACTED]', id: 'h2' }, + ], + }); + }); + + it('handles an empty nested object', () => { + const result = maskSensitive({ context: {} }); + expect(result).toEqual({ context: {} }); + }); +}); + +// ── Immutability ────────────────────────────────────────────────────────────── + +describe('maskSensitive — does not mutate original', () => { + it('does not modify the input object', () => { + const input = { + url: 'https://secret.example.com', + name: 'alice', + }; + maskSensitive(input); + expect(input.url).toBe('https://secret.example.com'); + expect(input.name).toBe('alice'); + }); + + it('does not modify nested objects in the input', () => { + const input = { + meta: { address: 'GABC', safe: 'ok' }, + }; + maskSensitive(input); + expect(input.meta.address).toBe('GABC'); + }); +}); + +// ── Edge cases ──────────────────────────────────────────────────────────────── + +describe('maskSensitive — edge cases', () => { + it('handles an empty object', () => { + expect(maskSensitive({})).toEqual({}); + }); + + it('is case-insensitive: masks "URL" key', () => { + const result = maskSensitive({ URL: 'https://example.com' } as any); + expect(result.URL).toBe('[REDACTED]'); + }); + + it('is case-insensitive: masks "Address" key', () => { + const result = maskSensitive({ Address: 'GABC' } as any); + expect(result.Address).toBe('[REDACTED]'); + }); +}); diff --git a/src/utils/mask-sensitive.utils.ts b/src/utils/mask-sensitive.utils.ts new file mode 100644 index 0000000..15feb94 --- /dev/null +++ b/src/utils/mask-sensitive.utils.ts @@ -0,0 +1,79 @@ +/** + * Sensitive field masking utility for structured log writes. + * + * Prevents callback URLs, wallet addresses, and other sensitive values from + * appearing in plain text in log output. Any key matching the sensitive key + * list has its value replaced with `[REDACTED]` before the object is passed + * to a logger call. + * + * Masking is applied recursively so nested objects are also sanitised. + */ + +/** + * Field names that must never appear in plain text in log output. + * + * Matching is case-insensitive exact key comparison. + * Add new names here when introducing log context that carries sensitive data. + */ +const SENSITIVE_KEYS = new Set([ + 'url', + 'callback', + 'address', + 'webhook_url', + 'recipient', +]); + +/** + * Returns true when the given key should be redacted. + */ +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(key.toLowerCase()); +} + +/** + * Returns a deep copy of `obj` with every sensitive field value replaced by + * the string `'[REDACTED]'`. + * + * Rules: + * - Keys matching the sensitive list → value set to `'[REDACTED]'` (regardless of depth) + * - Non-sensitive keys → value left unchanged + * - Nested plain objects → recursed into + * - Arrays → each element recursed into + * - Primitives / null / undefined → returned as-is + * + * The original object is never mutated. + * + * @example + * maskSensitive({ url: 'https://secret.example.com', count: 5 }) + * // → { url: '[REDACTED]', count: 5 } + * + * maskSensitive({ nested: { address: 'GABC...', label: 'wallet' } }) + * // → { nested: { address: '[REDACTED]', label: 'wallet' } } + */ +export function maskSensitive( + obj: Record +): Record { + return maskValue(obj) as Record; +} + +function maskValue(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + return value.map(item => maskValue(item)); + } + + if (typeof value === 'object') { + const result: Record = {}; + for (const [key, val] of Object.entries( + value as Record + )) { + result[key] = isSensitiveKey(key) ? '[REDACTED]' : maskValue(val); + } + return result; + } + + return value; +}