Skip to content

Commit a2faf8e

Browse files
authored
pagination (#117)
* pagination * reset loans pagination on status tab switch
1 parent 5cd7b67 commit a2faf8e

8 files changed

Lines changed: 204 additions & 68 deletions

File tree

backend/src/admin/loans.admin.controller.spec.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,21 @@ describe('LoansAdminController', (): void => {
7373
});
7474

7575
describe('GET /admin/loans', (): void => {
76-
it('returns all loans without filter', async (): Promise<void> => {
77-
mockService.findAll.mockResolvedValue([mockLoan]);
76+
it('returns a paginated list without filter', async (): Promise<void> => {
77+
const paginated = { data: [mockLoan], total: 1, page: 1, limit: 20 };
78+
mockService.findAll.mockResolvedValue(paginated);
7879
const result = await controller.findAll({} as FindLoansQueryDto);
7980
expect(mockService.findAll).toHaveBeenCalledWith({});
80-
expect(result).toEqual([mockLoan]);
81+
expect(result).toEqual(paginated);
8182
});
8283

8384
it('passes status filter to service', async (): Promise<void> => {
84-
mockService.findAll.mockResolvedValue([]);
85+
mockService.findAll.mockResolvedValue({
86+
data: [],
87+
total: 0,
88+
page: 1,
89+
limit: 20,
90+
});
8591
await controller.findAll({ status: LoanStatus.Active });
8692
expect(mockService.findAll).toHaveBeenCalledWith({
8793
status: LoanStatus.Active,

backend/src/admin/loans.admin.controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { UnknownUserException } from '@/lib/errors';
2020
import { LoansService } from '@/loans/loans.service';
2121
import { ExtendLoanDto } from '@/loans/dto/extend-loan.dto';
2222
import { FindLoansQueryDto } from '@/loans/dto/find-loans-query.dto';
23+
import { PaginatedLoansResponseDto } from '@/loans/dto/paginated-loans-response.dto';
2324
import { Loan } from '@/loans/entities/loan.entity';
2425

2526
type FirebaseRequest = { firebaseUser: DecodedIdToken };
@@ -36,7 +37,9 @@ export class LoansAdminController {
3637
) {}
3738

3839
@Get()
39-
findAll(@Query() query: FindLoansQueryDto): Promise<Loan[]> {
40+
findAll(
41+
@Query() query: FindLoansQueryDto
42+
): Promise<PaginatedLoansResponseDto> {
4043
return this.loansService.findAll(query);
4144
}
4245

backend/src/loans/dto/find-loans-query.dto.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsEnum, IsOptional } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
34

45
export enum LoanStatus {
56
Active = 'active',
@@ -12,4 +13,19 @@ export class FindLoansQueryDto {
1213
@IsOptional()
1314
@IsEnum(LoanStatus)
1415
status?: LoanStatus;
16+
17+
@ApiPropertyOptional({ default: 1, minimum: 1 })
18+
@IsOptional()
19+
@IsInt()
20+
@Min(1)
21+
@Type(() => Number)
22+
page?: number = 1;
23+
24+
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
25+
@IsOptional()
26+
@IsInt()
27+
@Min(1)
28+
@Max(100)
29+
@Type(() => Number)
30+
limit?: number = 20;
1531
}

backend/src/loans/loans.service.spec.ts

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,19 @@ const mockCopy = {
3939
const mockLoanRepository: jest.Mocked<
4040
Pick<
4141
Repository<Loan>,
42-
'create' | 'save' | 'find' | 'findOneBy' | 'findOne' | 'createQueryBuilder'
42+
| 'create'
43+
| 'save'
44+
| 'find'
45+
| 'findAndCount'
46+
| 'findOneBy'
47+
| 'findOne'
48+
| 'createQueryBuilder'
4349
>
4450
> = {
4551
create: jest.fn(),
4652
save: jest.fn(),
4753
find: jest.fn(),
54+
findAndCount: jest.fn(),
4855
findOneBy: jest.fn(),
4956
findOne: jest.fn(),
5057
createQueryBuilder: jest.fn(),
@@ -287,43 +294,82 @@ describe('LoansService', (): void => {
287294
describe('findAll', (): void => {
288295
const relations = ['copy', 'copy.item', 'copy.location', 'user'];
289296

290-
it('returns all loans when no status filter', async (): Promise<void> => {
291-
mockLoanRepository.find.mockResolvedValue([mockLoan]);
297+
it('returns a paginated, borrowed_at DESC ordered list with defaults', async (): Promise<void> => {
298+
mockLoanRepository.findAndCount.mockResolvedValue([[mockLoan], 1]);
292299
const result = await service.findAll({} as FindLoansQueryDto);
293-
expect(mockLoanRepository.find).toHaveBeenCalledWith({ relations });
294-
expect(result).toEqual([mockLoan]);
300+
expect(mockLoanRepository.findAndCount).toHaveBeenCalledWith({
301+
relations,
302+
order: { borrowed_at: 'DESC' },
303+
skip: 0,
304+
take: 20,
305+
});
306+
expect(result).toEqual({
307+
data: [mockLoan],
308+
total: 1,
309+
page: 1,
310+
limit: 20,
311+
});
312+
});
313+
314+
it('applies page and limit to skip/take', async (): Promise<void> => {
315+
mockLoanRepository.findAndCount.mockResolvedValue([[mockLoan], 45]);
316+
const result = await service.findAll({
317+
page: 3,
318+
limit: 10,
319+
} as FindLoansQueryDto);
320+
expect(mockLoanRepository.findAndCount).toHaveBeenCalledWith({
321+
relations,
322+
order: { borrowed_at: 'DESC' },
323+
skip: 20,
324+
take: 10,
325+
});
326+
expect(result).toEqual({
327+
data: [mockLoan],
328+
total: 45,
329+
page: 3,
330+
limit: 10,
331+
});
295332
});
296333

297334
it('filters active loans (not returned, due_date >= today)', async (): Promise<void> => {
298-
mockLoanRepository.find.mockResolvedValue([mockLoan]);
335+
mockLoanRepository.findAndCount.mockResolvedValue([[mockLoan], 1]);
299336
await service.findAll({ status: LoanStatus.Active });
300-
expect(mockLoanRepository.find).toHaveBeenCalledWith({
337+
expect(mockLoanRepository.findAndCount).toHaveBeenCalledWith({
301338
where: {
302339
returned_at: IsNull(),
303340
due_date: MoreThanOrEqual(expect.any(String)),
304341
},
305342
relations,
343+
order: { borrowed_at: 'DESC' },
344+
skip: 0,
345+
take: 20,
306346
});
307347
});
308348

309349
it('filters returned loans', async (): Promise<void> => {
310-
mockLoanRepository.find.mockResolvedValue([]);
350+
mockLoanRepository.findAndCount.mockResolvedValue([[], 0]);
311351
await service.findAll({ status: LoanStatus.Returned });
312-
expect(mockLoanRepository.find).toHaveBeenCalledWith({
352+
expect(mockLoanRepository.findAndCount).toHaveBeenCalledWith({
313353
where: { returned_at: Not(IsNull()) },
314354
relations,
355+
order: { borrowed_at: 'DESC' },
356+
skip: 0,
357+
take: 20,
315358
});
316359
});
317360

318361
it('filters overdue loans (not returned, due_date < today)', async (): Promise<void> => {
319-
mockLoanRepository.find.mockResolvedValue([]);
362+
mockLoanRepository.findAndCount.mockResolvedValue([[], 0]);
320363
await service.findAll({ status: LoanStatus.Overdue });
321-
expect(mockLoanRepository.find).toHaveBeenCalledWith({
364+
expect(mockLoanRepository.findAndCount).toHaveBeenCalledWith({
322365
where: {
323366
returned_at: IsNull(),
324367
due_date: LessThan(expect.any(String)),
325368
},
326369
relations,
370+
order: { borrowed_at: 'DESC' },
371+
skip: 0,
372+
take: 20,
327373
});
328374
});
329375
});

backend/src/loans/loans.service.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { ItemCopy } from '@/item-copies/entities/item-copy.entity';
1818
import { SlackNotificationsService } from '@/slack-notifications/slack-notifications.service';
1919
import { FindLoansQueryDto, LoanStatus } from './dto/find-loans-query.dto';
20+
import { PaginatedLoansResponseDto } from './dto/paginated-loans-response.dto';
2021
import { Loan } from './entities/loan.entity';
2122

2223
const UNIQUE_VIOLATION_CODES = new Set([
@@ -160,28 +161,32 @@ export class LoansService {
160161
'user',
161162
];
162163

163-
findAll(query: FindLoansQueryDto): Promise<Loan[]> {
164+
async findAll(query: FindLoansQueryDto): Promise<PaginatedLoansResponseDto> {
165+
const { status, page = 1, limit = 20 } = query;
164166
const today = this.today();
165167
const relations = LoansService.LOAN_RELATIONS;
166-
switch (query.status) {
167-
case LoanStatus.Active:
168-
return this.loanRepository.find({
169-
where: { returned_at: IsNull(), due_date: MoreThanOrEqual(today) },
170-
relations,
171-
});
172-
case LoanStatus.Returned:
173-
return this.loanRepository.find({
174-
where: { returned_at: Not(IsNull()) },
175-
relations,
176-
});
177-
case LoanStatus.Overdue:
178-
return this.loanRepository.find({
179-
where: { returned_at: IsNull(), due_date: LessThan(today) },
180-
relations,
181-
});
182-
default:
183-
return this.loanRepository.find({ relations });
184-
}
168+
169+
const whereByStatus = {
170+
[LoanStatus.Active]: {
171+
returned_at: IsNull(),
172+
due_date: MoreThanOrEqual(today),
173+
},
174+
[LoanStatus.Returned]: { returned_at: Not(IsNull()) },
175+
[LoanStatus.Overdue]: {
176+
returned_at: IsNull(),
177+
due_date: LessThan(today),
178+
},
179+
};
180+
181+
const [data, total] = await this.loanRepository.findAndCount({
182+
...(status ? { where: whereByStatus[status] } : {}),
183+
relations,
184+
order: { borrowed_at: 'DESC' },
185+
skip: (page - 1) * limit,
186+
take: limit,
187+
});
188+
189+
return { data, total, page, limit };
185190
}
186191

187192
async extendLoan(loanId: number, dueDays: number): Promise<Loan> {

backend/test/loans.e2e-spec.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ const MOCK_FIREBASE_USER = buildDecodedToken(
4949

5050
type FirebaseRequest = { firebaseUser: DecodedIdToken };
5151

52+
type PaginatedLoans = {
53+
data: Loan[];
54+
total: number;
55+
page: number;
56+
limit: number;
57+
};
58+
5259
async function buildApp(): Promise<{
5360
app: INestApplication<App>;
5461
ds: DataSource;
@@ -302,15 +309,47 @@ describe('LoansModule (e2e)', (): void => {
302309
});
303310

304311
describe('GET /admin/loans', (): void => {
305-
it('returns all loans without filter', async (): Promise<void> => {
312+
it('returns a paginated list without filter', async (): Promise<void> => {
306313
await request(app.getHttpServer()).post('/loans').send({ copyId });
307314

308315
await request(app.getHttpServer())
309316
.get('/admin/loans')
310317
.expect(StatusCodes.OK)
311318
.expect((res: Response) => {
312-
expect(Array.isArray(res.body)).toBe(true);
313-
expect((res.body as Loan[]).length).toBeGreaterThanOrEqual(1);
319+
const body = res.body as PaginatedLoans;
320+
expect(Array.isArray(body.data)).toBe(true);
321+
expect(body.data.length).toBeGreaterThanOrEqual(1);
322+
expect(body.total).toBeGreaterThanOrEqual(1);
323+
expect(body.page).toBe(1);
324+
expect(body.limit).toBe(20);
325+
});
326+
});
327+
328+
it('orders loans by borrowed_at DESC', async (): Promise<void> => {
329+
await request(app.getHttpServer())
330+
.get('/admin/loans')
331+
.expect(StatusCodes.OK)
332+
.expect((res: Response) => {
333+
const { data } = res.body as PaginatedLoans;
334+
const borrowedTimes = data.map(loan =>
335+
new Date(loan.borrowed_at).getTime()
336+
);
337+
const sorted = [...borrowedTimes].sort((a, b) => b - a);
338+
expect(borrowedTimes).toEqual(sorted);
339+
});
340+
});
341+
342+
it('applies page and limit', async (): Promise<void> => {
343+
await request(app.getHttpServer()).post('/loans').send({ copyId });
344+
345+
await request(app.getHttpServer())
346+
.get('/admin/loans?page=1&limit=1')
347+
.expect(StatusCodes.OK)
348+
.expect((res: Response) => {
349+
const body = res.body as PaginatedLoans;
350+
expect(body.data.length).toBe(1);
351+
expect(body.page).toBe(1);
352+
expect(body.limit).toBe(1);
314353
});
315354
});
316355

@@ -321,9 +360,9 @@ describe('LoansModule (e2e)', (): void => {
321360
.get('/admin/loans?status=active')
322361
.expect(StatusCodes.OK)
323362
.expect((res: Response) => {
324-
const body = res.body as Loan[];
325-
expect(body.length).toBeGreaterThanOrEqual(1);
326-
body.forEach(loan => expect(loan.returned_at).toBeNull());
363+
const { data } = res.body as PaginatedLoans;
364+
expect(data.length).toBeGreaterThanOrEqual(1);
365+
data.forEach(loan => expect(loan.returned_at).toBeNull());
327366
});
328367
});
329368

@@ -339,9 +378,9 @@ describe('LoansModule (e2e)', (): void => {
339378
.get('/admin/loans?status=returned')
340379
.expect(StatusCodes.OK)
341380
.expect((res: Response) => {
342-
const body = res.body as Loan[];
343-
expect(body.length).toBeGreaterThanOrEqual(1);
344-
body.forEach(loan => expectDateTimeString(loan.returned_at));
381+
const { data } = res.body as PaginatedLoans;
382+
expect(data.length).toBeGreaterThanOrEqual(1);
383+
data.forEach(loan => expectDateTimeString(loan.returned_at));
345384
});
346385
});
347386

@@ -359,9 +398,9 @@ describe('LoansModule (e2e)', (): void => {
359398
.get('/admin/loans?status=overdue')
360399
.expect(StatusCodes.OK)
361400
.expect((res: Response) => {
362-
const body = res.body as Loan[];
363-
expect(body.map(loan => loan.id)).toContain(created.id);
364-
body.forEach(loan => {
401+
const { data } = res.body as PaginatedLoans;
402+
expect(data.map(loan => loan.id)).toContain(created.id);
403+
data.forEach(loan => {
365404
expect(loan.returned_at).toBeNull();
366405
expect(loan.due_date < new Date().toISOString().split('T')[0]).toBe(
367406
true

0 commit comments

Comments
 (0)