From f35d66ebb2523c7762605665d45f53a5a3b8992e Mon Sep 17 00:00:00 2001 From: villadel Date: Fri, 26 Jun 2026 14:40:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20issues=20#484=20#491=20#490=20#489=20?= =?UTF-8?q?=E2=80=94=20zero-balance=20holdings=20test,=20alert=20invalid?= =?UTF-8?q?=20address=20test,=20buildValidationError=20helper,=20activity?= =?UTF-8?q?=20historical=20price=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 11 ++ .../alert-invalid-address.integration.test.ts | 78 ++++++++++++++ ...ivity-historical-price.integration.test.ts | 101 ++++++++++++++++++ ...-holdings-zero-balance.integration.test.ts | 91 ++++++++++++++++ src/utils/validation-error.utils.test.ts | 53 +++++++++ src/utils/validation-error.utils.ts | 30 ++++++ 6 files changed, 364 insertions(+) create mode 100644 src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts create mode 100644 src/modules/wallets/wallet-activity-historical-price.integration.test.ts create mode 100644 src/modules/wallets/wallet-holdings-zero-balance.integration.test.ts create mode 100644 src/utils/validation-error.utils.test.ts create mode 100644 src/utils/validation-error.utils.ts diff --git a/.gitignore b/.gitignore index 70d618f..58b26a8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,15 @@ pr.md CLAUDE.md plan.md +# Test snapshots +**/__snapshots__/ +*.snap + +# Coverage reports +coverage/ +.nyc_output/ + +# TypeScript build info +*.tsbuildinfo + .... \ No newline at end of file diff --git a/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts b/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts new file mode 100644 index 0000000..0174489 --- /dev/null +++ b/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts @@ -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): 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(); + }); +}); diff --git a/src/modules/wallets/wallet-activity-historical-price.integration.test.ts b/src/modules/wallets/wallet-activity-historical-price.integration.test.ts new file mode 100644 index 0000000..534b967 --- /dev/null +++ b/src/modules/wallets/wallet-activity-historical-price.integration.test.ts @@ -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); + }); +}); diff --git a/src/modules/wallets/wallet-holdings-zero-balance.integration.test.ts b/src/modules/wallets/wallet-holdings-zero-balance.integration.test.ts new file mode 100644 index 0000000..f761634 --- /dev/null +++ b/src/modules/wallets/wallet-holdings-zero-balance.integration.test.ts @@ -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 }, + }), + }) + ); + }); +}); diff --git a/src/utils/validation-error.utils.test.ts b/src/utils/validation-error.utils.test.ts new file mode 100644 index 0000000..86732a0 --- /dev/null +++ b/src/utils/validation-error.utils.test.ts @@ -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); + }); +}); diff --git a/src/utils/validation-error.utils.ts b/src/utils/validation-error.utils.ts new file mode 100644 index 0000000..cf0b674 --- /dev/null +++ b/src/utils/validation-error.utils.ts @@ -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, + }, + }; +}