Verify that API endpoints correctly implement pagination with cursors and offsets, filter data by multiple criteria, sort results, and serialise response payloads without leaking sensitive fields.
Data listing endpoints are among the most complex to test correctly. A bug in pagination logic can return duplicate records, skip records, or expose data from other users. A filter bug can return too many or too few results. A serialisation bug can leak password hashes or internal IDs to clients.
Integration tests for data flows require real database records seeded in known states so you can assert on precise counts, orders, and shapes. This lesson builds a testing methodology for listing endpoints that catches the edge cases users actually hit: empty results, last page, combined filters, and sort stability.
Seed a known number of records, then request specific pages and assert on item count, total, and page metadata.
1import request from 'supertest';
2import { app } from '../app';
3import { getTestPrisma } from '../test/database';
4import { TestFactory } from '../test/factory';
5
6let factory: TestFactory;
7
8beforeEach(async () => {
9 const prisma = getTestPrisma();
10 await prisma.$executeRaw`TRUNCATE "Course" CASCADE`;
11 factory = new TestFactory(prisma);
12
13 // Seed 25 published courses
14 const instructor = await factory.createUser({ role: 'INSTRUCTOR' });
15 for (let i = 1; i <= 25; i++) {
16 await factory.createCourse(instructor.id, {
17 title: `Course ${i.toString().padStart(2, '0')}`,
18 published: true,
19 });
20 }
21});
22
23describe('GET /courses (offset pagination)', () => {
24 it('returns the default page size of 10 items', async () => {
25 const res = await request(app).get('/courses');
26 expect(res.status).toBe(200);
27 expect(res.body.data).toHaveLength(10);
28 });
29
30 it('returns correct total count', async () => {
31 const res = await request(app).get('/courses');
32 expect(res.body.total).toBe(25);
33 });
34
35 it('returns correct items for page 2', async () => {
36 const page1 = await request(app).get('/courses?page=1&limit=10');
37 const page2 = await request(app).get('/courses?page=2&limit=10');
38
39 const page1Ids = page1.body.data.map((c: any) => c.id);
40 const page2Ids = page2.body.data.map((c: any) => c.id);
41
42 expect(page2.body.data).toHaveLength(10);
43 // No overlap between pages
44 expect(page1Ids.filter((id: string) => page2Ids.includes(id))).toHaveLength(0);
45 });
46
47 it('returns 5 items on the last page (page 3 of 10-per-page)', async () => {
48 const res = await request(app).get('/courses?page=3&limit=10');
49 expect(res.body.data).toHaveLength(5);
50 expect(res.body.hasNextPage).toBe(false);
51 });
52
53 it('returns empty data and hasNextPage false beyond last page', async () => {
54 const res = await request(app).get('/courses?page=99&limit=10');
55 expect(res.body.data).toHaveLength(0);
56 expect(res.body.hasNextPage).toBe(false);
57 });
58
59 it('returns 400 for non-positive page number', async () => {
60 const res = await request(app).get('/courses?page=0&limit=10');
61 expect(res.status).toBe(400);
62 });
63
64 it('returns 400 when limit exceeds maximum of 100', async () => {
65 const res = await request(app).get('/courses?page=1&limit=101');
66 expect(res.status).toBe(400);
67 });
68});Cursor pagination is stable under concurrent inserts. Tests verify that the cursor from one response correctly fetches the next page.
1describe('GET /courses (cursor pagination)', () => {
2 it('returns a nextCursor when more items exist', async () => {
3 const res = await request(app).get('/courses?limit=5');
4 expect(res.body.data).toHaveLength(5);
5 expect(res.body.nextCursor).toBeDefined();
6 expect(typeof res.body.nextCursor).toBe('string');
7 });
8
9 it('uses the cursor to fetch the next page without overlap', async () => {
10 const page1 = await request(app).get('/courses?limit=10');
11 const { nextCursor } = page1.body;
12
13 const page2 = await request(app).get(`/courses?limit=10&cursor=${nextCursor}`);
14
15 const page1Ids = new Set(page1.body.data.map((c: any) => c.id));
16 const page2Ids = page2.body.data.map((c: any) => c.id);
17
18 // No item appears on both pages
19 for (const id of page2Ids) {
20 expect(page1Ids.has(id)).toBe(false);
21 }
22 });
23
24 it('can paginate through all records without duplicates', async () => {
25 const allIds = new Set<string>();
26 let cursor: string | undefined;
27 let page = 0;
28
29 do {
30 const url = cursor ? `/courses?limit=7&cursor=${cursor}` : '/courses?limit=7';
31 const res = await request(app).get(url);
32 expect(res.status).toBe(200);
33
34 for (const course of res.body.data) {
35 expect(allIds.has(course.id)).toBe(false); // no duplicate
36 allIds.add(course.id);
37 }
38
39 cursor = res.body.nextCursor;
40 page++;
41 } while (cursor && page < 10);
42
43 expect(allIds.size).toBe(25); // all 25 courses retrieved exactly once
44 });
45
46 it('returns null nextCursor on the last page', async () => {
47 const res = await request(app).get('/courses?limit=25');
48 expect(res.body.nextCursor).toBeNull();
49 });
50});Seed records with distinct properties, then filter by each criterion and assert on exact result counts and content.
1beforeEach(async () => {
2 const prisma = getTestPrisma();
3 await prisma.$executeRaw`TRUNCATE "Course" CASCADE`;
4 const factory = new TestFactory(prisma);
5 const instructor = await factory.createUser({ role: 'INSTRUCTOR' });
6
7 // Seed courses with known combinations
8 await factory.createCourse(instructor.id, { level: 'BEGINNER', price: 0, published: true });
9 await factory.createCourse(instructor.id, { level: 'BEGINNER', price: 49, published: true });
10 await factory.createCourse(instructor.id, { level: 'INTERMEDIATE', price: 99, published: true });
11 await factory.createCourse(instructor.id, { level: 'ADVANCED', price: 149, published: true });
12 await factory.createCourse(instructor.id, { level: 'BEGINNER', price: 49, published: false }); // unpublished
13});
14
15describe('GET /courses filtering', () => {
16 it('filters by level=BEGINNER', async () => {
17 const res = await request(app).get('/courses?level=BEGINNER');
18 expect(res.body.data).toHaveLength(2); // published BEGINNER only
19 expect(res.body.data.every((c: any) => c.level === 'BEGINNER')).toBe(true);
20 });
21
22 it('filters free courses with price=0', async () => {
23 const res = await request(app).get('/courses?maxPrice=0');
24 expect(res.body.data).toHaveLength(1);
25 expect(res.body.data[0].price).toBe(0);
26 });
27
28 it('combines level and maxPrice filters', async () => {
29 const res = await request(app).get('/courses?level=BEGINNER&maxPrice=49');
30 expect(res.body.data).toHaveLength(2); // free + 49 BEGINNER
31 expect(res.body.data.every((c: any) => c.level === 'BEGINNER' && c.price <= 49)).toBe(true);
32 });
33
34 it('never returns unpublished courses', async () => {
35 const res = await request(app).get('/courses?level=BEGINNER');
36 expect(res.body.data.every((c: any) => c.published === true)).toBe(true);
37 });
38
39 it('returns empty array when no courses match filters', async () => {
40 const res = await request(app).get('/courses?level=ADVANCED&maxPrice=0');
41 expect(res.body.data).toHaveLength(0);
42 expect(res.body.total).toBe(0);
43 });
44
45 it('returns 400 for invalid level value', async () => {
46 const res = await request(app).get('/courses?level=EXPERT');
47 expect(res.status).toBe(400);
48 });
49});Serialisation tests verify that API responses include all expected fields and exclude sensitive or internal ones.
1describe('Response serialisation', () => {
2 it('excludes passwordHash from user responses', async () => {
3 const token = await registerAndGetToken('[email protected]');
4 const res = await request(app)
5 .get('/auth/profile')
6 .set('Authorization', `Bearer ${token}`);
7
8 expect(res.status).toBe(200);
9 expect(res.body).not.toHaveProperty('passwordHash');
10 expect(res.body).not.toHaveProperty('password_hash');
11 expect(res.body).not.toHaveProperty('password');
12 });
13
14 it('excludes internal database fields from course responses', async () => {
15 const prisma = getTestPrisma();
16 const factory = new TestFactory(prisma);
17 const instructor = await factory.createUser({ role: 'INSTRUCTOR' });
18 const course = await factory.createCourse(instructor.id);
19
20 const res = await request(app).get(`/courses/${course.id}`);
21
22 expect(res.status).toBe(200);
23 // Public fields present
24 expect(res.body.id).toBeDefined();
25 expect(res.body.title).toBeDefined();
26 expect(res.body.price).toBeDefined();
27 // Internal fields excluded
28 expect(res.body).not.toHaveProperty('deletedAt');
29 expect(res.body).not.toHaveProperty('internalNotes');
30 expect(res.body).not.toHaveProperty('stripeProductId');
31 });
32
33 it('includes computed fields in course list response', async () => {
34 const res = await request(app).get('/courses');
35 const course = res.body.data[0];
36 // Computed fields added by serialiser
37 expect(course).toHaveProperty('enrollmentCount');
38 expect(course).toHaveProperty('averageRating');
39 expect(typeof course.enrollmentCount).toBe('number');
40 });
41
42 it('formats dates as ISO 8601 strings', async () => {
43 const res = await request(app).get('/courses');
44 const course = res.body.data[0];
45 expect(course.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
46 });
47});Sort tests seed records with known values and assert on the relative position of items in the response.
1describe('GET /courses sorting', () => {
2 beforeEach(async () => {
3 const prisma = getTestPrisma();
4 await prisma.$executeRaw`TRUNCATE "Course" CASCADE`;
5 const factory = new TestFactory(prisma);
6 const instructor = await factory.createUser({ role: 'INSTRUCTOR' });
7
8 // Seed with known prices
9 await factory.createCourse(instructor.id, { title: 'Cheap Course', price: 9.99 });
10 await factory.createCourse(instructor.id, { title: 'Mid Course', price: 49.99 });
11 await factory.createCourse(instructor.id, { title: 'Premium Course', price: 149.99 });
12 });
13
14 it('sorts by price ascending when sortBy=price&order=asc', async () => {
15 const res = await request(app).get('/courses?sortBy=price&order=asc');
16 const prices = res.body.data.map((c: any) => c.price);
17 expect(prices).toEqual([...prices].sort((a, b) => a - b));
18 });
19
20 it('sorts by price descending when sortBy=price&order=desc', async () => {
21 const res = await request(app).get('/courses?sortBy=price&order=desc');
22 const prices = res.body.data.map((c: any) => c.price);
23 expect(prices).toEqual([...prices].sort((a, b) => b - a));
24 });
25
26 it('sorts by title alphabetically when sortBy=title', async () => {
27 const res = await request(app).get('/courses?sortBy=title&order=asc');
28 const titles = res.body.data.map((c: any) => c.title);
29 expect(titles).toEqual([...titles].sort());
30 });
31
32 it('returns 400 for invalid sortBy field', async () => {
33 const res = await request(app).get('/courses?sortBy=internalScore');
34 expect(res.status).toBe(400);
35 // Prevents sort injection attacks (arbitrary DB column names)
36 });
37
38 it('uses createdAt descending as the default sort', async () => {
39 const res = await request(app).get('/courses');
40 const dates = res.body.data.map((c: any) => new Date(c.createdAt).getTime());
41 expect(dates).toEqual([...dates].sort((a, b) => b - a));
42 });
43});A complete integration test suite for a course search endpoint covering pagination, multi-field filtering, sort order, field exclusion, and validation edge cases.
1// src/courses/courses.controller.ts (simplified)
2@Get('search')
3async search(
4 @Query('q') q: string,
5 @Query('level') level?: string,
6 @Query('maxPrice') maxPrice?: string,
7 @Query('page', new DefaultValuePipe(1), ParseIntPipe) page = 1,
8 @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit = 10,
9 @Query('sortBy') sortBy = 'createdAt',
10 @Query('order') order: 'asc' | 'desc' = 'desc',
11): Promise<PaginatedResponse<CourseDto>> {
12 return this.coursesService.search({ q, level, maxPrice: maxPrice ? parseFloat(maxPrice) : undefined, page, limit, sortBy, order });
13}
14
15// src/courses/courses.search.integration.test.ts
16import request from 'supertest';
17import { app } from '../app';
18import { getTestPrisma } from '../test/database';
19import { TestFactory } from '../test/factory';
20
21let factory: TestFactory;
22
23beforeAll(async () => {
24 const prisma = getTestPrisma();
25 factory = new TestFactory(prisma);
26 await prisma.$executeRaw`TRUNCATE "Course", "User" CASCADE`;
27
28 const instructor = await factory.createUser({ role: 'INSTRUCTOR' });
29
30 // Seed deterministic test data
31 const courses = [
32 { title: 'React Fundamentals', level: 'BEGINNER', price: 0, published: true },
33 { title: 'React Advanced Patterns', level: 'ADVANCED', price: 99, published: true },
34 { title: 'Vue 3 Essentials', level: 'BEGINNER', price: 49, published: true },
35 { title: 'Angular Mastery', level: 'INTERMEDIATE', price: 79, published: true },
36 { title: 'TypeScript Pro', level: 'INTERMEDIATE', price: 59, published: true },
37 { title: 'Node.js Backend', level: 'INTERMEDIATE', price: 69, published: true },
38 { title: 'Docker Basics', level: 'BEGINNER', price: 29, published: true },
39 { title: 'Kubernetes Advanced', level: 'ADVANCED', price: 149, published: true },
40 { title: 'GraphQL Design', level: 'INTERMEDIATE', price: 89, published: true },
41 { title: 'TDD Workshop', level: 'INTERMEDIATE', price: 79, published: true },
42 { title: 'Draft Course', level: 'BEGINNER', price: 0, published: false }, // unpublished
43 ];
44
45 for (const c of courses) {
46 await factory.createCourse(instructor.id, c);
47 }
48});
49
50describe('GET /courses/search', () => {
51 describe('text search', () => {
52 it('returns courses matching the query string', async () => {
53 const res = await request(app).get('/courses/search?q=react');
54 expect(res.status).toBe(200);
55 expect(res.body.data.length).toBeGreaterThanOrEqual(1);
56 expect(
57 res.body.data.every((c: any) =>
58 c.title.toLowerCase().includes('react')
59 )
60 ).toBe(true);
61 });
62
63 it('returns empty results for unmatched query', async () => {
64 const res = await request(app).get('/courses/search?q=zzznomatch');
65 expect(res.body.data).toHaveLength(0);
66 expect(res.body.total).toBe(0);
67 });
68 });
69
70 describe('combined filtering', () => {
71 it('returns only published INTERMEDIATE courses under $80', async () => {
72 const res = await request(app).get('/courses/search?level=INTERMEDIATE&maxPrice=80');
73 expect(res.status).toBe(200);
74 for (const course of res.body.data) {
75 expect(course.level).toBe('INTERMEDIATE');
76 expect(course.price).toBeLessThanOrEqual(80);
77 expect(course.published).toBe(true);
78 }
79 });
80 });
81
82 describe('pagination', () => {
83 it('returns correct total including only published courses', async () => {
84 const res = await request(app).get('/courses/search?q=');
85 expect(res.body.total).toBe(10); // 10 published, 1 draft excluded
86 });
87
88 it('pages through all results without duplicates', async () => {
89 const ids = new Set<string>();
90 for (let page = 1; page <= 3; page++) {
91 const res = await request(app).get(`/courses/search?q=&page=${page}&limit=4`);
92 for (const c of res.body.data) { ids.add(c.id); }
93 }
94 expect(ids.size).toBe(10);
95 });
96 });
97
98 describe('sorting', () => {
99 it('sorts by price ascending', async () => {
100 const res = await request(app).get('/courses/search?q=&sortBy=price&order=asc');
101 const prices = res.body.data.map((c: any) => c.price);
102 expect(prices).toEqual([...prices].sort((a, b) => a - b));
103 });
104 });
105
106 describe('serialisation', () => {
107 it('never includes internal or sensitive fields in response', async () => {
108 const res = await request(app).get('/courses/search?q=react');
109 for (const course of res.body.data) {
110 expect(course).not.toHaveProperty('deletedAt');
111 expect(course).not.toHaveProperty('stripeProductId');
112 expect(course).toHaveProperty('id');
113 expect(course).toHaveProperty('title');
114 expect(course).toHaveProperty('price');
115 }
116 });
117 });
118});This suite uses a single beforeAll seed (shared read-only data) rather than beforeEach (mutable data) because search tests only read — they never mutate state. The pagination correctness test uses a Set to accumulate IDs across pages and verifies the final count equals the seeded total. The 'never includes internal fields' test iterates over all results to catch cases where the serialiser conditionally excludes fields on some records but not others.
The next lesson covers integration test patterns: transaction rollback for fast isolation, seeding strategies, and the Page Object pattern for reusable request helpers.
Your progress is saved in this browser, on this device only.