diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index f3441cf..7a79c37 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,8 +1,5 @@ import { NextFunction, Request, Response } from "express"; -import { - CustomError, - InternalServerError, -} from "../modules/shared/application/errors"; +import { AppException } from "../shared/exceptions/AppException"; import { Logger } from "../utils/logger"; const logger = new Logger("ERROR_HANDLER"); @@ -49,23 +46,21 @@ export const errorHandler = ( traceId: req.traceId, }); - // Handle different types of errors - if (err instanceof CustomError) { + // Handle domain exceptions + if (err instanceof AppException) { return res.status(err.statusCode).json({ - code: err.code, + statusCode: err.statusCode, message: err.message, - ...(err.details && { details: err.details }), + errorCode: err.errorCode, ...(req.traceId && { traceId: req.traceId }), }); } - // For unknown errors, convert to InternalServerError - const internalError = new InternalServerError( - err.message || "An unexpected error occurred" - ); - - return res.status(internalError.statusCode).json({ - ...internalError.toJSON(), + // For unknown errors, return generic 500 error + return res.status(500).json({ + statusCode: 500, + message: "Internal server error", + errorCode: "INTERNAL_ERROR", ...(req.traceId && { traceId: req.traceId }), }); }; diff --git a/src/modules/project/__tests__/controllers/ProjectController.int.test.ts b/src/modules/project/__tests__/controllers/ProjectController.int.test.ts index 72a5249..059aad1 100644 --- a/src/modules/project/__tests__/controllers/ProjectController.int.test.ts +++ b/src/modules/project/__tests__/controllers/ProjectController.int.test.ts @@ -1,24 +1,199 @@ // Integration test for ProjectController import { Request, Response } from "express"; -import ProjectController from "../../presentation/controllers/Project.controller"; +import ProjectController from "../../presentation/controllers/Project.controller.stub"; +import { CreateProjectUseCase } from "../../use-cases/CreateProjectUseCase"; +import { GetProjectUseCase } from "../../use-cases/GetProjectUseCase"; +import { ListProjectsUseCase } from "../../use-cases/ListProjectsUseCase"; +import { UpdateProjectUseCase } from "../../use-cases/UpdateProjectUseCase"; +import { DeleteProjectUseCase } from "../../use-cases/DeleteProjectUseCase"; +import { ValidationException, NotFoundException } from "../../../../shared/exceptions/DomainExceptions"; + +// Mock use cases +const mockCreateProjectUseCase = { + execute: jest.fn(), +} as jest.Mocked; + +const mockGetProjectUseCase = { + execute: jest.fn(), +} as jest.Mocked; + +const mockListProjectsUseCase = { + execute: jest.fn(), +} as jest.Mocked; + +const mockUpdateProjectUseCase = { + execute: jest.fn(), +} as jest.Mocked; + +const mockDeleteProjectUseCase = { + execute: jest.fn(), +} as jest.Mocked; describe("ProjectController Integration", () => { - const controller = new ProjectController(); + let controller: ProjectController; + + beforeEach(() => { + controller = new ProjectController( + mockCreateProjectUseCase, + mockGetProjectUseCase, + mockListProjectsUseCase, + mockUpdateProjectUseCase, + mockDeleteProjectUseCase + ); + jest.clearAllMocks(); + }); it("should have a createProject method", () => { expect(typeof ProjectController.prototype.createProject).toBe("function"); }); - test("should return error if required fields are missing on createProject", async () => { - const req = { body: {} } as Partial; - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as Partial; - await controller.createProject(req as Request, res as Response); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.any(String) }) - ); + describe("createProject", () => { + test("should return 400 with standard error body for validation errors", async () => { + const validationError = new ValidationException("Validation failed: Title must be at least 3 characters long"); + mockCreateProjectUseCase.execute.mockRejectedValue(validationError); + + const req = { body: { title: "ab", description: "test" } } as Partial; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial; + + await expect(controller.createProject(req as Request, res as Response)).rejects.toThrow(ValidationException); + }); + + test("should return 201 with success response for valid project", async () => { + const mockProject = { + id: "123", + title: "Test Project", + description: "Test Description", + organizationId: "org-123", + status: "DRAFT" + }; + mockCreateProjectUseCase.execute.mockResolvedValue(mockProject); + + const req = { + body: { + title: "Test Project", + description: "Test Description", + organizationId: "org-123" + } + } as Partial; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial; + + await controller.createProject(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockProject + }); + }); + }); + + describe("getProjectById", () => { + test("should return 404 with standard error body for not found", async () => { + const notFoundError = new NotFoundException("Project not found"); + mockGetProjectUseCase.execute.mockRejectedValue(notFoundError); + + const req = { params: { id: "non-existent" } } as Partial; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial; + + await expect(controller.getProjectById(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + test("should return 200 with project data for valid id", async () => { + const mockProject = { + id: "123", + title: "Test Project", + description: "Test Description", + organizationId: "org-123", + status: "DRAFT" + }; + mockGetProjectUseCase.execute.mockResolvedValue(mockProject); + + const req = { params: { id: "123" } } as Partial; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial; + + await controller.getProjectById(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockProject + }); + }); + }); + + describe("updateProject", () => { + test("should return 400 with standard error body for validation errors", async () => { + const validationError = new ValidationException("Validation failed: Title must be at least 3 characters long"); + mockUpdateProjectUseCase.execute.mockRejectedValue(validationError); + + const req = { + params: { id: "123" }, + body: { title: "ab" } + } as Partial; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial; + + await expect(controller.updateProject(req as Request, res as Response)).rejects.toThrow(ValidationException); + }); + + test("should return 404 with standard error body for not found", async () => { + const notFoundError = new NotFoundException("Project not found"); + mockUpdateProjectUseCase.execute.mockRejectedValue(notFoundError); + + const req = { + params: { id: "non-existent" }, + body: { title: "Updated Title" } + } as Partial; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial; + + await expect(controller.updateProject(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + }); + + describe("deleteProject", () => { + test("should return 404 with standard error body for not found", async () => { + const notFoundError = new NotFoundException("Project not found"); + mockDeleteProjectUseCase.execute.mockRejectedValue(notFoundError); + + const req = { params: { id: "non-existent" } } as Partial; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial; + + await expect(controller.deleteProject(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + test("should return 204 for successful deletion", async () => { + mockDeleteProjectUseCase.execute.mockResolvedValue(undefined); + + const req = { params: { id: "123" } } as Partial; + const res = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + } as Partial; + + await controller.deleteProject(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(204); + expect(res.send).toHaveBeenCalled(); + }); }); }); diff --git a/src/modules/project/__tests__/repositories/PrismaProjectRepository.test.ts b/src/modules/project/__tests__/repositories/PrismaProjectRepository.test.ts new file mode 100644 index 0000000..4482942 --- /dev/null +++ b/src/modules/project/__tests__/repositories/PrismaProjectRepository.test.ts @@ -0,0 +1,265 @@ +import { PrismaProjectRepository } from "../../repositories/PrismaProjectRepository"; +import { PrismaClient } from "@prisma/client"; +import { Project, ProjectStatus } from "../../domain/Project"; +import { InternalServerException } from "../../../../shared/exceptions/DomainExceptions"; + +describe("PrismaProjectRepository", () => { + let repository: PrismaProjectRepository; + let mockPrisma: jest.Mocked; + + beforeEach(() => { + mockPrisma = { + project: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + } as any; + + repository = new PrismaProjectRepository(mockPrisma); + }); + + describe("findById", () => { + it("should return project when found", async () => { + const projectId = "project-123"; + const mockPrismaProject = { + id: projectId, + title: "Test Project", + description: "Test Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrisma.project.findUnique.mockResolvedValue(mockPrismaProject); + + const result = await repository.findById(projectId); + + expect(mockPrisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId } + }); + expect(result).toBeInstanceOf(Project); + expect(result?.title).toBe("Test Project"); + }); + + it("should return null when project not found", async () => { + const projectId = "non-existent"; + mockPrisma.project.findUnique.mockResolvedValue(null); + + const result = await repository.findById(projectId); + + expect(mockPrisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId } + }); + expect(result).toBeNull(); + }); + + it("should throw InternalServerException when database error occurs", async () => { + const projectId = "project-123"; + mockPrisma.project.findUnique.mockRejectedValue(new Error("Database connection failed")); + + await expect(repository.findById(projectId)).rejects.toThrow(InternalServerException); + expect(mockPrisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId } + }); + }); + }); + + describe("save", () => { + it("should save and return project", async () => { + const project = Project.create({ + title: "Test Project", + description: "Test Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + const mockSavedProject = { + id: project.id, + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrisma.project.create.mockResolvedValue(mockSavedProject); + + const result = await repository.save(project); + + expect(mockPrisma.project.create).toHaveBeenCalledWith({ + data: { + id: project.id, + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + } + }); + expect(result).toBeInstanceOf(Project); + }); + + it("should throw InternalServerException when save fails", async () => { + const project = Project.create({ + title: "Test Project", + description: "Test Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + mockPrisma.project.create.mockRejectedValue(new Error("Save failed")); + + await expect(repository.save(project)).rejects.toThrow(InternalServerException); + }); + }); + + describe("update", () => { + it("should update and return project", async () => { + const project = Project.create({ + title: "Updated Project", + description: "Updated Description", + organizationId: "org-123", + status: ProjectStatus.ACTIVE, + }); + + const mockUpdatedProject = { + id: project.id, + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrisma.project.update.mockResolvedValue(mockUpdatedProject); + + const result = await repository.update(project); + + expect(mockPrisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status, + updatedAt: expect.any(Date), + } + }); + expect(result).toBeInstanceOf(Project); + }); + + it("should throw InternalServerException when update fails", async () => { + const project = Project.create({ + title: "Updated Project", + description: "Updated Description", + organizationId: "org-123", + status: ProjectStatus.ACTIVE, + }); + + mockPrisma.project.update.mockRejectedValue(new Error("Update failed")); + + await expect(repository.update(project)).rejects.toThrow(InternalServerException); + }); + }); + + describe("delete", () => { + it("should delete project", async () => { + const projectId = "project-123"; + mockPrisma.project.delete.mockResolvedValue({} as any); + + await repository.delete(projectId); + + expect(mockPrisma.project.delete).toHaveBeenCalledWith({ + where: { id: projectId } + }); + }); + + it("should throw InternalServerException when delete fails", async () => { + const projectId = "project-123"; + mockPrisma.project.delete.mockRejectedValue(new Error("Delete failed")); + + await expect(repository.delete(projectId)).rejects.toThrow(InternalServerException); + }); + }); + + describe("findAll", () => { + it("should return all projects", async () => { + const mockProjects = [ + { + id: "project-1", + title: "Project 1", + description: "Description 1", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "project-2", + title: "Project 2", + description: "Description 2", + organizationId: "org-123", + status: ProjectStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrisma.project.findMany.mockResolvedValue(mockProjects); + + const result = await repository.findAll(); + + expect(mockPrisma.project.findMany).toHaveBeenCalled(); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(Project); + expect(result[1]).toBeInstanceOf(Project); + }); + + it("should throw InternalServerException when findMany fails", async () => { + mockPrisma.project.findMany.mockRejectedValue(new Error("Find failed")); + + await expect(repository.findAll()).rejects.toThrow(InternalServerException); + }); + }); + + describe("findByOrganizationId", () => { + it("should return projects for organization", async () => { + const organizationId = "org-123"; + const mockProjects = [ + { + id: "project-1", + title: "Project 1", + description: "Description 1", + organizationId, + status: ProjectStatus.DRAFT, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrisma.project.findMany.mockResolvedValue(mockProjects); + + const result = await repository.findByOrganizationId(organizationId); + + expect(mockPrisma.project.findMany).toHaveBeenCalledWith({ + where: { organizationId } + }); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Project); + }); + + it("should throw InternalServerException when findByOrganizationId fails", async () => { + const organizationId = "org-123"; + mockPrisma.project.findMany.mockRejectedValue(new Error("Find failed")); + + await expect(repository.findByOrganizationId(organizationId)).rejects.toThrow(InternalServerException); + }); + }); +}); diff --git a/src/modules/project/__tests__/use-cases/CreateProjectUseCase.test.ts b/src/modules/project/__tests__/use-cases/CreateProjectUseCase.test.ts new file mode 100644 index 0000000..145ae7b --- /dev/null +++ b/src/modules/project/__tests__/use-cases/CreateProjectUseCase.test.ts @@ -0,0 +1,115 @@ +import { CreateProjectUseCase } from "../../use-cases/CreateProjectUseCase"; +import { IProjectRepository } from "../../repositories/IProjectRepository"; +import { CreateProjectDto } from "../../dto/CreateProjectDto"; +import { Project, ProjectStatus } from "../../domain/Project"; +import { ValidationException, InternalServerException } from "../../../../shared/exceptions/DomainExceptions"; + +describe("CreateProjectUseCase", () => { + let useCase: CreateProjectUseCase; + let mockRepository: jest.Mocked; + + beforeEach(() => { + mockRepository = { + findById: jest.fn(), + findAll: jest.fn(), + findByOrganizationId: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + useCase = new CreateProjectUseCase(mockRepository); + }); + + describe("execute", () => { + it("should create and save a project with valid data", async () => { + const dto = new CreateProjectDto(); + dto.title = "Test Project"; + dto.description = "Test Description"; + dto.organizationId = "org-123"; + dto.status = ProjectStatus.DRAFT; + + const mockProject = Project.create({ + title: dto.title, + description: dto.description, + organizationId: dto.organizationId, + status: dto.status, + }); + + mockRepository.save.mockResolvedValue(mockProject); + + const result = await useCase.execute(dto); + + expect(mockRepository.save).toHaveBeenCalledWith(expect.any(Project)); + expect(result).toEqual(mockProject); + }); + + it("should throw ValidationException for invalid title", async () => { + const dto = new CreateProjectDto(); + dto.title = "ab"; // Too short + dto.description = "Test Description"; + dto.organizationId = "org-123"; + + await expect(useCase.execute(dto)).rejects.toThrow(ValidationException); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it("should throw ValidationException for invalid description", async () => { + const dto = new CreateProjectDto(); + dto.title = "Test Project"; + dto.description = "short"; // Too short + dto.organizationId = "org-123"; + + await expect(useCase.execute(dto)).rejects.toThrow(ValidationException); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it("should throw ValidationException for invalid organizationId", async () => { + const dto = new CreateProjectDto(); + dto.title = "Test Project"; + dto.description = "Test Description"; + dto.organizationId = "invalid-uuid"; + + await expect(useCase.execute(dto)).rejects.toThrow(ValidationException); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it("should throw InternalServerException when repository fails", async () => { + const dto = new CreateProjectDto(); + dto.title = "Test Project"; + dto.description = "Test Description"; + dto.organizationId = "org-123"; + + mockRepository.save.mockRejectedValue(new Error("Database error")); + + await expect(useCase.execute(dto)).rejects.toThrow(InternalServerException); + }); + + it("should use DRAFT status as default when not provided", async () => { + const dto = new CreateProjectDto(); + dto.title = "Test Project"; + dto.description = "Test Description"; + dto.organizationId = "org-123"; + // status not provided + + const mockProject = Project.create({ + title: dto.title, + description: dto.description, + organizationId: dto.organizationId, + status: ProjectStatus.DRAFT, + }); + + mockRepository.save.mockResolvedValue(mockProject); + + const result = await useCase.execute(dto); + + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + props: expect.objectContaining({ + status: ProjectStatus.DRAFT, + }), + }) + ); + expect(result).toEqual(mockProject); + }); + }); +}); diff --git a/src/modules/project/__tests__/use-cases/DeleteProjectUseCase.test.ts b/src/modules/project/__tests__/use-cases/DeleteProjectUseCase.test.ts new file mode 100644 index 0000000..a4c1780 --- /dev/null +++ b/src/modules/project/__tests__/use-cases/DeleteProjectUseCase.test.ts @@ -0,0 +1,76 @@ +import { DeleteProjectUseCase } from "../../use-cases/DeleteProjectUseCase"; +import { IProjectRepository } from "../../repositories/IProjectRepository"; +import { Project, ProjectStatus } from "../../domain/Project"; +import { NotFoundException, InternalServerException } from "../../../../shared/exceptions/DomainExceptions"; + +describe("DeleteProjectUseCase", () => { + let useCase: DeleteProjectUseCase; + let mockRepository: jest.Mocked; + + beforeEach(() => { + mockRepository = { + findById: jest.fn(), + findAll: jest.fn(), + findByOrganizationId: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + useCase = new DeleteProjectUseCase(mockRepository); + }); + + describe("execute", () => { + it("should delete project when found", async () => { + const projectId = "project-123"; + const mockProject = Project.create({ + title: "Test Project", + description: "Test Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + mockRepository.findById.mockResolvedValue(mockProject); + mockRepository.delete.mockResolvedValue(undefined); + + await useCase.execute(projectId); + + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.delete).toHaveBeenCalledWith(projectId); + }); + + it("should throw NotFoundException when project not found", async () => { + const projectId = "non-existent"; + mockRepository.findById.mockResolvedValue(null); + + await expect(useCase.execute(projectId)).rejects.toThrow(NotFoundException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.delete).not.toHaveBeenCalled(); + }); + + it("should throw InternalServerException when repository findById fails", async () => { + const projectId = "project-123"; + mockRepository.findById.mockRejectedValue(new Error("Database error")); + + await expect(useCase.execute(projectId)).rejects.toThrow(InternalServerException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.delete).not.toHaveBeenCalled(); + }); + + it("should throw InternalServerException when repository delete fails", async () => { + const projectId = "project-123"; + const mockProject = Project.create({ + title: "Test Project", + description: "Test Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + mockRepository.findById.mockResolvedValue(mockProject); + mockRepository.delete.mockRejectedValue(new Error("Delete failed")); + + await expect(useCase.execute(projectId)).rejects.toThrow(InternalServerException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.delete).toHaveBeenCalledWith(projectId); + }); + }); +}); diff --git a/src/modules/project/__tests__/use-cases/GetProjectUseCase.test.ts b/src/modules/project/__tests__/use-cases/GetProjectUseCase.test.ts new file mode 100644 index 0000000..f48a56a --- /dev/null +++ b/src/modules/project/__tests__/use-cases/GetProjectUseCase.test.ts @@ -0,0 +1,56 @@ +import { GetProjectUseCase } from "../../use-cases/GetProjectUseCase"; +import { IProjectRepository } from "../../repositories/IProjectRepository"; +import { Project, ProjectStatus } from "../../domain/Project"; +import { NotFoundException, InternalServerException } from "../../../../shared/exceptions/DomainExceptions"; + +describe("GetProjectUseCase", () => { + let useCase: GetProjectUseCase; + let mockRepository: jest.Mocked; + + beforeEach(() => { + mockRepository = { + findById: jest.fn(), + findAll: jest.fn(), + findByOrganizationId: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + useCase = new GetProjectUseCase(mockRepository); + }); + + describe("execute", () => { + it("should return project when found", async () => { + const projectId = "project-123"; + const mockProject = Project.create({ + title: "Test Project", + description: "Test Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + mockRepository.findById.mockResolvedValue(mockProject); + + const result = await useCase.execute(projectId); + + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(result).toEqual(mockProject); + }); + + it("should throw NotFoundException when project not found", async () => { + const projectId = "non-existent"; + mockRepository.findById.mockResolvedValue(null); + + await expect(useCase.execute(projectId)).rejects.toThrow(NotFoundException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + }); + + it("should throw InternalServerException when repository fails", async () => { + const projectId = "project-123"; + mockRepository.findById.mockRejectedValue(new Error("Database connection failed")); + + await expect(useCase.execute(projectId)).rejects.toThrow(InternalServerException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + }); + }); +}); diff --git a/src/modules/project/__tests__/use-cases/UpdateProjectUseCase.test.ts b/src/modules/project/__tests__/use-cases/UpdateProjectUseCase.test.ts new file mode 100644 index 0000000..2ee1aa7 --- /dev/null +++ b/src/modules/project/__tests__/use-cases/UpdateProjectUseCase.test.ts @@ -0,0 +1,136 @@ +import { UpdateProjectUseCase } from "../../use-cases/UpdateProjectUseCase"; +import { IProjectRepository } from "../../repositories/IProjectRepository"; +import { UpdateProjectDto } from "../../dto/UpdateProjectDto"; +import { Project, ProjectStatus } from "../../domain/Project"; +import { NotFoundException, ValidationException, InternalServerException } from "../../../../shared/exceptions/DomainExceptions"; + +describe("UpdateProjectUseCase", () => { + let useCase: UpdateProjectUseCase; + let mockRepository: jest.Mocked; + + beforeEach(() => { + mockRepository = { + findById: jest.fn(), + findAll: jest.fn(), + findByOrganizationId: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + useCase = new UpdateProjectUseCase(mockRepository); + }); + + describe("execute", () => { + it("should update and return project with valid data", async () => { + const projectId = "project-123"; + const dto = new UpdateProjectDto(); + dto.title = "Updated Project"; + dto.description = "Updated Description"; + + const existingProject = Project.create({ + title: "Original Project", + description: "Original Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + const updatedProject = Project.create({ + title: dto.title!, + description: dto.description!, + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + mockRepository.findById.mockResolvedValue(existingProject); + mockRepository.update.mockResolvedValue(updatedProject); + + const result = await useCase.execute(projectId, dto); + + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.update).toHaveBeenCalled(); + expect(result).toEqual(updatedProject); + }); + + it("should throw NotFoundException when project not found", async () => { + const projectId = "non-existent"; + const dto = new UpdateProjectDto(); + dto.title = "Updated Title"; + + mockRepository.findById.mockResolvedValue(null); + + await expect(useCase.execute(projectId, dto)).rejects.toThrow(NotFoundException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it("should throw ValidationException for invalid title", async () => { + const projectId = "project-123"; + const dto = new UpdateProjectDto(); + dto.title = "ab"; // Too short + + const existingProject = Project.create({ + title: "Original Project", + description: "Original Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + mockRepository.findById.mockResolvedValue(existingProject); + + await expect(useCase.execute(projectId, dto)).rejects.toThrow(ValidationException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it("should throw ValidationException for invalid description", async () => { + const projectId = "project-123"; + const dto = new UpdateProjectDto(); + dto.description = "short"; // Too short + + const existingProject = Project.create({ + title: "Original Project", + description: "Original Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + mockRepository.findById.mockResolvedValue(existingProject); + + await expect(useCase.execute(projectId, dto)).rejects.toThrow(ValidationException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it("should throw InternalServerException when repository findById fails", async () => { + const projectId = "project-123"; + const dto = new UpdateProjectDto(); + dto.title = "Updated Title"; + + mockRepository.findById.mockRejectedValue(new Error("Database error")); + + await expect(useCase.execute(projectId, dto)).rejects.toThrow(InternalServerException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it("should throw InternalServerException when repository update fails", async () => { + const projectId = "project-123"; + const dto = new UpdateProjectDto(); + dto.title = "Updated Title"; + + const existingProject = Project.create({ + title: "Original Project", + description: "Original Description", + organizationId: "org-123", + status: ProjectStatus.DRAFT, + }); + + mockRepository.findById.mockResolvedValue(existingProject); + mockRepository.update.mockRejectedValue(new Error("Update failed")); + + await expect(useCase.execute(projectId, dto)).rejects.toThrow(InternalServerException); + expect(mockRepository.findById).toHaveBeenCalledWith(projectId); + expect(mockRepository.update).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/project/presentation/controllers/Project.controller.stub.ts b/src/modules/project/presentation/controllers/Project.controller.stub.ts index b4db0a4..83a7baf 100644 --- a/src/modules/project/presentation/controllers/Project.controller.stub.ts +++ b/src/modules/project/presentation/controllers/Project.controller.stub.ts @@ -1,43 +1,122 @@ import { Request, Response } from "express"; +import { CreateProjectUseCase } from "../../use-cases/CreateProjectUseCase"; +import { GetProjectUseCase } from "../../use-cases/GetProjectUseCase"; +import { ListProjectsUseCase } from "../../use-cases/ListProjectsUseCase"; +import { UpdateProjectUseCase } from "../../use-cases/UpdateProjectUseCase"; +import { DeleteProjectUseCase } from "../../use-cases/DeleteProjectUseCase"; +import { CreateProjectDto } from "../../dto/CreateProjectDto"; +import { UpdateProjectDto } from "../../dto/UpdateProjectDto"; +import { AppException } from "../../../../shared/exceptions/AppException"; -/** - * Stub controller for Project functionality - * This replaces the original controller that referenced deleted services - * TODO: Implement proper project controller using new modular architecture - */ export default class ProjectController { + constructor( + private createProjectUseCase: CreateProjectUseCase, + private getProjectUseCase: GetProjectUseCase, + private listProjectsUseCase: ListProjectsUseCase, + private updateProjectUseCase: UpdateProjectUseCase, + private deleteProjectUseCase: DeleteProjectUseCase + ) {} + async createProject(req: Request, res: Response) { - res.status(501).json({ - message: "Project service temporarily disabled during migration", - error: "Service migration in progress" - }); + try { + const createProjectDto = new CreateProjectDto(); + Object.assign(createProjectDto, req.body); + + const project = await this.createProjectUseCase.execute(createProjectDto); + + res.status(201).json({ + success: true, + data: project + }); + } catch (error) { + if (error instanceof AppException) { + throw error; + } + throw error; + } } async getProjectById(req: Request, res: Response) { - res.status(501).json({ - message: "Project service temporarily disabled during migration", - error: "Service migration in progress" - }); + try { + const { id } = req.params; + const project = await this.getProjectUseCase.execute(id); + + res.status(200).json({ + success: true, + data: project + }); + } catch (error) { + if (error instanceof AppException) { + throw error; + } + throw error; + } } async getProjectsByOrganizationId(req: Request, res: Response) { - res.status(501).json({ - message: "Project service temporarily disabled during migration", - error: "Service migration in progress" - }); + try { + const { organizationId } = req.params; + const projects = await this.listProjectsUseCase.execute(organizationId); + + res.status(200).json({ + success: true, + data: projects + }); + } catch (error) { + if (error instanceof AppException) { + throw error; + } + throw error; + } + } + + async getAllProjects(req: Request, res: Response) { + try { + const projects = await this.listProjectsUseCase.execute(); + + res.status(200).json({ + success: true, + data: projects + }); + } catch (error) { + if (error instanceof AppException) { + throw error; + } + throw error; + } } async updateProject(req: Request, res: Response) { - res.status(501).json({ - message: "Project service temporarily disabled during migration", - error: "Service migration in progress" - }); + try { + const { id } = req.params; + const updateProjectDto = new UpdateProjectDto(); + Object.assign(updateProjectDto, req.body); + + const project = await this.updateProjectUseCase.execute(id, updateProjectDto); + + res.status(200).json({ + success: true, + data: project + }); + } catch (error) { + if (error instanceof AppException) { + throw error; + } + throw error; + } } async deleteProject(req: Request, res: Response) { - res.status(501).json({ - message: "Project service temporarily disabled during migration", - error: "Service migration in progress" - }); + try { + const { id } = req.params; + await this.deleteProjectUseCase.execute(id); + + res.status(204).send(); + } catch (error) { + if (error instanceof AppException) { + throw error; + } + throw error; + } } } \ No newline at end of file diff --git a/src/modules/project/repositories/PrismaProjectRepository.ts b/src/modules/project/repositories/PrismaProjectRepository.ts index 93c1158..7892ab9 100644 --- a/src/modules/project/repositories/PrismaProjectRepository.ts +++ b/src/modules/project/repositories/PrismaProjectRepository.ts @@ -1,95 +1,120 @@ -// import { PrismaClient } from '@prisma/client'; -// import { IProjectRepository } from './IProjectRepository'; -// import { Project, ProjectStatus } from '../domain/Project'; +import { PrismaClient } from '@prisma/client'; +import { IProjectRepository } from './IProjectRepository'; +import { Project, ProjectStatus } from '../domain/Project'; +import { InternalServerException } from '../../../shared/exceptions/DomainExceptions'; -// export class PrismaProjectRepository implements IProjectRepository { -// constructor(private prisma: PrismaClient) {} +export class PrismaProjectRepository implements IProjectRepository { + constructor(private prisma: PrismaClient) {} -// async findById(id: string): Promise { -// const project = await this.prisma.project.findUnique({ -// where: { id } -// }); + async findById(id: string): Promise { + try { + const project = await this.prisma.project.findUnique({ + where: { id } + }); -// if (!project) return null; + if (!project) return null; -// return Project.create({ -// title: project.title, -// description: project.description, -// organizationId: project.organizationId, -// status: project.status as ProjectStatus -// }); -// } + return Project.create({ + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status as ProjectStatus + }); + } catch (error) { + throw new InternalServerException(`Failed to find project with id ${id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } -// async findAll(): Promise { -// const projects = await this.prisma.project.findMany(); -// return projects.map(project => -// Project.create({ -// title: project.title, -// description: project.description, -// organizationId: project.organizationId, -// status: project.status as ProjectStatus -// }) -// ); -// } + async findAll(): Promise { + try { + const projects = await this.prisma.project.findMany(); + return projects.map(project => + Project.create({ + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status as ProjectStatus + }) + ); + } catch (error) { + throw new InternalServerException(`Failed to find all projects: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } -// async findByOrganizationId(organizationId: string): Promise { -// const projects = await this.prisma.project.findMany({ -// where: { organizationId } -// }); -// return projects.map(project => -// Project.create({ -// title: project.title, -// description: project.description, -// organizationId: project.organizationId, -// status: project.status as ProjectStatus -// }) -// ); -// } + async findByOrganizationId(organizationId: string): Promise { + try { + const projects = await this.prisma.project.findMany({ + where: { organizationId } + }); + return projects.map(project => + Project.create({ + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status as ProjectStatus + }) + ); + } catch (error) { + throw new InternalServerException(`Failed to find projects for organization ${organizationId}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } -// async save(project: Project): Promise { -// const savedProject = await this.prisma.project.create({ -// data: { -// id: project.id, -// title: project.title, -// description: project.description, -// organizationId: project.organizationId, -// status: project.status, -// createdAt: new Date(), -// updatedAt: new Date() -// } -// }); + async save(project: Project): Promise { + try { + const savedProject = await this.prisma.project.create({ + data: { + id: project.id, + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status, + createdAt: new Date(), + updatedAt: new Date() + } + }); -// return Project.create({ -// title: savedProject.title, -// description: savedProject.description, -// organizationId: savedProject.organizationId, -// status: savedProject.status as ProjectStatus -// }); -// } + return Project.create({ + title: savedProject.title, + description: savedProject.description, + organizationId: savedProject.organizationId, + status: savedProject.status as ProjectStatus + }); + } catch (error) { + throw new InternalServerException(`Failed to save project: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } -// async update(project: Project): Promise { -// const updatedProject = await this.prisma.project.update({ -// where: { id: project.id }, -// data: { -// title: project.title, -// description: project.description, -// organizationId: project.organizationId, -// status: project.status, -// updatedAt: new Date() -// } -// }); + async update(project: Project): Promise { + try { + const updatedProject = await this.prisma.project.update({ + where: { id: project.id }, + data: { + title: project.title, + description: project.description, + organizationId: project.organizationId, + status: project.status, + updatedAt: new Date() + } + }); -// return Project.create({ -// title: updatedProject.title, -// description: updatedProject.description, -// organizationId: updatedProject.organizationId, -// status: updatedProject.status as ProjectStatus -// }); -// } + return Project.create({ + title: updatedProject.title, + description: updatedProject.description, + organizationId: updatedProject.organizationId, + status: updatedProject.status as ProjectStatus + }); + } catch (error) { + throw new InternalServerException(`Failed to update project ${project.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } -// async delete(id: string): Promise { -// await this.prisma.project.delete({ -// where: { id } -// }); -// } -// } + async delete(id: string): Promise { + try { + await this.prisma.project.delete({ + where: { id } + }); + } catch (error) { + throw new InternalServerException(`Failed to delete project ${id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } +} diff --git a/src/modules/project/use-cases/CreateProjectUseCase.ts b/src/modules/project/use-cases/CreateProjectUseCase.ts index e14998c..e47b37d 100644 --- a/src/modules/project/use-cases/CreateProjectUseCase.ts +++ b/src/modules/project/use-cases/CreateProjectUseCase.ts @@ -1,11 +1,22 @@ import { IProjectRepository } from "../repositories/IProjectRepository"; import { Project, ProjectStatus } from "../domain/Project"; import { CreateProjectDto } from "../dto/CreateProjectDto"; +import { ValidationException } from "../../../shared/exceptions/DomainExceptions"; +import { validate } from "class-validator"; export class CreateProjectUseCase { constructor(private projectRepository: IProjectRepository) {} async execute(dto: CreateProjectDto): Promise { + // Validate DTO + const validationErrors = await validate(dto); + if (validationErrors.length > 0) { + const errorMessages = validationErrors.map(error => + Object.values(error.constraints || {}).join(', ') + ).join('; '); + throw new ValidationException(`Validation failed: ${errorMessages}`); + } + const project = Project.create({ title: dto.title, description: dto.description, diff --git a/src/modules/project/use-cases/DeleteProjectUseCase.ts b/src/modules/project/use-cases/DeleteProjectUseCase.ts index ffd6b76..3aa70f5 100644 --- a/src/modules/project/use-cases/DeleteProjectUseCase.ts +++ b/src/modules/project/use-cases/DeleteProjectUseCase.ts @@ -1,4 +1,5 @@ import { IProjectRepository } from "../repositories/IProjectRepository"; +import { NotFoundException } from "../../../shared/exceptions/DomainExceptions"; export class DeleteProjectUseCase { constructor(private projectRepository: IProjectRepository) {} @@ -7,7 +8,7 @@ export class DeleteProjectUseCase { const project = await this.projectRepository.findById(id); if (!project) { - throw new Error("Project not found"); + throw new NotFoundException("Project not found"); } await this.projectRepository.delete(id); diff --git a/src/modules/project/use-cases/GetProjectUseCase.ts b/src/modules/project/use-cases/GetProjectUseCase.ts index 5923acf..6806e94 100644 --- a/src/modules/project/use-cases/GetProjectUseCase.ts +++ b/src/modules/project/use-cases/GetProjectUseCase.ts @@ -1,5 +1,6 @@ import { IProjectRepository } from "../repositories/IProjectRepository"; import { Project } from "../domain/Project"; +import { NotFoundException } from "../../../shared/exceptions/DomainExceptions"; export class GetProjectUseCase { constructor(private projectRepository: IProjectRepository) {} @@ -8,7 +9,7 @@ export class GetProjectUseCase { const project = await this.projectRepository.findById(id); if (!project) { - throw new Error("Project not found"); + throw new NotFoundException("Project not found"); } return project; diff --git a/src/modules/project/use-cases/UpdateProjectUseCase.ts b/src/modules/project/use-cases/UpdateProjectUseCase.ts index b7a428d..05a6e3a 100644 --- a/src/modules/project/use-cases/UpdateProjectUseCase.ts +++ b/src/modules/project/use-cases/UpdateProjectUseCase.ts @@ -1,15 +1,26 @@ import { IProjectRepository } from "../repositories/IProjectRepository"; import { Project } from "../domain/Project"; import { UpdateProjectDto } from "../dto/UpdateProjectDto"; +import { NotFoundException, ValidationException } from "../../../shared/exceptions/DomainExceptions"; +import { validate } from "class-validator"; export class UpdateProjectUseCase { constructor(private projectRepository: IProjectRepository) {} async execute(id: string, dto: UpdateProjectDto): Promise { + // Validate DTO + const validationErrors = await validate(dto); + if (validationErrors.length > 0) { + const errorMessages = validationErrors.map(error => + Object.values(error.constraints || {}).join(', ') + ).join('; '); + throw new ValidationException(`Validation failed: ${errorMessages}`); + } + const project = await this.projectRepository.findById(id); if (!project) { - throw new Error("Project not found"); + throw new NotFoundException("Project not found"); } project.update(dto);