All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add @MaxLength(500) to BrainQueryDto.query and BrainQueryDto.search fields - Create BrainSearchDto with validated q (max 500 chars) and limit (1-100) fields - Update BrainController.search to use BrainSearchDto instead of raw query params - Add defensive validation in BrainService.search and BrainService.query methods: - Reject search terms exceeding 500 characters with BadRequestException - Clamp limit to valid range [1, 100] for defense-in-depth - Add comprehensive tests for DTO validation and service-level guards - Update existing controller tests for new search method signature Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import { BrainController } from "./brain.controller";
|
|
import { BrainService, BrainQueryResult, BrainContext } from "./brain.service";
|
|
import { IntentClassificationService } from "./intent-classification.service";
|
|
import type { IntentClassification } from "./interfaces";
|
|
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>;
|
|
};
|
|
let mockIntentService: {
|
|
classify: 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,
|
|
},
|
|
],
|
|
};
|
|
|
|
const mockIntentResult: IntentClassification = {
|
|
intent: "query_tasks",
|
|
confidence: 0.9,
|
|
entities: [],
|
|
method: "rule",
|
|
query: "show my tasks",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockService = {
|
|
query: vi.fn().mockResolvedValue(mockQueryResult),
|
|
getContext: vi.fn().mockResolvedValue(mockContext),
|
|
search: vi.fn().mockResolvedValue(mockQueryResult),
|
|
};
|
|
|
|
mockIntentService = {
|
|
classify: vi.fn().mockResolvedValue(mockIntentResult),
|
|
};
|
|
|
|
controller = new BrainController(
|
|
mockService as unknown as BrainService,
|
|
mockIntentService as unknown as IntentClassificationService
|
|
);
|
|
});
|
|
|
|
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 from DTO", async () => {
|
|
const result = await controller.search({ q: "test query", limit: 10 }, mockWorkspaceId);
|
|
|
|
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test query", 10);
|
|
expect(result).toEqual(mockQueryResult);
|
|
});
|
|
|
|
it("should use default limit when not provided in DTO", async () => {
|
|
await controller.search({ q: "test" }, mockWorkspaceId);
|
|
|
|
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "test", 20);
|
|
});
|
|
|
|
it("should handle empty search DTO", async () => {
|
|
await controller.search({}, mockWorkspaceId);
|
|
|
|
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "", 20);
|
|
});
|
|
|
|
it("should handle undefined q in DTO", async () => {
|
|
await controller.search({ limit: 10 }, mockWorkspaceId);
|
|
|
|
expect(mockService.search).toHaveBeenCalledWith(mockWorkspaceId, "", 10);
|
|
});
|
|
|
|
it("should return search result structure", async () => {
|
|
const result = await controller.search({ q: "test", limit: 10 }, mockWorkspaceId);
|
|
|
|
expect(result).toHaveProperty("tasks");
|
|
expect(result).toHaveProperty("events");
|
|
expect(result).toHaveProperty("projects");
|
|
expect(result).toHaveProperty("meta");
|
|
});
|
|
});
|
|
|
|
describe("classifyIntent", () => {
|
|
it("should call intentService.classify with query", async () => {
|
|
const dto = { query: "show my tasks" };
|
|
|
|
const result = await controller.classifyIntent(dto);
|
|
|
|
expect(mockIntentService.classify).toHaveBeenCalledWith("show my tasks", undefined);
|
|
expect(result).toEqual(mockIntentResult);
|
|
});
|
|
|
|
it("should pass useLlm flag when provided", async () => {
|
|
const dto = { query: "show my tasks", useLlm: true };
|
|
|
|
await controller.classifyIntent(dto);
|
|
|
|
expect(mockIntentService.classify).toHaveBeenCalledWith("show my tasks", true);
|
|
});
|
|
|
|
it("should return intent classification structure", async () => {
|
|
const result = await controller.classifyIntent({ query: "show my tasks" });
|
|
|
|
expect(result).toHaveProperty("intent");
|
|
expect(result).toHaveProperty("confidence");
|
|
expect(result).toHaveProperty("entities");
|
|
expect(result).toHaveProperty("method");
|
|
expect(result).toHaveProperty("query");
|
|
});
|
|
|
|
it("should handle different intent types", async () => {
|
|
const briefingResult: IntentClassification = {
|
|
intent: "briefing",
|
|
confidence: 0.95,
|
|
entities: [],
|
|
method: "rule",
|
|
query: "morning briefing",
|
|
};
|
|
mockIntentService.classify.mockResolvedValue(briefingResult);
|
|
|
|
const result = await controller.classifyIntent({ query: "morning briefing" });
|
|
|
|
expect(result.intent).toBe("briefing");
|
|
expect(result.confidence).toBe(0.95);
|
|
});
|
|
|
|
it("should handle intent with entities", async () => {
|
|
const resultWithEntities: IntentClassification = {
|
|
intent: "create_task",
|
|
confidence: 0.9,
|
|
entities: [
|
|
{
|
|
type: "priority",
|
|
value: "HIGH",
|
|
raw: "high priority",
|
|
start: 12,
|
|
end: 25,
|
|
},
|
|
],
|
|
method: "rule",
|
|
query: "create task high priority",
|
|
};
|
|
mockIntentService.classify.mockResolvedValue(resultWithEntities);
|
|
|
|
const result = await controller.classifyIntent({ query: "create task high priority" });
|
|
|
|
expect(result.entities).toHaveLength(1);
|
|
expect(result.entities[0].type).toBe("priority");
|
|
expect(result.entities[0].value).toBe("HIGH");
|
|
});
|
|
|
|
it("should handle LLM classification", async () => {
|
|
const llmResult: IntentClassification = {
|
|
intent: "search",
|
|
confidence: 0.85,
|
|
entities: [],
|
|
method: "llm",
|
|
query: "find something",
|
|
};
|
|
mockIntentService.classify.mockResolvedValue(llmResult);
|
|
|
|
const result = await controller.classifyIntent({ query: "find something", useLlm: true });
|
|
|
|
expect(result.method).toBe("llm");
|
|
expect(result.intent).toBe("search");
|
|
});
|
|
});
|
|
});
|