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>
383 lines
13 KiB
TypeScript
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 },
|
|
});
|
|
});
|
|
});
|
|
});
|