import { describe, it, expect, vi, beforeEach } from 'vitest' type Interceptor = { fulfilled?: (v: T) => T | Promise rejected?: (e: unknown) => unknown } interface MockInstance { defaults: { baseURL?: string } interceptors: { request: { use: (f: Interceptor['fulfilled'], r?: Interceptor['rejected']) => void } response: { use: (f: Interceptor['fulfilled'], r?: Interceptor['rejected']) => void } } get: ReturnType post: ReturnType patch: ReturnType delete: ReturnType __req: Interceptor<{ headers: Record }> __res: Interceptor } const hoisted = vi.hoisted(() => ({ instances: [] as unknown[] })) const instances = hoisted.instances as MockInstance[] vi.mock('axios', () => { return { default: { create: (cfg: { baseURL?: string }) => { const inst: MockInstance = { defaults: { baseURL: cfg?.baseURL }, interceptors: { request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } }, response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } }, }, get: vi.fn(() => Promise.resolve({ data: {} })), post: vi.fn(() => Promise.resolve({ data: {} })), patch: vi.fn(() => Promise.resolve({ data: {} })), delete: vi.fn(() => Promise.resolve({ data: {} })), __req: {}, __res: {}, } hoisted.instances.push(inst) return inst }, }, } }) import { useAuthStore } from '@/stores/authStore' import * as clientModule from '../client' describe('api/client', () => { const mod = clientModule const [api, publicApi] = instances beforeEach(() => { useAuthStore.setState({ token: null, isAuthenticated: false }) api.get.mockClear() api.post.mockClear() api.patch.mockClear() api.delete.mockClear() publicApi.get.mockClear() publicApi.post.mockClear() }) it('creates two axios instances with /api/v1 baseURL', () => { expect(instances).toHaveLength(2) expect(api.defaults.baseURL).toBe('/api/v1') expect(publicApi.defaults.baseURL).toBe('/api/v1') }) it('exports `api` matching the first created instance', () => { expect(mod.api).toBe(api) }) it('request interceptor adds Authorization header when token present', () => { useAuthStore.setState({ token: 'tok-123', isAuthenticated: true }) const cfg = { headers: {} as Record } const out = api.__req.fulfilled!(cfg) expect((out as typeof cfg).headers.Authorization).toBe('Bearer tok-123') }) it('request interceptor leaves headers untouched when no token', () => { const cfg = { headers: {} as Record } const out = api.__req.fulfilled!(cfg) expect((out as typeof cfg).headers.Authorization).toBeUndefined() }) it('response interceptor passes through 2xx responses', () => { const r = { status: 200, data: { ok: true } } expect(api.__res.fulfilled!(r)).toBe(r) }) it('response interceptor calls logout on 401', async () => { const logout = vi.spyOn(useAuthStore.getState(), 'logout') useAuthStore.setState({ token: 't', isAuthenticated: true, logout }) const err = { response: { status: 401 } } await expect(api.__res.rejected!(err)).rejects.toBe(err) expect(logout).toHaveBeenCalled() }) it('response interceptor does not call logout on non-401', async () => { const logout = vi.fn() useAuthStore.setState({ token: 't', isAuthenticated: true, logout }) const err = { response: { status: 500 } } await expect(api.__res.rejected!(err)).rejects.toBe(err) expect(logout).not.toHaveBeenCalled() }) it('response interceptor handles error with no response object', async () => { const logout = vi.fn() useAuthStore.setState({ logout }) const err = { message: 'network down' } await expect(api.__res.rejected!(err)).rejects.toBe(err) expect(logout).not.toHaveBeenCalled() }) it('publicApi has no request/response interceptors registered', () => { expect(publicApi.__req.fulfilled).toBeUndefined() expect(publicApi.__res.fulfilled).toBeUndefined() }) it('authApi.login posts to /auth/login', () => { mod.authApi.login('u', 'p') expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' }) }) it('canvasApi.load GETs /canvas', () => { mod.canvasApi.load() expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({})) }) it('canvasApi.save POSTs to /canvas/save with payload', () => { const payload = { nodes: [], edges: [], viewport: {} } mod.canvasApi.save(payload) expect(api.post).toHaveBeenCalledWith('/canvas/save', payload) }) it('nodesApi CRUD calls correct endpoints', () => { mod.nodesApi.create({ a: 1 }) expect(api.post).toHaveBeenCalledWith('/nodes', { a: 1 }) mod.nodesApi.update('n1', { b: 2 }) expect(api.patch).toHaveBeenCalledWith('/nodes/n1', { b: 2 }) mod.nodesApi.delete('n1') expect(api.delete).toHaveBeenCalledWith('/nodes/n1') }) it('edgesApi CRUD calls correct endpoints', () => { mod.edgesApi.create({ s: 'a', t: 'b' }) expect(api.post).toHaveBeenCalledWith('/edges', { s: 'a', t: 'b' }) mod.edgesApi.delete('e1') expect(api.delete).toHaveBeenCalledWith('/edges/e1') }) it('liveviewApi.load uses publicApi with key param', () => { mod.liveviewApi.load('k-1') expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1' } }) expect(api.get).not.toHaveBeenCalled() }) it('liveviewApi.load forwards design as design_id when provided', () => { mod.liveviewApi.load('k-1', 'design-9') expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1', design_id: 'design-9' } }) }) it('liveviewApi.getConfig hits the authenticated config endpoint', () => { mod.liveviewApi.getConfig() expect(api.get).toHaveBeenCalledWith('/liveview/config') }) it('scanApi endpoints route correctly', () => { mod.scanApi.trigger() expect(api.post).toHaveBeenCalledWith('/scan/trigger', {}) mod.scanApi.pending() expect(api.get).toHaveBeenCalledWith('/scan/pending') mod.scanApi.hidden() expect(api.get).toHaveBeenCalledWith('/scan/hidden') mod.scanApi.runs() expect(api.get).toHaveBeenCalledWith('/scan/runs') mod.scanApi.clearPending() expect(api.delete).toHaveBeenCalledWith('/scan/pending') mod.scanApi.approve('d1', { foo: 'bar' }) expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/approve', { foo: 'bar' }) mod.scanApi.hide('d1') expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/hide') mod.scanApi.ignore('d1') expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore') mod.scanApi.bulkApprove(['a', 'b']) expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'], design_id: undefined }) mod.scanApi.bulkApprove(['a'], 'design-9') expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a'], design_id: 'design-9' }) mod.scanApi.bulkHide(['a']) expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] }) mod.scanApi.restore('d1') expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/restore') mod.scanApi.bulkRestore(['a']) expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-restore', { device_ids: ['a'] }) mod.scanApi.stop('run-1') expect(api.post).toHaveBeenCalledWith('/scan/run-1/stop') mod.scanApi.getConfig() expect(api.get).toHaveBeenCalledWith('/scan/config') mod.scanApi.saveConfig({ ranges: ['1.0/24'] }) expect(api.post).toHaveBeenCalledWith('/scan/config', { ranges: ['1.0/24'] }) }) it('settingsApi get/save', () => { mod.settingsApi.get() expect(api.get).toHaveBeenCalledWith('/settings') mod.settingsApi.save({ interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 }) expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 }) }) it('zigbeeApi.testConnection/importNetwork/importToPending', () => { const cfg = { mqtt_host: 'h', mqtt_port: 1883 } mod.zigbeeApi.testConnection(cfg) expect(api.post).toHaveBeenCalledWith('/zigbee/test-connection', cfg) mod.zigbeeApi.importNetwork(cfg) expect(api.post).toHaveBeenCalledWith('/zigbee/import', cfg) mod.zigbeeApi.importToPending(cfg) expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg) }) it('zwaveApi.testConnection/importNetwork/importToPending', () => { const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' } mod.zwaveApi.testConnection(cfg) expect(api.post).toHaveBeenCalledWith('/zwave/test-connection', cfg) mod.zwaveApi.importNetwork(cfg) expect(api.post).toHaveBeenCalledWith('/zwave/import', cfg) mod.zwaveApi.importToPending(cfg) expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg) }) it('proxmoxApi.testConnection/importNetwork/importToPending', () => { const cfg = { host: 'pve', port: 8006, token_id: 'u@pam!t', token_secret: 's', verify_tls: true } mod.proxmoxApi.testConnection(cfg) expect(api.post).toHaveBeenCalledWith('/proxmox/test-connection', cfg) mod.proxmoxApi.importNetwork(cfg) expect(api.post).toHaveBeenCalledWith('/proxmox/import', cfg) mod.proxmoxApi.importToPending(cfg) expect(api.post).toHaveBeenCalledWith('/proxmox/import-pending', cfg) }) it('proxmoxApi.getConfig/saveConfig hit /proxmox/config', () => { mod.proxmoxApi.getConfig() expect(api.get).toHaveBeenCalledWith('/proxmox/config') const conf = { host: 'pve', port: 8006, verify_tls: true, sync_enabled: false, sync_interval: 3600 } mod.proxmoxApi.saveConfig(conf) expect(api.post).toHaveBeenCalledWith('/proxmox/config', conf) }) })