From c7992f047e888cf9140c9ce2f5fd47a93f3ba2e9 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:40:51 +0100 Subject: [PATCH 1/7] feat: implement dynamic settings UI with auto-discovery and basic/advanced toggle Add a schema-driven settings UI that auto-discovers all 50 settings across 9 namespaces from the backend registry. No frontend changes needed when new settings are added to the backend. New files: - API endpoint module (settings.ts) wrapping GET/PUT/DELETE endpoints - Pinia store (useSettingsStore) with generation-counter stale-response protection and localStorage-persisted basic/advanced toggle - SettingField component with type-appropriate inputs (text, number, toggle, select, textarea, password), client-side validation from schema constraints, dirty tracking, and per-setting save/reset - SettingGroupRenderer component grouping settings by group field with basic/advanced level filtering - SettingSourceBadge and SettingRestartBadge display components - 8 test files (64 new tests) including property-based tests Refactored SettingsPage replaces static Company tab with dynamic namespace tabs, adds basic/advanced toggle in header, preserves custom Providers card UI with dynamic settings below, and keeps User tab unchanged. Closes #454 Co-Authored-By: Claude Opus 4.6 (1M context) --- web/src/__tests__/api/settings.test.ts | 137 ++++++++ .../components/settings/SettingField.test.ts | 302 ++++++++++++++++++ .../settings/SettingGroupRenderer.test.ts | 202 ++++++++++++ .../settings/SettingRestartBadge.test.ts | 23 ++ .../settings/SettingSourceBadge.test.ts | 53 +++ .../stores/settings.property.test.ts | 219 +++++++++++++ web/src/__tests__/stores/settings.test.ts | 284 ++++++++++++++++ .../views/SettingsPage.dynamic.test.ts | 260 +++++++++++++++ web/src/__tests__/views/SettingsPage.test.ts | 49 ++- web/src/api/endpoints/settings.ts | 44 +++ web/src/api/types.ts | 47 +++ web/src/components/settings/SettingField.vue | 208 ++++++++++++ .../settings/SettingGroupRenderer.vue | 67 ++++ .../settings/SettingRestartBadge.vue | 7 + .../settings/SettingSourceBadge.vue | 26 ++ web/src/stores/settings.ts | 159 +++++++++ web/src/utils/constants.ts | 33 +- web/src/views/SettingsPage.vue | 157 ++++++--- 18 files changed, 2227 insertions(+), 50 deletions(-) create mode 100644 web/src/__tests__/api/settings.test.ts create mode 100644 web/src/__tests__/components/settings/SettingField.test.ts create mode 100644 web/src/__tests__/components/settings/SettingGroupRenderer.test.ts create mode 100644 web/src/__tests__/components/settings/SettingRestartBadge.test.ts create mode 100644 web/src/__tests__/components/settings/SettingSourceBadge.test.ts create mode 100644 web/src/__tests__/stores/settings.property.test.ts create mode 100644 web/src/__tests__/stores/settings.test.ts create mode 100644 web/src/__tests__/views/SettingsPage.dynamic.test.ts create mode 100644 web/src/api/endpoints/settings.ts create mode 100644 web/src/components/settings/SettingField.vue create mode 100644 web/src/components/settings/SettingGroupRenderer.vue create mode 100644 web/src/components/settings/SettingRestartBadge.vue create mode 100644 web/src/components/settings/SettingSourceBadge.vue create mode 100644 web/src/stores/settings.ts diff --git a/web/src/__tests__/api/settings.test.ts b/web/src/__tests__/api/settings.test.ts new file mode 100644 index 0000000000..65b7c7d2ec --- /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') + + 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..19f19b878d --- /dev/null +++ b/web/src/__tests__/components/settings/SettingField.test.ts @@ -0,0 +1,302 @@ +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') + // The button should now be enabled since value changed from 100.0 to 200 + expect(saveBtn).toBeDefined() + }) + + // ── 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', + ) + if (eyeBtn) { + await eyeBtn.trigger('click') + await flushPromises() + expect(wrapper.find('input[type="text"]').exists()).toBe(true) + } + }) +}) 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..2648444257 --- /dev/null +++ b/web/src/__tests__/components/settings/SettingGroupRenderer.test.ts @@ -0,0 +1,202 @@ +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, + } +} + +const basicLimits = makeEntry({ key: 'total_monthly', group: 'Limits' }) +const basicLimits2 = makeEntry({ key: 'per_task_limit', group: 'Limits', default: '5.0' }, { value: '5.0' }) +const advancedAlerts = makeEntry({ + key: 'alert_warn_at', + group: 'Alerts', + level: 'advanced', + type: 'int', + default: '75', +}, { value: '75' }) +const advancedDowngrade = 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 wrapper = mount(SettingGroupRenderer, { + props: { + entries: [basicLimits], + 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([basicLimits, '200.0']) + }) + + it('emits reset event from child SettingField', async () => { + const wrapper = mount(SettingGroupRenderer, { + props: { + entries: [basicLimits], + showAdvanced: false, + }, + }) + const settingField = wrapper.findComponent({ name: 'SettingField' }) + settingField.vm.$emit('reset') + + expect(wrapper.emitted('reset')).toBeTruthy() + expect(wrapper.emitted('reset')![0]).toEqual([basicLimits]) + }) +}) 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..f4b1f3bc94 --- /dev/null +++ b/web/src/__tests__/components/settings/SettingSourceBadge.test.ts @@ -0,0 +1,53 @@ +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('renders the source label for db', () => { + const wrapper = mount(SettingSourceBadge, { props: { source: 'db' } }) + expect(wrapper.text()).toContain('Database') + }) + + it('renders the source label for env', () => { + const wrapper = mount(SettingSourceBadge, { props: { source: 'env' } }) + expect(wrapper.text()).toContain('Environment') + }) + + it('renders the source label for yaml', () => { + const wrapper = mount(SettingSourceBadge, { props: { source: 'yaml' } }) + expect(wrapper.text()).toContain('YAML') + }) + + it('renders the source label for default', () => { + const wrapper = mount(SettingSourceBadge, { props: { source: 'default' } }) + expect(wrapper.text()).toContain('Default') + }) + + it('applies info severity for db source', () => { + const wrapper = mount(SettingSourceBadge, { props: { source: 'db' } }) + expect(wrapper.find('[data-severity="info"]').exists()).toBe(true) + }) + + it('applies warn severity for env source', () => { + const wrapper = mount(SettingSourceBadge, { props: { source: 'env' } }) + expect(wrapper.find('[data-severity="warn"]').exists()).toBe(true) + }) + + it('applies secondary severity for yaml source', () => { + const wrapper = mount(SettingSourceBadge, { props: { source: 'yaml' } }) + expect(wrapper.find('[data-severity="secondary"]').exists()).toBe(true) + }) + + it('applies contrast severity for default source', () => { + const wrapper = mount(SettingSourceBadge, { props: { source: 'default' } }) + expect(wrapper.find('[data-severity="contrast"]').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..c81e226d00 --- /dev/null +++ b/web/src/__tests__/stores/settings.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useSettingsStore } 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(), +})) + +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 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('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_show_advanced')).toBe('true') + + store.toggleAdvanced() + expect(store.showAdvanced).toBe(false) + expect(localStorage.getItem('settings_show_advanced')).toBe('false') + }) + + it('reads showAdvanced from localStorage on init', () => { + localStorage.setItem('settings_show_advanced', 'true') + const store = useSettingsStore() + expect(store.showAdvanced).toBe(true) + }) + + it('handles invalid localStorage value gracefully', () => { + localStorage.setItem('settings_show_advanced', 'garbage') + const store = useSettingsStore() + expect(store.showAdvanced).toBe(false) + }) +}) 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..236e9d308b --- /dev/null +++ b/web/src/__tests__/views/SettingsPage.dynamic.test.ts @@ -0,0 +1,260 @@ +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/tabview', () => ({ + default: { + props: ['value'], + emits: ['update:value'], + template: '
', + }, +})) + +vi.mock('primevue/tabpanel', () => ({ + default: { + props: ['header', 'value', 'disabled'], + 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..2b1e7367b7 100644 --- a/web/src/__tests__/views/SettingsPage.test.ts +++ b/web/src/__tests__/views/SettingsPage.test.ts @@ -40,11 +40,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 +102,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 +133,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 +144,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..dab627c624 --- /dev/null +++ b/web/src/api/endpoints/settings.ts @@ -0,0 +1,44 @@ +import { apiClient, unwrap } from '../client' +import type { ApiResponse, SettingDefinition, SettingEntry, UpdateSettingRequest } from '../types' + +export async function getSchema(): Promise { + const response = await apiClient.get>('/settings/_schema') + return unwrap(response) +} + +export async function getNamespaceSchema(namespace: string): 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: string): Promise { + const response = await apiClient.get>( + `/settings/${encodeURIComponent(namespace)}`, + ) + return unwrap(response) +} + +export async function updateSetting( + namespace: string, + key: string, + data: UpdateSettingRequest, +): Promise { + const response = await apiClient.put>( + `/settings/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, + data, + ) + return unwrap(response) +} + +export async function resetSetting(namespace: string, key: string): Promise { + await apiClient.delete( + `/settings/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, + ) +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index faa2c10599..e11c4e9319 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -863,3 +863,50 @@ 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: 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 +} + +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..63535fc685 --- /dev/null +++ b/web/src/components/settings/SettingField.vue @@ -0,0 +1,208 @@ + + +