Fixes 4 test failures identified in pipeline run 239: 1. RunnerJobsService cancel tests: - Use updateMany mock instead of update (service uses optimistic locking) - Add version field to mock objects - Use mockResolvedValueOnce for sequential findUnique calls 2. ActivityService error handling tests: - Update tests to expect null return (fire-and-forget pattern) - Activity logging now returns null on DB errors per security fix 3. SecretScannerService unreadable file test: - Handle root user case where chmod 0o000 doesn't prevent reads - Test now adapts expectations based on runtime permissions Quality gates: lint ✓ typecheck ✓ tests ✓ - @mosaic/orchestrator: 612 tests passing - @mosaic/web: 650 tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1398 lines
41 KiB
TypeScript
1398 lines
41 KiB
TypeScript
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>(ActivityService);
|
|
prisma = module.get<PrismaService>(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");
|
|
});
|
|
});
|
|
});
|