diff --git a/.gitignore b/.gitignore index a540c74c1..39f224c6b 100644 --- a/.gitignore +++ b/.gitignore @@ -414,19 +414,10 @@ todo.txt # Docker compose docker-compose.yml data/ -data/ -docker-compose.yml -package-lock.json -.dev.env - -package-lock.json +# Docker compose docker-compose.yml data/ -.dev.env - -/compose/compose.yaml -compose/compose.yaml data/ docker-compose.yml diff --git a/src/modules/invite/dto/all-invitations-response.dto.ts b/src/modules/invite/dto/all-invitations-response.dto.ts index 8631f00d5..ef06d9011 100644 --- a/src/modules/invite/dto/all-invitations-response.dto.ts +++ b/src/modules/invite/dto/all-invitations-response.dto.ts @@ -1,13 +1,24 @@ import { InviteDto } from './invite.dto'; import { ApiProperty } from '@nestjs/swagger'; +export class PaginatedInvitationsDto { + @ApiProperty({ type: [InviteDto] }) + invitations: InviteDto[]; + + @ApiProperty({ example: 250, description: 'Total number of invitations' }) + total: number; +} + export class FindAllInvitationsResponseDto { + @ApiProperty({ example: 'success' }) + status: string; + @ApiProperty({ example: 200 }) status_code: number; - @ApiProperty({ example: 'Successfully fetched invites' }) + @ApiProperty({ example: 'Invitations retrieved successfully' }) message: string; - @ApiProperty({ type: [InviteDto] }) - data: InviteDto[]; + @ApiProperty({ type: PaginatedInvitationsDto }) + data: PaginatedInvitationsDto; } diff --git a/src/modules/invite/dto/pagination-query.dto.ts b/src/modules/invite/dto/pagination-query.dto.ts new file mode 100644 index 000000000..cdb7c2416 --- /dev/null +++ b/src/modules/invite/dto/pagination-query.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PaginationQueryDto { + @ApiProperty({ + description: 'Page number (starts at 1)', + required: false, + default: 1, + type: Number, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiProperty({ + description: 'Number of items per page', + required: false, + default: 10, + type: Number, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 10; +} diff --git a/src/modules/invite/invite.controller.ts b/src/modules/invite/invite.controller.ts index fbbf8e1f0..69ff13219 100644 --- a/src/modules/invite/invite.controller.ts +++ b/src/modules/invite/invite.controller.ts @@ -10,6 +10,7 @@ import { Body, Res, UseGuards, + Query, // Added this import } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { InviteService } from './invite.service'; @@ -25,6 +26,7 @@ import { SendInvitationsResponseDto } from './dto/send-invitations-response.dto' import { AcceptInviteDto } from './dto/accept-invite.dto'; import { OwnershipGuard } from '@guards/authorization.guard'; import * as SYS_MSG from '@shared/constants/SystemMessages'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; // Added this import @ApiBearerAuth() @ApiTags('Organisation Invites') @Controller('organizations') @@ -43,10 +45,11 @@ export class InviteController { type: ErrorResponseDto, }) @Get('invites') - async findAllInvitations() { - const allInvites = await this.inviteService.findAllInvitations(); + async findAllInvitations(@Query() paginationQuery: PaginationQueryDto) { + const allInvites = await this.inviteService.findAllInvitations(paginationQuery.page, paginationQuery.limit); return allInvites; } + @ApiOperation({ summary: 'Get All Pending Invitations' }) @ApiResponse({ status: 200, diff --git a/src/modules/invite/invite.service.ts b/src/modules/invite/invite.service.ts index 4b75d2de4..4fb42bc45 100644 --- a/src/modules/invite/invite.service.ts +++ b/src/modules/invite/invite.service.ts @@ -52,11 +52,28 @@ export class InviteService { throw new InternalServerErrorException(`Internal server error: ${error.message}`); } } - async findAllInvitations(): Promise<{ status_code: number; message: string; data: InviteDto[] }> { + + async findAllInvitations( + page: number = 1, + limit: number = 10 + ): Promise<{ + status: string; + status_code: number; + message: string; + data: { invitations: InviteDto[]; total: number }; + }> { try { - const invites = await this.inviteRepository.find(); + // Calculate skip value for pagination + const skip = (page - 1) * limit; + + // Get paginated invites + const [invites, total] = await this.inviteRepository.findAndCount({ + skip, + take: limit, + }); - const allInvites: InviteDto[] = invites.map(invite => { + // Map to DTOs + const invitesDto: InviteDto[] = invites.map(invite => { return { token: invite.token, id: invite.id, @@ -68,9 +85,13 @@ export class InviteService { }); const responseData = { + status: 'success', status_code: HttpStatus.OK, - message: 'Successfully fetched invites', - data: allInvites, + message: 'Invitations retrieved successfully', + data: { + invitations: invitesDto, + total, + }, }; return responseData; diff --git a/src/modules/invite/tests/invite.service.spec.ts b/src/modules/invite/tests/invite.service.spec.ts index bf0b02620..7300dc54d 100644 --- a/src/modules/invite/tests/invite.service.spec.ts +++ b/src/modules/invite/tests/invite.service.spec.ts @@ -58,6 +58,7 @@ describe('InviteService', () => { save: jest.fn(), findOneBy: jest.fn(), update: jest.fn(), + findAndCount: jest.fn(), }, }, { @@ -163,15 +164,26 @@ describe('InviteService', () => { }); it('should fetch all invites', async () => { - jest.spyOn(repository, 'find').mockResolvedValue(mockInvites); + const expectedInvites = mockInvites.map(invite => { + const { created_at, updated_at, ...rest } = invite; + return rest; + }); + + jest.spyOn(repository, 'findAndCount').mockResolvedValue([mockInvites, mockInvites.length]); const result = await service.findAllInvitations(); expect(result).toEqual({ - status_code: 200, - message: 'Successfully fetched invites', - data: mockInvitesResponse, + status: 'success', + status_code: HttpStatus.OK, + message: 'Invitations retrieved successfully', + data: { + invitations: expectedInvites, + total: mockInvites.length, + }, }); + + expect(repository.findAndCount).toHaveBeenCalled(); }); it('should throw an internal server error if an exception occurs', async () => { @@ -195,6 +207,8 @@ describe('InviteService', () => { isGeneric: invite.isGeneric, organisation: invite.organisation, email: invite.email, + created_at: new Date(), + updated_at: new Date(), })), }); }); diff --git a/src/modules/invite/tests/test-invite.service.spec.ts b/src/modules/invite/tests/test-invite.service.spec.ts new file mode 100644 index 000000000..f65498b18 --- /dev/null +++ b/src/modules/invite/tests/test-invite.service.spec.ts @@ -0,0 +1,159 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InviteService } from '../invite.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Invite } from '../entities/invite.entity'; +import { Organisation } from '@modules/organisations/entities/organisations.entity'; +import { User } from '@modules/user/entities/user.entity'; +import { HttpStatus, InternalServerErrorException } from '@nestjs/common'; +import { MailerService } from '@nestjs-modules/mailer'; +import { EmailService } from '@modules/email/email.service'; +import { ConfigService } from '@nestjs/config'; +import { OrganisationsService } from '@modules/organisations/organisations.service'; +import { Repository, EntityManager } from 'typeorm'; + +describe('InviteService', () => { + let service: InviteService; + let inviteRepository: jest.Mocked>; + + const mockInviteRepository = { + find: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockOrganisationRepository = { + findOne: jest.fn(), + }; + + const mockUserRepository = { + findOne: jest.fn(), + }; + + const mockMailerService = { + sendMail: jest.fn(), + }; + + const mockEmailService = { + getTemplate: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + const mockOrganisationsService = { + addOrganisationMember: jest.fn(), + }; + + // Add a mock for EntityManager + const mockEntityManager = { + transaction: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InviteService, + { provide: getRepositoryToken(Invite), useValue: mockInviteRepository }, + { provide: getRepositoryToken(Organisation), useValue: mockOrganisationRepository }, + { provide: getRepositoryToken(User), useValue: mockUserRepository }, + { provide: MailerService, useValue: mockMailerService }, + { provide: EmailService, useValue: mockEmailService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: OrganisationsService, useValue: mockOrganisationsService }, + { provide: EntityManager, useValue: mockEntityManager }, // Add this line + ], + }).compile(); + + service = module.get(InviteService); + inviteRepository = module.get(getRepositoryToken(Invite)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findAllInvitations', () => { + it('should return paginated invitations with default parameters', async () => { + const mockInvites = [ + { + id: '1', + token: 'token1', + isAccepted: false, + isGeneric: true, + organisation: { id: 'org1', name: 'Org 1' }, + email: 'test1@example.com', + }, + ]; + + mockInviteRepository.findAndCount.mockResolvedValue([mockInvites, 1]); + + const result = await service.findAllInvitations(); + + expect(mockInviteRepository.findAndCount).toHaveBeenCalledWith({ + skip: 0, + take: 10, + }); + + expect(result).toEqual({ + status: 'success', + status_code: HttpStatus.OK, + message: 'Invitations retrieved successfully', + data: { + invitations: expect.any(Array), + total: 1, + }, + }); + expect(result.data.invitations).toHaveLength(1); + }); + + it('should return paginated invitations with custom parameters', async () => { + const mockInvites = [ + { + id: '1', + token: 'token1', + isAccepted: false, + isGeneric: true, + organisation: { id: 'org1', name: 'Org 1' }, + email: 'test1@example.com', + }, + { + id: '2', + token: 'token2', + isAccepted: true, + isGeneric: false, + organisation: { id: 'org2', name: 'Org 2' }, + email: 'test2@example.com', + }, + ]; + + mockInviteRepository.findAndCount.mockResolvedValue([mockInvites, 10]); + + const result = await service.findAllInvitations(2, 5); + + expect(mockInviteRepository.findAndCount).toHaveBeenCalledWith({ + skip: 5, + take: 5, + }); + + expect(result).toEqual({ + status: 'success', + status_code: HttpStatus.OK, + message: 'Invitations retrieved successfully', + data: { + invitations: expect.any(Array), + total: 10, + }, + }); + expect(result.data.invitations).toHaveLength(2); + }); + + it('should handle errors and throw InternalServerErrorException', async () => { + mockInviteRepository.findAndCount.mockRejectedValue(new Error('Database error')); + + await expect(service.findAllInvitations()).rejects.toThrow(InternalServerErrorException); + }); + }); +});