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
171 changes: 171 additions & 0 deletions src/modules/wallets/wallet-holdings.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Integration test: wallet holdings endpoint (#442)
//
// Covers: empty wallet → 200 + empty array, wallet with holdings → 200 + data,
// malformed address → 400.
// Uses Jest mocks — no database required.

import { httpGetWalletHoldings } from './wallet-holdings.controllers';
import * as walletHoldingsService from './wallet-holdings.service';
import { HoldingItem } from './wallet-holdings.service';

Check failure on line 9 in src/modules/wallets/wallet-holdings.integration.test.ts

View workflow job for this annotation

GitHub Actions / verify

Module '"./wallet-holdings.service"' has no exported member 'HoldingItem'.

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

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

function makeReq(params: Record<string, string> = {}): any {
return { params };
}

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 makeHolding(overrides: Partial<HoldingItem> = {}): HoldingItem {
return {
id: 'holding-1',
ownerAddress: VALID_ADDRESS,
creatorId: 'creator-1',
balance: '10',
currentPrice: '0',
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
};
}

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

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

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

it('returns 200 with empty holdings array and total_portfolio_value of 0 for a wallet with no holdings', async () => {
jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue({
holdings: [],

Check failure on line 55 in src/modules/wallets/wallet-holdings.integration.test.ts

View workflow job for this annotation

GitHub Actions / verify

Object literal may only specify known properties, and 'holdings' does not exist in type '[{ creator_id: string; creator_handle: string | null; key_count?: any; current_price?: any; total_value?: any; }[], number] | Promise<[{ creator_id: string; creator_handle: string | null; key_count?: any; current_price?: any; total_value?: any; }[], number]>'.
total_portfolio_value: '0',
});

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.holdings).toEqual([]);
expect(body.data.total_portfolio_value).toBe('0');
});

// ── Wallet with holdings ──────────────────────────────────────────────────

it('returns 200 with populated holdings for a wallet with positions', async () => {
const holdings: HoldingItem[] = [
makeHolding({ creatorId: 'creator-1', balance: '5', currentPrice: '100' }),
makeHolding({ id: 'holding-2', creatorId: 'creator-2', balance: '3', currentPrice: '50' }),
];
jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue({
holdings,

Check failure on line 78 in src/modules/wallets/wallet-holdings.integration.test.ts

View workflow job for this annotation

GitHub Actions / verify

Object literal may only specify known properties, and 'holdings' does not exist in type '[{ creator_id: string; creator_handle: string | null; key_count?: any; current_price?: any; total_value?: any; }[], number] | Promise<[{ creator_id: string; creator_handle: string | null; key_count?: any; current_price?: any; total_value?: any; }[], number]>'.
total_portfolio_value: '650',
});

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.holdings).toHaveLength(2);
expect(body.data.total_portfolio_value).toBe('650');
});

it('each holding item includes required fields', async () => {
const holding = makeHolding({
id: 'holding-abc',
ownerAddress: VALID_ADDRESS,
creatorId: 'creator-xyz',
balance: '7',
currentPrice: '0',
updatedAt: new Date('2026-03-01T00:00:00Z'),
});
jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue({
holdings: [holding],

Check failure on line 103 in src/modules/wallets/wallet-holdings.integration.test.ts

View workflow job for this annotation

GitHub Actions / verify

Object literal may only specify known properties, and 'holdings' does not exist in type '[{ creator_id: string; creator_handle: string | null; key_count?: any; current_price?: any; total_value?: any; }[], number] | Promise<[{ creator_id: string; creator_handle: string | null; key_count?: any; current_price?: any; total_value?: any; }[], number]>'.
total_portfolio_value: '0',
});

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

const item = res.json.mock.calls[0][0].data.holdings[0];
expect(item).toMatchObject({
id: 'holding-abc',
ownerAddress: VALID_ADDRESS,
creatorId: 'creator-xyz',
balance: '7',
currentPrice: '0',
});
expect(item.updatedAt).toBeDefined();
});

