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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,15 @@ pr.md
CLAUDE.md
plan.md

# Test snapshots
**/__snapshots__/
*.snap

# Coverage reports
coverage/
.nyc_output/

# TypeScript build info
*.tsbuildinfo

....
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Integration test: alert registration returns 400 for invalid Stellar wallet address (#491)
//
// Covers: POST /alerts with a malformed wallet_address is rejected with 400
// before any database write occurs.
// Uses Jest mocks — no database required.

import { httpCreateAlert } from '../alert.controllers';
import * as alertService from '../alert.service';

jest.mock('../../../utils/prisma.utils', () => ({
prisma: {
priceAlert: {
create: jest.fn(),
},
},
}));

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 makeReq(body: Record<string, unknown>): any {
return { body };
}

const VALID_PAYLOAD = {
creator_id: 'creator-1',
wallet_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
target_price: 100,
direction: 'above',
callback_url: 'https://example.com/cb',
};

describe('POST /alerts — invalid Stellar wallet address', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('returns 400 for a malformed wallet address', async () => {
const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'not-a-stellar-address' });
const res = makeRes();

await httpCreateAlert(req, res, makeNext());

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

it('error body identifies the wallet_address field', async () => {
const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'BADINPUT' });
const res = makeRes();

await httpCreateAlert(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body.success).toBe(false);
const details: Array<{ field: string; message: string }> = body.error.details ?? [];
const fieldNames = details.map((d) => d.field);
expect(fieldNames).toContain('wallet_address');
});

it('does not create an alert record after failed validation', async () => {
const createSpy = jest.spyOn(alertService, 'createAlert');
const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'invalid' });
const res = makeRes();

await httpCreateAlert(req, res, makeNext());

expect(createSpy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Integration test: activity feed returns historical price at time of trade (#489)
//
// Covers: two buy events for the same creator at different prices, with a third
// current snapshot price. Each event must show its own trade-time price, not
// the current snapshot price.
// Uses Jest mocks — no database required.

import { fetchWalletActivity } from './wallet-activity.service';
import { prisma } from '../../utils/prisma.utils';

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 = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const CREATOR_ID = 'creator-hist-price-1';

// Trade 1: price was 200 at trade time
const TRADE_1_PRICE = '200';
// Trade 2: price was 350 at trade time
const TRADE_2_PRICE = '350';
// Current snapshot price — neither trade should surface this value
const CURRENT_SNAPSHOT_PRICE = '500';

const trade1 = {
type: 'KEY_BOUGHT',
actor: WALLET_ADDRESS,
creatorId: CREATOR_ID,
payload: { amount: '5', price_at_trade: TRADE_1_PRICE, fee_paid: '1', ledger_sequence: 1001 },
createdAt: new Date('2026-01-10T00:00:00Z'),
};

const trade2 = {
type: 'KEY_BOUGHT',
actor: WALLET_ADDRESS,
creatorId: CREATOR_ID,
payload: { amount: '3', price_at_trade: TRADE_2_PRICE, fee_paid: '1', ledger_sequence: 1002 },
createdAt: new Date('2026-03-15T00:00:00Z'),
};

describe('Wallet activity feed — historical price preservation', () => {
beforeEach(() => {
jest.clearAllMocks();

mockPrisma.activity.findMany.mockResolvedValue([trade2, trade1]); // ordered newest first
mockPrisma.activity.count.mockResolvedValue(2);
mockPrisma.creatorProfile.findMany.mockResolvedValue([
{ id: CREATOR_ID, handle: 'hist-creator' },
]);
});

it('first trade event shows the price at the time of that trade', async () => {
const [items] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 });

// items[0] is trade2 (newest first)
expect(items[0].price_at_trade).toBe(TRADE_2_PRICE);
});

it('second trade event shows a different price matching its own trade time', async () => {
const [items] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 });

// items[1] is trade1 (older)
expect(items[1].price_at_trade).toBe(TRADE_1_PRICE);
expect(items[1].price_at_trade).not.toBe(items[0].price_at_trade);
});

it('neither event shows the current snapshot price', async () => {
const [items] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 });

for (const item of items) {
expect(item.price_at_trade).not.toBe(CURRENT_SNAPSHOT_PRICE);
}
});

it('both events belong to the same creator', async () => {
const [items] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 });

expect(items[0].creator_id).toBe(CREATOR_ID);
expect(items[1].creator_id).toBe(CREATOR_ID);
});

it('returns exactly two trade events', async () => {
const [items, total] = await fetchWalletActivity(WALLET_ADDRESS, { limit: 20, offset: 0 });

expect(items).toHaveLength(2);
expect(total).toBe(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Integration test: wallet holdings endpoint excludes zero-balance entries (#484)
//
// Covers: a wallet with one positive-balance and one zero-balance ownership record
// should only return the positive-balance creator in the response.
// Uses Jest mocks — no database required.

import { fetchWalletHoldings } from './wallet-holdings.service';
import { prisma } from '../../utils/prisma.utils';

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
keyOwnership: {
findMany: jest.fn(),
},
creatorProfile: {
findMany: jest.fn(),
},
creatorPriceSnapshot: {
findMany: jest.fn(),
},
},
}));

