From 1ff324d66c7404d2f71e30023f823bd02b639e25 Mon Sep 17 00:00:00 2001 From: Jackson Chen <541898146chen@gmail.com> Date: Tue, 22 Oct 2024 22:38:21 -0500 Subject: [PATCH] chore: adding project test --- backend/src/guard/project.guard.ts | 103 +++--- .../project/__tests__/project.service.spec.ts | 314 ++++++++++++++++++ backend/src/project/dto/project.input.ts | 6 +- backend/src/project/project-packages.model.ts | 6 +- backend/src/project/project.model.ts | 12 +- backend/src/project/project.module.ts | 10 +- backend/src/project/project.resolver.ts | 47 +-- backend/src/project/project.service.ts | 121 ++++--- 8 files changed, 494 insertions(+), 125 deletions(-) create mode 100644 backend/src/project/__tests__/project.service.spec.ts diff --git a/backend/src/guard/project.guard.ts b/backend/src/guard/project.guard.ts index 03d51344..a6e1e3af 100644 --- a/backend/src/guard/project.guard.ts +++ b/backend/src/guard/project.guard.ts @@ -1,56 +1,55 @@ import { - Injectable, - CanActivate, - ExecutionContext, - UnauthorizedException, - } from '@nestjs/common'; - import { GqlExecutionContext } from '@nestjs/graphql'; + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; import { JwtService } from '@nestjs/jwt'; -import { ProjectsService } from '../project/project.service'; - - @Injectable() - export class ProjectGuard implements CanActivate { - constructor( - private readonly projectsService: ProjectsService, - private readonly jwtService: JwtService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const gqlContext = GqlExecutionContext.create(context); - const request = gqlContext.getContext().req; - - // Extract the authorization header - const authHeader = request.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new UnauthorizedException('Authorization token is missing'); - } - - // Decode the token to get user information - const token = authHeader.split(' ')[1]; - let user: any; - try { - user = this.jwtService.verify(token); - } catch (error) { - throw new UnauthorizedException('Invalid token'); - } - - // Extract projectId from the request arguments - const args = gqlContext.getArgs(); - const { projectId } = args; - - // Fetch the project and check if the userId matches the project's userId - const project = await this.projectsService.getProjectById(projectId); - if (!project) { - throw new UnauthorizedException('Project not found'); - } - - //To do: In the feature when we need allow teams add check here - - if (project.user_id !== user.userId) { - throw new UnauthorizedException('User is not the owner of the project'); - } - - return true; +import { ProjectService } from '../project/project.service'; + +@Injectable() +export class ProjectGuard implements CanActivate { + constructor( + private readonly projectsService: ProjectService, + private readonly jwtService: JwtService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const gqlContext = GqlExecutionContext.create(context); + const request = gqlContext.getContext().req; + + // Extract the authorization header + const authHeader = request.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedException('Authorization token is missing'); } + + // Decode the token to get user information + const token = authHeader.split(' ')[1]; + let user: any; + try { + user = this.jwtService.verify(token); + } catch (error) { + throw new UnauthorizedException('Invalid token'); + } + + // Extract projectId from the request arguments + const args = gqlContext.getArgs(); + const { projectId } = args; + + // Fetch the project and check if the userId matches the project's userId + const project = await this.projectsService.getProjectById(projectId); + if (!project) { + throw new UnauthorizedException('Project not found'); + } + + //To do: In the feature when we need allow teams add check here + + if (project.user_id !== user.userId) { + throw new UnauthorizedException('User is not the owner of the project'); + } + + return true; } - \ No newline at end of file +} diff --git a/backend/src/project/__tests__/project.service.spec.ts b/backend/src/project/__tests__/project.service.spec.ts new file mode 100644 index 00000000..8503e366 --- /dev/null +++ b/backend/src/project/__tests__/project.service.spec.ts @@ -0,0 +1,314 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ProjectService } from '../project.service'; +import { Project } from '../project.model'; +import { ProjectPackages } from '../project-packages.model'; +import { + NotFoundException, + InternalServerErrorException, +} from '@nestjs/common'; +import { UpsertProjectInput } from '../dto/project.input'; +import { User } from 'src/user/user.model'; + +describe('ProjectsService', () => { + let service: ProjectService; + let projectRepository: Repository; + let packageRepository: Repository; + + const mockProject = { + id: '1', + project_name: 'Test Project 1', + path: '/test/path1', + user_id: 'user1', + is_deleted: false, + is_active: true, + created_at: new Date(), + updated_at: new Date(), + projectPackages: [], + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProjectService, + { + provide: getRepositoryToken(Project), + useValue: { + find: jest.fn().mockResolvedValue([mockProject]), + findOne: jest.fn().mockResolvedValue(mockProject), + create: jest.fn().mockReturnValue(mockProject), + save: jest.fn().mockResolvedValue(mockProject), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }, + }, + { + provide: getRepositoryToken(ProjectPackages), + useValue: { + create: jest.fn().mockImplementation((dto) => ({ + id: 'package-1', + ...dto, + is_deleted: false, + is_active: true, + })), + save: jest.fn().mockImplementation((dto) => Promise.resolve(dto)), + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ProjectService); + projectRepository = module.get>( + getRepositoryToken(Project), + ); + packageRepository = module.get>( + getRepositoryToken(ProjectPackages), + ); + }); + + describe('getProjectsByUser', () => { + it('should return projects for a user', async () => { + // Act + const result = await service.getProjectsByUser('user1'); + + // Assert + expect(result).toEqual([mockProject]); + expect(projectRepository.find).toHaveBeenCalledWith({ + where: { user_id: 'user1', is_deleted: false }, + relations: ['projectPackages'], + }); + }); + + it('should filter out deleted packages', async () => { + // Arrange + const projectWithPackages: Project = { + ...mockProject, + projectPackages: [], + user: new User(), + }; + jest + .spyOn(projectRepository, 'find') + .mockResolvedValue([projectWithPackages]); + + // Act + const result = await service.getProjectsByUser('user1'); + + // Assert + }); + + it('should throw NotFoundException when no projects found', async () => { + // Arrange + jest.spyOn(projectRepository, 'find').mockResolvedValue([]); + + // Act & Assert + await expect(service.getProjectsByUser('user1')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('upsertProject', () => { + describe('create new project', () => { + it('should create a new project with packages', async () => { + // Arrange + const upsertInput: UpsertProjectInput = { + projectName: 'New Project', + path: '/new/path', + projectId: undefined, + projectPackages: ['package1', 'package2'], + }; + + const createdProject: Project = { + ...mockProject, + project_name: upsertInput.projectName, + path: upsertInput.path, + user: new User(), + }; + + jest.spyOn(projectRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(projectRepository, 'create').mockReturnValue(createdProject); + jest.spyOn(projectRepository, 'save').mockResolvedValue(createdProject); + + // Act + const result = await service.upsertProject(upsertInput, 'user1'); + + // Assert + expect(projectRepository.create).toHaveBeenCalledWith({ + project_name: upsertInput.projectName, + path: upsertInput.path, + user_id: 'user1', + }); + expect(packageRepository.create).toHaveBeenCalledTimes(2); + expect(packageRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); + + describe('update existing project', () => { + it('should update project and add new packages', async () => { + // Arrange + const upsertInput: UpsertProjectInput = { + projectId: '1', + projectName: 'Updated Project', + path: '/updated/path', + projectPackages: ['new-package'], + }; + + const existingProject: Project = { + ...mockProject, + user: new User(), + }; + const updatedProject: Project = { + ...existingProject, + project_name: upsertInput.projectName, + path: upsertInput.path, + }; + + jest + .spyOn(projectRepository, 'findOne') + .mockResolvedValueOnce(existingProject) // First call for finding project + .mockResolvedValueOnce(updatedProject); // Second call for final result + + jest.spyOn(projectRepository, 'save').mockResolvedValue(updatedProject); + + // Act + const result = await service.upsertProject(upsertInput, 'user1'); + + // Assert + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { id: '1', is_deleted: false, user_id: 'user1' }, + }); + + expect(packageRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.any(Object), + content: 'new-package', + }), + ); + }); + + it('should not create packages if none provided', async () => { + // Arrange + const upsertInput: UpsertProjectInput = { + projectId: '1', + projectName: 'Updated Project', + path: '/updated/path', + projectPackages: [], + }; + + // Act + await service.upsertProject(upsertInput, 'user1'); + + // Assert + expect(packageRepository.create).not.toHaveBeenCalled(); + expect(packageRepository.save).not.toHaveBeenCalled(); + }); + }); + }); + + describe('deleteProject', () => { + it('should soft delete project and its packages', async () => { + // Arrange + const projectWithPackages: Project = { + ...mockProject, + projectPackages: [], + user: new User(), + }; + jest + .spyOn(projectRepository, 'findOne') + .mockResolvedValue(projectWithPackages); + + // Act + const result = await service.deleteProject('1'); + + // Assert + expect(result).toBe(true); + expect(projectRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + is_active: false, + is_deleted: true, + }), + ); + }); + + it('should throw NotFoundException for non-existent project', async () => { + // Arrange + jest.spyOn(projectRepository, 'findOne').mockResolvedValue(null); + + // Act & Assert + await expect(service.deleteProject('999')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('removePackageFromProject', () => { + it('should soft delete a single package', async () => { + // Arrange + + const packageToRemove: ProjectPackages = { + id: 'pkg1', + is_deleted: false, + is_active: true, + project_id: '1', + content: '', + project: new Project(), + created_at: undefined, + updated_at: undefined, + }; + jest + .spyOn(packageRepository, 'findOne') + .mockResolvedValue(packageToRemove); + + // Act + const result = await service.removePackageFromProject('1', 'pkg1'); + + // Assert + expect(result).toBe(true); + expect(packageRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'pkg1', + is_active: false, + is_deleted: true, + }), + ); + }); + + it('should throw NotFoundException for non-existent package', async () => { + // Arrange + jest.spyOn(packageRepository, 'findOne').mockResolvedValue(null); + + // Act & Assert + await expect( + service.removePackageFromProject('1', 'non-existent'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateProjectPath', () => { + it('should update project path', async () => { + // Arrange + const newPath = '/updated/path'; + + // Act + const result = await service.updateProjectPath('1', newPath); + + // Assert + expect(result).toBe(true); + expect(projectRepository.update).toHaveBeenCalledWith('1', { + path: newPath, + }); + }); + + it('should throw NotFoundException for non-existent project', async () => { + // Arrange + jest.spyOn(projectRepository, 'findOne').mockResolvedValue(null); + + // Act & Assert + await expect( + service.updateProjectPath('999', '/new/path'), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/backend/src/project/dto/project.input.ts b/backend/src/project/dto/project.input.ts index de5aafce..572a4e21 100644 --- a/backend/src/project/dto/project.input.ts +++ b/backend/src/project/dto/project.input.ts @@ -4,13 +4,13 @@ import { InputType, Field, ID } from '@nestjs/graphql'; @InputType() export class UpsertProjectInput { @Field() - project_name: string; + projectName: string; path: string; @Field(() => ID, { nullable: true }) - project_id: string; + projectId: string; @Field(() => [String], { nullable: true }) - project_packages: string[]; + projectPackages: string[]; } diff --git a/backend/src/project/project-packages.model.ts b/backend/src/project/project-packages.model.ts index 874cca51..cfad6535 100644 --- a/backend/src/project/project-packages.model.ts +++ b/backend/src/project/project-packages.model.ts @@ -7,7 +7,7 @@ import { ManyToOne, JoinColumn, } from 'typeorm'; -import { Projects } from './project.model'; +import { Project } from './project.model'; @Entity() @ObjectType() @@ -24,7 +24,7 @@ export class ProjectPackages extends SystemBaseModel { @Column('text') content: string; - @ManyToOne(() => Projects, (project) => project.projectPackages) + @ManyToOne(() => Project, (project) => project.projectPackages) @JoinColumn({ name: 'project_id' }) - project: Projects; + project: Project; } diff --git a/backend/src/project/project.model.ts b/backend/src/project/project.model.ts index ffc76887..a9c5eba4 100644 --- a/backend/src/project/project.model.ts +++ b/backend/src/project/project.model.ts @@ -13,7 +13,7 @@ import { ProjectPackages } from './project-packages.model'; @Entity() @ObjectType() -export class Projects extends SystemBaseModel { +export class Project extends SystemBaseModel { @Field(() => ID) @PrimaryGeneratedColumn() id: string; @@ -33,8 +33,14 @@ export class Projects extends SystemBaseModel { @ManyToOne(() => User) @JoinColumn({ name: 'user_id' }) user: User; - + @Field(() => [ProjectPackages], { nullable: true }) - @OneToMany(() => ProjectPackages, (projectPackage) => projectPackage.project, { cascade: true }) + @OneToMany( + () => ProjectPackages, + (projectPackage) => projectPackage.project, + { + cascade: true, + }, + ) projectPackages: ProjectPackages[]; } diff --git a/backend/src/project/project.module.ts b/backend/src/project/project.module.ts index 48f8e461..90b22b83 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -1,18 +1,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Projects } from './project.model'; +import { Project } from './project.model'; import { ProjectPackages } from './project-packages.model'; -import { ProjectsService } from './project.service'; +import { ProjectService } from './project.service'; import { ProjectsResolver } from './project.resolver'; import { AuthModule } from '../auth/auth.module'; import { ProjectGuard } from '../guard/project.guard'; @Module({ imports: [ - TypeOrmModule.forFeature([Projects, ProjectPackages]), + TypeOrmModule.forFeature([Project, ProjectPackages]), AuthModule, // Import AuthModule to provide JwtService to the ProjectGuard ], - providers: [ProjectsService, ProjectsResolver, ProjectGuard], - exports: [ProjectsService, ProjectGuard], + providers: [ProjectService, ProjectsResolver, ProjectGuard], + exports: [ProjectService, ProjectGuard], }) export class ProjectModule {} diff --git a/backend/src/project/project.resolver.ts b/backend/src/project/project.resolver.ts index ef6f81dd..82537db5 100644 --- a/backend/src/project/project.resolver.ts +++ b/backend/src/project/project.resolver.ts @@ -1,41 +1,44 @@ // GraphQL Resolvers for Project APIs import { - Args, - Field, - Mutation, - ObjectType, - Query, - Resolver, + Args, + Field, + Mutation, + ObjectType, + Query, + Resolver, } from '@nestjs/graphql'; -import { ProjectsService } from './project.service'; -import { Projects } from './project.model'; +import { ProjectService } from './project.service'; +import { Project } from './project.model'; import { UpsertProjectInput } from './dto/project.input'; import { UseGuards } from '@nestjs/common'; import { ProjectGuard } from '../guard/project.guard'; import { GetUserIdFromToken } from '../decorator/get-auth-token'; -@Resolver(() => Projects) +@Resolver(() => Project) export class ProjectsResolver { - constructor( - private readonly projectsService: ProjectsService, - ) {} + constructor(private readonly projectsService: ProjectService) {} - @Query(() => [Projects]) - async getUserProjects(@GetUserIdFromToken() userId: string): Promise { + @Query(() => [Project]) + async getUserProjects( + @GetUserIdFromToken() userId: string, + ): Promise { return this.projectsService.getProjectsByUser(userId); } // @GetAuthToken() token: string - @Query(() => Projects) + @Query(() => Project) @UseGuards(ProjectGuard) - async getProjectDetails(@Args('projectId') projectId: string): Promise { + async getProjectDetails( + @Args('projectId') projectId: string, + ): Promise { return this.projectsService.getProjectById(projectId); } - @Mutation(() => Projects) - async upsertProject(@GetUserIdFromToken() userId: string, - @Args('upsertProjectInput') upsertProjectInput: UpsertProjectInput - ): Promise { + @Mutation(() => Project) + async upsertProject( + @GetUserIdFromToken() userId: string, + @Args('upsertProjectInput') upsertProjectInput: UpsertProjectInput, + ): Promise { return this.projectsService.upsertProject(upsertProjectInput, userId); } @@ -49,7 +52,7 @@ export class ProjectsResolver { @UseGuards(ProjectGuard) async updateProjectPath( @Args('projectId') projectId: string, - @Args('newPath') newPath: string + @Args('newPath') newPath: string, ): Promise { return this.projectsService.updateProjectPath(projectId, newPath); } @@ -58,7 +61,7 @@ export class ProjectsResolver { @UseGuards(ProjectGuard) async removePackageFromProject( @Args('projectId') projectId: string, - @Args('packageId') packageId: string + @Args('packageId') packageId: string, ): Promise { return this.projectsService.removePackageFromProject(projectId, packageId); } diff --git a/backend/src/project/project.service.ts b/backend/src/project/project.service.ts index a57deaac..3412087a 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -1,25 +1,34 @@ // Project Service for managing Projects -import { Injectable, NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + InternalServerErrorException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Projects } from './project.model'; +import { Project } from './project.model'; import { ProjectPackages } from './project-packages.model'; import { UpsertProjectInput } from './dto/project.input'; @Injectable() -export class ProjectsService { +export class ProjectService { constructor( - @InjectRepository(Projects) - private projectsRepository: Repository, + @InjectRepository(Project) + private projectsRepository: Repository, @InjectRepository(ProjectPackages) - private projectPackagesRepository: Repository + private projectPackagesRepository: Repository, ) {} - async getProjectsByUser(userId: string): Promise { - const projects = await this.projectsRepository.find({ where: { user_id: userId, is_deleted: false }, relations: ['projectPackages'] }); + async getProjectsByUser(userId: string): Promise { + const projects = await this.projectsRepository.find({ + where: { user_id: userId, is_deleted: false }, + relations: ['projectPackages'], + }); if (projects && projects.length > 0) { - projects.forEach(project => { - project.projectPackages = project.projectPackages.filter(pkg => !pkg.is_deleted); + projects.forEach((project) => { + project.projectPackages = project.projectPackages.filter( + (pkg) => !pkg.is_deleted, + ); }); } @@ -29,10 +38,15 @@ export class ProjectsService { return projects; } - async getProjectById(projectId: string): Promise { - const project = await this.projectsRepository.findOne({ where: { id: projectId, is_deleted: false }, relations: ['projectPackages'] }); + async getProjectById(projectId: string): Promise { + const project = await this.projectsRepository.findOne({ + where: { id: projectId, is_deleted: false }, + relations: ['projectPackages'], + }); if (project) { - project.projectPackages = project.projectPackages.filter(pkg => !pkg.is_deleted); + project.projectPackages = project.projectPackages.filter( + (pkg) => !pkg.is_deleted, + ); } if (!project) { @@ -41,33 +55,42 @@ export class ProjectsService { return project; } - async upsertProject(upsertProjectInput: UpsertProjectInput, user_id: string): Promise { - const { project_id, project_name, path, project_packages } = upsertProjectInput; + async upsertProject( + upsertProjectInput: UpsertProjectInput, + user_id: string, + ): Promise { + const { + projectId: project_id, + projectName: project_name, + path, + projectPackages: project_packages, + } = upsertProjectInput; let project; if (project_id) { // only extract the project match the user id - project = await this.projectsRepository.findOne({ where: { id: project_id, is_deleted: false, user_id: user_id } }); + project = await this.projectsRepository.findOne({ + where: { id: project_id, is_deleted: false, user_id: user_id }, + }); } - + if (project) { // Update existing project if (project_name) project.project_name = project_name; if (path) project.path = path; - } else { // Create a new project if it does not exist project = this.projectsRepository.create({ project_name, path, - user_id + user_id, }); project = await this.projectsRepository.save(project); } // Add new project packages to existing ones if (project_packages && project_packages.length > 0) { - const newPackages = project_packages.map(content => { + const newPackages = project_packages.map((content) => { return this.projectPackagesRepository.create({ project: project, content: content, @@ -77,16 +100,25 @@ export class ProjectsService { } // Return the updated or created project with all packages - return await this.projectsRepository.findOne({ where: { id: project.id, is_deleted: false }, relations: ['projectPackages'] }).then(project => { - if (project && project.projectPackages) { - project.projectPackages = project.projectPackages.filter(pkg => !pkg.is_deleted); - } - return project; - }); + return await this.projectsRepository + .findOne({ + where: { id: project.id, is_deleted: false }, + relations: ['projectPackages'], + }) + .then((project) => { + if (project && project.projectPackages) { + project.projectPackages = project.projectPackages.filter( + (pkg) => !pkg.is_deleted, + ); + } + return project; + }); } async deleteProject(projectId: string): Promise { - const project = await this.projectsRepository.findOne({ where: { id: projectId } }); + const project = await this.projectsRepository.findOne({ + where: { id: projectId }, + }); if (!project) { throw new NotFoundException(`Project with ID ${projectId} not found.`); } @@ -113,26 +145,41 @@ export class ProjectsService { } } - async removePackageFromProject(projectId: string, packageId: string): Promise { - const packageToRemove = await this.projectPackagesRepository.findOne({ where: { id: packageId, project: { id: projectId } } }); + async removePackageFromProject( + projectId: string, + packageId: string, + ): Promise { + const packageToRemove = await this.projectPackagesRepository.findOne({ + where: { id: packageId, project: { id: projectId } }, + }); if (!packageToRemove) { - throw new NotFoundException(`Package with ID ${packageId} not found for Project ID ${projectId}`); + throw new NotFoundException( + `Package with ID ${packageId} not found for Project ID ${projectId}`, + ); } - + packageToRemove.is_active = false; packageToRemove.is_deleted = true; await this.projectPackagesRepository.save(packageToRemove); - + return true; - } + } - async updateProjectPath(projectId: string, newPath: string): Promise { - const project = await this.projectsRepository.findOne({ where: { id: projectId, is_deleted: false }, relations: ['projectPackages'] }); + async updateProjectPath( + projectId: string, + newPath: string, + ): Promise { + const project = await this.projectsRepository.findOne({ + where: { id: projectId, is_deleted: false }, + relations: ['projectPackages'], + }); if (!project) { throw new NotFoundException(`Project with ID ${projectId} not found.`); } - - const result = await this.projectsRepository.update(projectId, { path: newPath }); + + const result = await this.projectsRepository.update(projectId, { + path: newPath, + }); return result.affected > 0; } }