it('passes the address to the service', async () => {
const spy = jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue({
holdings: [],

Check failure on line 124 in src/modules/wallets/wallet-holdings.integration.test.ts

View workflow job for this annotation

GitHub Actions / verify

Object literal may only specify known properties, and 'holdings' does not exist in type '[{ creator_id: string; creator_handle: string | null; key_count?: any; current_price?: any; total_value?: any; }[], number] | Promise<[{ creator_id: string; creator_handle: string | null; key_count?: any; current_price?: any; total_value?: any; }[], number]>'.
total_portfolio_value: '0',
});

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

expect(spy).toHaveBeenCalledWith(VALID_ADDRESS);
});

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

it('returns 400 for a malformed Stellar address', async () => {
const req = makeReq({ address: MALFORMED_ADDRESS });
const res = makeRes();
await httpGetWalletHoldings(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('does not call the service when the address is invalid', async () => {
const spy = jest.spyOn(walletHoldingsService, 'fetchWalletHoldings');

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

expect(spy).not.toHaveBeenCalled();
});

// ── Error forwarding ──────────────────────────────────────────────────────

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

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

expect(next).toHaveBeenCalledWith(err);
});
});
74 changes: 74 additions & 0 deletions src/modules/webhooks/webhook-payload.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { buildWebhookPayload } from './webhook-payload.utils';
import type { TradeEvent, WebhookEventPayload } from './webhook.types';

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

function makeTradeEvent(overrides: Partial<TradeEvent> = {}): TradeEvent {
return {
type: 'buy',
creatorId: 'creator-1',
buyerOrSellerAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
amount: '10',
price: '50',
feePaid: '1',
timestamp: '2026-01-01T00:00:00.000Z',
...overrides,
};
}

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

describe('buildWebhookPayload', () => {
it('maps all fields correctly for a buy event', () => {
const event = makeTradeEvent({ type: 'buy' });
const payload = buildWebhookPayload(event);

expect(payload).toEqual<WebhookEventPayload>({
event_type: 'buy',
creator_id: 'creator-1',
buyer_or_seller_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
amount: '10',
price: '50',
fee_paid: '1',
timestamp: '2026-01-01T00:00:00.000Z',
});
});

it('sets event_type to sell for a sell event and maps fields correctly', () => {
const event = makeTradeEvent({
type: 'sell',
creatorId: 'creator-2',
buyerOrSellerAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
amount: '5',
price: '200',
feePaid: '2',
timestamp: '2026-06-15T12:00:00.000Z',
});
const payload = buildWebhookPayload(event);

expect(payload.event_type).toBe('sell');
expect(payload.creator_id).toBe('creator-2');
expect(payload.buyer_or_seller_address).toBe('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB');
expect(payload.amount).toBe('5');
expect(payload.price).toBe('200');
expect(payload.fee_paid).toBe('2');
expect(payload.timestamp).toBe('2026-06-15T12:00:00.000Z');
});

it('produces no extra keys beyond the WebhookEventPayload contract', () => {
const event = makeTradeEvent();
const payload = buildWebhookPayload(event);

const expectedKeys: Array<keyof WebhookEventPayload> = [
'event_type',
'creator_id',
'buyer_or_seller_address',
'amount',
'price',
'fee_paid',
'timestamp',
];

expect(Object.keys(payload).sort()).toEqual(expectedKeys.sort());
});
});
13 changes: 13 additions & 0 deletions src/modules/webhooks/webhook-payload.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { TradeEvent, WebhookEventPayload } from './webhook.types';

export function buildWebhookPayload(event: TradeEvent): WebhookEventPayload {
return {
event_type: event.type,
creator_id: event.creatorId,
buyer_or_seller_address: event.buyerOrSellerAddress,
amount: event.amount,
price: event.price,
fee_paid: event.feePaid,
timestamp: event.timestamp,
};
}
24 changes: 15 additions & 9 deletions src/modules/webhooks/webhook.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { prisma } from '../../utils/prisma.utils';
import { logger } from '../../utils/logger.utils';
import { envConfig } from '../../config';
import { maskWebhookUrl } from '../../utils/webhook-mask.utils';
import { buildWebhookPayload } from './webhook-payload.utils';
import type { CreateWebhookInput, TradeEvent, WebhookEventPayload, WebhookEventName } from './webhook.types';