const mockPrisma = prisma as unknown as {
keyOwnership: { findMany: jest.Mock };
creatorProfile: { findMany: jest.Mock };
creatorPriceSnapshot: { findMany: jest.Mock };
};

const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const CREATOR_WITH_BALANCE = 'creator-positive-balance';
const CREATOR_ZERO_BALANCE = 'creator-zero-balance';

describe('GET /wallets/:address/holdings — zero-balance exclusion', () => {
beforeEach(() => {
jest.clearAllMocks();

// The service filters balance > 0 at the DB layer, so the mock returns
// only the positive-balance row (simulating what prisma would return
// with the `balance: { gt: 0 }` where clause).
mockPrisma.keyOwnership.findMany.mockResolvedValue([
{
ownerAddress: WALLET_ADDRESS,
creatorId: CREATOR_WITH_BALANCE,
balance: '3',
createdAt: new Date('2026-01-01T00:00:00Z'),
},
]);

mockPrisma.creatorProfile.findMany.mockResolvedValue([
{ id: CREATOR_WITH_BALANCE, handle: 'active-creator' },
]);

mockPrisma.creatorPriceSnapshot.findMany.mockResolvedValue([
{ creatorId: CREATOR_WITH_BALANCE, currentPrice: BigInt(500) },
]);
});

it('excludes zero-balance creator from response', async () => {
const [items] = await fetchWalletHoldings(WALLET_ADDRESS);

const returnedIds = items.map((item) => item.creator_id);
expect(returnedIds).not.toContain(CREATOR_ZERO_BALANCE);
});

it('includes positive-balance creator with correct balance', async () => {
const [items] = await fetchWalletHoldings(WALLET_ADDRESS);

expect(items[0].creator_id).toBe(CREATOR_WITH_BALANCE);
expect(items[0].key_count).toBe('3');
});

it('response length matches only non-zero entries', async () => {
const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS);

expect(items).toHaveLength(1);
expect(total).toBe(1);
});

it('service queries DB with balance > 0 filter', async () => {
await fetchWalletHoldings(WALLET_ADDRESS);

expect(mockPrisma.keyOwnership.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
balance: { gt: 0 },
}),
})
);
});
});
53 changes: 53 additions & 0 deletions src/utils/validation-error.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Unit tests for buildValidationError helper (#490)
//
// Covers: correct shape returned for multiple field/message/code combinations.

import { buildValidationError } from './validation-error.utils';

describe('buildValidationError', () => {
it('returns correct shape with all three fields', () => {
const result = buildValidationError('wallet_address', 'Invalid Stellar address', 'INVALID_ADDRESS');

expect(result).toEqual({
error: {
code: 'INVALID_ADDRESS',
field: 'wallet_address',
message: 'Invalid Stellar address',
},
});
});

it('returns correct shape for a required field error', () => {
const result = buildValidationError('creator_id', 'creator_id is required', 'REQUIRED');

expect(result.error.field).toBe('creator_id');
expect(result.error.message).toBe('creator_id is required');
expect(result.error.code).toBe('REQUIRED');
});

it('returns correct shape for a range violation', () => {
const result = buildValidationError('target_price', 'target_price must be positive', 'OUT_OF_RANGE');

expect(result.error.field).toBe('target_price');
expect(result.error.message).toBe('target_price must be positive');
expect(result.error.code).toBe('OUT_OF_RANGE');
});

it('error object contains exactly the three expected keys', () => {
const result = buildValidationError('email', 'Invalid email format', 'INVALID_FORMAT');

expect(Object.keys(result.error)).toEqual(['code', 'field', 'message']);
});

it('preserves arbitrary field names and messages without mutation', () => {
const field = 'callback_url';
const message = 'callback_url must be a valid URL';
const code = 'INVALID_URL';

const result = buildValidationError(field, message, code);

expect(result.error.field).toBe(field);
expect(result.error.message).toBe(message);
expect(result.error.code).toBe(code);
});
});
30 changes: 30 additions & 0 deletions src/utils/validation-error.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Builds a standardized 422 Unprocessable Entity validation error response body.
*
* Use this helper wherever a validation error needs a structured response shape
* that identifies the offending field and error code.
*
* @example
* res.status(422).json(buildValidationError('wallet_address', 'Invalid Stellar address', 'INVALID_ADDRESS'));
*/
export interface ValidationErrorResponse {
error: {
code: string;
field: string;
message: string;
};
}

export function buildValidationError(
field: string,
message: string,
code: string
): ValidationErrorResponse {
return {
error: {
code,
field,
message,
},
};
}
Loading