Skip to content
Merged
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
8 changes: 8 additions & 0 deletions docs/api-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ Public activity feed endpoints.
| :----- | :---------- | :------------------------------- |
| `GET` | `/activity` | Return the public activity feed. |

## Wallets Module

Wallet activity and trade history endpoints.

| Method | Path | Description |
| :----- | :--------------------------- | :------------------------------------------------------------ |
| `GET` | `/wallets/:address/activity` | Return paginated trade history (buys and sells) for a wallet. |

## Ownership Module

Ownership lookup endpoints.
Expand Down
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import adminRouter from './admin/admin.routes';
import activityRouter from './activity/activity.routes';
import ownershipRouter from './ownership/ownership.routes';
import webhookRouter from './webhooks/webhook.router';
import walletsRouter from './wallets/wallets.routes';
import { BASE as CREATORS_BASE } from '../constants/creator.constants';

const router = Router();
Expand All @@ -25,5 +26,6 @@ router.use('/admin', adminRouter);
router.use('/activity', activityRouter);
router.use('/ownership', ownershipRouter);
router.use(CREATORS_BASE, webhookRouter);
router.use('/wallets', walletsRouter);

export default router;
55 changes: 55 additions & 0 deletions src/modules/wallets/wallet-activity.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Request, Response, NextFunction } from 'express';
import { WalletActivityParamsSchema, WalletActivityQuerySchema } from './wallet-activity.schemas';
import { fetchWalletActivity } from './wallet-activity.service';
import { sendSuccess, sendValidationError } from '../../utils/api-response.utils';
import { buildOffsetPaginationMeta } from '../../utils/pagination.utils';

export async function httpGetWalletActivity(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const parsedParams = WalletActivityParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
sendValidationError(
res,
'Invalid wallet address',
parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
field: `address`,
message: issue.message,
}))
);
return;
}

const parsedQuery = WalletActivityQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
sendValidationError(
res,
'Invalid query parameters',
parsedQuery.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
field: issue.path.join('.'),
message: issue.message,
}))
);
return;
}

const [items, total] = await fetchWalletActivity(
parsedParams.data.address,
parsedQuery.data
);

sendSuccess(res, {
items,
meta: buildOffsetPaginationMeta({
limit: parsedQuery.data.limit,
offset: parsedQuery.data.offset,
total,
}),
});
} catch (error) {
next(error);
}
}
248 changes: 248 additions & 0 deletions src/modules/wallets/wallet-activity.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// Integration test: wallet activity feed endpoint (#424)
//
// Covers: mixed history, type filter, creator_id filter, empty wallet,
// 400 on malformed address, pagination metadata.
// Uses Jest mocks — no database required.

import { httpGetWalletActivity } from './wallet-activity.controllers';
import * as walletActivityService from './wallet-activity.service';
import { WalletActivityItem } from './wallet-activity.schemas';

// ── Helpers ───────────────────────────────────────────────────────────────────

const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const MALFORMED_ADDRESS = 'not-a-stellar-address';

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.setHeader = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}

function makeNext(): jest.Mock {
return jest.fn();
}

function makeActivity(overrides: Partial<WalletActivityItem> = {}): WalletActivityItem {
return {
type: 'buy',
creator_id: 'creator-1',
creator_handle: 'alice',
amount: '10',
price_at_trade: '50',
fee_paid: '1',
ledger_sequence: 100,
timestamp: new Date('2026-01-01T00:00:00Z'),
...overrides,
};
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('GET /wallets/:address/activity', () => {
afterEach(() => {
jest.restoreAllMocks();
});

// ── Happy path: mixed history ─────────────────────────────────────────────

it('returns 200 with items and meta for a wallet with mixed trade history', async () => {
const activities: WalletActivityItem[] = [
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]);

const req = makeReq({ address: VALID_ADDRESS });
const res = makeRes();
await httpGetWalletActivity(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(2);
expect(body.data.meta.total).toBe(2);
});

it('each item includes required trade fields', async () => {
const activity = makeActivity({
type: 'buy',
creator_id: 'creator-1',
creator_handle: 'alice',
amount: '5',
price_at_trade: '100',
fee_paid: '2',
ledger_sequence: 42,
timestamp: new Date('2026-03-01T00:00:00Z'),
});
jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[activity], 1]);

const req = makeReq({ address: VALID_ADDRESS });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

const item = res.json.mock.calls[0][0].data.items[0];
expect(item).toMatchObject({
type: 'buy',
creator_id: 'creator-1',
creator_handle: 'alice',
amount: '5',
price_at_trade: '100',
fee_paid: '2',
ledger_sequence: 42,
});
expect(item.timestamp).toBeDefined();
});

// ── Empty wallet ──────────────────────────────────────────────────────────

it('returns 200 with empty items array (not 404) for a wallet with no activity', async () => {
jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]);

const req = makeReq({ address: VALID_ADDRESS });
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).toEqual([]);
expect(body.data.meta.total).toBe(0);
expect(body.data.meta.hasMore).toBe(false);
});

