diff --git a/apps/backend/src/task/task.controller.spec.ts b/apps/backend/src/task/task.controller.spec.ts index c3ccbf4..1324a5e 100644 --- a/apps/backend/src/task/task.controller.spec.ts +++ b/apps/backend/src/task/task.controller.spec.ts @@ -4,6 +4,7 @@ import { TasksController } from './task.controller'; import { Task } from './types/task.entity'; import { TaskCategory } from './types/category'; import { BadRequestException } from '@nestjs/common'; +import { Label } from '../label/types/label.entity'; const mockCreateTaskDTO = { title: 'Task 1', @@ -59,6 +60,7 @@ export const mockTaskService: Partial = { ), getAllTasks: jest.fn(() => Promise.resolve(mockTasks)), createTask: jest.fn(), + removeTaskLabels: jest.fn(), updateTask: jest.fn(), }; @@ -67,6 +69,7 @@ describe('TasksController', () => { let tasksService: TasksService; beforeEach(async () => { + jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ controllers: [TasksController], providers: [ @@ -155,4 +158,83 @@ describe('TasksController', () => { /* Tests for add labels to task by id */ /* Tests for remove labels from task by id */ + describe('POST /tasks/:taskId/remove_labels', () => { + it('should successfully remove labels from task', async () => { + const taskId = 1; + const labelIds = [10, 20]; + const mockTaskAfterRemoval: Task = { + id: taskId, + title: 'Test Task', + description: null, + dateCreated: new Date('2024-01-01'), + dueDate: new Date('2024-12-31'), + category: TaskCategory.DRAFT, + labels: [{ id: 30, name: 'Label 3' } as Label], + }; + // mock service method output + jest + .spyOn(mockTaskService, 'removeTaskLabels') + .mockResolvedValue(mockTaskAfterRemoval); + + const result = await controller.removeTaskLabels(taskId, labelIds); + + expect(result).toEqual(mockTaskAfterRemoval); + expect(mockTaskService.removeTaskLabels).toHaveBeenCalledWith( + taskId, + labelIds, + ); + }); + + it('should throw BadRequestException when taskId does not exist', async () => { + const taskId = null; + const labelIds = [10, 20]; + + await expect( + controller.removeTaskLabels(taskId, labelIds), + ).rejects.toThrow( + new BadRequestException("taskId with ID null doesn't exist"), + ); + + expect(mockTaskService.removeTaskLabels).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when labelIds do not exist', async () => { + const taskId = 1; + const labelIds = [10, null, 20]; + + await expect( + controller.removeTaskLabels(taskId, labelIds), + ).rejects.toThrow( + new BadRequestException('at least 1 label id does not exist'), + ); + + expect(mockTaskService.removeTaskLabels).not.toHaveBeenCalled(); + }); + + it('should call service when all validations pass', async () => { + const taskId = 1; + const labelIds = [10, 20, 30]; + const mockTaskAfterRemoval: Task = { + id: taskId, + title: 'Test Task', + description: null, + dateCreated: new Date('2024-01-01'), + dueDate: new Date('2024-12-31'), + category: TaskCategory.DRAFT, + labels: [], + }; + + jest + .spyOn(mockTaskService, 'removeTaskLabels') + .mockResolvedValue(mockTaskAfterRemoval); + + const result = await controller.removeTaskLabels(taskId, labelIds); + + expect(mockTaskService.removeTaskLabels).toHaveBeenCalledWith( + taskId, + labelIds, + ); + expect(result).toEqual(mockTaskAfterRemoval); + }); + }); }); diff --git a/apps/backend/src/task/task.controller.ts b/apps/backend/src/task/task.controller.ts index 40938ea..fc36f9a 100644 --- a/apps/backend/src/task/task.controller.ts +++ b/apps/backend/src/task/task.controller.ts @@ -9,6 +9,7 @@ import { Query, Put, BadRequestException, + ParseIntPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { TasksService } from './task.service'; @@ -16,6 +17,7 @@ import { Task } from './types/task.entity'; import { CreateTaskDTO } from './dtos/create-task.dto'; import { UpdateTaskDTO } from './dtos/update-task.dto'; import { TaskCategory } from './types/category'; +import { Label } from '../label/types/label.entity'; @ApiTags('tasks') @Controller('tasks') @@ -102,10 +104,28 @@ export class TasksController { */ /** Remove labels from task by its ID - * @param id The ID of the task to remove labels from. - * @param labels The labels to remove from the task. + * @param taskId The ID of the task to remove labels from. + * @param labelIds The labels to remove from the task. * @returns The updated task. * @throws BadRequestException if the task with the given ID does not exist. * @throws BadRequestException if the labels are invalid. */ + @Post('/:taskId/remove_labels') + async removeTaskLabels( + @Param('taskId', ParseIntPipe) taskId: number, + @Body('labelIds') labelIds: number[], + ) { + if (!taskId || typeof taskId !== 'number') { + throw new BadRequestException(`taskId with ID ${taskId} doesn't exist`); + } + // checking that all the labels exist + for (const id of labelIds) { + if (!id || typeof id !== 'number') { + throw new BadRequestException('at least 1 label id does not exist'); + } + } + + // type validation done, now business logic: + return await this.tasksService.removeTaskLabels(taskId, labelIds); + } } diff --git a/apps/backend/src/task/task.service.spec.ts b/apps/backend/src/task/task.service.spec.ts index 81cf335..224dd41 100644 --- a/apps/backend/src/task/task.service.spec.ts +++ b/apps/backend/src/task/task.service.spec.ts @@ -8,6 +8,7 @@ import { TaskCategory } from './types/category'; import { mockTasks } from './task.controller.spec'; import { mock } from 'jest-mock-extended'; import { BadRequestException } from '@nestjs/common'; +import { Label } from '../label/types/label.entity'; const mockTaskRepository = mock>(); @@ -189,4 +190,125 @@ describe('TasksService', () => { /* Tests for add labels to task by id */ /* Tests for remove labels from task by id */ + + describe('removeTaskLabels', () => { + let service: TasksService; + // setup/reset mock service + beforeEach(async () => { + mockTaskRepository.findOne.mockReset(); + mockTaskRepository.save.mockReset(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TasksService, + { + provide: getRepositoryToken(Task), + useValue: mockTaskRepository, + }, + ], + }).compile(); + + service = module.get(TasksService); + }); + + it('should successfully remove 2 valid labels from task', async () => { + const taskId = 1; + const labelIdsToRemove = [10, 20]; + + const mockTask: Task = { + id: taskId, + title: 'Test Task', + description: null, + dateCreated: new Date('2024-01-01'), + dueDate: new Date('2024-12-31'), + category: TaskCategory.DRAFT, + labels: [ + { id: 10, name: 'Label 1' } as Label, + { id: 20, name: 'Label 2' } as Label, + { id: 30, name: 'Label 3' } as Label, + ], + }; + + const expectedTaskAfterRemoval: Task = { + id: taskId, + title: 'Test Task', + description: null, + dateCreated: new Date('2024-01-01'), + dueDate: new Date('2024-12-31'), + category: TaskCategory.DRAFT, + labels: [{ id: 30, name: 'Label 3' } as Label], + }; + + mockTaskRepository.findOne.mockResolvedValue(mockTask); + mockTaskRepository.save.mockResolvedValue(expectedTaskAfterRemoval); + + const result = await service.removeTaskLabels(taskId, labelIdsToRemove); + + expect(mockTaskRepository.findOne).toHaveBeenCalledWith({ + where: { id: taskId }, + relations: ['labels'], + }); + + // verify the task was modified correctly before saving + expect(mockTaskRepository.save).toHaveBeenCalledWith( + expectedTaskAfterRemoval, + ); + + expect(result).toEqual(expectedTaskAfterRemoval); + expect(result.labels).toHaveLength(1); + expect(result.labels[0].id).toBe(30); + }); + + it('should throw BadRequestException when one label is invalid', async () => { + const taskId = 1; + const labelIdsToRemove = [10, 99]; // 10 is valid, 99 is invalid + + const mockTask: Task = { + id: taskId, + title: 'Test Task', + description: null, + dateCreated: new Date('2024-01-01'), + dueDate: new Date('2024-12-31'), + category: TaskCategory.DRAFT, + labels: [ + { id: 10, name: 'Label 1' } as Label, + { id: 20, name: 'Label 2' } as Label, + ], + }; + + mockTaskRepository.findOne.mockResolvedValue(mockTask); + + await expect( + service.removeTaskLabels(taskId, labelIdsToRemove), + ).rejects.toThrow('Label IDs 99 are not assigned to this task'); + + expect(mockTaskRepository.findOne).toHaveBeenCalledWith({ + where: { id: taskId }, + relations: ['labels'], + }); + + // verify save was never called since validation failed + expect(mockTaskRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when taskId does not exist', async () => { + const nonExistentTaskId = 999; + const labelIdsToRemove = [10, 20]; + + mockTaskRepository.findOne.mockResolvedValue(null); + + await expect( + service.removeTaskLabels(nonExistentTaskId, labelIdsToRemove), + ).rejects.toThrow( + new BadRequestException('taskId does not exist in database'), + ); + + expect(mockTaskRepository.findOne).toHaveBeenCalledWith({ + where: { id: nonExistentTaskId }, + relations: ['labels'], + }); + + expect(mockTaskRepository.save).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/task/task.service.ts b/apps/backend/src/task/task.service.ts index d69c7ae..a289179 100644 --- a/apps/backend/src/task/task.service.ts +++ b/apps/backend/src/task/task.service.ts @@ -65,4 +65,34 @@ export class TasksService { /** Add labels to task by its ID. */ /** Remove labels from task by its ID. */ + async removeTaskLabels(taskId: number, labelIds: number[]) { + const task = await this.taskRepository.findOne({ + where: { id: taskId }, + relations: ['labels'], // will do the JOIN for us + }); + if (!task) { + throw new BadRequestException( + `taskId with ID ${taskId} does not exist in database`, + ); + } + // validate that the labelIds are associated with the given task + const currentLabelIds = task.labels.map((label) => label.id); + const invalidLabelIds = labelIds.filter( + (id) => !currentLabelIds.includes(id), + ); + + if (invalidLabelIds.length == 1) { + throw new BadRequestException( + `Label ID ${invalidLabelIds[0]} is not assigned to this task`, + ); + } else if (invalidLabelIds.length > 1) { + throw new BadRequestException( + `Label IDs ${invalidLabelIds.join(', ')} are not assigned to this task`, + ); + } + + // they are valid, now remove + task.labels = task.labels.filter((label) => !labelIds.includes(label.id)); + return this.taskRepository.save(task); + } }