function normalizeEvents(events: string[]): ('BUY' | 'SELL')[] {
Expand Down Expand Up @@ -38,6 +40,11 @@ export async function createWebhook(
},
});

logger.info(
{ creator_id: creatorId, webhook_id: webhook.id, event_types: input.events, registered_at: webhook.createdAt.toISOString() },
'Webhook registered'
);

return {
...webhook,
events: denormalizeEvents(webhook.events as ('BUY' | 'SELL')[]),
Expand Down Expand Up @@ -66,6 +73,12 @@ export async function deleteWebhook(webhookId: string, creatorId: string) {
}

await prisma.webhook.delete({ where: { id: webhookId } });

logger.info(
{ creator_id: creatorId, webhook_id: webhookId, deleted_at: new Date().toISOString() },
'Webhook deleted'
);

return { id: webhookId };
}

Expand All @@ -85,15 +98,7 @@ export async function dispatchWebhookEvent(tradeEvent: TradeEvent) {
if (webhooks.length === 0) return;

for (const webhook of webhooks) {
const payload: WebhookEventPayload = {
event_type: tradeEvent.type,
creator_id: tradeEvent.creatorId,
buyer_or_seller_address: tradeEvent.buyerOrSellerAddress,
amount: tradeEvent.amount,
price: tradeEvent.price,
fee_paid: tradeEvent.feePaid,
timestamp: tradeEvent.timestamp,
};
const payload: WebhookEventPayload = buildWebhookPayload(tradeEvent);

await prisma.webhookEvent.create({
data: {
Expand Down Expand Up @@ -154,6 +159,7 @@ async function attemptDelivery(
{
webhook_id: webhookId,
creator_id: payload.creator_id,
callback_url: maskWebhookUrl(callbackUrl),
attempt_number: attempt + 1,
backoff_delay_ms: delay,
last_error_code: errMsg,
Expand Down
31 changes: 31 additions & 0 deletions src/utils/webhook-mask.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { maskWebhookUrl } from './webhook-mask.utils';

describe('maskWebhookUrl', () => {
it('strips query string containing a secret', () => {
expect(maskWebhookUrl('https://api.example.com/hook?token=secret')).toBe('https://api.example.com');
});

it('strips path segments', () => {
expect(maskWebhookUrl('https://hooks.slack.com/services/T123/B456/xyzxyz')).toBe('https://hooks.slack.com');
});

it('returns the URL as-is when there is no path or query string', () => {
expect(maskWebhookUrl('http://localhost:3000')).toBe('http://localhost:3000');
});

it('returns [invalid url] for a non-URL string', () => {
expect(maskWebhookUrl('not a url')).toBe('[invalid url]');
});

it('returns [invalid url] for an empty string', () => {
expect(maskWebhookUrl('')).toBe('[invalid url]');
});

it('strips path and preserves non-standard port', () => {
expect(maskWebhookUrl('https://internal.corp:8443/webhooks/receiver?auth=abc')).toBe('https://internal.corp:8443');
});

it('strips fragment identifiers', () => {
expect(maskWebhookUrl('https://example.com/path#section')).toBe('https://example.com');
});
});
24 changes: 24 additions & 0 deletions src/utils/webhook-mask.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Strips everything after the host from a webhook URL, returning only
* `scheme://host` (or `scheme://host:port` when a non-standard port is
* present). This prevents secrets embedded in paths or query strings from
* leaking into log output.
*
* @example
* maskWebhookUrl('https://api.example.com/hook?token=secret')
* // → 'https://api.example.com'
*
* maskWebhookUrl('https://hooks.slack.com/services/T123/B456/xyzxyz')
* // → 'https://hooks.slack.com'
*
* maskWebhookUrl('not a url')
* // → '[invalid url]'
*/
export function maskWebhookUrl(url: string): string {
try {
const parsed = new URL(url);
return parsed.origin;
} catch {
return '[invalid url]';
}
}
Loading