Implement per-workspace LLM provider and personality configuration with proper hierarchy (workspace > user > system fallback). Schema: - Add WorkspaceLlmSettings model with provider/personality FKs - One-to-one relation with Workspace - JSON settings field for extensibility Service: - getSettings: Retrieves/creates workspace settings - updateSettings: Updates with null value support - getEffectiveLlmProvider: Hierarchy-based provider selection - getEffectivePersonality: Hierarchy-based personality selection Endpoints: - GET /workspaces/:id/settings/llm - Get settings - PATCH /workspaces/:id/settings/llm - Update settings - GET /workspaces/:id/settings/llm/effective-provider - GET /workspaces/:id/settings/llm/effective-personality Configuration hierarchy: 1. Workspace-configured provider/personality 2. User-specific provider (for providers) 3. System default fallback Tests: 34 passing with 100% coverage Fixes #133 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
269 lines
8.9 KiB
TypeScript
269 lines
8.9 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { ExecutionContext } from "@nestjs/common";
|
|
import { WorkspaceSettingsController } from "./workspace-settings.controller";
|
|
import { WorkspaceSettingsService } from "./workspace-settings.service";
|
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
import type { WorkspaceLlmSettings, LlmProviderInstance, Personality } from "@prisma/client";
|
|
import type { AuthenticatedRequest } from "../common/types/user.types";
|
|
|
|
describe("WorkspaceSettingsController", () => {
|
|
let controller: WorkspaceSettingsController;
|
|
let service: WorkspaceSettingsService;
|
|
|
|
const mockWorkspaceId = "workspace-123";
|
|
const mockUserId = "user-123";
|
|
|
|
const mockSettings: WorkspaceLlmSettings = {
|
|
id: "settings-123",
|
|
workspaceId: mockWorkspaceId,
|
|
defaultLlmProviderId: "provider-123",
|
|
defaultPersonalityId: "personality-123",
|
|
settings: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const mockProvider: LlmProviderInstance = {
|
|
id: "provider-123",
|
|
providerType: "ollama",
|
|
displayName: "Test Provider",
|
|
userId: null,
|
|
config: { endpoint: "http://localhost:11434" },
|
|
isDefault: true,
|
|
isEnabled: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const mockPersonality: Personality = {
|
|
id: "personality-123",
|
|
workspaceId: mockWorkspaceId,
|
|
name: "default",
|
|
displayName: "Default",
|
|
description: "Default personality",
|
|
systemPrompt: "You are a helpful assistant",
|
|
temperature: null,
|
|
maxTokens: null,
|
|
llmProviderInstanceId: null,
|
|
isDefault: true,
|
|
isEnabled: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const mockAuthGuard = {
|
|
canActivate: vi.fn((context: ExecutionContext) => {
|
|
const request = context.switchToHttp().getRequest();
|
|
request.user = {
|
|
id: mockUserId,
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
emailVerified: true,
|
|
image: null,
|
|
authProviderId: null,
|
|
preferences: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
return true;
|
|
}),
|
|
};
|
|
|
|
const mockAuthRequest: AuthenticatedRequest = {
|
|
user: {
|
|
id: mockUserId,
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
emailVerified: true,
|
|
image: null,
|
|
authProviderId: null,
|
|
preferences: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
} as AuthenticatedRequest;
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
controllers: [WorkspaceSettingsController],
|
|
providers: [
|
|
{
|
|
provide: WorkspaceSettingsService,
|
|
useValue: {
|
|
getSettings: vi.fn(),
|
|
updateSettings: vi.fn(),
|
|
getEffectiveLlmProvider: vi.fn(),
|
|
getEffectivePersonality: vi.fn(),
|
|
},
|
|
},
|
|
],
|
|
})
|
|
.overrideGuard(AuthGuard)
|
|
.useValue(mockAuthGuard)
|
|
.compile();
|
|
|
|
controller = module.get<WorkspaceSettingsController>(WorkspaceSettingsController);
|
|
service = module.get<WorkspaceSettingsService>(WorkspaceSettingsService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("getSettings", () => {
|
|
it("should return workspace settings", async () => {
|
|
vi.spyOn(service, "getSettings").mockResolvedValue(mockSettings);
|
|
|
|
const result = await controller.getSettings(mockWorkspaceId);
|
|
|
|
expect(result).toEqual(mockSettings);
|
|
expect(service.getSettings).toHaveBeenCalledWith(mockWorkspaceId);
|
|
});
|
|
|
|
it("should handle service errors", async () => {
|
|
vi.spyOn(service, "getSettings").mockRejectedValue(new Error("Service error"));
|
|
|
|
await expect(controller.getSettings(mockWorkspaceId)).rejects.toThrow("Service error");
|
|
});
|
|
|
|
it("should work with valid workspace ID", async () => {
|
|
vi.spyOn(service, "getSettings").mockResolvedValue(mockSettings);
|
|
|
|
const result = await controller.getSettings(mockWorkspaceId);
|
|
|
|
expect(result.workspaceId).toBe(mockWorkspaceId);
|
|
});
|
|
});
|
|
|
|
describe("updateSettings", () => {
|
|
it("should update workspace settings", async () => {
|
|
const updateDto = {
|
|
defaultLlmProviderId: "new-provider-123",
|
|
defaultPersonalityId: "new-personality-123",
|
|
};
|
|
|
|
const updatedSettings = { ...mockSettings, ...updateDto };
|
|
vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings);
|
|
|
|
const result = await controller.updateSettings(mockWorkspaceId, updateDto);
|
|
|
|
expect(result).toEqual(updatedSettings);
|
|
expect(service.updateSettings).toHaveBeenCalledWith(mockWorkspaceId, updateDto);
|
|
});
|
|
|
|
it("should allow partial updates", async () => {
|
|
const updateDto = {
|
|
defaultLlmProviderId: "new-provider-123",
|
|
};
|
|
|
|
const updatedSettings = {
|
|
...mockSettings,
|
|
defaultLlmProviderId: updateDto.defaultLlmProviderId,
|
|
};
|
|
vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings);
|
|
|
|
const result = await controller.updateSettings(mockWorkspaceId, updateDto);
|
|
|
|
expect(result.defaultLlmProviderId).toBe(updateDto.defaultLlmProviderId);
|
|
});
|
|
|
|
it("should handle null values", async () => {
|
|
const updateDto = {
|
|
defaultLlmProviderId: null,
|
|
};
|
|
|
|
const updatedSettings = { ...mockSettings, defaultLlmProviderId: null };
|
|
vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings);
|
|
|
|
const result = await controller.updateSettings(mockWorkspaceId, updateDto);
|
|
|
|
expect(result.defaultLlmProviderId).toBeNull();
|
|
});
|
|
|
|
it("should handle service errors", async () => {
|
|
const updateDto = { defaultLlmProviderId: "invalid-id" };
|
|
vi.spyOn(service, "updateSettings").mockRejectedValue(new Error("Provider not found"));
|
|
|
|
await expect(controller.updateSettings(mockWorkspaceId, updateDto)).rejects.toThrow(
|
|
"Provider not found"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getEffectiveProvider", () => {
|
|
it("should return effective provider with authenticated user", async () => {
|
|
vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider);
|
|
|
|
const result = await controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest);
|
|
|
|
expect(result).toEqual(mockProvider);
|
|
expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, mockUserId);
|
|
});
|
|
|
|
it("should return effective provider without user ID when not authenticated", async () => {
|
|
const unauthRequest = { user: undefined } as AuthenticatedRequest;
|
|
vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider);
|
|
|
|
const result = await controller.getEffectiveProvider(mockWorkspaceId, unauthRequest);
|
|
|
|
expect(result).toEqual(mockProvider);
|
|
expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, undefined);
|
|
});
|
|
|
|
it("should handle no provider available error", async () => {
|
|
vi.spyOn(service, "getEffectiveLlmProvider").mockRejectedValue(
|
|
new Error("No LLM provider available")
|
|
);
|
|
|
|
await expect(
|
|
controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest)
|
|
).rejects.toThrow("No LLM provider available");
|
|
});
|
|
|
|
it("should pass user ID to service when available", async () => {
|
|
vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider);
|
|
|
|
await controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest);
|
|
|
|
expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, mockUserId);
|
|
});
|
|
});
|
|
|
|
describe("getEffectivePersonality", () => {
|
|
it("should return effective personality", async () => {
|
|
vi.spyOn(service, "getEffectivePersonality").mockResolvedValue(mockPersonality);
|
|
|
|
const result = await controller.getEffectivePersonality(mockWorkspaceId);
|
|
|
|
expect(result).toEqual(mockPersonality);
|
|
expect(service.getEffectivePersonality).toHaveBeenCalledWith(mockWorkspaceId);
|
|
});
|
|
|
|
it("should handle no personality available error", async () => {
|
|
vi.spyOn(service, "getEffectivePersonality").mockRejectedValue(
|
|
new Error("No personality available")
|
|
);
|
|
|
|
await expect(controller.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow(
|
|
"No personality available"
|
|
);
|
|
});
|
|
|
|
it("should work with valid workspace ID", async () => {
|
|
vi.spyOn(service, "getEffectivePersonality").mockResolvedValue(mockPersonality);
|
|
|
|
const result = await controller.getEffectivePersonality(mockWorkspaceId);
|
|
|
|
expect(result.workspaceId).toBe(mockWorkspaceId);
|
|
});
|
|
});
|
|
|
|
describe("endpoint paths", () => {
|
|
it("should be accessible at /workspaces/:workspaceId/settings/llm", () => {
|
|
const metadata = Reflect.getMetadata("path", WorkspaceSettingsController);
|
|
expect(metadata).toBe("workspaces/:workspaceId/settings/llm");
|
|
});
|
|
});
|
|
});
|