Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/backend/src/task/task.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -59,6 +60,7 @@ export const mockTaskService: Partial<TasksService> = {
),
getAllTasks: jest.fn(() => Promise.resolve(mockTasks)),
createTask: jest.fn(),
removeTaskLabels: jest.fn(),
updateTask: jest.fn(),
};

Expand All @@ -67,6 +69,7 @@ describe('TasksController', () => {
let tasksService: TasksService;

beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [TasksController],
providers: [
Expand Down Expand Up @@ -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);
});
});
});
24 changes: 22 additions & 2 deletions apps/backend/src/task/task.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
Query,
Put,
BadRequestException,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { TasksService } from './task.service';
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')
Expand Down Expand Up @@ -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);
}
}
122 changes: 122 additions & 0 deletions apps/backend/src/task/task.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Repository<Task>>();

Expand Down Expand Up @@ -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>(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();
});
});
});
30 changes: 30 additions & 0 deletions apps/backend/src/task/task.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}