Skip to content
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions web/src/__tests__/__mocks__/primevue.ts
Original file line number Diff line number Diff line change
@@ -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: '<input :type="type" :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)" />',
}

export const InputNumberMock = {
props: ['modelValue', 'min', 'max', 'minFractionDigits', 'maxFractionDigits', 'useGrouping', 'disabled'],
emits: ['update:modelValue'],
template: '<input type="number" :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', Number($event.target.value))" />',
}

export const ToggleSwitchMock = {
props: ['modelValue', 'disabled', 'ariaLabel'],
emits: ['update:modelValue'],
template: '<button role="switch" :aria-checked="modelValue" :aria-label="ariaLabel" :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)">{{ modelValue }}</button>',
}

export const SelectMock = {
props: ['modelValue', 'options', 'disabled'],
emits: ['update:modelValue'],
template: '<select :value="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.value)"><option v-for="o in options" :key="o" :value="o">{{ o }}</option></select>',
}

export const TextareaMock = {
props: ['modelValue', 'rows', 'disabled'],
emits: ['update:modelValue'],
template: '<textarea :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)"></textarea>',
}

export const ButtonMock = {
props: ['label', 'icon', 'size', 'severity', 'text', 'disabled', 'loading', 'type', 'ariaLabel'],
template: '<button :disabled="disabled" :type="type || \'button\'">{{ label }}</button>',
}

export const TagMock = {
props: ['value', 'severity'],
template: '<span :data-severity="severity">{{ value }}</span>',
}

/**
* 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.
*/
137 changes: 137 additions & 0 deletions web/src/__tests__/api/settings.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>; put: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }
let unwrap: ReturnType<typeof vi.fn>

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<typeof vi.fn>
})

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' })
})
})
Loading
Loading