diff --git a/test/unit/application/task/task.service.test.ts b/test/unit/application/task/task.service.test.ts new file mode 100644 index 0000000..e8885e9 --- /dev/null +++ b/test/unit/application/task/task.service.test.ts @@ -0,0 +1,402 @@ +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 { + 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", + }); + }); + }); +});