import { describe, it, expect, vi, beforeEach } from 'vitest' import { shallowMount, VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { defineComponent, h } from 'vue' // Mock rpc-client before importing anything that uses it vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn().mockResolvedValue({ backups: [] }), login: vi.fn(), logout: vi.fn(), changePassword: vi.fn(), totpStatus: vi.fn().mockResolvedValue({ enabled: false }), totpSetupBegin: vi.fn(), totpSetupConfirm: vi.fn(), totpDisable: vi.fn(), getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }), }, })) // Mock websocket module vi.mock('@/api/websocket', () => ({ wsClient: { connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn(), subscribe: vi.fn(), isConnected: vi.fn().mockReturnValue(false), onConnectionStateChange: vi.fn(), }, applyDataPatch: vi.fn(), })) // Stub the ControllerIndicator component vi.mock('@/components/ControllerIndicator.vue', () => ({ default: defineComponent({ name: 'ControllerIndicator', render: () => h('div') }), })) // Mock useModalKeyboard composable vi.mock('@/composables/useModalKeyboard', () => ({ useModalKeyboard: vi.fn(), })) // Stub vue-router const pushMock = vi.fn() vi.mock('vue-router', () => ({ useRouter: () => ({ push: pushMock, }), RouterLink: defineComponent({ name: 'RouterLink', props: { to: { type: String, default: '' } }, setup(_, { slots }) { return () => h('a', {}, slots.default?.()) }, }), })) // Stub global fetch for the Claude status check in onMounted vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not available'))) import { createI18n } from 'vue-i18n' import en from '@/locales/en.json' import Settings from '../Settings.vue' import { rpcClient } from '@/api/rpc-client' import { useAppStore } from '@/stores/app' const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) const mockedRpc = vi.mocked(rpcClient) function mountSettings(storeOverrides?: Partial>): VueWrapper { const pinia = createPinia() setActivePinia(pinia) const store = useAppStore() // Set default store state for tests store.isAuthenticated = true store.$patch({ data: { 'server-info': { id: 'test-node', version: '0.1.0-alpha', name: 'Test Node', pubkey: 'test-pubkey', 'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null }, 'lan-address': '192.168.1.100', 'tor-address': null, unread: 0, 'wifi-ssids': [], 'zram-enabled': false, }, 'package-data': {}, ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' }, }, }) if (storeOverrides) { store.$patch(storeOverrides as Record) } return shallowMount(Settings, { global: { plugins: [pinia, i18n], stubs: { Teleport: true, RouterLink: defineComponent({ name: 'RouterLink', props: { to: { type: String, default: '' } }, setup(_, { slots }) { return () => h('a', {}, slots.default?.()) }, }), }, }, }) } describe('Settings View', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() mockedRpc.totpStatus.mockResolvedValue({ enabled: false }) mockedRpc.call.mockResolvedValue({ backups: [] }) mockedRpc.getTorAddress.mockResolvedValue({ tor_address: null }) pushMock.mockResolvedValue(undefined) }) it('renders without errors', () => { const wrapper = mountSettings() expect(wrapper.exists()).toBe(true) }) it('displays the Settings heading', () => { const wrapper = mountSettings() const heading = wrapper.find('h1') expect(heading.exists()).toBe(true) expect(heading.text()).toBe('Settings') }) it('displays the Account section with server name and version', () => { const wrapper = mountSettings() const html = wrapper.html() // Account section heading const sectionHeadings = wrapper.findAll('h2') const accountHeading = sectionHeadings.find((h) => h.text() === 'Account') expect(accountHeading).toBeDefined() // Server name rendered expect(html).toContain('Test Node') // Version rendered expect(html).toContain('0.1.0') }) it('displays the version from server info', () => { const wrapper = mountSettings() const html = wrapper.html() expect(html).toContain('0.1.0') expect(html).toContain('Version') }) it('displays the Interface Mode section', () => { const wrapper = mountSettings() const sectionHeadings = wrapper.findAll('h2') const modeHeading = sectionHeadings.find((h) => h.text() === 'Interface Mode') expect(modeHeading).toBeDefined() }) it('displays the Claude Authentication section', () => { const wrapper = mountSettings() const sectionHeadings = wrapper.findAll('h2') const claudeHeading = sectionHeadings.find((h) => h.text() === 'Claude Authentication') expect(claudeHeading).toBeDefined() }) it('displays the AI Data Access section', () => { const wrapper = mountSettings() const sectionHeadings = wrapper.findAll('h2') const aiHeading = sectionHeadings.find((h) => h.text() === 'AI Data Access') expect(aiHeading).toBeDefined() }) it('displays the System Updates section', () => { const wrapper = mountSettings() const sectionHeadings = wrapper.findAll('h2') const updatesHeading = sectionHeadings.find((h) => h.text() === 'System Updates') expect(updatesHeading).toBeDefined() }) it('displays the Backup & Restore section', () => { const wrapper = mountSettings() const sectionHeadings = wrapper.findAll('h2') const backupHeading = sectionHeadings.find((h) => h.text().includes('Backup')) expect(backupHeading).toBeDefined() }) it('displays the Network section', () => { const wrapper = mountSettings() const sectionHeadings = wrapper.findAll('h2') const networkHeading = sectionHeadings.find((h) => h.text() === 'Network') expect(networkHeading).toBeDefined() }) it('displays a Logout button', () => { const wrapper = mountSettings() const buttons = wrapper.findAll('button') const logoutButton = buttons.find((b) => b.text().includes('Logout')) expect(logoutButton).toBeDefined() expect(logoutButton!.exists()).toBe(true) }) it('logout button triggers store logout and navigates to login', async () => { const wrapper = mountSettings() const store = useAppStore() const logoutSpy = vi.spyOn(store, 'logout').mockResolvedValue() const buttons = wrapper.findAll('button') const logoutButton = buttons.find((b) => b.text().includes('Logout')) expect(logoutButton).toBeDefined() await logoutButton!.trigger('click') // Allow async handlers to settle await vi.dynamicImportSettled() expect(logoutSpy).toHaveBeenCalled() expect(pushMock).toHaveBeenCalledWith('/login') }) it('displays a Change Password button', () => { const wrapper = mountSettings() const buttons = wrapper.findAll('button') const changePasswordButton = buttons.find((b) => b.text().includes('Change Password')) expect(changePasswordButton).toBeDefined() expect(changePasswordButton!.exists()).toBe(true) }) it('displays Two-Factor Authentication section with status', () => { const wrapper = mountSettings() const html = wrapper.html() expect(html).toContain('Two-Factor Authentication') }) it('shows Enable 2FA button when TOTP is not enabled', () => { const wrapper = mountSettings() const buttons = wrapper.findAll('button') const enable2faButton = buttons.find((b) => b.text().includes('Enable 2FA')) expect(enable2faButton).toBeDefined() }) it('displays session status as currently logged in', () => { const wrapper = mountSettings() expect(wrapper.html()).toContain('Currently logged in') }) it('shows server name from the store', () => { const wrapper = mountSettings() expect(wrapper.html()).toContain('Server Name') expect(wrapper.html()).toContain('Test Node') }) it('defaults version to 0.0.0 when server info has no version', () => { const pinia = createPinia() setActivePinia(pinia) const store = useAppStore() store.$patch({ isAuthenticated: true, data: { 'server-info': { id: 'test', version: '', name: null, pubkey: '', 'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null }, 'lan-address': null, 'tor-address': null, unread: 0, 'wifi-ssids': [], }, 'package-data': {}, ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' }, }, }) const wrapper = shallowMount(Settings, { global: { plugins: [pinia, i18n], stubs: { Teleport: true, RouterLink: defineComponent({ name: 'RouterLink', props: { to: { type: String, default: '' } }, setup(_, { slots }) { return () => h('a', {}, slots.default?.()) }, }), }, }, }) // When version is empty string, computed returns '0.0.0' from the fallback const html = wrapper.html() expect(html).toContain('0.0.0') }) it('calls totpStatus on mount to check 2FA state', async () => { mountSettings() // onMounted calls loadTotpStatus which calls rpcClient.totpStatus expect(mockedRpc.totpStatus).toHaveBeenCalled() }) it('calls backup.list on mount to load backups', async () => { mountSettings() // onMounted calls loadBackups which calls rpcClient.call with backup.list expect(mockedRpc.call).toHaveBeenCalledWith({ method: 'backup.list' }) }) })