diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index c7f42a1..12c00a8 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -62,6 +62,7 @@ export async function processTradeEvents(events: IndexerChainEvent[]): Promise ({ + prisma: { + creatorPriceSnapshot: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})); + +const mockPrisma = prisma as unknown as { + creatorPriceSnapshot: { + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; +}; + +const mockLogger = logger as unknown as { + debug: jest.Mock; + error: jest.Mock; +}; + +const CREATOR_ID = 'creator-debug-log-1'; + +describe('#636 price snapshot write debug log', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('emits a debug log with all five fields after the first (create) snapshot write', async () => { + mockPrisma.creatorPriceSnapshot.findUnique.mockResolvedValue(null); + mockPrisma.creatorPriceSnapshot.create.mockResolvedValue({}); + + const tradeAt = new Date('2026-01-01T00:00:00Z'); + await upsertPriceSnapshot({ + creatorId: CREATOR_ID, + price: BigInt(1_000_000), + tradeAt, + ledger: 5000, + }); + + expect(mockPrisma.creatorPriceSnapshot.create).toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + + const [fields] = mockLogger.debug.mock.calls[0]; + expect(fields).toMatchObject({ + creator_id: CREATOR_ID, + new_price: '1000000', + previous_price: null, + ledger: 5000, + }); + expect(fields.ingested_at).toEqual(expect.any(String)); + expect(() => new Date(fields.ingested_at).toISOString()).not.toThrow(); + }); + + it('sets previous_price to null on the first snapshot for a creator', async () => { + mockPrisma.creatorPriceSnapshot.findUnique.mockResolvedValue(null); + mockPrisma.creatorPriceSnapshot.create.mockResolvedValue({}); + + await upsertPriceSnapshot({ + creatorId: CREATOR_ID, + price: BigInt(2_000_000), + tradeAt: new Date('2026-01-01T00:00:00Z'), + ledger: 5001, + }); + + const [fields] = mockLogger.debug.mock.calls[0]; + expect(fields.previous_price).toBeNull(); + }); + + it('emits a debug log with the previous price on a subsequent (update) write', async () => { + mockPrisma.creatorPriceSnapshot.findUnique.mockResolvedValue({ + creatorId: CREATOR_ID, + currentPrice: BigInt(1_000_000), + price24hAgo: BigInt(1_000_000), + lastTradeAt: new Date('2025-12-01T00:00:00Z'), + }); + mockPrisma.creatorPriceSnapshot.update.mockResolvedValue({}); + + await upsertPriceSnapshot({ + creatorId: CREATOR_ID, + price: BigInt(3_000_000), + tradeAt: new Date('2026-01-02T00:00:00Z'), + ledger: 5002, + }); + + expect(mockPrisma.creatorPriceSnapshot.update).toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + + const [fields] = mockLogger.debug.mock.calls[0]; + expect(fields).toMatchObject({ + creator_id: CREATOR_ID, + new_price: '3000000', + previous_price: '1000000', + ledger: 5002, + }); + expect(fields.ingested_at).toEqual(expect.any(String)); + }); + + it('does not emit the debug log when the write fails', async () => { + mockPrisma.creatorPriceSnapshot.findUnique.mockResolvedValue(null); + mockPrisma.creatorPriceSnapshot.create.mockRejectedValue( + new Error('db write failed') + ); + + await expect( + upsertPriceSnapshot({ + creatorId: CREATOR_ID, + price: BigInt(1_000_000), + tradeAt: new Date('2026-01-01T00:00:00Z'), + ledger: 5003, + }) + ).rejects.toThrow('db write failed'); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/indexer/price-snapshot.service.ts b/src/modules/indexer/price-snapshot.service.ts index 84ae5ca..eb66b6c 100644 --- a/src/modules/indexer/price-snapshot.service.ts +++ b/src/modules/indexer/price-snapshot.service.ts @@ -11,6 +11,8 @@ export interface TradeEventPayload { price: bigint; /** ISO timestamp of the trade */ tradeAt: Date; + /** Ledger sequence number the trade was included in */ + ledger?: number; } /** @@ -25,7 +27,7 @@ export interface TradeEventPayload { export async function upsertPriceSnapshot( event: TradeEventPayload ): Promise { - const { creatorId, price, tradeAt } = event; + const { creatorId, price, tradeAt, ledger } = event; try { const existing = await prisma.creatorPriceSnapshot.findUnique({ @@ -47,8 +49,8 @@ export async function upsertPriceSnapshot( creator_id: creatorId, new_price: price.toString(), previous_price: null, - ledger_sequence: null, - written_at: tradeAt.toISOString(), + ledger: ledger ?? null, + ingested_at: new Date().toISOString(), }, 'price-snapshot: written (first trade)' ); @@ -89,8 +91,8 @@ export async function upsertPriceSnapshot( creator_id: creatorId, new_price: price.toString(), previous_price: existing.currentPrice.toString(), - ledger_sequence: null, - written_at: tradeAt.toISOString(), + ledger: ledger ?? null, + ingested_at: new Date().toISOString(), }, 'price-snapshot: written' ); diff --git a/src/modules/wallet/__tests__/wallet.utils.test.ts b/src/modules/wallet/__tests__/wallet.utils.test.ts index 7e586aa..a565597 100644 --- a/src/modules/wallet/__tests__/wallet.utils.test.ts +++ b/src/modules/wallet/__tests__/wallet.utils.test.ts @@ -49,6 +49,30 @@ describe('isValidStellarAddress', () => { it('returns false for a random non-address string', () => { expect(isValidStellarAddress('not-a-stellar-address')).toBe(false); }); + + // Adjacent invalid formats seen in real API submissions (#638) + + it('returns false for an otherwise-valid address with one lowercase character', () => { + const addr = 'G' + 'a' + 'A'.repeat(54); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false for an address starting with G that contains a 0 (not valid base32)', () => { + const addr = 'G' + '0' + 'A'.repeat(54); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false for a valid address with a leading space', () => { + expect(isValidStellarAddress(' ' + VALID_ADDRESS)).toBe(false); + }); + + it('returns false for a valid address with a trailing newline', () => { + expect(isValidStellarAddress(VALID_ADDRESS + '\n')).toBe(false); + }); + + it('returns true for the existing valid address (regression guard)', () => { + expect(isValidStellarAddress(VALID_ADDRESS)).toBe(true); + }); }); describe('StellarAddressSchema', () => { diff --git a/src/modules/wallets/__tests__/wallet-activity-ledger-order.integration.test.ts b/src/modules/wallets/__tests__/wallet-activity-ledger-order.integration.test.ts new file mode 100644 index 0000000..4cc218a --- /dev/null +++ b/src/modules/wallets/__tests__/wallet-activity-ledger-order.integration.test.ts @@ -0,0 +1,198 @@ +// Integration test: wallet activity feed ordered by ledger number, descending (#637) +// +// Seeds three trades for the same wallet at ledgers 1000, 2000 and 3000 and +// confirms the endpoint returns them most-recent-ledger-first, with the +// correct `type` for each trade, and that pagination (limit + cursor) +// is respected. +// +// Uses Jest mocks — no database required. + +import { httpGetWalletActivity } from '../wallet-activity.controllers'; +import { prisma } from '../../../utils/prisma.utils'; +import { encodeCursor } from '../../../utils/cursor.utils'; +import type { ActivityFeedCursorPayload } from '../wallet-activity.service'; + +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 = + 'GBRST3QZ5XQQ74345MTHXMY3R745B6N5J2S7K6D6NCT7YIHMHQ45X2WZ'; + +// Three trades seeded for the same wallet at ledgers 1000, 2000 and 3000. +// Stored here newest-ledger-first, matching what Prisma would hand back +// once `orderBy` sorts them descending. +const SEEDED_TRADES_LEDGER_DESC = [ + { + id: 'activity-ledger-3000', + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: 'creator-alpha', + payload: { + amount: '3', + price_at_trade: '30', + fee_paid: '0.3', + ledger_sequence: 3000, + }, + createdAt: new Date('2026-03-01T00:00:00Z'), + }, + { + id: 'activity-ledger-2000', + type: 'KEY_SOLD', + actor: WALLET_ADDRESS, + creatorId: 'creator-alpha', + payload: { + amount: '2', + price_at_trade: '20', + fee_paid: '0.2', + ledger_sequence: 2000, + }, + createdAt: new Date('2026-02-01T00:00:00Z'), + }, + { + id: 'activity-ledger-1000', + type: 'KEY_BOUGHT', + actor: WALLET_ADDRESS, + creatorId: 'creator-alpha', + payload: { + amount: '1', + price_at_trade: '10', + fee_paid: '0.1', + ledger_sequence: 1000, + }, + createdAt: new Date('2026-01-01T00:00:00Z'), + }, +]; + +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(); +} + +// Simulates Prisma's cursor-based pagination against the seeded, already +// ledger-descending-sorted fixture set. +function findManyForPage({ + skip, + take, + cursor, +}: { + skip?: number; + take: number; + cursor?: { id: string }; +}) { + let startIndex = 0; + if (cursor) { + startIndex = + SEEDED_TRADES_LEDGER_DESC.findIndex(row => row.id === cursor.id) + 1; + } else if (skip) { + startIndex = skip; + } + return Promise.resolve( + SEEDED_TRADES_LEDGER_DESC.slice(startIndex, startIndex + take) + ); +} + +describe('wallet activity feed — ledger-descending order (#637)', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockPrisma.creatorProfile.findMany.mockResolvedValue([ + { id: 'creator-alpha', handle: 'alpha' }, + ]); + mockPrisma.activity.count.mockResolvedValue( + SEEDED_TRADES_LEDGER_DESC.length + ); + mockPrisma.activity.findMany.mockImplementation(findManyForPage); + }); + + it('returns the three seeded trades ordered ledger 3000, 2000, 1000 with the correct type', async () => { + const req = makeReq({ address: WALLET_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const items = res.json.mock.calls[0][0].data.items; + + expect(items.map((i: any) => i.ledger_sequence)).toEqual([ + 3000, 2000, 1000, + ]); + expect(items.map((i: any) => i.type)).toEqual(['buy', 'sell', 'buy']); + }); + + it('respects the pagination limit, returning only the first page and a next cursor', async () => { + const req = makeReq( + { address: WALLET_ADDRESS }, + { limit: '2', offset: '0' } + ); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const items = body.data.items; + + expect(items).toHaveLength(2); + expect(items.map((i: any) => i.ledger_sequence)).toEqual([3000, 2000]); + expect(body.data.meta.hasMore).toBe(true); + + const expectedCursor = encodeCursor({ + id: 'activity-ledger-2000', + }); + expect(body.data.meta.nextCursor).toBe(expectedCursor); + }); + + it('respects the cursor, returning the remaining trade after the first page', async () => { + const cursor = encodeCursor({ + id: 'activity-ledger-2000', + }); + + const req = makeReq( + { address: WALLET_ADDRESS }, + { limit: '2', cursor } + ); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const items = body.data.items; + + expect(items).toHaveLength(1); + expect(items[0].ledger_sequence).toBe(1000); + expect(items[0].type).toBe('buy'); + // nextCursor is the authoritative "no more pages" signal for cursor + // consumers — it is derived from the cursor page itself, unlike + // meta.hasMore which is computed from the (unset) offset param. + expect(body.data.meta.nextCursor).toBeNull(); + }); +}); diff --git a/src/utils/test/pagination.utils.test.ts b/src/utils/test/pagination.utils.test.ts index cb23fe6..36c87ca 100644 --- a/src/utils/test/pagination.utils.test.ts +++ b/src/utils/test/pagination.utils.test.ts @@ -102,4 +102,21 @@ describe('buildPaginatedResponse', () => { expect(result.has_more).toBe(false); expect(result.next_cursor).toBeNull(); }); + + it('returns has_more: true with 1 item when limit is 1 and there are 2 results', () => { + const result = buildPaginatedResponse(items.slice(0, 2), 1, cursorFn); + + expect(result.items).toEqual(items.slice(0, 1)); + expect(result.has_more).toBe(true); + expect(result.next_cursor).toBe('1'); + }); + + it('returns has_more: false with the single item when limit is 1 and there is exactly 1 result', () => { + const result = buildPaginatedResponse(items.slice(0, 1), 1, cursorFn); + + expect(result.items).toEqual(items.slice(0, 1)); + expect(result.items).toHaveLength(1); + expect(result.has_more).toBe(false); + expect(result.next_cursor).toBeNull(); + }); });