archy/neode-ui/src/views/__tests__/settings.test.ts

318 lines
10 KiB
TypeScript

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<ReturnType<typeof useAppStore>>): 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<string, unknown>)
}
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' })
})
})