feat(#22): implement brain query API
- Create brain module with service, controller, and DTOs - POST /api/brain/query - Structured queries for tasks, events, projects - GET /api/brain/context - Get current workspace context for agents - GET /api/brain/search - Search across all entities - Support filters: status, priority, date ranges, assignee, etc. - 41 tests covering service (27) and controller (14) - Integrated with AuthGuard, WorkspaceGuard, PermissionGuard
This commit is contained in:
507
apps/api/src/brain/brain.service.test.ts
Normal file
507
apps/api/src/brain/brain.service.test.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { BrainService } from "./brain.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { TaskStatus, TaskPriority, ProjectStatus, EntityType } from "@prisma/client";
|
||||
|
||||
describe("BrainService", () => {
|
||||
let service: BrainService;
|
||||
let mockPrisma: {
|
||||
task: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
event: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
project: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
workspace: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const mockWorkspaceId = "123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
const mockTasks = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Test Task 1",
|
||||
description: "Description 1",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2025-02-01"),
|
||||
assignee: { id: "user-1", name: "John Doe", email: "john@example.com" },
|
||||
project: { id: "project-1", name: "Project 1", color: "#ff0000" },
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Test Task 2",
|
||||
description: null,
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: null,
|
||||
assignee: null,
|
||||
project: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockEvents = [
|
||||
{
|
||||
id: "event-1",
|
||||
title: "Test Event 1",
|
||||
description: "Event description",
|
||||
startTime: new Date("2025-02-01T10:00:00Z"),
|
||||
endTime: new Date("2025-02-01T11:00:00Z"),
|
||||
allDay: false,
|
||||
location: "Conference Room A",
|
||||
project: { id: "project-1", name: "Project 1", color: "#ff0000" },
|
||||
},
|
||||
];
|
||||
|
||||
const mockProjects = [
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Project 1",
|
||||
description: "Project description",
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startDate: new Date("2025-01-01"),
|
||||
endDate: new Date("2025-06-30"),
|
||||
color: "#ff0000",
|
||||
_count: { tasks: 5, events: 3 },
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
task: {
|
||||
findMany: vi.fn().mockResolvedValue(mockTasks),
|
||||
count: vi.fn().mockResolvedValue(10),
|
||||
},
|
||||
event: {
|
||||
findMany: vi.fn().mockResolvedValue(mockEvents),
|
||||
count: vi.fn().mockResolvedValue(5),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue(mockProjects),
|
||||
count: vi.fn().mockResolvedValue(3),
|
||||
},
|
||||
workspace: {
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||
id: mockWorkspaceId,
|
||||
name: "Test Workspace",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
service = new BrainService(mockPrisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
describe("query", () => {
|
||||
it("should query all entity types by default", async () => {
|
||||
const result = await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.meta.totalTasks).toBe(2);
|
||||
expect(result.meta.totalEvents).toBe(1);
|
||||
expect(result.meta.totalProjects).toBe(1);
|
||||
});
|
||||
|
||||
it("should query only specified entity types", async () => {
|
||||
const result = await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
entities: [EntityType.TASK],
|
||||
});
|
||||
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
expect(result.events).toHaveLength(0);
|
||||
expect(result.projects).toHaveLength(0);
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalled();
|
||||
expect(mockPrisma.event.findMany).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.project.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should apply task filters", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
tasks: {
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
workspaceId: mockWorkspaceId,
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply task statuses filter (array)", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
tasks: {
|
||||
statuses: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { in: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS] },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply overdue filter", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
tasks: {
|
||||
overdue: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
dueDate: expect.objectContaining({ lt: expect.any(Date) }),
|
||||
status: { in: [TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS] },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply unassigned filter", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
tasks: {
|
||||
unassigned: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
assigneeId: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply due date range filter", async () => {
|
||||
const dueDateFrom = new Date("2025-01-01");
|
||||
const dueDateTo = new Date("2025-01-31");
|
||||
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
tasks: {
|
||||
dueDateFrom,
|
||||
dueDateTo,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
dueDate: { gte: dueDateFrom, lte: dueDateTo },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply event filters", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
events: {
|
||||
allDay: true,
|
||||
upcoming: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.event.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
allDay: true,
|
||||
startTime: { gte: expect.any(Date) },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply event date range filter", async () => {
|
||||
const startFrom = new Date("2025-02-01");
|
||||
const startTo = new Date("2025-02-28");
|
||||
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
events: {
|
||||
startFrom,
|
||||
startTo,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.event.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
startTime: { gte: startFrom, lte: startTo },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply project filters", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
projects: {
|
||||
status: ProjectStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.project.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: ProjectStatus.ACTIVE,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply project statuses filter (array)", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
projects: {
|
||||
statuses: [ProjectStatus.PLANNING, ProjectStatus.ACTIVE],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPrisma.project.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { in: [ProjectStatus.PLANNING, ProjectStatus.ACTIVE] },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply search term across tasks", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
search: "test",
|
||||
entities: [EntityType.TASK],
|
||||
});
|
||||
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: [
|
||||
{ title: { contains: "test", mode: "insensitive" } },
|
||||
{ description: { contains: "test", mode: "insensitive" } },
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply search term across events", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
search: "conference",
|
||||
entities: [EntityType.EVENT],
|
||||
});
|
||||
|
||||
expect(mockPrisma.event.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: [
|
||||
{ title: { contains: "conference", mode: "insensitive" } },
|
||||
{ description: { contains: "conference", mode: "insensitive" } },
|
||||
{ location: { contains: "conference", mode: "insensitive" } },
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply search term across projects", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
search: "project",
|
||||
entities: [EntityType.PROJECT],
|
||||
});
|
||||
|
||||
expect(mockPrisma.project.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: [
|
||||
{ name: { contains: "project", mode: "insensitive" } },
|
||||
{ description: { contains: "project", mode: "insensitive" } },
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should respect limit parameter", async () => {
|
||||
await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should include query and filters in meta", async () => {
|
||||
const result = await service.query({
|
||||
workspaceId: mockWorkspaceId,
|
||||
query: "What tasks are due?",
|
||||
tasks: { status: TaskStatus.IN_PROGRESS },
|
||||
});
|
||||
|
||||
expect(result.meta.query).toBe("What tasks are due?");
|
||||
expect(result.meta.filters.tasks).toEqual({ status: TaskStatus.IN_PROGRESS });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContext", () => {
|
||||
it("should return context with summary", async () => {
|
||||
const result = await service.getContext({
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result.timestamp).toBeInstanceOf(Date);
|
||||
expect(result.workspace.id).toBe(mockWorkspaceId);
|
||||
expect(result.workspace.name).toBe("Test Workspace");
|
||||
expect(result.summary).toEqual({
|
||||
activeTasks: 10,
|
||||
overdueTasks: 10,
|
||||
upcomingEvents: 5,
|
||||
activeProjects: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("should include tasks when requested", async () => {
|
||||
const result = await service.getContext({
|
||||
workspaceId: mockWorkspaceId,
|
||||
includeTasks: true,
|
||||
});
|
||||
|
||||
expect(result.tasks).toBeDefined();
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
expect(result.tasks![0].isOverdue).toBeDefined();
|
||||
});
|
||||
|
||||
it("should include events when requested", async () => {
|
||||
const result = await service.getContext({
|
||||
workspaceId: mockWorkspaceId,
|
||||
includeEvents: true,
|
||||
});
|
||||
|
||||
expect(result.events).toBeDefined();
|
||||
expect(result.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should include projects when requested", async () => {
|
||||
const result = await service.getContext({
|
||||
workspaceId: mockWorkspaceId,
|
||||
includeProjects: true,
|
||||
});
|
||||
|
||||
expect(result.projects).toBeDefined();
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects![0].taskCount).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use custom eventDays", async () => {
|
||||
await service.getContext({
|
||||
workspaceId: mockWorkspaceId,
|
||||
eventDays: 14,
|
||||
});
|
||||
|
||||
expect(mockPrisma.event.count).toHaveBeenCalled();
|
||||
expect(mockPrisma.event.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not include tasks when explicitly disabled", async () => {
|
||||
const result = await service.getContext({
|
||||
workspaceId: mockWorkspaceId,
|
||||
includeTasks: false,
|
||||
includeEvents: true,
|
||||
includeProjects: true,
|
||||
});
|
||||
|
||||
expect(result.tasks).toBeUndefined();
|
||||
expect(result.events).toBeDefined();
|
||||
expect(result.projects).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not include events when explicitly disabled", async () => {
|
||||
const result = await service.getContext({
|
||||
workspaceId: mockWorkspaceId,
|
||||
includeTasks: true,
|
||||
includeEvents: false,
|
||||
includeProjects: true,
|
||||
});
|
||||
|
||||
expect(result.tasks).toBeDefined();
|
||||
expect(result.events).toBeUndefined();
|
||||
expect(result.projects).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not include projects when explicitly disabled", async () => {
|
||||
const result = await service.getContext({
|
||||
workspaceId: mockWorkspaceId,
|
||||
includeTasks: true,
|
||||
includeEvents: true,
|
||||
includeProjects: false,
|
||||
});
|
||||
|
||||
expect(result.tasks).toBeDefined();
|
||||
expect(result.events).toBeDefined();
|
||||
expect(result.projects).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("should search across all entities", async () => {
|
||||
const result = await service.search(mockWorkspaceId, "test");
|
||||
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.meta.query).toBe("test");
|
||||
});
|
||||
|
||||
it("should respect limit parameter", async () => {
|
||||
await service.search(mockWorkspaceId, "test", 5);
|
||||
|
||||
expect(mockPrisma.task.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty search term", async () => {
|
||||
const result = await service.search(mockWorkspaceId, "");
|
||||
|
||||
expect(result.tasks).toBeDefined();
|
||||
expect(result.events).toBeDefined();
|
||||
expect(result.projects).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user