diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 154519f..8b2c663 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,62 @@ pnpm build Run `pnpm exec prisma generate` again whenever Prisma schema changes. +## Writing Integration Tests + +When adding new endpoints, you must include an integration test that exercises the full request lifecycle against a database. + +### Folder Structure and Naming +Integration tests belong in the `src/__tests__/integration/` directory (for cross-module tests) or adjacent to the controller they test (e.g., `src/modules/creators/creators.integration.test.ts`). They must be suffixed with `.test.ts` or `.integration.test.ts`. + +### Seeding the Database +Use Prisma to seed test fixtures in a `beforeAll` block, and ensure you clean them up in an `afterAll` block to maintain a pristine test environment. Do not rely on external seed scripts for unit or integration tests. + +### Minimal Worked Example + +```typescript +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +describe('GET /api/v1/example', () => { + beforeAll(async () => { + // 1. Seed database with test fixtures + await prisma.user.create({ + data: { + id: 'test-user', + email: 'test@example.com', + passwordHash: 'hash', + firstName: 'Test', + lastName: 'User' + } + }); + }); + + afterAll(async () => { + // 2. Clean up fixtures + await prisma.user.delete({ where: { id: 'test-user' } }); + await prisma.$disconnect(); + }); + + it('returns 200 and data for an existing record', async () => { + // 3. Execute the request + const res = await supertest(app).get('/api/v1/example/test-user'); + + // 4. Assert response + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); +}); +``` + +### Running Integration Tests Locally +To run only the integration tests, you can use jest with a path or name filter: +```bash +pnpm test -- src/__tests__/integration +# or run a specific file +pnpm test -- creator-holders-404.test.ts +``` + ## Backend contribution rules - Do not commit secrets, service accounts, or live credentials. diff --git a/src/__tests__/integration/creator-holders-404.test.ts b/src/__tests__/integration/creator-holders-404.test.ts new file mode 100644 index 0000000..8320428 --- /dev/null +++ b/src/__tests__/integration/creator-holders-404.test.ts @@ -0,0 +1,21 @@ +import supertest from 'supertest'; +import app from '../../app'; + +describe('GET /api/v1/creators/:id/holders 404', () => { + it('returns 404 with a clear error body for non-existent creator', async () => { + // A random CUID that does not exist in the database + const nonexistentId = 'nonexistent-creator-123'; + + const res = await supertest(app).get(`/api/v1/creators/${nonexistentId}/holders`); + + expect(res.status).toBe(404); + + expect(res.body).toEqual({ + success: false, + error: { + code: 'NOT_FOUND', + message: 'Creator not found', + } + }); + }); +}); diff --git a/src/modules/wallets/wallet-activity.controllers.ts b/src/modules/wallets/wallet-activity.controllers.ts index a50d4e9..e73858b 100644 --- a/src/modules/wallets/wallet-activity.controllers.ts +++ b/src/modules/wallets/wallet-activity.controllers.ts @@ -3,6 +3,7 @@ import { WalletActivityParamsSchema, WalletActivityQuerySchema } from './wallet- import { fetchWalletActivity } from './wallet-activity.service'; import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; +import { logger } from '../../utils/logger.utils'; export async function httpGetWalletActivity( req: Request, @@ -36,10 +37,28 @@ export async function httpGetWalletActivity( return; } + const t0 = performance.now(); const [items, total] = await fetchWalletActivity( parsedParams.data.address, parsedQuery.data ); + const duration = performance.now() - t0; + + const filters_applied = []; + if (parsedQuery.data.type) filters_applied.push('type'); + if (parsedQuery.data.creator_id) filters_applied.push('creator_id'); + + const address = parsedParams.data.address; + const maskedAddress = address.length >= 8 + ? `${address.slice(0, 4)}...${address.slice(-4)}` + : address; + + logger.debug({ + wallet_address: maskedAddress, + result_count: items.length, + query_duration_ms: Math.round(duration), + filters_applied + }, 'Wallet activity feed query'); sendSuccess(res, { items, diff --git a/src/utils/pagination.utils.ts b/src/utils/pagination.utils.ts index a638d98..9345495 100644 --- a/src/utils/pagination.utils.ts +++ b/src/utils/pagination.utils.ts @@ -65,3 +65,49 @@ export const buildOffsetPaginationMeta = ({ hasMore: safeOffset + safeLimit < safeTotal, }; }; + +export type CursorPaginationOptions = { + cursor?: any; + limit: number; +}; + +export type CursorPaginationResult = { + data: T[]; + nextCursor?: any; + hasMore: boolean; +}; + +/** + * Helper for cursor-based pagination. + * Appends cursor filtering and limit to a query function. + * + * @param query A function that accepts pagination args and executes the DB query + * @param options Pagination options containing cursor and limit + * @param getCursor Optional function to extract the cursor from the last item. Defaults to extracting the `id` property. + */ +export async function paginateQuery( + query: (args: { take: number; skip?: number; cursor?: any }) => Promise, + { cursor, limit }: CursorPaginationOptions, + getCursor?: (item: T) => any +): Promise> { + const take = limit + 1; + const args: { take: number; skip?: number; cursor?: any } = { take }; + + if (cursor) { + args.cursor = cursor; + args.skip = 1; + } + + const results = await query(args); + + const hasMore = results.length > limit; + const data = hasMore ? results.slice(0, limit) : results; + + let nextCursor = undefined; + if (data.length > 0 && hasMore) { + const lastItem = data[data.length - 1]; + nextCursor = getCursor ? getCursor(lastItem) : (lastItem as any).id; + } + + return { data, nextCursor, hasMore }; +} diff --git a/src/utils/test/pagination.utils.test.ts b/src/utils/test/pagination.utils.test.ts new file mode 100644 index 0000000..3984fc1 --- /dev/null +++ b/src/utils/test/pagination.utils.test.ts @@ -0,0 +1,56 @@ +import { paginateQuery } from '../pagination.utils'; + +describe('paginateQuery', () => { + const mockData = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + { id: 4, name: 'David' }, + { id: 5, name: 'Eve' } + ]; + + const mockQueryFn = jest.fn().mockImplementation(async ({ take, skip, cursor }) => { + let startIndex = 0; + if (cursor) { + startIndex = mockData.findIndex(item => item.id === cursor); + if (startIndex === -1) return []; + } + if (skip) startIndex += skip; + + return mockData.slice(startIndex, startIndex + take); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns first page with no cursor', async () => { + const result = await paginateQuery(mockQueryFn, { limit: 2 }); + + expect(mockQueryFn).toHaveBeenCalledWith({ take: 3 }); + expect(result.data).toHaveLength(2); + expect(result.data).toEqual([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe(2); + }); + + it('returns subsequent page with cursor', async () => { + const result = await paginateQuery(mockQueryFn, { cursor: 2, limit: 2 }); + + expect(mockQueryFn).toHaveBeenCalledWith({ take: 3, skip: 1, cursor: 2 }); + expect(result.data).toHaveLength(2); + expect(result.data).toEqual([{ id: 3, name: 'Charlie' }, { id: 4, name: 'David' }]); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe(4); + }); + + it('returns last page where hasMore is false', async () => { + const result = await paginateQuery(mockQueryFn, { cursor: 4, limit: 2 }); + + expect(mockQueryFn).toHaveBeenCalledWith({ take: 3, skip: 1, cursor: 4 }); + expect(result.data).toHaveLength(1); + expect(result.data).toEqual([{ id: 5, name: 'Eve' }]); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }); +});