forked from mosaicstack/stack
- PreferencesService: platform defaults, user overrides, IMMUTABLE_KEYS enforcement - PreferencesController: GET /api/preferences, POST /api/preferences, DELETE /api/preferences/:key - PreferencesModule: global module exporting PreferencesService and SystemOverrideService - SystemOverrideService: Valkey-backed session-scoped system prompt override with 5-min TTL + renew - CommandRegistryService: register /system command (socket execution) - CommandExecutorService: handle /system command via SystemOverrideService - AgentService: inject system override before each prompt turn, renew TTL; store userId in session - ChatGateway: pass userId when creating agent sessions - PreferencesService unit tests: 11 tests covering defaults, overrides, enforcement wins, immutable key errors Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
168 lines
6.0 KiB
TypeScript
168 lines
6.0 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js';
|
|
import type { Db } from '@mosaic/db';
|
|
|
|
/**
|
|
* Build a mock Drizzle DB where the select chain supports:
|
|
* db.select().from().where() → resolves to `listRows`
|
|
* db.select().from().where().limit(n) → resolves to `singleRow`
|
|
*/
|
|
function makeMockDb(
|
|
listRows: Array<{ key: string; value: unknown }> = [],
|
|
singleRow: Array<{ id: string }> = [],
|
|
): Db {
|
|
const chainWithLimit = {
|
|
limit: vi.fn().mockResolvedValue(singleRow),
|
|
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
|
};
|
|
const selectFrom = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnValue(chainWithLimit),
|
|
};
|
|
const updateResult = {
|
|
set: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockResolvedValue([]),
|
|
};
|
|
const deleteResult = {
|
|
where: vi.fn().mockResolvedValue([]),
|
|
};
|
|
const insertResult = {
|
|
values: vi.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
return {
|
|
select: vi.fn().mockReturnValue(selectFrom),
|
|
update: vi.fn().mockReturnValue(updateResult),
|
|
delete: vi.fn().mockReturnValue(deleteResult),
|
|
insert: vi.fn().mockReturnValue(insertResult),
|
|
} as unknown as Db;
|
|
}
|
|
|
|
describe('PreferencesService', () => {
|
|
describe('getEffective', () => {
|
|
it('returns platform defaults when user has no overrides', async () => {
|
|
const db = makeMockDb([]);
|
|
const service = new PreferencesService(db);
|
|
const result = await service.getEffective('user-1');
|
|
|
|
expect(result['agent.thinkingLevel']).toBe('auto');
|
|
expect(result['agent.streamingEnabled']).toBe(true);
|
|
expect(result['session.autoCompactEnabled']).toBe(true);
|
|
expect(result['session.autoCompactThreshold']).toBe(0.8);
|
|
});
|
|
|
|
it('applies user overrides for mutable keys', async () => {
|
|
const db = makeMockDb([
|
|
{ key: 'agent.thinkingLevel', value: 'high' },
|
|
{ key: 'response.language', value: 'es' },
|
|
]);
|
|
|
|
const service = new PreferencesService(db);
|
|
const result = await service.getEffective('user-1');
|
|
|
|
expect(result['agent.thinkingLevel']).toBe('high');
|
|
expect(result['response.language']).toBe('es');
|
|
});
|
|
|
|
it('ignores user overrides for immutable keys — enforcement always wins', async () => {
|
|
const db = makeMockDb([
|
|
{ key: 'limits.maxThinkingLevel', value: 'high' },
|
|
{ key: 'limits.rateLimit', value: 9999 },
|
|
]);
|
|
|
|
const service = new PreferencesService(db);
|
|
const result = await service.getEffective('user-1');
|
|
|
|
// Should still be null (platform default), not the user-supplied values
|
|
expect(result['limits.maxThinkingLevel']).toBeNull();
|
|
expect(result['limits.rateLimit']).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('set', () => {
|
|
it('returns error when attempting to override an immutable key', async () => {
|
|
const db = makeMockDb();
|
|
const service = new PreferencesService(db);
|
|
|
|
const result = await service.set('user-1', 'limits.maxThinkingLevel', 'high');
|
|
expect(result.success).toBe(false);
|
|
expect(result.message).toContain('platform enforcement');
|
|
});
|
|
|
|
it('returns error when attempting to override limits.rateLimit', async () => {
|
|
const db = makeMockDb();
|
|
const service = new PreferencesService(db);
|
|
|
|
const result = await service.set('user-1', 'limits.rateLimit', 100);
|
|
expect(result.success).toBe(false);
|
|
expect(result.message).toContain('platform enforcement');
|
|
});
|
|
|
|
it('upserts a mutable preference and returns success — insert path', async () => {
|
|
// singleRow=[] → no existing row → insert path
|
|
const db = makeMockDb([], []);
|
|
const service = new PreferencesService(db);
|
|
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
|
expect(result.success).toBe(true);
|
|
expect(result.message).toContain('"agent.thinkingLevel"');
|
|
});
|
|
|
|
it('upserts a mutable preference and returns success — update path', async () => {
|
|
// singleRow has an id → existing row → update path
|
|
const db = makeMockDb([], [{ id: 'existing-id' }]);
|
|
const service = new PreferencesService(db);
|
|
const result = await service.set('user-1', 'agent.thinkingLevel', 'low');
|
|
expect(result.success).toBe(true);
|
|
expect(result.message).toContain('"agent.thinkingLevel"');
|
|
});
|
|
});
|
|
|
|
describe('reset', () => {
|
|
it('returns error when attempting to reset an immutable key', async () => {
|
|
const db = makeMockDb();
|
|
const service = new PreferencesService(db);
|
|
|
|
const result = await service.reset('user-1', 'limits.rateLimit');
|
|
expect(result.success).toBe(false);
|
|
expect(result.message).toContain('platform enforcement');
|
|
});
|
|
|
|
it('deletes user override and returns default value in message', async () => {
|
|
const db = makeMockDb();
|
|
const service = new PreferencesService(db);
|
|
const result = await service.reset('user-1', 'agent.thinkingLevel');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.message).toContain('"auto"'); // platform default for agent.thinkingLevel
|
|
});
|
|
});
|
|
|
|
describe('IMMUTABLE_KEYS', () => {
|
|
it('contains only the enforcement keys', () => {
|
|
expect(IMMUTABLE_KEYS.has('limits.maxThinkingLevel')).toBe(true);
|
|
expect(IMMUTABLE_KEYS.has('limits.rateLimit')).toBe(true);
|
|
expect(IMMUTABLE_KEYS.has('agent.thinkingLevel')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('PLATFORM_DEFAULTS', () => {
|
|
it('has all expected keys', () => {
|
|
const expectedKeys = [
|
|
'agent.defaultModel',
|
|
'agent.thinkingLevel',
|
|
'agent.streamingEnabled',
|
|
'response.language',
|
|
'response.codeAnnotations',
|
|
'safety.confirmDestructiveTools',
|
|
'session.autoCompactThreshold',
|
|
'session.autoCompactEnabled',
|
|
'limits.maxThinkingLevel',
|
|
'limits.rateLimit',
|
|
];
|
|
for (const key of expectedKeys) {
|
|
expect(Object.prototype.hasOwnProperty.call(PLATFORM_DEFAULTS, key)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
});
|