import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { TasksService } from "./tasks.service"; import { PrismaService } from "../prisma/prisma.service"; import { ActivityService } from "../activity/activity.service"; import { TaskStatus, TaskPriority } from "@prisma/client"; import { NotFoundException } from "@nestjs/common"; import { Prisma } from "@prisma/client"; describe("TasksService", () => { let service: TasksService; let prisma: PrismaService; let activityService: ActivityService; const mockPrismaService = { task: { create: vi.fn(), findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn(), update: vi.fn(), delete: vi.fn(), }, withWorkspaceContext: vi.fn(), }; const mockActivityService = { logTaskCreated: vi.fn(), logTaskUpdated: vi.fn(), logTaskDeleted: vi.fn(), logTaskCompleted: vi.fn(), logTaskAssigned: vi.fn(), }; const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; const mockTaskId = "550e8400-e29b-41d4-a716-446655440003"; const mockTask = { id: mockTaskId, workspaceId: mockWorkspaceId, title: "Test Task", description: "Test Description", status: TaskStatus.NOT_STARTED, priority: TaskPriority.MEDIUM, dueDate: new Date("2026-02-01T12:00:00Z"), assigneeId: null, creatorId: mockUserId, projectId: null, parentId: null, sortOrder: 0, metadata: {}, createdAt: new Date(), updatedAt: new Date(), completedAt: null, }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ TasksService, { provide: PrismaService, useValue: mockPrismaService, }, { provide: ActivityService, useValue: mockActivityService, }, ], }).compile(); service = module.get(TasksService); prisma = module.get(PrismaService); activityService = module.get(ActivityService); // Clear all mocks before each test vi.clearAllMocks(); mockPrismaService.withWorkspaceContext.mockImplementation(async (_userId, _workspaceId, fn) => { return fn(mockPrismaService as unknown as PrismaService); }); }); it("should be defined", () => { expect(service).toBeDefined(); }); describe("create", () => { it("should create a task and log activity", async () => { const createDto = { title: "New Task", description: "Task description", priority: TaskPriority.HIGH, }; mockPrismaService.task.create.mockResolvedValue(mockTask); mockActivityService.logTaskCreated.mockResolvedValue({}); const result = await service.create(mockWorkspaceId, mockUserId, createDto); expect(result).toEqual(mockTask); expect(prisma.withWorkspaceContext).toHaveBeenCalledWith( mockUserId, mockWorkspaceId, expect.any(Function) ); expect(prisma.task.create).toHaveBeenCalledWith({ data: { title: createDto.title, description: createDto.description ?? null, dueDate: null, workspace: { connect: { id: mockWorkspaceId } }, creator: { connect: { id: mockUserId } }, status: TaskStatus.NOT_STARTED, priority: TaskPriority.HIGH, sortOrder: 0, metadata: {}, }, include: { assignee: { select: { id: true, name: true, email: true }, }, creator: { select: { id: true, name: true, email: true }, }, project: { select: { id: true, name: true, color: true }, }, }, }); expect(activityService.logTaskCreated).toHaveBeenCalledWith( mockWorkspaceId, mockUserId, mockTask.id, { title: mockTask.title } ); }); it("should set completedAt when status is COMPLETED", async () => { const createDto = { title: "Completed Task", status: TaskStatus.COMPLETED, }; mockPrismaService.task.create.mockResolvedValue({ ...mockTask, status: TaskStatus.COMPLETED, completedAt: new Date(), }); await service.create(mockWorkspaceId, mockUserId, createDto); expect(prisma.task.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ completedAt: expect.any(Date), }), }) ); }); }); describe("findAll", () => { it("should return paginated tasks with default pagination", async () => { const tasks = [mockTask]; mockPrismaService.task.findMany.mockResolvedValue(tasks); mockPrismaService.task.count.mockResolvedValue(1); const result = await service.findAll({ workspaceId: mockWorkspaceId }); expect(result).toEqual({ data: tasks, meta: { total: 1, page: 1, limit: 50, totalPages: 1, }, }); expect(prisma.task.findMany).toHaveBeenCalledWith({ where: { workspaceId: mockWorkspaceId }, include: expect.any(Object), orderBy: { createdAt: "desc" }, skip: 0, take: 50, }); }); it("should use workspace context when userId is provided", async () => { mockPrismaService.task.findMany.mockResolvedValue([mockTask]); mockPrismaService.task.count.mockResolvedValue(1); await service.findAll({ workspaceId: mockWorkspaceId }, mockUserId); expect(prisma.withWorkspaceContext).toHaveBeenCalledWith( mockUserId, mockWorkspaceId, expect.any(Function) ); }); it("should fallback to direct Prisma access when userId is missing", async () => { mockPrismaService.task.findMany.mockResolvedValue([mockTask]); mockPrismaService.task.count.mockResolvedValue(1); await service.findAll({ workspaceId: mockWorkspaceId }); expect(prisma.withWorkspaceContext).not.toHaveBeenCalled(); expect(prisma.task.findMany).toHaveBeenCalled(); }); it("should filter by status", async () => { mockPrismaService.task.findMany.mockResolvedValue([mockTask]); mockPrismaService.task.count.mockResolvedValue(1); await service.findAll({ workspaceId: mockWorkspaceId, status: TaskStatus.IN_PROGRESS, }); expect(prisma.task.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { workspaceId: mockWorkspaceId, status: TaskStatus.IN_PROGRESS, }, }) ); }); it("should filter by priority", async () => { mockPrismaService.task.findMany.mockResolvedValue([mockTask]); mockPrismaService.task.count.mockResolvedValue(1); await service.findAll({ workspaceId: mockWorkspaceId, priority: TaskPriority.HIGH, }); expect(prisma.task.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { workspaceId: mockWorkspaceId, priority: TaskPriority.HIGH, }, }) ); }); it("should filter by assigneeId", async () => { mockPrismaService.task.findMany.mockResolvedValue([mockTask]); mockPrismaService.task.count.mockResolvedValue(1); await service.findAll({ workspaceId: mockWorkspaceId, assigneeId: mockUserId, }); expect(prisma.task.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { workspaceId: mockWorkspaceId, assigneeId: mockUserId, }, }) ); }); it("should filter by date range", async () => { const dueDateFrom = new Date("2026-02-01"); const dueDateTo = new Date("2026-02-28"); mockPrismaService.task.findMany.mockResolvedValue([mockTask]); mockPrismaService.task.count.mockResolvedValue(1); await service.findAll({ workspaceId: mockWorkspaceId, dueDateFrom, dueDateTo, }); expect(prisma.task.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { workspaceId: mockWorkspaceId, dueDate: { gte: dueDateFrom, lte: dueDateTo, }, }, }) ); }); it("should handle pagination correctly", async () => { mockPrismaService.task.findMany.mockResolvedValue([mockTask]); mockPrismaService.task.count.mockResolvedValue(100); const result = await service.findAll({ workspaceId: mockWorkspaceId, page: 2, limit: 10, }); expect(result.meta).toEqual({ total: 100, page: 2, limit: 10, totalPages: 10, }); expect(prisma.task.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 10, take: 10, }) ); }); }); describe("findOne", () => { it("should return a task by id", async () => { mockPrismaService.task.findUnique.mockResolvedValue(mockTask); const result = await service.findOne(mockTaskId, mockWorkspaceId); expect(result).toEqual(mockTask); expect(prisma.task.findUnique).toHaveBeenCalledWith({ where: { id: mockTaskId, workspaceId: mockWorkspaceId, }, include: expect.any(Object), }); }); it("should throw NotFoundException if task not found", async () => { mockPrismaService.task.findUnique.mockResolvedValue(null); await expect(service.findOne(mockTaskId, mockWorkspaceId)).rejects.toThrow(NotFoundException); }); it("should enforce workspace isolation when finding task", async () => { const otherWorkspaceId = "550e8400-e29b-41d4-a716-446655440099"; mockPrismaService.task.findUnique.mockResolvedValue(null); await expect(service.findOne(mockTaskId, otherWorkspaceId)).rejects.toThrow( NotFoundException ); expect(prisma.task.findUnique).toHaveBeenCalledWith({ where: { id: mockTaskId, workspaceId: otherWorkspaceId, }, include: expect.any(Object), }); }); }); describe("update", () => { it("should update a task and log activity", async () => { const updateDto = { title: "Updated Task", status: TaskStatus.IN_PROGRESS, }; mockPrismaService.task.findUnique.mockResolvedValue(mockTask); mockPrismaService.task.update.mockResolvedValue({ ...mockTask, ...updateDto, }); mockActivityService.logTaskUpdated.mockResolvedValue({}); const result = await service.update(mockTaskId, mockWorkspaceId, mockUserId, updateDto); expect(result.title).toBe("Updated Task"); expect(activityService.logTaskUpdated).toHaveBeenCalledWith( mockWorkspaceId, mockUserId, mockTaskId, { changes: updateDto } ); }); it("should enforce workspace isolation when updating task", async () => { const otherWorkspaceId = "550e8400-e29b-41d4-a716-446655440099"; mockPrismaService.task.findUnique.mockResolvedValue(null); await expect( service.update(mockTaskId, otherWorkspaceId, mockUserId, { title: "Hacked" }) ).rejects.toThrow(NotFoundException); expect(prisma.task.findUnique).toHaveBeenCalledWith({ where: { id: mockTaskId, workspaceId: otherWorkspaceId }, }); }); it("should set completedAt when status changes to COMPLETED", async () => { const updateDto = { status: TaskStatus.COMPLETED }; mockPrismaService.task.findUnique.mockResolvedValue(mockTask); mockPrismaService.task.update.mockResolvedValue({ ...mockTask, status: TaskStatus.COMPLETED, completedAt: new Date(), }); await service.update(mockTaskId, mockWorkspaceId, mockUserId, updateDto); expect(prisma.task.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ completedAt: expect.any(Date), }), }) ); expect(activityService.logTaskCompleted).toHaveBeenCalled(); }); it("should clear completedAt when status changes from COMPLETED", async () => { const completedTask = { ...mockTask, status: TaskStatus.COMPLETED, completedAt: new Date(), }; const updateDto = { status: TaskStatus.IN_PROGRESS }; mockPrismaService.task.findUnique.mockResolvedValue(completedTask); mockPrismaService.task.update.mockResolvedValue({ ...completedTask, status: TaskStatus.IN_PROGRESS, completedAt: null, }); await service.update(mockTaskId, mockWorkspaceId, mockUserId, updateDto); expect(prisma.task.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ completedAt: null, }), }) ); }); it("should log assignment when assigneeId changes", async () => { const updateDto = { assigneeId: "550e8400-e29b-41d4-a716-446655440099" }; mockPrismaService.task.findUnique.mockResolvedValue(mockTask); mockPrismaService.task.update.mockResolvedValue({ ...mockTask, assigneeId: updateDto.assigneeId, }); await service.update(mockTaskId, mockWorkspaceId, mockUserId, updateDto); expect(activityService.logTaskAssigned).toHaveBeenCalledWith( mockWorkspaceId, mockUserId, mockTaskId, updateDto.assigneeId ); }); it("should throw NotFoundException if task not found", async () => { mockPrismaService.task.findUnique.mockResolvedValue(null); await expect( service.update(mockTaskId, mockWorkspaceId, mockUserId, { title: "Test" }) ).rejects.toThrow(NotFoundException); }); }); describe("remove", () => { it("should delete a task and log activity", async () => { mockPrismaService.task.findUnique.mockResolvedValue(mockTask); mockPrismaService.task.delete.mockResolvedValue(mockTask); mockActivityService.logTaskDeleted.mockResolvedValue({}); await service.remove(mockTaskId, mockWorkspaceId, mockUserId); expect(prisma.task.delete).toHaveBeenCalledWith({ where: { id: mockTaskId, workspaceId: mockWorkspaceId, }, }); expect(activityService.logTaskDeleted).toHaveBeenCalledWith( mockWorkspaceId, mockUserId, mockTaskId, { title: mockTask.title } ); }); it("should throw NotFoundException if task not found", async () => { mockPrismaService.task.findUnique.mockResolvedValue(null); await expect(service.remove(mockTaskId, mockWorkspaceId, mockUserId)).rejects.toThrow( NotFoundException ); }); it("should enforce workspace isolation when deleting task", async () => { const otherWorkspaceId = "550e8400-e29b-41d4-a716-446655440099"; mockPrismaService.task.findUnique.mockResolvedValue(null); await expect(service.remove(mockTaskId, otherWorkspaceId, mockUserId)).rejects.toThrow( NotFoundException ); expect(prisma.task.findUnique).toHaveBeenCalledWith({ where: { id: mockTaskId, workspaceId: otherWorkspaceId }, }); }); }); describe("database constraint violations", () => { it("should handle foreign key constraint violations on create", async () => { const createDto = { title: "Task with invalid assignee", assigneeId: "non-existent-user-id", }; const prismaError = new Prisma.PrismaClientKnownRequestError( "Foreign key constraint failed", { code: "P2003", clientVersion: "5.0.0", } ); mockPrismaService.task.create.mockRejectedValue(prismaError); await expect(service.create(mockWorkspaceId, mockUserId, createDto)).rejects.toThrow( Prisma.PrismaClientKnownRequestError ); }); it("should handle foreign key constraint violations on update", async () => { const updateDto = { assigneeId: "non-existent-user-id", }; mockPrismaService.task.findUnique.mockResolvedValue(mockTask); const prismaError = new Prisma.PrismaClientKnownRequestError( "Foreign key constraint failed", { code: "P2003", clientVersion: "5.0.0", } ); mockPrismaService.task.update.mockRejectedValue(prismaError); await expect( service.update(mockTaskId, mockWorkspaceId, mockUserId, updateDto) ).rejects.toThrow(Prisma.PrismaClientKnownRequestError); }); it("should handle record not found on update (P2025)", async () => { mockPrismaService.task.findUnique.mockResolvedValue(mockTask); const prismaError = new Prisma.PrismaClientKnownRequestError("Record to update not found", { code: "P2025", clientVersion: "5.0.0", }); mockPrismaService.task.update.mockRejectedValue(prismaError); await expect( service.update(mockTaskId, mockWorkspaceId, mockUserId, { title: "Updated" }) ).rejects.toThrow(Prisma.PrismaClientKnownRequestError); }); }); });