import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { ActivityService } from "./activity.service"; import { PrismaService } from "../prisma/prisma.service"; import { ActivityAction, EntityType } from "@prisma/client"; import type { CreateActivityLogInput, QueryActivityLogDto } from "./interfaces/activity.interface"; describe("ActivityService", () => { let service: ActivityService; let prisma: PrismaService; const mockPrismaService = { activityLog: { create: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), count: vi.fn(), }, }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ActivityService, { provide: PrismaService, useValue: mockPrismaService, }, ], }).compile(); service = module.get(ActivityService); prisma = module.get(PrismaService); vi.clearAllMocks(); }); describe("logActivity", () => { const createInput: CreateActivityLogInput = { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", details: { title: "New Task" }, ipAddress: "127.0.0.1", userAgent: "Mozilla/5.0", }; it("should create an activity log entry", async () => { const mockActivityLog = { id: "activity-123", ...createInput, createdAt: new Date(), }; mockPrismaService.activityLog.create.mockResolvedValue(mockActivityLog); const result = await service.logActivity(createInput); expect(result).toEqual(mockActivityLog); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: createInput, }); }); it("should create activity log without optional fields", async () => { const minimalInput: CreateActivityLogInput = { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: EntityType.EVENT, entityId: "event-123", }; const mockActivityLog = { id: "activity-456", ...minimalInput, details: {}, createdAt: new Date(), }; mockPrismaService.activityLog.create.mockResolvedValue(mockActivityLog); const result = await service.logActivity(minimalInput); expect(result).toEqual(mockActivityLog); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: minimalInput, }); }); }); describe("findAll", () => { const mockActivities = [ { id: "activity-1", workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", details: {}, createdAt: new Date("2024-01-01"), user: { id: "user-123", name: "Test User", email: "test@example.com", }, }, { id: "activity-2", workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: EntityType.TASK, entityId: "task-123", details: {}, createdAt: new Date("2024-01-02"), user: { id: "user-123", name: "Test User", email: "test@example.com", }, }, ]; it("should return paginated activity logs", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); mockPrismaService.activityLog.count.mockResolvedValue(2); const result = await service.findAll(query); expect(result.data).toEqual(mockActivities); expect(result.meta).toEqual({ total: 2, page: 1, limit: 10, totalPages: 1, }); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith({ where: { workspaceId: "workspace-123", }, include: { user: { select: { id: true, name: true, email: true, }, }, }, orderBy: { createdAt: "desc", }, skip: 0, take: 10, }); }); it("should filter by userId", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", userId: "user-123", page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([mockActivities[0]]); mockPrismaService.activityLog.count.mockResolvedValue(1); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: "workspace-123", userId: "user-123", }), }) ); }); it("should filter by action", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", action: ActivityAction.CREATED, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([mockActivities[0]]); mockPrismaService.activityLog.count.mockResolvedValue(1); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: "workspace-123", action: ActivityAction.CREATED, }), }) ); }); it("should filter by entityType and entityId", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", entityType: EntityType.TASK, entityId: "task-123", page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); mockPrismaService.activityLog.count.mockResolvedValue(2); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: "workspace-123", entityType: EntityType.TASK, entityId: "task-123", }), }) ); }); it("should filter by date range", async () => { const startDate = new Date("2024-01-01"); const endDate = new Date("2024-01-31"); const query: QueryActivityLogDto = { workspaceId: "workspace-123", startDate, endDate, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); mockPrismaService.activityLog.count.mockResolvedValue(2); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: "workspace-123", createdAt: { gte: startDate, lte: endDate, }, }), }) ); }); it("should handle inverted date range (startDate > endDate)", async () => { const startDate = new Date("2024-12-31"); const endDate = new Date("2024-01-01"); const query: QueryActivityLogDto = { workspaceId: "workspace-123", startDate, endDate, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); await service.findAll(query); // Service should pass through inverted dates (let database handle it) expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ createdAt: { gte: startDate, lte: endDate, }, }), }) ); }); it("should handle dates in the future", async () => { const startDate = new Date("2030-01-01"); const endDate = new Date("2030-12-31"); const query: QueryActivityLogDto = { workspaceId: "workspace-123", startDate, endDate, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); const result = await service.findAll(query); expect(result.data).toEqual([]); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ createdAt: { gte: startDate, lte: endDate, }, }), }) ); }); it("should handle very large date ranges", async () => { const startDate = new Date("1970-01-01"); const endDate = new Date("2099-12-31"); const query: QueryActivityLogDto = { workspaceId: "workspace-123", startDate, endDate, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); mockPrismaService.activityLog.count.mockResolvedValue(2); const result = await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ createdAt: { gte: startDate, lte: endDate, }, }), }) ); }); it("should handle only startDate without endDate", async () => { const startDate = new Date("2024-01-01"); const query: QueryActivityLogDto = { workspaceId: "workspace-123", startDate, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); mockPrismaService.activityLog.count.mockResolvedValue(2); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ createdAt: { gte: startDate, }, }), }) ); }); it("should handle only endDate without startDate", async () => { const endDate = new Date("2024-12-31"); const query: QueryActivityLogDto = { workspaceId: "workspace-123", endDate, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); mockPrismaService.activityLog.count.mockResolvedValue(2); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ createdAt: { lte: endDate, }, }), }) ); }); it("should use default pagination values", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", }; mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); mockPrismaService.activityLog.count.mockResolvedValue(2); const result = await service.findAll(query); expect(result.meta.page).toBe(1); expect(result.meta.limit).toBe(50); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 0, take: 50, }) ); }); it("should calculate correct pagination", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", page: 2, limit: 25, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(100); const result = await service.findAll(query); expect(result.meta).toEqual({ total: 100, page: 2, limit: 25, totalPages: 4, }); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 25, take: 25, }) ); }); it("should handle page 0 as-is (nullish coalescing does not coerce 0 to 1)", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", page: 0, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(50); const result = await service.findAll(query); // Page 0 is kept as-is because ?? only defaults null/undefined expect(result.meta.page).toBe(0); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: -10, // (0 - 1) * 10 = -10 take: 10, }) ); }); it("should handle negative page numbers", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", page: -5, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(50); const result = await service.findAll(query); // Negative numbers are truthy, so -5 is used as-is expect(result.meta.page).toBe(-5); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: -60, // (-5 - 1) * 10 = -60 take: 10, }) ); }); it("should handle extremely large limit values", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", page: 1, limit: 10000, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(100); const result = await service.findAll(query); expect(result.meta.limit).toBe(10000); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 0, take: 10000, }) ); }); it("should handle page beyond total pages", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", page: 100, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(25); const result = await service.findAll(query); expect(result.meta).toEqual({ total: 25, page: 100, limit: 10, totalPages: 3, }); expect(result.data).toEqual([]); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 990, // (100 - 1) * 10 take: 10, }) ); }); it("should handle empty result set with pagination", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-empty", page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); const result = await service.findAll(query); expect(result.meta).toEqual({ total: 0, page: 1, limit: 10, totalPages: 0, }); expect(result.data).toEqual([]); }); }); describe("findOne", () => { it("should return a single activity log by id", async () => { const mockActivity = { id: "activity-123", workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", details: {}, createdAt: new Date(), user: { id: "user-123", name: "Test User", email: "test@example.com", }, }; mockPrismaService.activityLog.findUnique.mockResolvedValue(mockActivity); const result = await service.findOne("activity-123", "workspace-123"); expect(result).toEqual(mockActivity); expect(mockPrismaService.activityLog.findUnique).toHaveBeenCalledWith({ where: { id: "activity-123", workspaceId: "workspace-123", }, include: { user: { select: { id: true, name: true, email: true, }, }, }, }); }); it("should return null if activity log not found", async () => { mockPrismaService.activityLog.findUnique.mockResolvedValue(null); const result = await service.findOne("nonexistent", "workspace-123"); expect(result).toBeNull(); }); }); describe("getAuditTrail", () => { const mockAuditTrail = [ { id: "activity-1", workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", details: { title: "New Task" }, createdAt: new Date("2024-01-01"), user: { id: "user-123", name: "Test User", email: "test@example.com", }, }, { id: "activity-2", workspaceId: "workspace-123", userId: "user-456", action: ActivityAction.UPDATED, entityType: EntityType.TASK, entityId: "task-123", details: { title: "Updated Task" }, createdAt: new Date("2024-01-02"), user: { id: "user-456", name: "Another User", email: "another@example.com", }, }, ]; it("should return audit trail for an entity", async () => { mockPrismaService.activityLog.findMany.mockResolvedValue(mockAuditTrail); const result = await service.getAuditTrail("workspace-123", EntityType.TASK, "task-123"); expect(result).toEqual(mockAuditTrail); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith({ where: { workspaceId: "workspace-123", entityType: EntityType.TASK, entityId: "task-123", }, include: { user: { select: { id: true, name: true, email: true, }, }, }, orderBy: { createdAt: "asc", }, }); }); it("should return empty array if no audit trail found", async () => { mockPrismaService.activityLog.findMany.mockResolvedValue([]); const result = await service.getAuditTrail( "workspace-123", EntityType.PROJECT, "project-999" ); expect(result).toEqual([]); }); }); describe("negative validation", () => { it("should handle invalid UUID formats gracefully", async () => { const query: QueryActivityLogDto = { workspaceId: "not-a-uuid", userId: "also-not-uuid", page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); // Service should pass through to Prisma, which may reject it const result = await service.findAll(query); expect(result.data).toEqual([]); }); it("should handle invalid enum values by passing through", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", action: "INVALID_ACTION" as ActivityAction, entityType: "INVALID_TYPE" as EntityType, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); const result = await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ action: "INVALID_ACTION", entityType: "INVALID_TYPE", }), }) ); }); it("should handle extremely long strings", async () => { const longString = "a".repeat(10000); const query: QueryActivityLogDto = { workspaceId: longString, entityId: longString, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: longString, entityId: longString, }), }) ); }); it("should handle SQL injection attempts in string fields", async () => { const maliciousInput = "'; DROP TABLE activityLog; --"; const query: QueryActivityLogDto = { workspaceId: maliciousInput, entityId: maliciousInput, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); // Prisma should sanitize this, service just passes through await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: maliciousInput, }), }) ); }); it("should handle special characters in filters", async () => { const specialChars = "!@#$%^&*(){}[]|\\:;\"'<>?/~`"; const query: QueryActivityLogDto = { workspaceId: specialChars, entityId: specialChars, page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: specialChars, }), }) ); }); it("should handle database errors gracefully when logging activity (fire-and-forget)", async () => { const input: CreateActivityLogInput = { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", }; const dbError = new Error("Database connection failed"); mockPrismaService.activityLog.create.mockRejectedValue(dbError); // Activity logging is fire-and-forget - returns null on error instead of throwing const result = await service.logActivity(input); expect(result).toBeNull(); }); it("should handle extremely large details objects", async () => { const hugeDetails = { data: "x".repeat(100000), nested: { level1: { level2: { level3: { data: "y".repeat(50000), }, }, }, }, }; const input: CreateActivityLogInput = { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", details: hugeDetails, }; mockPrismaService.activityLog.create.mockResolvedValue({ id: "activity-123", ...input, createdAt: new Date(), }); const result = await service.logActivity(input); expect(result.details).toEqual(hugeDetails); }); }); describe("helper methods", () => { const mockActivityLog = { id: "activity-123", workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", details: {}, createdAt: new Date(), }; beforeEach(() => { mockPrismaService.activityLog.create.mockResolvedValue(mockActivityLog); }); it("should log task creation with details", async () => { const result = await service.logTaskCreated("workspace-123", "user-123", "task-123", { title: "New Task", }); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", details: { title: "New Task" }, }, }); expect(result).toEqual(mockActivityLog); }); it("should log task update with changes", async () => { const result = await service.logTaskUpdated("workspace-123", "user-123", "task-123", { changes: { status: "IN_PROGRESS" }, }); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: EntityType.TASK, entityId: "task-123", details: { changes: { status: "IN_PROGRESS" } }, }, }); expect(result).toEqual(mockActivityLog); }); it("should log task deletion without details", async () => { const result = await service.logTaskDeleted("workspace-123", "user-123", "task-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.DELETED, entityType: EntityType.TASK, entityId: "task-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log task completion", async () => { const result = await service.logTaskCompleted("workspace-123", "user-123", "task-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.COMPLETED, entityType: EntityType.TASK, entityId: "task-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log task assignment with assignee details", async () => { const result = await service.logTaskAssigned( "workspace-123", "user-123", "task-123", "user-456" ); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.ASSIGNED, entityType: EntityType.TASK, entityId: "task-123", details: { assigneeId: "user-456" }, }, }); expect(result).toEqual(mockActivityLog); }); it("should log event creation", async () => { const result = await service.logEventCreated("workspace-123", "user-123", "event-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.EVENT, entityId: "event-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log event update", async () => { const result = await service.logEventUpdated("workspace-123", "user-123", "event-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: EntityType.EVENT, entityId: "event-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log event deletion", async () => { const result = await service.logEventDeleted("workspace-123", "user-123", "event-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.DELETED, entityType: EntityType.EVENT, entityId: "event-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log project creation", async () => { const result = await service.logProjectCreated("workspace-123", "user-123", "project-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.PROJECT, entityId: "project-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log project update", async () => { const result = await service.logProjectUpdated("workspace-123", "user-123", "project-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: EntityType.PROJECT, entityId: "project-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log project deletion", async () => { const result = await service.logProjectDeleted("workspace-123", "user-123", "project-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.DELETED, entityType: EntityType.PROJECT, entityId: "project-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log workspace creation", async () => { const result = await service.logWorkspaceCreated("workspace-123", "user-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.WORKSPACE, entityId: "workspace-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log workspace update", async () => { const result = await service.logWorkspaceUpdated("workspace-123", "user-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: EntityType.WORKSPACE, entityId: "workspace-123", }, }); expect(result).toEqual(mockActivityLog); }); it("should log workspace member addition with role", async () => { const result = await service.logWorkspaceMemberAdded( "workspace-123", "user-123", "user-456", "MEMBER" ); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.WORKSPACE, entityId: "workspace-123", details: { memberId: "user-456", role: "MEMBER" }, }, }); expect(result).toEqual(mockActivityLog); }); it("should log workspace member removal with member ID", async () => { const result = await service.logWorkspaceMemberRemoved( "workspace-123", "user-123", "user-456" ); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.DELETED, entityType: EntityType.WORKSPACE, entityId: "workspace-123", details: { memberId: "user-456" }, }, }); expect(result).toEqual(mockActivityLog); }); it("should log user update", async () => { const result = await service.logUserUpdated("workspace-123", "user-123"); expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ data: { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: EntityType.USER, entityId: "user-123", }, }); expect(result).toEqual(mockActivityLog); }); }); describe("database error handling", () => { it("should handle database connection failures in logActivity (fire-and-forget)", async () => { const createInput: CreateActivityLogInput = { workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", }; const dbError = new Error("Connection refused"); mockPrismaService.activityLog.create.mockRejectedValue(dbError); // Activity logging is fire-and-forget - returns null on error instead of throwing const result = await service.logActivity(createInput); expect(result).toBeNull(); }); it("should handle Prisma timeout errors in findAll", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", }; const timeoutError = new Error("Query timeout"); mockPrismaService.activityLog.findMany.mockRejectedValue(timeoutError); await expect(service.findAll(query)).rejects.toThrow("Query timeout"); }); it("should handle Prisma errors in findOne", async () => { const dbError = new Error("Record not found"); mockPrismaService.activityLog.findUnique.mockRejectedValue(dbError); await expect(service.findOne("activity-123", "workspace-123")).rejects.toThrow( "Record not found" ); }); it("should handle malformed query parameters in findAll", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", startDate: new Date("invalid-date"), }; mockPrismaService.activityLog.findMany.mockRejectedValue(new Error("Invalid date format")); await expect(service.findAll(query)).rejects.toThrow("Invalid date format"); }); it("should handle database errors in getAuditTrail", async () => { const dbError = new Error("Database connection lost"); mockPrismaService.activityLog.findMany.mockRejectedValue(dbError); await expect( service.getAuditTrail("workspace-123", EntityType.TASK, "task-123") ).rejects.toThrow("Database connection lost"); }); it("should handle count query failures in findAll", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-123", page: 1, limit: 10, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockRejectedValue(new Error("Count query failed")); await expect(service.findAll(query)).rejects.toThrow("Count query failed"); }); }); describe("multi-tenant isolation", () => { it("should prevent cross-workspace data leakage in findAll", async () => { const workspace1Query: QueryActivityLogDto = { workspaceId: "workspace-111", page: 1, limit: 10, }; const workspace1Activities = [ { id: "activity-1", workspaceId: "workspace-111", userId: "user-123", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-123", details: {}, createdAt: new Date(), user: { id: "user-123", name: "User 1", email: "user1@example.com", }, }, ]; mockPrismaService.activityLog.findMany.mockResolvedValue(workspace1Activities); mockPrismaService.activityLog.count.mockResolvedValue(1); const result = await service.findAll(workspace1Query); expect(result.data).toHaveLength(1); expect(result.data[0].workspaceId).toBe("workspace-111"); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: "workspace-111", }), }) ); }); it("should enforce workspace filtering in findOne", async () => { const activityId = "activity-shared-123"; const workspaceId = "workspace-222"; mockPrismaService.activityLog.findUnique.mockResolvedValue(null); const result = await service.findOne(activityId, workspaceId); expect(result).toBeNull(); expect(mockPrismaService.activityLog.findUnique).toHaveBeenCalledWith({ where: { id: activityId, workspaceId: workspaceId, }, include: { user: { select: { id: true, name: true, email: true, }, }, }, }); }); it("should isolate audit trails by workspace", async () => { const workspaceId = "workspace-333"; const entityType = EntityType.TASK; const entityId = "task-shared-456"; const workspace3Activities = [ { id: "activity-1", workspaceId: "workspace-333", userId: "user-789", action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-shared-456", details: {}, createdAt: new Date(), user: { id: "user-789", name: "User 3", email: "user3@example.com", }, }, ]; mockPrismaService.activityLog.findMany.mockResolvedValue(workspace3Activities); const result = await service.getAuditTrail(workspaceId, entityType, entityId); expect(result).toHaveLength(1); expect(result[0].workspaceId).toBe("workspace-333"); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: "workspace-333", entityType: EntityType.TASK, entityId: "task-shared-456", }), }) ); }); it("should verify workspace filtering with multiple filters in findAll", async () => { const query: QueryActivityLogDto = { workspaceId: "workspace-444", userId: "user-999", action: ActivityAction.UPDATED, entityType: EntityType.PROJECT, }; mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); await service.findAll(query); expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ workspaceId: "workspace-444", userId: "user-999", action: ActivityAction.UPDATED, entityType: EntityType.PROJECT, }), }) ); }); it("should handle user with multiple workspaces correctly", async () => { const userId = "multi-workspace-user"; const workspace1Query: QueryActivityLogDto = { workspaceId: "workspace-aaa", userId, }; const workspace2Query: QueryActivityLogDto = { workspaceId: "workspace-bbb", userId, }; const workspace1Activities = [ { id: "activity-w1", workspaceId: "workspace-aaa", userId, action: ActivityAction.CREATED, entityType: EntityType.TASK, entityId: "task-w1", details: {}, createdAt: new Date(), user: { id: userId, name: "Multi User", email: "multi@example.com" }, }, ]; const workspace2Activities = [ { id: "activity-w2", workspaceId: "workspace-bbb", userId, action: ActivityAction.CREATED, entityType: EntityType.EVENT, entityId: "event-w2", details: {}, createdAt: new Date(), user: { id: userId, name: "Multi User", email: "multi@example.com" }, }, ]; mockPrismaService.activityLog.findMany.mockResolvedValueOnce(workspace1Activities); mockPrismaService.activityLog.count.mockResolvedValueOnce(1); const result1 = await service.findAll(workspace1Query); expect(result1.data).toHaveLength(1); expect(result1.data[0].workspaceId).toBe("workspace-aaa"); mockPrismaService.activityLog.findMany.mockResolvedValueOnce(workspace2Activities); mockPrismaService.activityLog.count.mockResolvedValueOnce(1); const result2 = await service.findAll(workspace2Query); expect(result2.data).toHaveLength(1); expect(result2.data[0].workspaceId).toBe("workspace-bbb"); }); }); });