Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}, query: Record<string, string> = {}): 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<ActivityFeedCursorPayload>({ 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<ActivityFeedCursorPayload>({ 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);
});
});
15 changes: 9 additions & 6 deletions src/modules/wallets/wallet-activity.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 11 additions & 10 deletions src/modules/wallets/wallet-activity.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeNext(): jest.Mock {

function makeActivity(overrides: Partial<WalletActivityItem> = {}): WalletActivityItem {
return {
id: 'activity-1',
type: 'buy',
creator_id: 'creator-1',
creator_handle: 'alice',
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/modules/wallets/wallet-activity.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof WalletActivityQuerySchema>;

export const WalletActivityItemSchema = z.object({
id: z.string(),
type: z.enum(['buy', 'sell']),
creator_id: z.string(),
creator_handle: z.string().nullable(),
Expand Down
44 changes: 37 additions & 7 deletions src/modules/wallets/wallet-activity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -32,18 +41,31 @@ export async function fetchWalletActivity(
where.creatorId = creator_id;
}

let prismaCursor: { id: string } | undefined;
if (cursor) {
try {
const decoded = decodeCursor<ActivityFeedCursorPayload>(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.
Expand All @@ -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<string, any>;
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,
Expand All @@ -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<ActivityFeedCursorPayload>({ id: lastItem.id });
}

return [items, total, nextCursor];
}
Loading