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
56 changes: 56 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/integration/creator-holders-404.test.ts
Original file line number Diff line number Diff line change
@@ -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',
}
});
});
});
19 changes: 19 additions & 0 deletions src/modules/wallets/wallet-activity.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions src/utils/pagination.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,49 @@ export const buildOffsetPaginationMeta = ({
hasMore: safeOffset + safeLimit < safeTotal,
};
};

export type CursorPaginationOptions = {
cursor?: any;
limit: number;
};

export type CursorPaginationResult<T> = {
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<T>(
query: (args: { take: number; skip?: number; cursor?: any }) => Promise<T[]>,
{ cursor, limit }: CursorPaginationOptions,
getCursor?: (item: T) => any
): Promise<CursorPaginationResult<T>> {
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 };
}
56 changes: 56 additions & 0 deletions src/utils/test/pagination.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading