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", }); }); }); });