Files
stack/apps/api/src/workspace-settings/workspace-settings.service.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

383 lines
13 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { WorkspaceSettingsService } from "./workspace-settings.service";
import { PrismaService } from "../prisma/prisma.service";
import type { WorkspaceLlmSettings, LlmProviderInstance, Personality } from "@prisma/client";
describe("WorkspaceSettingsService", () => {
let service: WorkspaceSettingsService;
let prisma: PrismaService;
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 mockUserProvider: LlmProviderInstance = {
id: "user-provider-123",
providerType: "ollama",
displayName: "User Provider",
userId: mockUserId,
config: { endpoint: "http://user-ollama:11434" },
isDefault: false,
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(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkspaceSettingsService,
{
provide: PrismaService,
useValue: {
workspaceLlmSettings: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
llmProviderInstance: {
findFirst: vi.fn(),
findUnique: vi.fn(),
},
personality: {
findFirst: vi.fn(),
findUnique: vi.fn(),
},
},
},
],
}).compile();
service = module.get<WorkspaceSettingsService>(WorkspaceSettingsService);
prisma = module.get<PrismaService>(PrismaService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getSettings", () => {
it("should return existing settings for workspace", async () => {
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
const result = await service.getSettings(mockWorkspaceId);
expect(result).toEqual(mockSettings);
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
});
});
it("should create default settings if not exists", async () => {
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null);
vi.spyOn(prisma.workspaceLlmSettings, "create").mockResolvedValue(mockSettings);
const result = await service.getSettings(mockWorkspaceId);
expect(result).toEqual(mockSettings);
expect(prisma.workspaceLlmSettings.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
settings: {},
},
});
});
it("should handle workspace with no settings gracefully", async () => {
const newSettings = {
...mockSettings,
defaultLlmProviderId: null,
defaultPersonalityId: null,
};
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null);
vi.spyOn(prisma.workspaceLlmSettings, "create").mockResolvedValue(newSettings);
const result = await service.getSettings(mockWorkspaceId);
expect(result).toBeDefined();
expect(result.workspaceId).toBe(mockWorkspaceId);
});
});
describe("updateSettings", () => {
it("should update existing settings", async () => {
const updateDto = {
defaultLlmProviderId: "new-provider-123",
defaultPersonalityId: "new-personality-123",
};
const updatedSettings = { ...mockSettings, ...updateDto };
vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings);
const result = await service.updateSettings(mockWorkspaceId, updateDto);
expect(result).toEqual(updatedSettings);
expect(prisma.workspaceLlmSettings.update).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
data: updateDto,
});
});
it("should allow setting provider to null", async () => {
const updateDto = {
defaultLlmProviderId: null,
};
const updatedSettings = { ...mockSettings, defaultLlmProviderId: null };
vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings);
const result = await service.updateSettings(mockWorkspaceId, updateDto);
expect(result.defaultLlmProviderId).toBeNull();
});
it("should allow setting personality to null", async () => {
const updateDto = {
defaultPersonalityId: null,
};
const updatedSettings = { ...mockSettings, defaultPersonalityId: null };
vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings);
const result = await service.updateSettings(mockWorkspaceId, updateDto);
expect(result.defaultPersonalityId).toBeNull();
});
it("should update custom settings object", async () => {
const updateDto = {
settings: { customKey: "customValue" },
};
const updatedSettings = { ...mockSettings, settings: updateDto.settings };
vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings);
const result = await service.updateSettings(mockWorkspaceId, updateDto);
expect(result.settings).toEqual(updateDto.settings);
});
});
describe("getEffectiveLlmProvider", () => {
it("should return workspace provider when set", async () => {
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
vi.spyOn(prisma.llmProviderInstance, "findUnique").mockResolvedValue(mockProvider);
const result = await service.getEffectiveLlmProvider(mockWorkspaceId);
expect(result).toEqual(mockProvider);
expect(prisma.llmProviderInstance.findUnique).toHaveBeenCalledWith({
where: { id: mockSettings.defaultLlmProviderId! },
});
});
it("should return user provider when workspace provider not set and userId provided", async () => {
const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null };
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
settingsWithoutProvider
);
vi.spyOn(prisma.llmProviderInstance, "findFirst")
.mockResolvedValueOnce(mockUserProvider)
.mockResolvedValueOnce(null);
const result = await service.getEffectiveLlmProvider(mockWorkspaceId, mockUserId);
expect(result).toEqual(mockUserProvider);
expect(prisma.llmProviderInstance.findFirst).toHaveBeenCalledWith({
where: {
userId: mockUserId,
isEnabled: true,
},
});
});
it("should fall back to system default when workspace and user providers not set", async () => {
const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null };
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
settingsWithoutProvider
);
vi.spyOn(prisma.llmProviderInstance, "findFirst")
.mockResolvedValueOnce(null) // No user provider
.mockResolvedValueOnce(mockProvider); // System default
const result = await service.getEffectiveLlmProvider(mockWorkspaceId, mockUserId);
expect(result).toEqual(mockProvider);
expect(prisma.llmProviderInstance.findFirst).toHaveBeenNthCalledWith(2, {
where: {
userId: null,
isDefault: true,
isEnabled: true,
},
});
});
it("should throw error when no provider available", async () => {
const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null };
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
settingsWithoutProvider
);
vi.spyOn(prisma.llmProviderInstance, "findFirst").mockResolvedValue(null);
await expect(service.getEffectiveLlmProvider(mockWorkspaceId)).rejects.toThrow(
`No LLM provider available for workspace ${mockWorkspaceId}`
);
});
it("should throw error when workspace provider is set but not found", async () => {
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
vi.spyOn(prisma.llmProviderInstance, "findUnique").mockResolvedValue(null);
await expect(service.getEffectiveLlmProvider(mockWorkspaceId)).rejects.toThrow(
`LLM provider ${mockSettings.defaultLlmProviderId} not found`
);
});
});
describe("getEffectivePersonality", () => {
it("should return workspace personality when set", async () => {
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
vi.spyOn(prisma.personality, "findUnique").mockResolvedValue(mockPersonality);
const result = await service.getEffectivePersonality(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
where: {
id: mockSettings.defaultPersonalityId!,
},
});
});
it("should fall back to default personality when workspace personality not set", async () => {
const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null };
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
settingsWithoutPersonality
);
vi.spyOn(prisma.personality, "findFirst").mockResolvedValue(mockPersonality);
const result = await service.getEffectivePersonality(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
isDefault: true,
isEnabled: true,
},
});
});
it("should fall back to any enabled personality when no default exists", async () => {
const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null };
const nonDefaultPersonality = { ...mockPersonality, isDefault: false };
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
settingsWithoutPersonality
);
vi.spyOn(prisma.personality, "findFirst")
.mockResolvedValueOnce(null) // No default personality
.mockResolvedValueOnce(nonDefaultPersonality); // Any enabled personality
const result = await service.getEffectivePersonality(mockWorkspaceId);
expect(result).toEqual(nonDefaultPersonality);
expect(prisma.personality.findFirst).toHaveBeenNthCalledWith(2, {
where: {
workspaceId: mockWorkspaceId,
isEnabled: true,
},
});
});
it("should throw error when no personality available", async () => {
const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null };
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(
settingsWithoutPersonality
);
vi.spyOn(prisma.personality, "findFirst").mockResolvedValue(null);
await expect(service.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow(
`No personality available for workspace ${mockWorkspaceId}`
);
});
it("should throw error when workspace personality is set but not found", async () => {
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
vi.spyOn(prisma.personality, "findUnique").mockResolvedValue(null);
await expect(service.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow(
`Personality ${mockSettings.defaultPersonalityId} not found`
);
});
});
describe("workspace isolation", () => {
it("should only access settings for specified workspace", async () => {
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings);
await service.getSettings(mockWorkspaceId);
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
});
});
it("should not allow cross-workspace settings access", async () => {
const otherWorkspaceId = "other-workspace-123";
vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null);
const result1 = await service.getSettings(mockWorkspaceId);
const result2 = await service.getSettings(otherWorkspaceId);
// Each workspace should have separate calls
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledTimes(2);
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
});
expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({
where: { workspaceId: otherWorkspaceId },
});
});
});
});