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:
Jason Woltje
2026-01-29 19:40:30 -06:00
parent 1cb54b56b0
commit 1bd21b33d7
8 changed files with 1392 additions and 0 deletions

View File

@@ -0,0 +1,279 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { BrainController } from "./brain.controller";
import { BrainService, BrainQueryResult, BrainContext } from "./brain.service";
import { TaskStatus, TaskPriority, ProjectStatus, EntityType } from "@prisma/client";
describe("BrainController", () => {
let controller: BrainController;
let mockService: {
query: ReturnType<typeof vi.fn>;
getContext: ReturnType<typeof vi.fn>;
search: ReturnType<typeof vi.fn>;
};
const mockWorkspaceId = "123e4567-e89b-12d3-a456-426614174000";
const mockQueryResult: BrainQueryResult = {
tasks: [
{
id: "task-1",
title: "Test Task",
description: null,
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: null,
assignee: null,
project: null,
},
],
events: [
{
id: "event-1",
title: "Test Event",
description: null,
startTime: new Date("2025-02-01T10:00:00Z"),
endTime: new Date("2025-02-01T11:00:00Z"),
allDay: false,
location: null,
project: null,
},
],
projects: [
{
id: "project-1",
name: "Test Project",
description: null,
status: ProjectStatus.ACTIVE,
startDate: null,
endDate: null,
color: null,
_count: { tasks: 5, events: 2 },
},
],
meta: {
totalTasks: 1,
totalEvents: 1,
totalProjects: 1,
filters: {},
},
};
const mockContext: BrainContext = {
timestamp: new Date(),
workspace: { id: mockWorkspaceId, name: "Test Workspace" },
summary: {
activeTasks: 10,
overdueTasks: 2,
upcomingEvents: 5,
activeProjects: 3,
},
tasks: [
{
id: "task-1",
title: "Test Task",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: null,
isOverdue: false,
},
],
events: [
{
id: "event-1",
title: "Test Event",
startTime: new Date("2025-02-01T10:00:00Z"),
endTime: new Date("2025-02-01T11:00:00Z"),
allDay: false,
location: null,
},
],
projects: [
{
id: "project-1",
name: "Test Project",
status: ProjectStatus.ACTIVE,
taskCount: 5,
},
],
};
beforeEach(() => {
mockService = {
query: vi.fn().mockResolvedValue(mockQueryResult),
getContext: vi.fn().mockResolvedValue(mockContext),
search: vi.fn().mockResolvedValue(mockQueryResult),
};
controller = new BrainController(mockService as unknown as BrainService);
});
describe("query", () => {
it("should call service.query with merged workspaceId", async () => {
const queryDto = {
workspaceId: "different-id",
query: "What tasks are due?",
};
const result = await controller.query(queryDto, mockWorkspaceId);
expect(mockService.query).toHaveBeenCalledWith({
...queryDto,
workspaceId: mockWorkspaceId,
});
expect(result).toEqual(mockQueryResult);
});
it("should handle query with filters", async () => {
const queryDto = {
workspaceId: mockWorkspaceId,
entities: [EntityType.TASK, EntityType.EVENT],
tasks: { status: TaskStatus.IN_PROGRESS },
events: { upcoming: true },
};
await controller.query(queryDto, mockWorkspaceId);
expect(mockService.query).toHaveBeenCalledWith({
...queryDto,
workspaceId: mockWorkspaceId,
});
});
it("should handle query with search term", async () => {
const queryDto = {
workspaceId: mockWorkspaceId,
search: "important",
limit: 10,
};
await controller.query(queryDto, mockWorkspaceId);
expect(mockService.query).toHaveBeenCalledWith({
...queryDto,
workspaceId: mockWorkspaceId,
});
});
it("should return query result structure", async () => {
const result = await controller.query(
{ workspaceId: mockWorkspaceId },
mockWorkspaceId
);
expect(result).toHaveProperty("tasks");
expect(result).toHaveProperty("events");
expect(result).toHaveProperty("projects");
expect(result).toHaveProperty("meta");
expect(result.tasks).toHaveLength(1);
expect(result.events).toHaveLength(1);
expect(result.projects).toHaveLength(1);
});
});
describe("getContext", () => {
it("should call service.getContext with merged workspaceId", async () => {
const contextDto = {
workspaceId: "different-id",
includeTasks: true,
};
const result = await controller.getContext(contextDto, mockWorkspaceId);
expect(mockService.getContext).toHaveBeenCalledWith({
...contextDto,
workspaceId: mockWorkspaceId,
});
expect(result).toEqual(mockContext);
});
it("should handle context with all options", async () => {
const contextDto = {
workspaceId: mockWorkspaceId,
includeTasks: true,
includeEvents: true,
includeProjects: true,
eventDays: 14,
};
await controller.getContext(contextDto, mockWorkspaceId);
expect(mockService.getContext).toHaveBeenCalledWith({
...contextDto,
workspaceId: mockWorkspaceId,
});
});
it("should return context structure", async () => {
const result = await controller.getContext(
{ workspaceId: mockWorkspaceId },
mockWorkspaceId
);
expect(result).toHaveProperty("timestamp");
expect(result).toHaveProperty("workspace");
expect(result).toHaveProperty("summary");
expect(result.summary).toHaveProperty("activeTasks");
expect(result.summary).toHaveProperty("overdueTasks");
expect(result.summary).toHaveProperty("upcomingEvents");
expect(result.summary).toHaveProperty("activeProjects");
});
it("should include detailed lists when requested", async () => {
const result = await controller.getContext(
{
workspaceId: mockWorkspaceId,
includeTasks: true,
includeEvents: true,
includeProjects: true,
},
mockWorkspaceId
);
expect(result.tasks).toBeDefined();
expect(result.events).toBeDefined();
expect(result.projects).toBeDefined();
});
});
describe("search", () => {
it("should call service.search with parameters", async () => {
const result = await controller.search("test query", "10", mockWorkspaceId);
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test query", 10);
expect(result).toEqual(mockQueryResult);
});
it("should use default limit when not provided", async () => {
await controller.search("test", undefined as unknown as string, mockWorkspaceId);
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test", 20);
});
it("should cap limit at 100", async () => {
await controller.search("test", "500", mockWorkspaceId);
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test", 100);
});
it("should handle empty search term", async () => {
await controller.search(undefined as unknown as string, "10", mockWorkspaceId);
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "", 10);
});
it("should handle invalid limit", async () => {
await controller.search("test", "invalid", mockWorkspaceId);
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test", 20);
});
it("should return search result structure", async () => {
const result = await controller.search("test", "10", mockWorkspaceId);
expect(result).toHaveProperty("tasks");
expect(result).toHaveProperty("events");
expect(result).toHaveProperty("projects");
expect(result).toHaveProperty("meta");
});
});
});