diff --git a/src/modules/wallets/__tests__/wallet-activity-cursor-pagination.integration.test.ts b/src/modules/wallets/__tests__/wallet-activity-cursor-pagination.integration.test.ts new file mode 100644 index 0000000..0e219e1 --- /dev/null +++ b/src/modules/wallets/__tests__/wallet-activity-cursor-pagination.integration.test.ts @@ -0,0 +1,109 @@ +import { httpGetWalletActivity } from '../wallet-activity.controllers'; +import * as walletActivityService from '../wallet-activity.service'; +import { encodeCursor } from '../../../utils/cursor.utils'; +import type { ActivityFeedCursorPayload } from '../wallet-activity.service'; + +function makeReq(params: Record = {}, query: Record = {}): any { + return { params, query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +const WALLET_ADDRESS = 'GBRST3QZ5XQQ74345MTHXMY3R745B6N5J2S7K6D6NCT7YIHMHQ45X2WZ'; + +function makeFixture(index: number) { + return { + id: `activity-${index}`, + type: 'buy' as const, + creator_id: `creator-${index}`, + creator_handle: `handle-${index}`, + amount: index * 100, + price_at_trade: index * 10, + fee_paid: index, + ledger_sequence: 1000 + index, + timestamp: new Date(`2024-0${index}-01T00:00:00.000Z`), + }; +} + +const ALL_FIXTURES = [6, 5, 4, 3, 2, 1].map(makeFixture); +const PAGE_ONE_FIXTURES = ALL_FIXTURES.slice(0, 3); +const PAGE_TWO_FIXTURES = ALL_FIXTURES.slice(3, 6); + +describe('wallet activity feed cursor-based pagination integration', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns a valid next cursor on the first page', async () => { + const lastOnPageOne = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1]; + const nextCursorStr = encodeCursor({ id: lastOnPageOne.id }); + + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([ + PAGE_ONE_FIXTURES, + ALL_FIXTURES.length, + nextCursorStr + ]); + + const req = makeReq({ address: WALLET_ADDRESS }, { limit: '3', offset: '0' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toHaveLength(3); + expect(body.data.meta.nextCursor).toBe(nextCursorStr); + expect(body.data.meta.hasMore).toBe(true); + }); + + it('fetches the second page using the cursor and confirms no duplicates', async () => { + const lastOnPageOne = PAGE_ONE_FIXTURES[PAGE_ONE_FIXTURES.length - 1]; + const nextCursorStr = encodeCursor({ id: lastOnPageOne.id }); + + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([ + PAGE_ONE_FIXTURES, + ALL_FIXTURES.length, + nextCursorStr + ]); + + const reqOne = makeReq({ address: WALLET_ADDRESS }, { limit: '3', offset: '0' }); + const resOne = makeRes(); + await httpGetWalletActivity(reqOne, resOne, makeNext()); + const pageOneIds = resOne.json.mock.calls[0][0].data.items.map((i: any) => i.id); + + jest.restoreAllMocks(); + + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([ + PAGE_TWO_FIXTURES, + ALL_FIXTURES.length, + null + ]); + + const reqTwo = makeReq({ address: WALLET_ADDRESS }, { limit: '3', cursor: nextCursorStr }); + const resTwo = makeRes(); + await httpGetWalletActivity(reqTwo, resTwo, makeNext()); + + const bodyTwo = resTwo.json.mock.calls[0][0]; + const pageTwoIds = bodyTwo.data.items.map((i: any) => i.id); + + expect(bodyTwo.data.items).toHaveLength(3); + expect(bodyTwo.data.meta.nextCursor).toBeNull(); + expect(bodyTwo.data.meta.hasMore).toBe(false); + + const overlap = pageOneIds.filter((id: string) => pageTwoIds.includes(id)); + expect(overlap).toHaveLength(0); + + const allExpectedIds = ALL_FIXTURES.map(f => f.id); + const combinedIds = [...pageOneIds, ...pageTwoIds]; + expect(combinedIds).toEqual(allExpectedIds); + }); +}); diff --git a/src/modules/wallets/wallet-activity.controllers.ts b/src/modules/wallets/wallet-activity.controllers.ts index a50d4e9..2b1dfc7 100644 --- a/src/modules/wallets/wallet-activity.controllers.ts +++ b/src/modules/wallets/wallet-activity.controllers.ts @@ -36,18 +36,21 @@ export async function httpGetWalletActivity( return; } - const [items, total] = await fetchWalletActivity( + const [items, total, nextCursor] = await fetchWalletActivity( parsedParams.data.address, parsedQuery.data ); sendSuccess(res, { items, - meta: buildOffsetPaginationMeta({ - limit: parsedQuery.data.limit, - offset: parsedQuery.data.offset, - total, - }), + meta: { + ...buildOffsetPaginationMeta({ + limit: parsedQuery.data.limit, + offset: parsedQuery.data.offset, + total, + }), + nextCursor, + }, }); } catch (error) { next(error); diff --git a/src/modules/wallets/wallet-activity.integration.test.ts b/src/modules/wallets/wallet-activity.integration.test.ts index edc7874..d17c540 100644 --- a/src/modules/wallets/wallet-activity.integration.test.ts +++ b/src/modules/wallets/wallet-activity.integration.test.ts @@ -31,6 +31,7 @@ function makeNext(): jest.Mock { function makeActivity(overrides: Partial = {}): WalletActivityItem { return { + id: 'activity-1', type: 'buy', creator_id: 'creator-1', creator_handle: 'alice', @@ -57,7 +58,7 @@ describe('GET /wallets/:address/activity', () => { makeActivity({ type: 'buy', creator_id: 'creator-1', creator_handle: 'alice' }), makeActivity({ type: 'sell', creator_id: 'creator-2', creator_handle: 'bob' }), ]; - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([activities, 2]); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([activities, 2, null]); const req = makeReq({ address: VALID_ADDRESS }); const res = makeRes(); @@ -81,7 +82,7 @@ describe('GET /wallets/:address/activity', () => { ledger_sequence: 42, timestamp: new Date('2026-03-01T00:00:00Z'), }); - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[activity], 1]); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[activity], 1, null]); const req = makeReq({ address: VALID_ADDRESS }); const res = makeRes(); @@ -103,7 +104,7 @@ describe('GET /wallets/:address/activity', () => { // ── Empty wallet ────────────────────────────────────────────────────────── it('returns 200 with empty items array (not 404) for a wallet with no activity', async () => { - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0, null]); const req = makeReq({ address: VALID_ADDRESS }); const res = makeRes(); @@ -119,7 +120,7 @@ describe('GET /wallets/:address/activity', () => { // ── type filter ─────────────────────────────────────────────────────────── it('passes type=buy filter to the service', async () => { - const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]); + const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0, null]); const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' }); const res = makeRes(); @@ -132,7 +133,7 @@ describe('GET /wallets/:address/activity', () => { }); it('passes type=sell filter to the service', async () => { - const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]); + const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0, null]); const req = makeReq({ address: VALID_ADDRESS }, { type: 'sell' }); const res = makeRes(); @@ -146,7 +147,7 @@ describe('GET /wallets/:address/activity', () => { it('returns only buy events when type=buy', async () => { const buys: WalletActivityItem[] = [makeActivity({ type: 'buy' })]; - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([buys, 1]); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([buys, 1, null]); const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' }); const res = makeRes(); @@ -159,7 +160,7 @@ describe('GET /wallets/:address/activity', () => { // ── creator_id filter ───────────────────────────────────────────────────── it('passes creator_id filter to the service', async () => { - const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]); + const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0, null]); const req = makeReq({ address: VALID_ADDRESS }, { creator_id: 'creator-abc' }); const res = makeRes(); @@ -175,7 +176,7 @@ describe('GET /wallets/:address/activity', () => { const items: WalletActivityItem[] = [ makeActivity({ creator_id: 'creator-abc', creator_handle: 'target' }), ]; - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1]); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1, null]); const req = makeReq({ address: VALID_ADDRESS }, { creator_id: 'creator-abc' }); const res = makeRes(); @@ -210,7 +211,7 @@ describe('GET /wallets/:address/activity', () => { it('meta reflects limit and offset correctly', async () => { const items = Array.from({ length: 5 }, () => makeActivity()); - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 50]); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 50, null]); const req = makeReq({ address: VALID_ADDRESS }, { limit: '5', offset: '10' }); const res = makeRes(); @@ -225,7 +226,7 @@ describe('GET /wallets/:address/activity', () => { it('hasMore is false when all items fit in one page', async () => { const items = [makeActivity()]; - jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1]); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1, null]); const req = makeReq({ address: VALID_ADDRESS }, { limit: '20', offset: '0' }); const res = makeRes(); diff --git a/src/modules/wallets/wallet-activity.schemas.ts b/src/modules/wallets/wallet-activity.schemas.ts index 6fc4cf6..a6af6b0 100644 --- a/src/modules/wallets/wallet-activity.schemas.ts +++ b/src/modules/wallets/wallet-activity.schemas.ts @@ -23,11 +23,13 @@ export const WalletActivityQuerySchema = z.object({ }), type: z.enum(['buy', 'sell']).optional(), creator_id: z.string().optional(), + cursor: z.string().optional(), }).strict(); export type WalletActivityQueryType = z.infer; export const WalletActivityItemSchema = z.object({ + id: z.string(), type: z.enum(['buy', 'sell']), creator_id: z.string(), creator_handle: z.string().nullable(), diff --git a/src/modules/wallets/wallet-activity.service.ts b/src/modules/wallets/wallet-activity.service.ts index d148158..06285f3 100644 --- a/src/modules/wallets/wallet-activity.service.ts +++ b/src/modules/wallets/wallet-activity.service.ts @@ -9,11 +9,20 @@ import { WalletActivityItem, WalletActivityQueryType } from './wallet-activity.s * The payload stored in Activity for trades is expected to contain: * { amount, price_at_trade, fee_paid, ledger_sequence } */ +import { decodeCursor, encodeCursor } from '../../utils/cursor.utils'; + +/** + * Shape of decoded activity cursor + */ +export interface ActivityFeedCursorPayload { + id: string; +} + export async function fetchWalletActivity( address: string, query: WalletActivityQueryType -): Promise<[WalletActivityItem[], number]> { - const { limit, offset, type, creator_id } = query; +): Promise<[WalletActivityItem[], number, string | null]> { + const { limit, offset, type, creator_id, cursor } = query; // Map the public-facing type param to the internal ActivityType enum values. const typeFilter = @@ -32,18 +41,31 @@ export async function fetchWalletActivity( where.creatorId = creator_id; } + let prismaCursor: { id: string } | undefined; + if (cursor) { + try { + const decoded = decodeCursor(cursor); + if (decoded && decoded.id) { + prismaCursor = { id: decoded.id }; + } + } catch (_e) { + // Ignore tampered cursor and fall back + } + } + const [rows, total] = await Promise.all([ prisma.activity.findMany({ where, - orderBy: { createdAt: 'desc' }, - skip: offset, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: prismaCursor ? 1 : offset, take: limit, + ...(prismaCursor ? { cursor: prismaCursor } : {}), }), prisma.activity.count({ where }), ]); if (rows.length === 0) { - return [[], total]; + return [[], total, null]; } // Resolve creator handles in a single batched query. @@ -54,9 +76,10 @@ export async function fetchWalletActivity( }); const handleMap = new Map(creatorProfiles.map((c: { id: string; handle: string }) => [c.id, c.handle])); - const items: WalletActivityItem[] = rows.map((row: { type: string; creatorId: string | null; payload: unknown; createdAt: Date }) => { + const items: WalletActivityItem[] = rows.map((row: { id: string; type: string; creatorId: string | null; payload: unknown; createdAt: Date }) => { const payload = (row.payload ?? {}) as Record; return { + id: row.id, type: row.type === 'KEY_BOUGHT' ? 'buy' : 'sell', creator_id: row.creatorId ?? '', creator_handle: row.creatorId ? (handleMap.get(row.creatorId) ?? null) : null, @@ -68,5 +91,12 @@ export async function fetchWalletActivity( }; }); - return [items, total]; + const hasMore = prismaCursor ? items.length === limit : offset + limit < total; + let nextCursor: string | null = null; + if (hasMore && items.length > 0) { + const lastItem = items[items.length - 1]; + nextCursor = encodeCursor({ id: lastItem.id }); + } + + return [items, total, nextCursor]; }