emi-challenge-be/test/unit/application/task/task.service.test.ts

403 lines
15 KiB
TypeScript
Raw Permalink Normal View History

import { TaskService } from "../../../../src/application/task/task.service.js";
import type { TaskRepository } from "../../../../src/domain/task/task.repository.js";
import type { StateRepository } from "../../../../src/domain/state/state.repository.js";
import type { Task } from "../../../../src/domain/task/task.entity.js";
import { AppError } from "../../../../src/shared/app-error.js";
function createMockTaskRepo(): TaskRepository {
return {
findAll: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
}
function createMockStateRepo(): StateRepository {
return {
findAll: jest.fn(),
findByName: jest.fn(),
};
}
function createSampleTask(overrides?: Partial<Task>): Task {
return {
id: "task-1",
title: "Test Task",
description: "Test Description",
dueDate: "2024-12-31",
completed: false,
deletedAt: null,
stateHistory: [{ state: "new", date: "2024-01-01" }],
notes: ["note 1", "note 2"],
...overrides,
};
}
describe("TaskService", () => {
let taskService: TaskService;
let taskRepo: TaskRepository;
let stateRepo: StateRepository;
beforeEach(() => {
taskRepo = createMockTaskRepo();
stateRepo = createMockStateRepo();
taskService = new TaskService(taskRepo, stateRepo);
});
describe("getAll", () => {
it("should return all tasks", async () => {
const tasks = [createSampleTask(), createSampleTask({ id: "task-2" })];
(taskRepo.findAll as jest.Mock).mockResolvedValue(tasks);
const result = await taskService.getAll();
expect(result).toEqual(tasks);
expect(taskRepo.findAll).toHaveBeenCalledTimes(1);
});
it("should return empty array when no tasks exist", async () => {
(taskRepo.findAll as jest.Mock).mockResolvedValue([]);
const result = await taskService.getAll();
expect(result).toEqual([]);
});
});
describe("getById", () => {
it("should return a task by id", async () => {
const task = createSampleTask();
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
const result = await taskService.getById("task-1");
expect(result).toEqual(task);
expect(taskRepo.findById).toHaveBeenCalledWith("task-1");
});
it("should throw 404 when task not found", async () => {
(taskRepo.findById as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.getById("non-existent")).rejects.toThrow(AppError);
await expect(taskService.getById("non-existent")).rejects.toMatchObject({
status: 404,
message: "Task with id 'non-existent' not found",
});
});
});
describe("create", () => {
it("should create a task with default state 'new'", async () => {
const dto = { title: "New Task", description: "Description", dueDate: "2024-12-31" };
const createdTask = createSampleTask({ title: dto.title, description: dto.description });
(taskRepo.create as jest.Mock).mockResolvedValue(createdTask);
const result = await taskService.create(dto);
expect(result).toEqual(createdTask);
expect(taskRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
title: dto.title,
description: dto.description,
dueDate: dto.dueDate,
completed: false,
stateHistory: [expect.objectContaining({ state: "new" })],
})
);
});
it("should create a task with provided notes", async () => {
const dto = {
title: "New Task",
description: "Description",
dueDate: "2024-12-31",
notes: ["note 1"],
};
(taskRepo.create as jest.Mock).mockResolvedValue(createSampleTask());
await taskService.create(dto);
expect(taskRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ notes: ["note 1"] })
);
});
it("should create a task with empty notes when not provided", async () => {
const dto = { title: "New Task", description: "Description", dueDate: "2024-12-31" };
(taskRepo.create as jest.Mock).mockResolvedValue(createSampleTask());
await taskService.create(dto);
expect(taskRepo.create).toHaveBeenCalledWith(expect.objectContaining({ notes: [] }));
});
});
describe("update", () => {
it("should update task title", async () => {
const task = createSampleTask();
const updated = { ...task, title: "Updated Title" };
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.update as jest.Mock).mockResolvedValue(updated);
const result = await taskService.update("task-1", { title: "Updated Title" });
expect(result.title).toBe("Updated Title");
expect(taskRepo.update).toHaveBeenCalledWith("task-1", { title: "Updated Title" });
});
it("should update multiple fields", async () => {
const task = createSampleTask();
const updated = { ...task, title: "New", completed: true };
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.update as jest.Mock).mockResolvedValue(updated);
const result = await taskService.update("task-1", { title: "New", completed: true });
expect(result.title).toBe("New");
expect(result.completed).toBe(true);
});
it("should throw 404 when task not found", async () => {
(taskRepo.findById as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.update("non-existent", { title: "test" })).rejects.toThrow(AppError);
});
it("should throw 500 when update fails", async () => {
const task = createSampleTask();
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.update as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.update("task-1", { title: "test" })).rejects.toMatchObject({
status: 500,
message: "Failed to update task",
});
});
});
describe("delete", () => {
it("should soft delete a task", async () => {
const task = createSampleTask();
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.delete as jest.Mock).mockResolvedValue(true);
await taskService.delete("task-1");
expect(taskRepo.delete).toHaveBeenCalledWith("task-1");
});
it("should throw 404 when task not found", async () => {
(taskRepo.findById as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.delete("non-existent")).rejects.toThrow(AppError);
});
it("should throw 500 when delete fails", async () => {
const task = createSampleTask();
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.delete as jest.Mock).mockResolvedValue(false);
await expect(taskService.delete("task-1")).rejects.toMatchObject({
status: 500,
message: "Failed to delete task",
});
});
it("should throw 404 when trying to get a deleted task", async () => {
const deletedTask = createSampleTask({ deletedAt: new Date() });
(taskRepo.findById as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.getById("task-1")).rejects.toMatchObject({
status: 404,
});
});
});
describe("transitionState", () => {
it("should transition from 'new' to 'active'", async () => {
const task = createSampleTask({ stateHistory: [{ state: "new", date: "2024-01-01" }] });
const updated = { ...task, stateHistory: [...task.stateHistory, { state: "active" as const, date: "2024-01-02" }] };
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(stateRepo.findByName as jest.Mock).mockResolvedValue({ name: "active" });
(taskRepo.update as jest.Mock).mockResolvedValue(updated);
const result = await taskService.transitionState("task-1", "active");
expect(result.stateHistory).toHaveLength(2);
expect(result.stateHistory[1].state).toBe("active");
});
it("should transition from 'active' to 'resolved'", async () => {
const task = createSampleTask({ stateHistory: [{ state: "active", date: "2024-01-01" }] });
const updated = { ...task, stateHistory: [...task.stateHistory, { state: "resolved" as const, date: "2024-01-02" }] };
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(stateRepo.findByName as jest.Mock).mockResolvedValue({ name: "resolved" });
(taskRepo.update as jest.Mock).mockResolvedValue(updated);
const result = await taskService.transitionState("task-1", "resolved");
expect(result.stateHistory[1].state).toBe("resolved");
});
it("should transition from 'active' to 'closed' and set completed to true", async () => {
const task = createSampleTask({ stateHistory: [{ state: "active", date: "2024-01-01" }] });
const updated = { ...task, completed: true, stateHistory: [...task.stateHistory, { state: "closed" as const, date: "2024-01-02" }] };
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(stateRepo.findByName as jest.Mock).mockResolvedValue({ name: "closed" });
(taskRepo.update as jest.Mock).mockResolvedValue(updated);
const result = await taskService.transitionState("task-1", "closed");
expect(result.completed).toBe(true);
});
it("should transition from 'resolved' back to 'active'", async () => {
const task = createSampleTask({ stateHistory: [{ state: "resolved", date: "2024-01-01" }] });
const updated = { ...task, stateHistory: [...task.stateHistory, { state: "active" as const, date: "2024-01-02" }] };
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(stateRepo.findByName as jest.Mock).mockResolvedValue({ name: "active" });
(taskRepo.update as jest.Mock).mockResolvedValue(updated);
const result = await taskService.transitionState("task-1", "active");
expect(result.stateHistory[1].state).toBe("active");
});
it("should throw 400 when transition is not allowed (new -> closed)", async () => {
const task = createSampleTask({ stateHistory: [{ state: "new", date: "2024-01-01" }] });
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
await expect(taskService.transitionState("task-1", "closed")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("Cannot transition from 'new' to 'closed'"),
});
});
it("should throw 400 when transition is not allowed (closed -> anything)", async () => {
const task = createSampleTask({ stateHistory: [{ state: "closed", date: "2024-01-01" }] });
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
await expect(taskService.transitionState("task-1", "active")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("Cannot transition from 'closed'"),
});
});
it("should throw 400 when state does not exist in database", async () => {
const task = createSampleTask({ stateHistory: [{ state: "new", date: "2024-01-01" }] });
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(stateRepo.findByName as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.transitionState("task-1", "active")).rejects.toMatchObject({
status: 400,
message: "Invalid state 'active'",
});
});
it("should throw 500 when update fails", async () => {
const task = createSampleTask({ stateHistory: [{ state: "new", date: "2024-01-01" }] });
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(stateRepo.findByName as jest.Mock).mockResolvedValue({ name: "active" });
(taskRepo.update as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.transitionState("task-1", "active")).rejects.toMatchObject({
status: 500,
message: "Failed to transition task state",
});
});
it("should throw 500 when task has no state history", async () => {
const task = createSampleTask({ stateHistory: [] });
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
await expect(taskService.transitionState("task-1", "active")).rejects.toMatchObject({
status: 500,
message: "Task has no state history",
});
});
});
describe("addNote", () => {
it("should add a note to task", async () => {
const task = createSampleTask({ notes: ["existing note"] });
const updated = { ...task, notes: ["existing note", "new note"] };
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.update as jest.Mock).mockResolvedValue(updated);
const result = await taskService.addNote("task-1", { note: "new note" });
expect(result.notes).toContain("new note");
expect(taskRepo.update).toHaveBeenCalledWith("task-1", { notes: ["existing note", "new note"] });
});
it("should throw 404 when task not found", async () => {
(taskRepo.findById as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.addNote("non-existent", { note: "test" })).rejects.toThrow(AppError);
});
it("should throw 500 when update fails", async () => {
const task = createSampleTask();
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.update as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.addNote("task-1", { note: "test" })).rejects.toMatchObject({
status: 500,
message: "Failed to add note",
});
});
});
describe("deleteNote", () => {
it("should delete a note by index", async () => {
const task = createSampleTask({ notes: ["note 1", "note 2", "note 3"] });
const updated = { ...task, notes: ["note 1", "note 3"] };
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.update as jest.Mock).mockResolvedValue(updated);
const result = await taskService.deleteNote("task-1", 1);
expect(result.notes).toEqual(["note 1", "note 3"]);
});
it("should throw 400 when note index is negative", async () => {
const task = createSampleTask();
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
await expect(taskService.deleteNote("task-1", -1)).rejects.toMatchObject({
status: 400,
message: "Invalid note index -1",
});
});
it("should throw 400 when note index is out of bounds", async () => {
const task = createSampleTask({ notes: ["note 1"] });
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
await expect(taskService.deleteNote("task-1", 5)).rejects.toMatchObject({
status: 400,
message: "Invalid note index 5",
});
});
it("should throw 404 when task not found", async () => {
(taskRepo.findById as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.deleteNote("non-existent", 0)).rejects.toThrow(AppError);
});
it("should throw 500 when update fails", async () => {
const task = createSampleTask({ notes: ["note 1"] });
(taskRepo.findById as jest.Mock).mockResolvedValue(task);
(taskRepo.update as jest.Mock).mockResolvedValue(undefined);
await expect(taskService.deleteNote("task-1", 0)).rejects.toMatchObject({
status: 500,
message: "Failed to delete note",
});
});
});
});