// ── type filter ───────────────────────────────────────────────────────────

it('passes type=buy filter to the service', async () => {
const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]);

const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

expect(spy).toHaveBeenCalledWith(
VALID_ADDRESS,
expect.objectContaining({ type: 'buy' })
);
});

it('passes type=sell filter to the service', async () => {
const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]);

const req = makeReq({ address: VALID_ADDRESS }, { type: 'sell' });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

expect(spy).toHaveBeenCalledWith(
VALID_ADDRESS,
expect.objectContaining({ type: 'sell' })
);
});

it('returns only buy events when type=buy', async () => {
const buys: WalletActivityItem[] = [makeActivity({ type: 'buy' })];
jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([buys, 1]);

const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body.data.items.every((i: WalletActivityItem) => i.type === 'buy')).toBe(true);
});

// ── creator_id filter ─────────────────────────────────────────────────────

it('passes creator_id filter to the service', async () => {
const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]);

const req = makeReq({ address: VALID_ADDRESS }, { creator_id: 'creator-abc' });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

expect(spy).toHaveBeenCalledWith(
VALID_ADDRESS,
expect.objectContaining({ creator_id: 'creator-abc' })
);
});

it('returns only trades for the specified creator when creator_id is set', async () => {
const items: WalletActivityItem[] = [
makeActivity({ creator_id: 'creator-abc', creator_handle: 'target' }),
];
jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1]);

const req = makeReq({ address: VALID_ADDRESS }, { creator_id: 'creator-abc' });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body.data.items.every((i: WalletActivityItem) => i.creator_id === 'creator-abc')).toBe(true);
});

// ── Malformed address → 400 ───────────────────────────────────────────────

it('returns 400 for a malformed Stellar address', async () => {
const req = makeReq({ address: MALFORMED_ADDRESS });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(400);
const body = res.json.mock.calls[0][0];
expect(body.success).toBe(false);
expect(body.error.code).toBe('VALIDATION_ERROR');
});

it('returns 400 for an invalid type filter value', async () => {
const req = makeReq({ address: VALID_ADDRESS }, { type: 'transfer' });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(400);
});

// ── Pagination ────────────────────────────────────────────────────────────

it('meta reflects limit and offset correctly', async () => {
const items = Array.from({ length: 5 }, () => makeActivity());
jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 50]);

const req = makeReq({ address: VALID_ADDRESS }, { limit: '5', offset: '10' });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

const meta = res.json.mock.calls[0][0].data.meta;
expect(meta.limit).toBe(5);
expect(meta.offset).toBe(10);
expect(meta.total).toBe(50);
expect(meta.hasMore).toBe(true);
});

it('hasMore is false when all items fit in one page', async () => {
const items = [makeActivity()];
jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1]);

const req = makeReq({ address: VALID_ADDRESS }, { limit: '20', offset: '0' });
const res = makeRes();
await httpGetWalletActivity(req, res, makeNext());

expect(res.json.mock.calls[0][0].data.meta.hasMore).toBe(false);
});

it('forwards service errors to next()', async () => {
const err = new Error('db down');
jest.spyOn(walletActivityService, 'fetchWalletActivity').mockRejectedValue(err);

const req = makeReq({ address: VALID_ADDRESS });
const res = makeRes();
const next = makeNext();
await httpGetWalletActivity(req, res, next);

expect(next).toHaveBeenCalledWith(err);
});
});
41 changes: 41 additions & 0 deletions src/modules/wallets/wallet-activity.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { z } from 'zod';
import { StellarAddressSchema } from '../wallet/wallet.schemas';
import { safeIntParam } from '../../utils/query.utils';
import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults';
import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants';

export const WalletActivityParamsSchema = z.object({
address: StellarAddressSchema,
});

export const WalletActivityQuerySchema = z.object({
limit: safeIntParam({
defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit,
min: MIN_PAGE_SIZE,
max: MAX_PAGE_SIZE,
label: 'Limit',
}),
offset: safeIntParam({
defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset,
min: 0,
max: Number.MAX_SAFE_INTEGER,
label: 'Offset',
}),
type: z.enum(['buy', 'sell']).optional(),
creator_id: z.string().optional(),
}).strict();

export type WalletActivityQueryType = z.infer<typeof WalletActivityQuerySchema>;

export const WalletActivityItemSchema = z.object({
type: z.enum(['buy', 'sell']),
creator_id: z.string(),
creator_handle: z.string().nullable(),
amount: z.any(),
price_at_trade: z.any(),
fee_paid: z.any(),
ledger_sequence: z.number().nullable(),
timestamp: z.date(),
});

export type WalletActivityItem = z.infer<typeof WalletActivityItemSchema>;
Loading
Loading