318 lines
10 KiB
TypeScript
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' })
|
|
})
|
|
})
|