Files
stack/apps/api/src/workspace-settings/workspace-settings.controller.spec.ts
Jason Woltje 0c78923138 feat(#133): add workspace-scoped LLM configuration
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>
2026-01-31 13:15:36 -06:00

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");
});
});
});