diff --git a/CLAUDE.md b/CLAUDE.md index d3354cb577..3e84fe8038 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,10 +140,10 @@ src/synthorg/ web/ # Vue 3 + PrimeVue + Tailwind CSS dashboard src/ api/ # Axios client, endpoint modules, TypeScript types (mirrors backend Pydantic models) - components/ # Vue components organized by feature (agents/, approvals/, budget/, common/, dashboard/, layout/, messages/, org-chart/, providers/, setup/, tasks/) + components/ # Vue components organized by feature (agents/, approvals/, budget/, common/, dashboard/, layout/, messages/, org-chart/, providers/, settings/, setup/, tasks/) composables/ # Reusable composition functions (useAuth, useLoginLockout, usePolling, useOptimisticUpdate, useWebSocketSubscription) router/ # Vue Router config with auth guards - stores/ # Pinia stores (auth, agents, tasks, budget, messages, meetings, approvals, websocket, analytics, company, providers, setup) + stores/ # Pinia stores (auth, agents, tasks, budget, messages, meetings, approvals, websocket, analytics, company, providers, settings, setup) styles/ # Global CSS and PrimeVue theme configuration utils/ # Constants, formatters, error helpers views/ # Page-level components (LoginPage, SetupPage, DashboardPage, OrgChartPage, TaskBoardPage, MessageFeedPage, ApprovalQueuePage, AgentProfilesPage, AgentDetailPage, BudgetPanelPage, MeetingLogsPage, ArtifactBrowserPage, SettingsPage) diff --git a/web/src/__tests__/__mocks__/primevue.ts b/web/src/__tests__/__mocks__/primevue.ts new file mode 100644 index 0000000000..77d301473a --- /dev/null +++ b/web/src/__tests__/__mocks__/primevue.ts @@ -0,0 +1,53 @@ +/** + * Shared PrimeVue component mocks for settings tests. + * + * All mocks include interactive behavior (event emission) so tests can + * simulate user input uniformly across all test files. + */ + +export const InputTextMock = { + props: ['modelValue', 'type', 'placeholder', 'disabled'], + emits: ['update:modelValue'], + template: '', +} + +export const InputNumberMock = { + props: ['modelValue', 'min', 'max', 'minFractionDigits', 'maxFractionDigits', 'useGrouping', 'disabled'], + emits: ['update:modelValue'], + template: '', +} + +export const ToggleSwitchMock = { + props: ['modelValue', 'disabled', 'ariaLabel'], + emits: ['update:modelValue'], + template: '', +} + +export const SelectMock = { + props: ['modelValue', 'options', 'disabled'], + emits: ['update:modelValue'], + template: '', +} + +export const TextareaMock = { + props: ['modelValue', 'rows', 'disabled'], + emits: ['update:modelValue'], + template: '', +} + +export const ButtonMock = { + props: ['label', 'icon', 'size', 'severity', 'text', 'disabled', 'loading', 'type', 'ariaLabel'], + template: '', +} + +export const TagMock = { + props: ['value', 'severity'], + template: '{{ value }}', +} + +/** + * Note: vi.mock() factories are hoisted above imports by Vitest, so these + * exports cannot be used via a registerPrimeVueMocks() helper function. + * Instead, copy the vi.mock() calls directly into each test file that needs + * them, using the mock objects above as a reference for consistent behavior. + */ diff --git a/web/src/__tests__/api/settings.test.ts b/web/src/__tests__/api/settings.test.ts new file mode 100644 index 0000000000..6340a97df5 --- /dev/null +++ b/web/src/__tests__/api/settings.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SettingDefinition, SettingEntry } from '@/api/types' + +vi.mock('@/api/client', () => ({ + apiClient: { + get: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, + unwrap: vi.fn((response) => response.data.data), + unwrapVoid: vi.fn(), +})) + +const mockDefinition: SettingDefinition = { + namespace: 'budget', + key: 'total_monthly', + type: 'float', + default: '100.0', + description: 'Monthly budget in USD', + group: 'Limits', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: 0.0, + max_value: null, + yaml_path: 'budget.total_monthly', +} + +const mockEntry: SettingEntry = { + definition: mockDefinition, + value: '100.0', + source: 'default', + updated_at: null, +} + +describe('settings API endpoints', () => { + let apiClient: { get: ReturnType; put: ReturnType; delete: ReturnType } + let unwrap: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + const client = await import('@/api/client') + apiClient = client.apiClient as unknown as typeof apiClient + unwrap = client.unwrap as unknown as ReturnType + }) + + it('getSchema fetches all definitions', async () => { + const data = [mockDefinition] + apiClient.get.mockResolvedValue({ data: { data, success: true } }) + unwrap.mockReturnValue(data) + + const { getSchema } = await import('@/api/endpoints/settings') + const result = await getSchema() + + expect(apiClient.get).toHaveBeenCalledWith('/settings/_schema') + expect(result).toEqual(data) + }) + + it('getNamespaceSchema fetches definitions for a namespace', async () => { + const data = [mockDefinition] + apiClient.get.mockResolvedValue({ data: { data, success: true } }) + unwrap.mockReturnValue(data) + + const { getNamespaceSchema } = await import('@/api/endpoints/settings') + const result = await getNamespaceSchema('budget') + + expect(apiClient.get).toHaveBeenCalledWith('/settings/_schema/budget') + expect(result).toEqual(data) + }) + + it('getAllSettings fetches all resolved entries', async () => { + const data = [mockEntry] + apiClient.get.mockResolvedValue({ data: { data, success: true } }) + unwrap.mockReturnValue(data) + + const { getAllSettings } = await import('@/api/endpoints/settings') + const result = await getAllSettings() + + expect(apiClient.get).toHaveBeenCalledWith('/settings') + expect(result).toEqual(data) + }) + + it('getNamespaceSettings fetches entries for a namespace', async () => { + const data = [mockEntry] + apiClient.get.mockResolvedValue({ data: { data, success: true } }) + unwrap.mockReturnValue(data) + + const { getNamespaceSettings } = await import('@/api/endpoints/settings') + const result = await getNamespaceSettings('budget') + + expect(apiClient.get).toHaveBeenCalledWith('/settings/budget') + expect(result).toEqual(data) + }) + + it('updateSetting sends PUT with value', async () => { + apiClient.put.mockResolvedValue({ data: { data: mockEntry, success: true } }) + unwrap.mockReturnValue(mockEntry) + + const { updateSetting } = await import('@/api/endpoints/settings') + const result = await updateSetting('budget', 'total_monthly', { value: '200.0' }) + + expect(apiClient.put).toHaveBeenCalledWith('/settings/budget/total_monthly', { value: '200.0' }) + expect(result).toEqual(mockEntry) + }) + + it('resetSetting sends DELETE', async () => { + apiClient.delete.mockResolvedValue({ data: { data: null, success: true } }) + + const { resetSetting } = await import('@/api/endpoints/settings') + await resetSetting('budget', 'total_monthly') + + expect(apiClient.delete).toHaveBeenCalledWith('/settings/budget/total_monthly') + }) + + it('encodes namespace with special characters', async () => { + const data: SettingDefinition[] = [] + apiClient.get.mockResolvedValue({ data: { data, success: true } }) + unwrap.mockReturnValue(data) + + const { getNamespaceSchema } = await import('@/api/endpoints/settings') + await getNamespaceSchema('a/b' as never) + + expect(apiClient.get).toHaveBeenCalledWith('/settings/_schema/a%2Fb') + }) + + it('encodes key with special characters in updateSetting', async () => { + apiClient.put.mockResolvedValue({ data: { data: mockEntry, success: true } }) + unwrap.mockReturnValue(mockEntry) + + const { updateSetting } = await import('@/api/endpoints/settings') + await updateSetting('budget', 'a/b', { value: 'test' }) + + expect(apiClient.put).toHaveBeenCalledWith('/settings/budget/a%2Fb', { value: 'test' }) + }) +}) diff --git a/web/src/__tests__/components/settings/SettingField.test.ts b/web/src/__tests__/components/settings/SettingField.test.ts new file mode 100644 index 0000000000..4f027e482c --- /dev/null +++ b/web/src/__tests__/components/settings/SettingField.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import type { SettingDefinition, SettingEntry } from '@/api/types' + +vi.mock('primevue/inputtext', () => ({ + default: { + props: ['modelValue', 'type', 'placeholder', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/inputnumber', () => ({ + default: { + props: ['modelValue', 'min', 'max', 'minFractionDigits', 'maxFractionDigits', 'useGrouping', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/toggleswitch', () => ({ + default: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/select', () => ({ + default: { + props: ['modelValue', 'options', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/textarea', () => ({ + default: { + props: ['modelValue', 'rows', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/button', () => ({ + default: { + props: ['label', 'icon', 'size', 'severity', 'text', 'disabled', 'loading'], + template: '', + }, +})) + +vi.mock('primevue/tag', () => ({ + default: { + props: ['value', 'severity'], + template: '{{ value }}', + }, +})) + +import SettingField from '@/components/settings/SettingField.vue' + +function makeDefinition(overrides: Partial = {}): SettingDefinition { + return { + namespace: 'budget', + key: 'total_monthly', + type: 'float', + default: '100.0', + description: 'Monthly budget in USD', + group: 'Limits', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: null, + max_value: null, + yaml_path: null, + ...overrides, + } +} + +function makeEntry(overrides: Partial = {}, defOverrides: Partial = {}): SettingEntry { + return { + definition: makeDefinition(defOverrides), + value: '100.0', + source: 'default', + updated_at: null, + ...overrides, + } +} + +describe('SettingField', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ── Rendering ───────────────────────────────────────────── + + it('renders description as help text', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry(), saving: false }, + }) + expect(wrapper.text()).toContain('Monthly budget in USD') + }) + + it('renders restart required badge when restart_required is true', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry({}, { restart_required: true }), saving: false }, + }) + expect(wrapper.text()).toContain('Restart Required') + }) + + it('does not render restart badge when restart_required is false', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry(), saving: false }, + }) + expect(wrapper.text()).not.toContain('Restart Required') + }) + + it('renders source badge', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry({ source: 'yaml' }), saving: false }, + }) + expect(wrapper.text()).toContain('YAML') + }) + + it('shows advanced chip for advanced-level settings', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry({}, { level: 'advanced' }), saving: false }, + }) + expect(wrapper.text()).toContain('Advanced') + }) + + // ── Input types ─────────────────────────────────────────── + + it('renders text input for string type', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry({}, { type: 'str' }), saving: false }, + }) + expect(wrapper.find('input[type="text"]').exists()).toBe(true) + }) + + it('renders password input for sensitive string type', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry({}, { type: 'str', sensitive: true }), saving: false }, + }) + expect(wrapper.find('input[type="password"]').exists()).toBe(true) + }) + + it('renders number input for int type', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry({ value: '50' }, { type: 'int', default: '50' }), saving: false }, + }) + expect(wrapper.find('input[type="number"]').exists()).toBe(true) + }) + + it('renders number input for float type', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry(), saving: false }, + }) + expect(wrapper.find('input[type="number"]').exists()).toBe(true) + }) + + it('renders switch for bool type', () => { + const wrapper = mount(SettingField, { + props: { + entry: makeEntry({ value: 'true' }, { type: 'bool', default: 'true' }), + saving: false, + }, + }) + expect(wrapper.find('[role="switch"]').exists()).toBe(true) + }) + + it('renders select for enum type', () => { + const wrapper = mount(SettingField, { + props: { + entry: makeEntry({ value: 'cost_aware' }, { + type: 'enum', + default: 'cost_aware', + enum_values: ['cost_aware', 'round_robin', 'latency'], + }), + saving: false, + }, + }) + expect(wrapper.find('select').exists()).toBe(true) + }) + + it('renders textarea for json type', () => { + const wrapper = mount(SettingField, { + props: { + entry: makeEntry({ value: '{}' }, { type: 'json', default: '{}' }), + saving: false, + }, + }) + expect(wrapper.find('textarea').exists()).toBe(true) + }) + + // ── Dirty tracking ──────────────────────────────────────── + + it('save button is disabled when value has not changed', () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry(), saving: false }, + }) + const saveBtn = wrapper.findAll('button').find((b) => b.text() === 'Save') + expect(saveBtn?.attributes('disabled')).toBeDefined() + }) + + it('save button is enabled when value changes', async () => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry(), saving: false }, + }) + const input = wrapper.find('input[type="number"]') + await input.setValue(200) + await flushPromises() + + const saveBtn = wrapper.findAll('button').find((b) => b.text() === 'Save') + expect(saveBtn).toBeDefined() + // The button should now be enabled since value changed from 100.0 to 200 + expect(saveBtn!.attributes('disabled')).toBeUndefined() + }) + + // ── Events ──────────────────────────────────────────────── + + it('emits save event with new value', async () => { + const entry = makeEntry({ value: '100.0' }, { type: 'float' }) + const wrapper = mount(SettingField, { + props: { entry, saving: false }, + }) + const input = wrapper.find('input[type="number"]') + await input.setValue(200) + await flushPromises() + + const saveBtn = wrapper.findAll('button').find((b) => b.text() === 'Save') + await saveBtn?.trigger('click') + + expect(wrapper.emitted('save')).toBeTruthy() + expect(wrapper.emitted('save')![0][0]).toBe('200') + }) + + it('emits reset event when reset button clicked', async () => { + const entry = makeEntry({ value: '200.0', source: 'db' }) + const wrapper = mount(SettingField, { + props: { entry, saving: false }, + }) + + const resetBtn = wrapper.findAll('button').find((b) => b.text() === 'Reset') + await resetBtn?.trigger('click') + + expect(wrapper.emitted('reset')).toBeTruthy() + }) + + // ── Validation ──────────────────────────────────────────── + + it('shows validation error for invalid integer', async () => { + const entry = makeEntry({ value: '50' }, { type: 'int', default: '50' }) + const wrapper = mount(SettingField, { + props: { entry, saving: false }, + }) + const input = wrapper.find('input[type="number"]') + await input.setValue(3.5) + await flushPromises() + + expect(wrapper.text()).toContain('Must be a whole number') + }) + + it('shows range error for out-of-bounds value', async () => { + const entry = makeEntry({ value: '50' }, { + type: 'int', + default: '50', + min_value: 1, + max_value: 100, + }) + const wrapper = mount(SettingField, { + props: { entry, saving: false }, + }) + const input = wrapper.find('input[type="number"]') + await input.setValue(200) + await flushPromises() + + expect(wrapper.text()).toContain('Must be at most 100') + }) + + // ── Sensitive toggle ────────────────────────────────────── + + it('toggles password visibility on eye button click', async () => { + const wrapper = mount(SettingField, { + props: { + entry: makeEntry({}, { type: 'str', sensitive: true }), + saving: false, + }, + }) + expect(wrapper.find('input[type="password"]').exists()).toBe(true) + + // Find the eye toggle button (not Save/Reset) + const eyeBtn = wrapper.findAll('button').find( + (b) => b.text() !== 'Save' && b.text() !== 'Reset', + ) + expect(eyeBtn).toBeDefined() + await eyeBtn!.trigger('click') + await flushPromises() + expect(wrapper.find('input[type="text"]').exists()).toBe(true) + }) + + // ── Format JSON ─────────────────────────────────────────── + + it('formats valid JSON on Format JSON button click', async () => { + const wrapper = mount(SettingField, { + props: { + entry: makeEntry({ value: '{"a":1}' }, { type: 'json', default: '{}' }), + saving: false, + }, + }) + const formatBtn = wrapper.findAll('button').find((b) => b.text() === 'Format JSON') + expect(formatBtn).toBeDefined() + await formatBtn!.trigger('click') + await flushPromises() + + const textarea = wrapper.find('textarea') + expect(textarea.element.value).toBe('{\n "a": 1\n}') + }) + + it('does nothing when formatting invalid JSON', async () => { + const wrapper = mount(SettingField, { + props: { + entry: makeEntry({ value: '{bad json' }, { type: 'json', default: '{}' }), + saving: false, + }, + }) + const formatBtn = wrapper.findAll('button').find((b) => b.text() === 'Format JSON') + await formatBtn!.trigger('click') + await flushPromises() + + const textarea = wrapper.find('textarea') + expect(textarea.element.value).toBe('{bad json') + }) + + // ── Reset disabled state ────────────────────────────────── + + it.each(['default', 'yaml', 'env'] as const)( + 'disables reset button when source is %s', + (source) => { + const wrapper = mount(SettingField, { + props: { entry: makeEntry({ source }), saving: false }, + }) + const resetBtn = wrapper.findAll('button').find((b) => b.text() === 'Reset') + expect(resetBtn?.attributes('disabled')).toBeDefined() + }, + ) + + // ── Watch re-sync ───────────────────────────────────────── + + it('resets local value when entry prop changes externally', async () => { + const entry = makeEntry({ value: '100.0' }) + const wrapper = mount(SettingField, { + props: { entry, saving: false }, + }) + // Dirty the local value + const input = wrapper.find('input[type="number"]') + await input.setValue(999) + await flushPromises() + + // Simulate prop update (server returns new value after save) + const updatedEntry = makeEntry({ value: '200.0' }) + await wrapper.setProps({ entry: updatedEntry }) + await flushPromises() + + // Local value should re-sync to the new server value + expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe('200') + }) + + // ── handleSave guard ────────────────────────────────────── + + it('does not emit save when validation fails', async () => { + const entry = makeEntry({ value: '50' }, { type: 'int', default: '50', min_value: 1, max_value: 100 }) + const wrapper = mount(SettingField, { + props: { entry, saving: false }, + }) + // Set an out-of-range value + const input = wrapper.find('input[type="number"]') + await input.setValue(200) + await flushPromises() + + // Try to save via button click + const saveBtn = wrapper.findAll('button').find((b) => b.text() === 'Save') + await saveBtn?.trigger('click') + + expect(wrapper.emitted('save')).toBeFalsy() + }) +}) diff --git a/web/src/__tests__/components/settings/SettingGroupRenderer.test.ts b/web/src/__tests__/components/settings/SettingGroupRenderer.test.ts new file mode 100644 index 0000000000..b13d463cbc --- /dev/null +++ b/web/src/__tests__/components/settings/SettingGroupRenderer.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { SettingDefinition, SettingEntry } from '@/api/types' + +vi.mock('primevue/inputtext', () => ({ + default: { + props: ['modelValue', 'type', 'placeholder', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/inputnumber', () => ({ + default: { + props: ['modelValue', 'min', 'max', 'minFractionDigits', 'maxFractionDigits', 'useGrouping', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/toggleswitch', () => ({ + default: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/select', () => ({ + default: { + props: ['modelValue', 'options', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/textarea', () => ({ + default: { + props: ['modelValue', 'rows', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/button', () => ({ + default: { + props: ['label', 'icon', 'size', 'severity', 'text', 'disabled', 'loading'], + template: '', + }, +})) + +vi.mock('primevue/tag', () => ({ + default: { + props: ['value', 'severity'], + template: '{{ value }}', + }, +})) + +import SettingGroupRenderer from '@/components/settings/SettingGroupRenderer.vue' + +function makeDef(overrides: Partial = {}): SettingDefinition { + return { + namespace: 'budget', + key: 'total_monthly', + type: 'float', + default: '100.0', + description: 'Monthly budget in USD', + group: 'Limits', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: null, + max_value: null, + yaml_path: null, + ...overrides, + } +} + +function makeEntry(defOverrides: Partial = {}, entryOverrides: Partial = {}): SettingEntry { + return { + definition: makeDef(defOverrides), + value: defOverrides.default ?? '100.0', + source: 'default', + updated_at: null, + ...entryOverrides, + } +} + +function basicLimits() { + return makeEntry({ key: 'total_monthly', group: 'Limits' }) +} + +function basicLimits2() { + return makeEntry({ key: 'per_task_limit', group: 'Limits', default: '5.0' }, { value: '5.0' }) +} + +function advancedAlerts() { + return makeEntry({ + key: 'alert_warn_at', + group: 'Alerts', + level: 'advanced', + type: 'int', + default: '75', + }, { value: '75' }) +} + +function advancedDowngrade() { + return makeEntry({ + key: 'auto_downgrade_enabled', + group: 'Auto-Downgrade', + level: 'advanced', + type: 'bool', + default: 'false', + }, { value: 'false' }) +} + +describe('SettingGroupRenderer', () => { + it('renders group headings', () => { + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [basicLimits(), basicLimits2()], + showAdvanced: false, + }, + }) + expect(wrapper.text()).toContain('Limits') + }) + + it('renders multiple groups when entries span groups', () => { + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [basicLimits(), advancedAlerts()], + showAdvanced: true, + }, + }) + expect(wrapper.text()).toContain('Limits') + expect(wrapper.text()).toContain('Alerts') + }) + + it('filters out advanced settings when showAdvanced is false', () => { + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [basicLimits(), advancedAlerts()], + showAdvanced: false, + }, + }) + expect(wrapper.text()).toContain('total_monthly') + expect(wrapper.text()).not.toContain('alert_warn_at') + }) + + it('shows advanced settings when showAdvanced is true', () => { + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [basicLimits(), advancedAlerts()], + showAdvanced: true, + }, + }) + expect(wrapper.text()).toContain('total_monthly') + expect(wrapper.text()).toContain('alert_warn_at') + }) + + it('hides group headings that have no visible settings in basic mode', () => { + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [advancedAlerts(), advancedDowngrade()], + showAdvanced: false, + }, + }) + // Both entries are advanced, so nothing should render + expect(wrapper.text()).not.toContain('Alerts') + expect(wrapper.text()).not.toContain('Auto-Downgrade') + }) + + it('shows empty state when no entries provided', () => { + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [], + showAdvanced: false, + }, + }) + expect(wrapper.text()).toContain('No settings') + }) + + it('emits save event from child SettingField', async () => { + const entry = basicLimits() + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [entry], + showAdvanced: false, + }, + }) + // Find the SettingField and trigger its save event + const settingField = wrapper.findComponent({ name: 'SettingField' }) + settingField.vm.$emit('save', '200.0') + + expect(wrapper.emitted('save')).toBeTruthy() + expect(wrapper.emitted('save')![0]).toEqual([entry, '200.0']) + }) + + it('emits reset event from child SettingField', async () => { + const entry = basicLimits() + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [entry], + showAdvanced: false, + }, + }) + const settingField = wrapper.findComponent({ name: 'SettingField' }) + settingField.vm.$emit('reset') + + expect(wrapper.emitted('reset')).toBeTruthy() + expect(wrapper.emitted('reset')![0]).toEqual([entry]) + }) +}) diff --git a/web/src/__tests__/components/settings/SettingRestartBadge.test.ts b/web/src/__tests__/components/settings/SettingRestartBadge.test.ts new file mode 100644 index 0000000000..d2ed0d587e --- /dev/null +++ b/web/src/__tests__/components/settings/SettingRestartBadge.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +vi.mock('primevue/tag', () => ({ + default: { + props: ['value', 'severity'], + template: '{{ value }}', + }, +})) + +import SettingRestartBadge from '@/components/settings/SettingRestartBadge.vue' + +describe('SettingRestartBadge', () => { + it('renders restart required text', () => { + const wrapper = mount(SettingRestartBadge) + expect(wrapper.text()).toContain('Restart Required') + }) + + it('uses warn severity', () => { + const wrapper = mount(SettingRestartBadge) + expect(wrapper.find('[data-severity="warn"]').exists()).toBe(true) + }) +}) diff --git a/web/src/__tests__/components/settings/SettingSourceBadge.test.ts b/web/src/__tests__/components/settings/SettingSourceBadge.test.ts new file mode 100644 index 0000000000..dc90077cf9 --- /dev/null +++ b/web/src/__tests__/components/settings/SettingSourceBadge.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +vi.mock('primevue/tag', () => ({ + default: { + props: ['value', 'severity'], + template: '{{ value }}', + }, +})) + +import SettingSourceBadge from '@/components/settings/SettingSourceBadge.vue' + +describe('SettingSourceBadge', () => { + it.each([ + ['db', 'Database', 'info'], + ['env', 'Environment', 'warn'], + ['yaml', 'YAML', 'secondary'], + ['default', 'Default', 'contrast'], + ] as const)('renders %s source with label "%s" and severity "%s"', (source, label, severity) => { + const wrapper = mount(SettingSourceBadge, { props: { source } }) + expect(wrapper.text()).toContain(label) + expect(wrapper.find(`[data-severity="${severity}"]`).exists()).toBe(true) + }) +}) diff --git a/web/src/__tests__/stores/settings.property.test.ts b/web/src/__tests__/stores/settings.property.test.ts new file mode 100644 index 0000000000..3d8dddccf6 --- /dev/null +++ b/web/src/__tests__/stores/settings.property.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import fc from 'fast-check' +import { setActivePinia, createPinia } from 'pinia' +import { useSettingsStore, validateSettingValue } from '@/stores/settings' +import type { SettingDefinition, SettingEntry } from '@/api/types' + +vi.mock('@/api/endpoints/settings', () => ({ + getSchema: vi.fn(), + getAllSettings: vi.fn(), + updateSetting: vi.fn(), + resetSetting: vi.fn(), +})) + +/** Arbitrary for SettingDefinition with valid cross-field constraints. */ +const arbSettingDefinition = fc.oneof( + // String type + fc.record({ + namespace: fc.constantFrom('api', 'company', 'budget', 'security') as fc.Arbitrary, + key: fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + type: fc.constant('str' as const), + default: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null }), + description: fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + group: fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + level: fc.constantFrom('basic', 'advanced') as fc.Arbitrary, + sensitive: fc.boolean(), + restart_required: fc.boolean(), + enum_values: fc.constant([]) as fc.Arbitrary, + validator_pattern: fc.constant(null) as fc.Arbitrary, + min_value: fc.constant(null) as fc.Arbitrary, + max_value: fc.constant(null) as fc.Arbitrary, + yaml_path: fc.constant(null) as fc.Arbitrary, + }), + // Integer type with range + fc.record({ + namespace: fc.constantFrom('budget', 'api') as fc.Arbitrary, + key: fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + type: fc.constant('int' as const), + default: fc.constant('50'), + description: fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + group: fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + level: fc.constantFrom('basic', 'advanced') as fc.Arbitrary, + sensitive: fc.constant(false), + restart_required: fc.boolean(), + enum_values: fc.constant([]) as fc.Arbitrary, + validator_pattern: fc.constant(null) as fc.Arbitrary, + min_value: fc.option(fc.integer({ min: 0, max: 50 }), { nil: null }), + max_value: fc.option(fc.integer({ min: 51, max: 200 }), { nil: null }), + yaml_path: fc.constant(null) as fc.Arbitrary, + }), + // Boolean type + fc.record({ + namespace: fc.constantFrom('security', 'backup') as fc.Arbitrary, + key: fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + type: fc.constant('bool' as const), + default: fc.constantFrom('true', 'false'), + description: fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + group: fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + level: fc.constantFrom('basic', 'advanced') as fc.Arbitrary, + sensitive: fc.constant(false), + restart_required: fc.boolean(), + enum_values: fc.constant([]) as fc.Arbitrary, + validator_pattern: fc.constant(null) as fc.Arbitrary, + min_value: fc.constant(null) as fc.Arbitrary, + max_value: fc.constant(null) as fc.Arbitrary, + yaml_path: fc.constant(null) as fc.Arbitrary, + }), + // Enum type + fc.record({ + namespace: fc.constantFrom('memory', 'providers') as fc.Arbitrary, + key: fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + type: fc.constant('enum' as const), + default: fc.constant('option_a'), + description: fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + group: fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + level: fc.constantFrom('basic', 'advanced') as fc.Arbitrary, + sensitive: fc.constant(false), + restart_required: fc.boolean(), + enum_values: fc.constant(['option_a', 'option_b', 'option_c'] as string[]), + validator_pattern: fc.constant(null) as fc.Arbitrary, + min_value: fc.constant(null) as fc.Arbitrary, + max_value: fc.constant(null) as fc.Arbitrary, + yaml_path: fc.constant(null) as fc.Arbitrary, + }), +) + +describe('settings store (property-based)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() + }) + + it('namespaces always contains only unique values', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(arbSettingDefinition, { minLength: 0, maxLength: 20 }), + async (definitions) => { + setActivePinia(createPinia()) + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue(definitions) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([]) + + const store = useSettingsStore() + await store.fetchAll() + + const ns = store.namespaces + expect(new Set(ns).size).toBe(ns.length) + }, + ), + { numRuns: 50 }, + ) + }) + + it('entriesByNamespace returns only entries matching the namespace', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(arbSettingDefinition, { minLength: 1, maxLength: 10 }), + async (definitions) => { + setActivePinia(createPinia()) + const entries: SettingEntry[] = definitions.map((d) => ({ + definition: d, + value: d.default ?? '', + source: 'default' as const, + updated_at: null, + })) + + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue(definitions) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue(entries) + + const store = useSettingsStore() + await store.fetchAll() + + for (const ns of store.namespaces) { + const filtered = store.entriesByNamespace(ns) + for (const entry of filtered) { + expect(entry.definition.namespace).toBe(ns) + } + } + }, + ), + { numRuns: 50 }, + ) + }) + + it('validateSettingValue returns null for valid values and string for invalid', () => { + // Valid integers within range + fc.assert( + fc.property( + fc.integer({ min: 1, max: 100 }), + (num) => { + const def: SettingDefinition = { + namespace: 'budget', + key: 'test', + type: 'int', + default: '50', + description: 'Test', + group: 'Test', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: 1, + max_value: 100, + yaml_path: null, + } + expect(validateSettingValue(String(num), def)).toBeNull() + }, + ), + { numRuns: 100 }, + ) + }) + + it('validateSettingValue rejects non-numeric strings for int type', () => { + fc.assert( + fc.property( + fc.string().filter((s) => isNaN(Number(s)) || s.trim() === ''), + (value) => { + const def: SettingDefinition = { + namespace: 'budget', + key: 'test', + type: 'int', + default: '50', + description: 'Test', + group: 'Test', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: null, + max_value: null, + yaml_path: null, + } + const result = validateSettingValue(value, def) + expect(result).not.toBeNull() + }, + ), + { numRuns: 100 }, + ) + }) + + it('toggleAdvanced always produces a boolean', () => { + fc.assert( + fc.property( + fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }), + (toggles) => { + setActivePinia(createPinia()) + const store = useSettingsStore() + for (const _ of toggles) { + store.toggleAdvanced() + } + expect(typeof store.showAdvanced).toBe('boolean') + }, + ), + ) + }) +}) diff --git a/web/src/__tests__/stores/settings.test.ts b/web/src/__tests__/stores/settings.test.ts new file mode 100644 index 0000000000..a31047966e --- /dev/null +++ b/web/src/__tests__/stores/settings.test.ts @@ -0,0 +1,542 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useSettingsStore, validateSettingValue } from '@/stores/settings' +import { SETTINGS_ADVANCED_KEY } from '@/utils/constants' +import type { SettingDefinition, SettingEntry } from '@/api/types' + +vi.mock('@/api/endpoints/settings', () => ({ + getSchema: vi.fn(), + getAllSettings: vi.fn(), + updateSetting: vi.fn(), + resetSetting: vi.fn(), +})) + +const mockDefinition: SettingDefinition = { + namespace: 'budget', + key: 'total_monthly', + type: 'float', + default: '100.0', + description: 'Monthly budget in USD', + group: 'Limits', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: 0.0, + max_value: null, + yaml_path: 'budget.total_monthly', +} + +const mockAdvancedDefinition: SettingDefinition = { + ...mockDefinition, + key: 'auto_downgrade_enabled', + type: 'bool', + default: 'false', + description: 'Enable automatic model downgrade', + group: 'Auto-Downgrade', + level: 'advanced', +} + +const mockSecurityDefinition: SettingDefinition = { + ...mockDefinition, + namespace: 'security', + key: 'enabled', + type: 'bool', + default: 'true', + description: 'Enable security engine', + group: 'General', +} + +const mockAdvancedOnlyDefinition: SettingDefinition = { + ...mockDefinition, + namespace: 'coordination', + key: 'wave_timeout', + type: 'int', + default: '30', + description: 'Wave execution timeout in seconds', + group: 'Waves', + level: 'advanced', +} + +const mockEntry: SettingEntry = { + definition: mockDefinition, + value: '100.0', + source: 'default', + updated_at: null, +} + +const mockAdvancedEntry: SettingEntry = { + definition: mockAdvancedDefinition, + value: 'false', + source: 'default', + updated_at: null, +} + +const mockSecurityEntry: SettingEntry = { + definition: mockSecurityDefinition, + value: 'true', + source: 'yaml', + updated_at: null, +} + +describe('useSettingsStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() + }) + + it('initializes with empty state', () => { + const store = useSettingsStore() + expect(store.schema).toEqual([]) + expect(store.entries).toEqual([]) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(store.savingKey).toBeNull() + expect(store.showAdvanced).toBe(false) + }) + + it('fetchAll loads schema and entries in parallel', async () => { + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition, mockSecurityDefinition]) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([mockEntry, mockSecurityEntry]) + + const store = useSettingsStore() + await store.fetchAll() + + expect(settingsApi.getSchema).toHaveBeenCalledOnce() + expect(settingsApi.getAllSettings).toHaveBeenCalledOnce() + expect(store.schema).toHaveLength(2) + expect(store.entries).toHaveLength(2) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('fetchAll sets loading during fetch', async () => { + const settingsApi = await import('@/api/endpoints/settings') + let resolveSchema!: (v: SettingDefinition[]) => void + vi.mocked(settingsApi.getSchema).mockReturnValue( + new Promise((r) => { resolveSchema = r }), + ) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([]) + + const store = useSettingsStore() + const promise = store.fetchAll() + + expect(store.loading).toBe(true) + resolveSchema([]) + await promise + expect(store.loading).toBe(false) + }) + + it('fetchAll sets error on failure', async () => { + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockRejectedValue(new Error('Network error')) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([]) + + const store = useSettingsStore() + await store.fetchAll() + + expect(store.error).toBe('Network error') + expect(store.loading).toBe(false) + }) + + it('fetchAll ignores stale responses via generation counter', async () => { + const settingsApi = await import('@/api/endpoints/settings') + let resolveFirst!: (v: SettingDefinition[]) => void + vi.mocked(settingsApi.getSchema) + .mockReturnValueOnce(new Promise((r) => { resolveFirst = r })) + .mockResolvedValueOnce([mockDefinition]) + vi.mocked(settingsApi.getAllSettings) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([mockEntry]) + + const store = useSettingsStore() + + // First fetch (will be slow) + const first = store.fetchAll() + + // Second fetch (will be fast) + await store.fetchAll() + expect(store.schema).toEqual([mockDefinition]) + + // Now resolve the first (stale) -- should be ignored + resolveFirst([mockSecurityDefinition]) + await first + expect(store.schema).toEqual([mockDefinition]) + }) + + it('namespaces returns unique sorted namespaces from schema', async () => { + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue([ + mockSecurityDefinition, + mockDefinition, + mockAdvancedDefinition, + ]) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([]) + + const store = useSettingsStore() + await store.fetchAll() + + // budget comes before security in NAMESPACE_ORDER + expect(store.namespaces).toEqual(['budget', 'security']) + }) + + it('namespaces excludes advanced-only namespaces in basic mode', async () => { + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue([ + mockDefinition, // budget, basic + mockAdvancedOnlyDefinition, // coordination, advanced + ]) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([]) + + const store = useSettingsStore() + await store.fetchAll() + + // In basic mode, coordination (all-advanced) should be hidden + expect(store.showAdvanced).toBe(false) + expect(store.namespaces).toEqual(['budget']) + expect(store.namespaces).not.toContain('coordination') + + // Toggle to advanced -- coordination should appear + store.toggleAdvanced() + expect(store.namespaces).toEqual(['budget', 'coordination']) + }) + + it('entriesByNamespace filters entries by namespace', async () => { + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition, mockSecurityDefinition]) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([mockEntry, mockSecurityEntry]) + + const store = useSettingsStore() + await store.fetchAll() + + expect(store.entriesByNamespace('budget')).toEqual([mockEntry]) + expect(store.entriesByNamespace('security')).toEqual([mockSecurityEntry]) + expect(store.entriesByNamespace('api')).toEqual([]) + }) + + it('updateSetting calls API and refreshes entries', async () => { + const settingsApi = await import('@/api/endpoints/settings') + const updatedEntry: SettingEntry = { ...mockEntry, value: '200.0', source: 'db' } + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition]) + vi.mocked(settingsApi.getAllSettings) + .mockResolvedValueOnce([mockEntry]) + .mockResolvedValueOnce([updatedEntry]) + vi.mocked(settingsApi.updateSetting).mockResolvedValue(updatedEntry) + + const store = useSettingsStore() + await store.fetchAll() + expect(store.entries[0].value).toBe('100.0') + + await store.updateSetting('budget', 'total_monthly', '200.0') + + expect(settingsApi.updateSetting).toHaveBeenCalledWith('budget', 'total_monthly', { value: '200.0' }) + // After update, the store re-fetches entries + expect(store.entries[0].value).toBe('200.0') + expect(store.savingKey).toBeNull() + }) + + it('updateSetting sets savingKey during save', async () => { + const settingsApi = await import('@/api/endpoints/settings') + let resolveUpdate!: (v: SettingEntry) => void + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition]) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([mockEntry]) + vi.mocked(settingsApi.updateSetting).mockReturnValue( + new Promise((r) => { resolveUpdate = r }), + ) + + const store = useSettingsStore() + await store.fetchAll() + const promise = store.updateSetting('budget', 'total_monthly', '200.0') + + expect(store.savingKey).toBe('budget/total_monthly') + resolveUpdate(mockEntry) + await promise + expect(store.savingKey).toBeNull() + }) + + it('updateSetting propagates errors and clears savingKey', async () => { + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition]) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([mockEntry]) + vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Validation failed')) + + const store = useSettingsStore() + await store.fetchAll() + + await expect(store.updateSetting('budget', 'total_monthly', 'bad')) + .rejects.toThrow('Validation failed') + expect(store.savingKey).toBeNull() + }) + + it('resetSetting calls API and refreshes entries', async () => { + const settingsApi = await import('@/api/endpoints/settings') + const dbEntry: SettingEntry = { ...mockEntry, value: '200.0', source: 'db' } + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition]) + vi.mocked(settingsApi.getAllSettings) + .mockResolvedValueOnce([dbEntry]) + .mockResolvedValueOnce([mockEntry]) + vi.mocked(settingsApi.resetSetting).mockResolvedValue(undefined) + + const store = useSettingsStore() + await store.fetchAll() + expect(store.entries[0].source).toBe('db') + + await store.resetSetting('budget', 'total_monthly') + + expect(settingsApi.resetSetting).toHaveBeenCalledWith('budget', 'total_monthly') + expect(store.entries[0].source).toBe('default') + }) + + it('toggleAdvanced flips state and persists to localStorage', () => { + const store = useSettingsStore() + expect(store.showAdvanced).toBe(false) + + store.toggleAdvanced() + expect(store.showAdvanced).toBe(true) + expect(localStorage.getItem(SETTINGS_ADVANCED_KEY)).toBe('true') + + store.toggleAdvanced() + expect(store.showAdvanced).toBe(false) + expect(localStorage.getItem(SETTINGS_ADVANCED_KEY)).toBe('false') + }) + + it('reads showAdvanced from localStorage on init', () => { + localStorage.setItem(SETTINGS_ADVANCED_KEY, 'true') + const store = useSettingsStore() + expect(store.showAdvanced).toBe(true) + }) + + it('handles invalid localStorage value gracefully', () => { + localStorage.setItem(SETTINGS_ADVANCED_KEY, 'garbage') + const store = useSettingsStore() + expect(store.showAdvanced).toBe(false) + }) + + it('updateSetting applies returned entry even when refresh fails', async () => { + const settingsApi = await import('@/api/endpoints/settings') + const updatedEntry: SettingEntry = { ...mockEntry, value: '200.0', source: 'db' } + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition]) + vi.mocked(settingsApi.getAllSettings) + .mockResolvedValueOnce([mockEntry]) // initial fetch + .mockRejectedValueOnce(new Error('Network error')) // refresh after update + vi.mocked(settingsApi.updateSetting).mockResolvedValue(updatedEntry) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useSettingsStore() + await store.fetchAll() + + // Should NOT throw -- the update itself succeeded + await store.updateSetting('budget', 'total_monthly', '200.0') + expect(store.savingKey).toBeNull() + // Entries reflect the returned entry despite refresh failure + expect(store.entries[0].value).toBe('200.0') + expect(store.entries[0].source).toBe('db') + expect(warnSpy).toHaveBeenCalledWith('Settings refresh failed after update:', expect.any(Error)) + warnSpy.mockRestore() + }) + + it('resetSetting optimistically reverts to default when refresh fails', async () => { + const settingsApi = await import('@/api/endpoints/settings') + const dbEntry: SettingEntry = { ...mockEntry, value: '200.0', source: 'db' } + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition]) + vi.mocked(settingsApi.getAllSettings) + .mockResolvedValueOnce([dbEntry]) // initial fetch + .mockRejectedValueOnce(new Error('Network error')) // refresh after reset + vi.mocked(settingsApi.resetSetting).mockResolvedValue(undefined) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useSettingsStore() + await store.fetchAll() + expect(store.entries[0].source).toBe('db') + + // Should NOT throw -- the reset itself succeeded + await store.resetSetting('budget', 'total_monthly') + expect(store.savingKey).toBeNull() + // Entry optimistically reverted to default despite refresh failure + expect(store.entries[0].value).toBe('100.0') + expect(store.entries[0].source).toBe('default') + expect(warnSpy).toHaveBeenCalledWith('Settings refresh failed after reset:', expect.any(Error)) + warnSpy.mockRestore() + }) + + it('resetSetting propagates errors and clears savingKey', async () => { + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue([mockDefinition]) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([mockEntry]) + vi.mocked(settingsApi.resetSetting).mockRejectedValue(new Error('Not found')) + + const store = useSettingsStore() + await store.fetchAll() + + await expect(store.resetSetting('budget', 'total_monthly')) + .rejects.toThrow('Not found') + expect(store.savingKey).toBeNull() + }) +}) + +describe('validateSettingValue', () => { + function makeDef(overrides: Partial = {}): SettingDefinition { + return { + namespace: 'budget', + key: 'test', + type: 'str', + default: null, + description: 'Test', + group: 'Test', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: null, + max_value: null, + yaml_path: null, + ...overrides, + } + } + + // ── Integer validation ─────────────────────────────────── + + it('accepts valid integer', () => { + expect(validateSettingValue('42', makeDef({ type: 'int' }))).toBeNull() + }) + + it('accepts negative integer', () => { + expect(validateSettingValue('-7', makeDef({ type: 'int' }))).toBeNull() + }) + + it('rejects decimal for int (Number.isInteger check)', () => { + expect(validateSettingValue('3.5', makeDef({ type: 'int' }))).not.toBeNull() + }) + + it('rejects empty string for int', () => { + expect(validateSettingValue('', makeDef({ type: 'int' }))).not.toBeNull() + }) + + it('rejects non-numeric string for int', () => { + expect(validateSettingValue('abc', makeDef({ type: 'int' }))).not.toBeNull() + }) + + it('enforces int min_value', () => { + expect(validateSettingValue('-1', makeDef({ type: 'int', min_value: 0 }))).not.toBeNull() + }) + + it('enforces int max_value', () => { + expect(validateSettingValue('200', makeDef({ type: 'int', max_value: 100 }))).not.toBeNull() + }) + + it('accepts int at boundary values', () => { + expect(validateSettingValue('0', makeDef({ type: 'int', min_value: 0, max_value: 100 }))).toBeNull() + expect(validateSettingValue('100', makeDef({ type: 'int', min_value: 0, max_value: 100 }))).toBeNull() + }) + + // ── Float validation ────────────────────────────────────── + + it('accepts valid float', () => { + expect(validateSettingValue('3.14', makeDef({ type: 'float' }))).toBeNull() + }) + + it('rejects empty string for float', () => { + expect(validateSettingValue('', makeDef({ type: 'float' }))).not.toBeNull() + }) + + it('rejects non-numeric string for float', () => { + expect(validateSettingValue('abc', makeDef({ type: 'float' }))).not.toBeNull() + }) + + it('rejects Infinity for float', () => { + expect(validateSettingValue('Infinity', makeDef({ type: 'float' }))).not.toBeNull() + }) + + it('enforces float min_value', () => { + expect(validateSettingValue('-1', makeDef({ type: 'float', min_value: 0 }))).not.toBeNull() + }) + + it('enforces float max_value', () => { + expect(validateSettingValue('200', makeDef({ type: 'float', max_value: 100 }))).not.toBeNull() + }) + + // ── Bool validation ─────────────────────────────────────── + + it('accepts "true" and "false" for bool', () => { + expect(validateSettingValue('true', makeDef({ type: 'bool' }))).toBeNull() + expect(validateSettingValue('false', makeDef({ type: 'bool' }))).toBeNull() + }) + + it('accepts "1" and "0" for bool (backend compatibility)', () => { + expect(validateSettingValue('1', makeDef({ type: 'bool' }))).toBeNull() + expect(validateSettingValue('0', makeDef({ type: 'bool' }))).toBeNull() + }) + + it('accepts case-insensitive bool values', () => { + expect(validateSettingValue('True', makeDef({ type: 'bool' }))).toBeNull() + expect(validateSettingValue('FALSE', makeDef({ type: 'bool' }))).toBeNull() + }) + + it('rejects invalid bool value', () => { + expect(validateSettingValue('yes', makeDef({ type: 'bool' }))).not.toBeNull() + }) + + // ── Enum validation ─────────────────────────────────────── + + it('accepts value in enum_values', () => { + expect(validateSettingValue('a', makeDef({ type: 'enum', enum_values: ['a', 'b'] }))).toBeNull() + }) + + it('rejects value not in enum_values', () => { + expect(validateSettingValue('c', makeDef({ type: 'enum', enum_values: ['a', 'b'] }))).not.toBeNull() + }) + + // ── JSON validation ─────────────────────────────────────── + + it('accepts valid JSON', () => { + expect(validateSettingValue('{"key": "value"}', makeDef({ type: 'json' }))).toBeNull() + }) + + it('accepts JSON array', () => { + expect(validateSettingValue('[1, 2, 3]', makeDef({ type: 'json' }))).toBeNull() + }) + + it('rejects invalid JSON', () => { + expect(validateSettingValue('{bad json', makeDef({ type: 'json' }))).not.toBeNull() + }) + + it('rejects JSON primitives (only objects and arrays allowed)', () => { + expect(validateSettingValue('"hello"', makeDef({ type: 'json' }))).not.toBeNull() + expect(validateSettingValue('123', makeDef({ type: 'json' }))).not.toBeNull() + expect(validateSettingValue('true', makeDef({ type: 'json' }))).not.toBeNull() + expect(validateSettingValue('null', makeDef({ type: 'json' }))).not.toBeNull() + }) + + // ── Pattern validation ──────────────────────────────────── + + it('accepts value matching validator_pattern (fullmatch)', () => { + expect(validateSettingValue('abc', makeDef({ type: 'str', validator_pattern: '[a-z]+' }))).toBeNull() + }) + + it('rejects partial match (fullmatch semantics)', () => { + // "abc123" partially matches \d+ but should fail fullmatch + expect(validateSettingValue('abc123', makeDef({ type: 'str', validator_pattern: '\\d+' }))).not.toBeNull() + }) + + it('handles invalid regex in definition gracefully', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // Invalid regex should not throw -- just skip validation + expect(validateSettingValue('anything', makeDef({ type: 'str', validator_pattern: '[invalid' }))).toBeNull() + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('rejects values exceeding max length', () => { + const longValue = 'a'.repeat(8193) + expect(validateSettingValue(longValue, makeDef({ type: 'str' }))).not.toBeNull() + }) + + it('accepts values within max length', () => { + const okValue = 'a'.repeat(8192) + expect(validateSettingValue(okValue, makeDef({ type: 'str' }))).toBeNull() + }) +}) diff --git a/web/src/__tests__/views/SettingsPage.dynamic.test.ts b/web/src/__tests__/views/SettingsPage.dynamic.test.ts new file mode 100644 index 0000000000..f50397b563 --- /dev/null +++ b/web/src/__tests__/views/SettingsPage.dynamic.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' +import type { SettingDefinition, SettingEntry } from '@/api/types' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn(), go: vi.fn() }), + useRoute: () => ({ params: {}, query: {} }), + RouterLink: { template: '' }, + createRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + go: vi.fn(), + beforeEach: vi.fn(), + currentRoute: { value: { path: '/' } }, + }), + createWebHistory: vi.fn(), +})) + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: vi.fn() }), +})) + +vi.mock('primevue/tabs', () => ({ + default: { + props: ['value'], + emits: ['update:value'], + template: '
', + }, +})) + +vi.mock('primevue/tablist', () => ({ + default: { template: '
' }, +})) + +vi.mock('primevue/tab', () => ({ + default: { + props: ['value', 'disabled'], + template: '
', + }, +})) + +vi.mock('primevue/tabpanels', () => ({ + default: { template: '
' }, +})) + +vi.mock('primevue/tabpanel', () => ({ + default: { + props: ['value'], + template: '
', + }, +})) + +vi.mock('primevue/inputtext', () => ({ + default: { + props: ['modelValue', 'type', 'placeholder'], + template: '', + }, +})) + +vi.mock('primevue/button', () => ({ + default: { + props: ['label', 'icon', 'type', 'size', 'loading', 'disabled', 'severity', 'text'], + template: '', + }, +})) + +vi.mock('primevue/toggleswitch', () => ({ + default: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/inputnumber', () => ({ + default: { + props: ['modelValue', 'min', 'max', 'minFractionDigits', 'maxFractionDigits', 'useGrouping', 'disabled'], + template: '', + }, +})) + +vi.mock('primevue/select', () => ({ + default: { + props: ['modelValue', 'options', 'disabled'], + template: '', + }, +})) + +vi.mock('primevue/textarea', () => ({ + default: { + props: ['modelValue', 'rows', 'disabled'], + template: '', + }, +})) + +vi.mock('primevue/tag', () => ({ + default: { + props: ['value', 'severity'], + template: '{{ value }}', + }, +})) + +vi.mock('@/components/layout/AppShell.vue', () => ({ + default: { template: '
' }, +})) + +vi.mock('@/components/common/PageHeader.vue', () => ({ + default: { + props: ['title', 'subtitle'], + template: '

{{ title }}

{{ subtitle }}

', + }, +})) + +vi.mock('@/components/common/LoadingSkeleton.vue', () => ({ + default: { + props: ['lines'], + template: '
Loading...
', + }, +})) + +vi.mock('@/components/common/ErrorBoundary.vue', () => ({ + default: { + props: ['error'], + template: '
', + }, +})) + +vi.mock('@/api/endpoints/company', () => ({ + getCompanyConfig: vi.fn().mockResolvedValue({ + company_name: 'Test Corp', + agents: [{ name: 'alice', role: 'Developer' }], + }), + listDepartments: vi.fn().mockResolvedValue({ data: [], total: 0 }), + getDepartment: vi.fn(), +})) + +vi.mock('@/api/endpoints/providers', () => ({ + listProviders: vi.fn().mockResolvedValue({}), + getProvider: vi.fn(), + getProviderModels: vi.fn(), + listPresets: vi.fn().mockResolvedValue([]), + createFromPreset: vi.fn(), +})) + +vi.mock('@/api/endpoints/auth', () => ({ + getMe: vi.fn(), + login: vi.fn(), + setup: vi.fn(), + changePassword: vi.fn(), +})) + +const budgetDef: SettingDefinition = { + namespace: 'budget', + key: 'total_monthly', + type: 'float', + default: '100.0', + description: 'Monthly budget in USD', + group: 'Limits', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: 0.0, + max_value: null, + yaml_path: 'budget.total_monthly', +} + +const securityDef: SettingDefinition = { + namespace: 'security', + key: 'enabled', + type: 'bool', + default: 'true', + description: 'Enable security engine', + group: 'General', + level: 'basic', + sensitive: false, + restart_required: false, + enum_values: [], + validator_pattern: null, + min_value: null, + max_value: null, + yaml_path: null, +} + +const budgetEntry: SettingEntry = { + definition: budgetDef, + value: '100.0', + source: 'default', + updated_at: null, +} + +const securityEntry: SettingEntry = { + definition: securityDef, + value: 'true', + source: 'yaml', + updated_at: null, +} + +vi.mock('@/api/endpoints/settings', () => ({ + getSchema: vi.fn(), + getAllSettings: vi.fn(), + updateSetting: vi.fn(), + resetSetting: vi.fn(), +})) + +import SettingsPage from '@/views/SettingsPage.vue' + +describe('SettingsPage (dynamic)', () => { + beforeEach(async () => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() + + // Configure mock return values here (not in factory, since variables aren't available there) + const settingsApi = await import('@/api/endpoints/settings') + vi.mocked(settingsApi.getSchema).mockResolvedValue([budgetDef, securityDef]) + vi.mocked(settingsApi.getAllSettings).mockResolvedValue([budgetEntry, securityEntry]) + }) + + it('renders Settings heading', () => { + const wrapper = mount(SettingsPage) + expect(wrapper.find('h1').text()).toBe('Settings') + }) + + it('shows loading skeleton initially', () => { + const wrapper = mount(SettingsPage) + expect(wrapper.find('[data-testid="loading-skeleton"]').exists()).toBe(true) + }) + + it('fetches settings schema and entries on mount', async () => { + const settingsApi = await import('@/api/endpoints/settings') + mount(SettingsPage) + await flushPromises() + expect(settingsApi.getSchema).toHaveBeenCalled() + expect(settingsApi.getAllSettings).toHaveBeenCalled() + }) + + it('renders dynamic namespace tabs after loading', async () => { + const wrapper = mount(SettingsPage) + await flushPromises() + + // Should have tabs for budget, security, providers, and user + const tabs = wrapper.findAll('[data-tab]') + const tabValues = tabs.map((t) => t.attributes('data-tab')) + expect(tabValues).toContain('budget') + expect(tabValues).toContain('security') + expect(tabValues).toContain('providers') + expect(tabValues).toContain('user') + }) + + it('renders setting fields inside namespace tabs', async () => { + const wrapper = mount(SettingsPage) + await flushPromises() + + expect(wrapper.text()).toContain('total_monthly') + expect(wrapper.text()).toContain('Monthly budget in USD') + }) + + it('preserves user tab with password change form', async () => { + const wrapper = mount(SettingsPage) + await flushPromises() + + expect(wrapper.text()).toContain('Change Password') + }) + + it('renders basic/advanced toggle', async () => { + const wrapper = mount(SettingsPage) + await flushPromises() + + // The toggle switch should be rendered in the header actions slot + expect(wrapper.find('[role="switch"]').exists()).toBe(true) + }) +}) diff --git a/web/src/__tests__/views/SettingsPage.test.ts b/web/src/__tests__/views/SettingsPage.test.ts index aabb9dedb2..f8f87e74e2 100644 --- a/web/src/__tests__/views/SettingsPage.test.ts +++ b/web/src/__tests__/views/SettingsPage.test.ts @@ -20,13 +20,28 @@ vi.mock('primevue/usetoast', () => ({ useToast: () => ({ add: vi.fn() }), })) -vi.mock('primevue/tabview', () => ({ +vi.mock('primevue/tabs', () => ({ + default: { template: '
' }, +})) + +vi.mock('primevue/tablist', () => ({ + default: { template: '
' }, +})) + +vi.mock('primevue/tab', () => ({ + default: { + props: ['value', 'disabled'], + template: '
', + }, +})) + +vi.mock('primevue/tabpanels', () => ({ default: { template: '
' }, })) vi.mock('primevue/tabpanel', () => ({ default: { - props: ['header', 'value'], + props: ['value'], template: '
', }, })) @@ -40,11 +55,47 @@ vi.mock('primevue/inputtext', () => ({ vi.mock('primevue/button', () => ({ default: { - props: ['label', 'icon', 'type', 'size', 'loading', 'disabled'], + props: ['label', 'icon', 'type', 'size', 'loading', 'disabled', 'severity', 'text'], template: '', }, })) +vi.mock('primevue/toggleswitch', () => ({ + default: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('primevue/inputnumber', () => ({ + default: { + props: ['modelValue', 'min', 'max', 'minFractionDigits', 'maxFractionDigits', 'useGrouping', 'disabled'], + template: '', + }, +})) + +vi.mock('primevue/select', () => ({ + default: { + props: ['modelValue', 'options', 'disabled'], + template: '', + }, +})) + +vi.mock('primevue/textarea', () => ({ + default: { + props: ['modelValue', 'rows', 'disabled'], + template: '', + }, +})) + +vi.mock('primevue/tag', () => ({ + default: { + props: ['value', 'severity'], + template: '{{ value }}', + }, +})) + vi.mock('primevue/datatable', () => ({ default: { props: ['value', 'stripedRows'], @@ -66,7 +117,7 @@ vi.mock('@/components/layout/AppShell.vue', () => ({ vi.mock('@/components/common/PageHeader.vue', () => ({ default: { props: ['title', 'subtitle'], - template: '

{{ title }}

{{ subtitle }}

', + template: '

{{ title }}

{{ subtitle }}

', }, })) @@ -97,6 +148,8 @@ vi.mock('@/api/endpoints/providers', () => ({ listProviders: vi.fn().mockResolvedValue({}), getProvider: vi.fn(), getProviderModels: vi.fn(), + listPresets: vi.fn().mockResolvedValue([]), + createFromPreset: vi.fn(), })) vi.mock('@/api/endpoints/auth', () => ({ @@ -106,6 +159,13 @@ vi.mock('@/api/endpoints/auth', () => ({ changePassword: vi.fn(), })) +vi.mock('@/api/endpoints/settings', () => ({ + getSchema: vi.fn().mockResolvedValue([]), + getAllSettings: vi.fn().mockResolvedValue([]), + updateSetting: vi.fn(), + resetSetting: vi.fn(), +})) + import SettingsPage from '@/views/SettingsPage.vue' describe('SettingsPage', () => { diff --git a/web/src/api/endpoints/settings.ts b/web/src/api/endpoints/settings.ts new file mode 100644 index 0000000000..43570f9b5c --- /dev/null +++ b/web/src/api/endpoints/settings.ts @@ -0,0 +1,45 @@ +import { apiClient, unwrap, unwrapVoid } from '../client' +import type { ApiResponse, SettingDefinition, SettingEntry, SettingNamespace, UpdateSettingRequest } from '../types' + +export async function getSchema(): Promise { + const response = await apiClient.get>('/settings/_schema') + return unwrap(response) +} + +export async function getNamespaceSchema(namespace: SettingNamespace): Promise { + const response = await apiClient.get>( + `/settings/_schema/${encodeURIComponent(namespace)}`, + ) + return unwrap(response) +} + +export async function getAllSettings(): Promise { + const response = await apiClient.get>('/settings') + return unwrap(response) +} + +export async function getNamespaceSettings(namespace: SettingNamespace): Promise { + const response = await apiClient.get>( + `/settings/${encodeURIComponent(namespace)}`, + ) + return unwrap(response) +} + +export async function updateSetting( + namespace: SettingNamespace, + key: string, + data: UpdateSettingRequest, +): Promise { + const response = await apiClient.put>( + `/settings/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, + data, + ) + return unwrap(response) +} + +export async function resetSetting(namespace: SettingNamespace, key: string): Promise { + const response = await apiClient.delete>( + `/settings/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, + ) + unwrapVoid(response) +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index faa2c10599..5f12c678f8 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -863,3 +863,51 @@ export interface SetupAgentResponse { model_provider: string model_id: string } + +// ── Settings ──────────────────────────────────────────────── + +export type SettingNamespace = + | 'api' + | 'company' + | 'providers' + | 'memory' + | 'budget' + | 'security' + | 'coordination' + | 'observability' + | 'backup' + +export type SettingType = 'str' | 'int' | 'float' | 'bool' | 'enum' | 'json' + +export type SettingLevel = 'basic' | 'advanced' + +export type SettingSource = 'db' | 'env' | 'yaml' | 'default' + +export interface SettingDefinition { + namespace: SettingNamespace + key: string + type: SettingType + default: string | null + description: string + group: string + level: SettingLevel + sensitive: boolean + restart_required: boolean + enum_values: readonly string[] + validator_pattern: string | null + min_value: number | null + max_value: number | null + yaml_path: string | null +} + +export interface SettingEntry { + definition: SettingDefinition + value: string + source: SettingSource + updated_at: string | null +} + +/** Backend enforces max_length=8192 on value. */ +export interface UpdateSettingRequest { + value: string +} diff --git a/web/src/components/settings/SettingField.vue b/web/src/components/settings/SettingField.vue new file mode 100644 index 0000000000..251fd6de70 --- /dev/null +++ b/web/src/components/settings/SettingField.vue @@ -0,0 +1,210 @@ + + +