403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|
|
});
|