From 373960f6ea9395c1f251278245c1bbbeee3fca57 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 14 May 2026 13:25:41 +0200 Subject: [PATCH] test(frontend): cover api client, ProxmoxGroupNode, CustomStyleModal --- frontend/src/api/__tests__/client.test.ts | 210 ++++++++++++++++++ .../nodes/__tests__/ProxmoxGroupNode.test.tsx | 111 +++++++++ .../__tests__/CustomStyleModal.test.tsx | 127 +++++++++++ 3 files changed, 448 insertions(+) create mode 100644 frontend/src/api/__tests__/client.test.ts create mode 100644 frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx create mode 100644 frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts new file mode 100644 index 0000000..800cd08 --- /dev/null +++ b/frontend/src/api/__tests__/client.test.ts @@ -0,0 +1,210 @@ +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') + }) + + 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('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'] }) + 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 }) + expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 }) + }) + + 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) + }) +}) diff --git a/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx new file mode 100644 index 0000000..6afcb3f --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import { ProxmoxGroupNode } from '../ProxmoxGroupNode' +import { useCanvasStore } from '@/stores/canvasStore' +import { useThemeStore } from '@/stores/themeStore' +import type { NodeData, NodeProperty } from '@/types' +import type { NodeProps, Node } from '@xyflow/react' + +function renderNode(data: Partial = {}, selected = false) { + const fullData: NodeData = { + label: 'pve-01', + type: 'proxmox', + status: 'online', + services: [], + ...data, + } + const props = { + id: 'p1', + data: fullData, + selected, + type: 'proxmox', + zIndex: 0, + isConnectable: true, + xPos: 0, + yPos: 0, + dragging: false, + deletable: true, + draggable: true, + selectable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + width: 300, + height: 200, + dragHandle: undefined, + parentId: undefined, + sourcePosition: undefined, + targetPosition: undefined, + } as unknown as NodeProps> + return render( + + + + ) +} + +describe('ProxmoxGroupNode', () => { + beforeEach(() => { + useCanvasStore.setState({ hideIp: false }) + useThemeStore.setState({ activeTheme: 'default' }) + }) + + it('renders the node label', () => { + const { getByText } = renderNode({ label: 'My Proxmox' }) + expect(getByText('My Proxmox')).toBeDefined() + }) + + it('renders ip when provided', () => { + const { getByText } = renderNode({ ip: '192.168.1.10' }) + expect(getByText('192.168.1.10')).toBeDefined() + }) + + it('renders multiple ips when comma separated', () => { + const { getByText } = renderNode({ ip: '10.0.0.1, 10.0.0.2' }) + expect(getByText('10.0.0.1')).toBeDefined() + expect(getByText('10.0.0.2')).toBeDefined() + }) + + it('masks ip when hideIp is enabled in store', () => { + useCanvasStore.setState({ hideIp: true }) + const { queryByText } = renderNode({ ip: '192.168.1.10' }) + expect(queryByText('192.168.1.10')).toBeNull() + }) + + it('renders visible properties only', () => { + const properties: NodeProperty[] = [ + { key: 'CPU', value: '16 cores', icon: null, visible: true }, + { key: 'Hidden', value: 'should-not-show', icon: null, visible: false }, + ] + const { getByText, queryByText } = renderNode({ properties }) + expect(getByText('CPU')).toBeDefined() + expect(getByText(/16 cores/)).toBeDefined() + expect(queryByText('Hidden')).toBeNull() + expect(queryByText(/should-not-show/)).toBeNull() + }) + + it('renders status dot with title matching status', () => { + const { container } = renderNode({ status: 'offline' }) + const dot = container.querySelector('[title="offline"]') + expect(dot).not.toBeNull() + }) + + it('container_mode === false renders as BaseNode (no resizer group border)', () => { + const { container } = renderNode({ container_mode: false }) + // NodeResizer should not be present when not group-rendered + expect(container.querySelector('.react-flow__resize-control')).toBeNull() + }) + + it('container_mode default renders the group border container', () => { + const { container } = renderNode({}) + // Group border div has rounded-xl border-2 classes + expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull() + }) + + it('renders cluster handles in both modes', () => { + const { container: groupC } = renderNode({}) + expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2) + const { container: nodeC } = renderNode({ container_mode: false }) + expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx new file mode 100644 index 0000000..0ea98a7 --- /dev/null +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CustomStyleModal } from '../CustomStyleModal' +import { useThemeStore } from '@/stores/themeStore' +import { useCanvasStore } from '@/stores/canvasStore' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })) +import { toast } from 'sonner' + +describe('CustomStyleModal', () => { + beforeEach(() => { + useThemeStore.setState({ customStyle: { nodes: {}, edges: {} } }) + useCanvasStore.setState({ hasUnsavedChanges: false }) + vi.mocked(toast.success).mockReset() + }) + + it('renders nothing when closed', () => { + const { container } = render() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders title and tabs', () => { + render() + expect(screen.getByText('Custom Style Editor')).toBeDefined() + expect(screen.getByRole('button', { name: 'Nodes' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Edges' })).toBeDefined() + }) + + it('starts with empty selection placeholder', () => { + render() + expect(screen.getByText(/Select a node type/)).toBeDefined() + }) + + it('switches to edges tab and shows the right placeholder', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + expect(screen.getByText(/edge type from the list/i)).toBeDefined() + }) + + it('selecting a node type opens the node editor', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Router' })) + expect(screen.getByText(/Apply to existing/)).toBeDefined() + expect(screen.getByText('Default size')).toBeDefined() + }) + + it('selecting an edge type opens the edge editor with path style buttons', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + expect(screen.getByRole('button', { name: 'Bezier' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Smooth' })).toBeDefined() + }) + + it('Apply-to-existing node button calls store and toasts', () => { + const applyTypeNodeStyle = vi.fn() + useCanvasStore.setState({ applyTypeNodeStyle }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Router' })) + fireEvent.click(screen.getByRole('button', { name: /Apply to existing Router/ })) + expect(applyTypeNodeStyle).toHaveBeenCalledOnce() + expect(applyTypeNodeStyle.mock.calls[0][0]).toBe('router') + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Router')) + }) + + it('Apply-to-existing edge button calls store and toasts', () => { + const applyTypeEdgeStyle = vi.fn() + useCanvasStore.setState({ applyTypeEdgeStyle }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ })) + expect(applyTypeEdgeStyle).toHaveBeenCalledOnce() + expect(applyTypeEdgeStyle.mock.calls[0][0]).toBe('ethernet') + }) + + it('Save Custom Style sets customStyle, marks unsaved, closes, toasts', () => { + const onClose = vi.fn() + const markUnsaved = vi.fn() + useCanvasStore.setState({ markUnsaved }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' })) + expect(markUnsaved).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Custom style saved')) + }) + + it('Apply All to Canvas calls applyAllCustomStyles, markUnsaved, closes', () => { + const onClose = vi.fn() + const markUnsaved = vi.fn() + const applyAllCustomStyles = vi.fn() + useCanvasStore.setState({ markUnsaved, applyAllCustomStyles }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Apply All to Canvas' })) + expect(applyAllCustomStyles).toHaveBeenCalledOnce() + expect(markUnsaved).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + }) + + it('Cancel button closes without saving', () => { + const onClose = vi.fn() + const markUnsaved = vi.fn() + useCanvasStore.setState({ markUnsaved }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledOnce() + expect(markUnsaved).not.toHaveBeenCalled() + }) + + it('editing path style updates the edge draft', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + const smoothBtn = screen.getByRole('button', { name: 'Smooth' }) + fireEvent.click(smoothBtn) + // The clicked button should now be styled selected (cyan border) + expect(smoothBtn.getAttribute('style')).toContain('rgb(0, 212, 255)') + }) + + it('changing width input updates node draft', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Router' })) + const widthInputs = screen.getAllByRole('spinbutton') + fireEvent.change(widthInputs[0], { target: { value: '250' } }) + expect((widthInputs[0] as HTMLInputElement).value).toBe('250') + }) +})