/** * Tests for energy/hooks.ts — Modbus API wrappers. * * Coverage: * 1. useDevices — calls GET /api/modbus/devices and returns data. * 2. useProfiles — calls GET /api/modbus/profiles. * 3. useCreateDevice — calls POST /api/modbus/devices with body. * 4. useUpdateDevice — calls PATCH /api/modbus/devices/{uuid} with body. * 5. useDeleteDevice — calls DELETE /api/modbus/devices/{uuid}. * 6. useTestReadDevice — calls POST /api/modbus/devices/{uuid}/test (no body required). */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' // --------------------------------------------------------------------------- // Mock apiClient // --------------------------------------------------------------------------- const mockGet = vi.fn() const mockPost = vi.fn() const mockPatch = vi.fn() const mockDelete = vi.fn() vi.mock('../api/client', () => ({ default: { GET: (...args: unknown[]) => mockGet(...args), POST: (...args: unknown[]) => mockPost(...args), PATCH: (...args: unknown[]) => mockPatch(...args), DELETE: (...args: unknown[]) => mockDelete(...args), }, ApiError: class ApiError extends Error { status: number body: unknown constructor(status: number, body: unknown) { super(`API error ${status}`) this.name = 'ApiError' this.status = status this.body = body } }, registerLoginRedirect: vi.fn(), })) // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const DEVICE = { uuid: 'test-uuid-1', friendly_name: 'SDM120 Main', transport: 'tcp', host: '192.168.1.100', port: 502, unit_id: 1, profile: 'sdm120', poll_interval_s: 5, enabled: true, last_poll_at: null, last_poll_ok: null, created_at: '2026-06-01T00:00:00Z', updated_at: '2026-06-01T00:00:00Z', } // --------------------------------------------------------------------------- // Wrapper // --------------------------------------------------------------------------- function makeWrapper() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) function Wrapper({ children }: { children: ReactNode }) { return {children} } return { qc, Wrapper } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('useDevices', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /api/modbus/devices and returns device list', async () => { mockGet.mockResolvedValue({ data: { items: [DEVICE], total: 1 } }) const { Wrapper } = makeWrapper() const { useDevices } = await import('./hooks') const { result } = renderHook(() => useDevices(), { wrapper: Wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices') expect(result.current.data?.items).toHaveLength(1) expect(result.current.data?.items[0].uuid).toBe('test-uuid-1') }) }) describe('useProfiles', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /api/modbus/profiles and returns profiles', async () => { mockGet.mockResolvedValue({ data: { profiles: [{ name: 'sdm120', description: 'Eastron SDM120' }] }, }) const { Wrapper } = makeWrapper() const { useProfiles } = await import('./hooks') const { result } = renderHook(() => useProfiles(), { wrapper: Wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles') expect(result.current.data?.profiles[0].name).toBe('sdm120') }) }) describe('useCreateDevice', () => { beforeEach(() => vi.clearAllMocks()) it('calls POST /api/modbus/devices with the provided body', async () => { mockPost.mockResolvedValue({ data: DEVICE }) // LIST query to satisfy invalidation mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) const { Wrapper } = makeWrapper() const { useCreateDevice } = await import('./hooks') const { result } = renderHook(() => useCreateDevice(), { wrapper: Wrapper }) const body = { friendly_name: 'SDM120 Main', transport: 'tcp', host: '192.168.1.100', port: 502, unit_id: 1, profile: 'sdm120', poll_interval_s: 5, enabled: true, } await act(async () => { await result.current.mutateAsync(body) }) expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices', { body }) }) }) describe('useUpdateDevice', () => { beforeEach(() => vi.clearAllMocks()) it('calls PATCH /api/modbus/devices/{uuid} with uuid in path and body', async () => { mockPatch.mockResolvedValue({ data: DEVICE }) mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) const { Wrapper } = makeWrapper() const { useUpdateDevice } = await import('./hooks') const { result } = renderHook(() => useUpdateDevice(), { wrapper: Wrapper }) await act(async () => { await result.current.mutateAsync({ uuid: 'test-uuid-1', body: { enabled: false } }) }) expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', { params: { path: { uuid: 'test-uuid-1' } }, body: { enabled: false }, }) }) }) describe('useDeleteDevice', () => { beforeEach(() => vi.clearAllMocks()) it('calls DELETE /api/modbus/devices/{uuid} without query param when cascade omitted', async () => { mockDelete.mockResolvedValue({ data: null }) mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) const { Wrapper } = makeWrapper() const { useDeleteDevice } = await import('./hooks') const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper }) await act(async () => { await result.current.mutateAsync({ uuid: 'test-uuid-1' }) }) expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', { params: { path: { uuid: 'test-uuid-1' } }, }) }) it('calls DELETE /api/modbus/devices/{uuid} with cascade=true in query when cascade=true', async () => { mockDelete.mockResolvedValue({ data: { deleted: true, readings_deleted: 5, toggles_deleted: 2 }, }) mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) const { Wrapper } = makeWrapper() const { useDeleteDevice } = await import('./hooks') const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper }) await act(async () => { await result.current.mutateAsync({ uuid: 'test-uuid-1', cascade: true }) }) expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', { params: { path: { uuid: 'test-uuid-1' }, query: { cascade: true } }, }) }) }) describe('useTestReadDevice', () => { beforeEach(() => vi.clearAllMocks()) it('calls POST /api/modbus/devices/{uuid}/test with uuid in path', async () => { mockPost.mockResolvedValue({ data: { ok: true, payload: { voltage: 230.2 } }, }) const { Wrapper } = makeWrapper() const { useTestReadDevice } = await import('./hooks') const { result } = renderHook(() => useTestReadDevice(), { wrapper: Wrapper }) await act(async () => { await result.current.mutateAsync('test-uuid-1') }) expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/test', { params: { path: { uuid: 'test-uuid-1' } }, }) }) }) describe('useLatestReading', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /api/modbus/devices/{uuid}/latest with path param', async () => { mockGet.mockResolvedValue({ data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }, }) const { Wrapper } = makeWrapper() const { useLatestReading } = await import('./hooks') const { result } = renderHook(() => useLatestReading('test-uuid-1'), { wrapper: Wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/latest', { params: { path: { uuid: 'test-uuid-1' } }, }) expect(result.current.data?.found).toBe(true) expect(result.current.data?.payload).toEqual({ voltage: 230.2 }) }) it('returns found=false when device has no readings', async () => { mockGet.mockResolvedValue({ data: { found: false, recorded_at: null, payload: null }, }) const { Wrapper } = makeWrapper() const { useLatestReading } = await import('./hooks') const { result } = renderHook(() => useLatestReading('test-uuid-2'), { wrapper: Wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data?.found).toBe(false) expect(result.current.data?.payload).toBeNull() }) }) describe('useMetrics', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /api/modbus/devices/{uuid}/metrics with path param', async () => { mockGet.mockResolvedValue({ data: { profile: 'sdm120', metrics: [{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' }], }, }) const { Wrapper } = makeWrapper() const { useMetrics } = await import('./hooks') const { result } = renderHook(() => useMetrics('test-uuid-1'), { wrapper: Wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/metrics', { params: { path: { uuid: 'test-uuid-1' } }, }) expect(result.current.data?.metrics).toHaveLength(1) expect(result.current.data?.metrics[0].key).toBe('voltage') }) }) describe('useLatestReading — auto-refresh option', () => { beforeEach(() => vi.clearAllMocks()) it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => { mockGet.mockResolvedValue({ data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }, }) const { Wrapper } = makeWrapper() const { useLatestReading } = await import('./hooks') const { result } = renderHook( () => useLatestReading('test-uuid-1', { refetchIntervalMs: 5_000 }), { wrapper: Wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) // Query succeeds — refetchIntervalMs is wired through without error. expect(result.current.data?.found).toBe(true) }) it('works without options (no auto-refresh)', async () => { mockGet.mockResolvedValue({ data: { found: false, recorded_at: null, payload: null }, }) const { Wrapper } = makeWrapper() const { useLatestReading } = await import('./hooks') const { result } = renderHook( () => useLatestReading('test-uuid-2'), { wrapper: Wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data?.found).toBe(false) }) }) describe('useReadings — auto-refresh option', () => { beforeEach(() => vi.clearAllMocks()) it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => { mockGet.mockResolvedValue({ data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] }, }) const { Wrapper } = makeWrapper() const { useReadings } = await import('./hooks') const { result } = renderHook( () => useReadings('test-uuid-1', {}, { refetchIntervalMs: 5_000 }), { wrapper: Wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) // Query succeeds — refetchIntervalMs is wired through without error. expect(result.current.data?.items).toHaveLength(1) }) it('enforces minimum refetchInterval of 2000ms', async () => { mockGet.mockResolvedValue({ data: { items: [] } }) const { Wrapper } = makeWrapper() const { useReadings } = await import('./hooks') // Pass a very small value — should be clamped to 2000ms internally. const { result } = renderHook( () => useReadings('test-uuid-1', {}, { refetchIntervalMs: 100 }), { wrapper: Wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) // No crash; the hook accepted the option and clamped it internally. expect(result.current.isSuccess).toBe(true) }) }) describe('useReadings', () => { beforeEach(() => vi.clearAllMocks()) it('calls GET /api/modbus/devices/{uuid}/readings with window and limit', async () => { mockGet.mockResolvedValue({ data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] }, }) const { Wrapper } = makeWrapper() const { useReadings } = await import('./hooks') const params = { start: '2026-06-22T09:00:00Z', end: '2026-06-22T10:00:00Z', limit: 500 } const { result } = renderHook(() => useReadings('test-uuid-1', params), { wrapper: Wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', { params: { path: { uuid: 'test-uuid-1' }, query: { start: '2026-06-22T09:00:00Z', end: '2026-06-22T10:00:00Z', limit: 500, }, }, }) expect(result.current.data?.items).toHaveLength(1) }) it('caps limit at READINGS_MAX_LIMIT (1000) even if caller requests more', async () => { mockGet.mockResolvedValue({ data: { items: [] } }) const { Wrapper } = makeWrapper() const { useReadings } = await import('./hooks') const { result } = renderHook( () => useReadings('test-uuid-1', { limit: 99999 }), { wrapper: Wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) // The query param limit should be capped at 1000. expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ limit: 1000 }), }), }), ) }) it('omits start/end from query params when not provided', async () => { mockGet.mockResolvedValue({ data: { items: [] } }) const { Wrapper } = makeWrapper() const { useReadings } = await import('./hooks') const { result } = renderHook( () => useReadings('test-uuid-1', {}), { wrapper: Wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', expect.objectContaining({ params: expect.objectContaining({ query: expect.not.objectContaining({ start: expect.anything() }), }), }), ) }) // ------------------------------------------------------------------------- // Rolling window: spanMs causes queryFn to compute end=now() on each call // ------------------------------------------------------------------------- it('when spanMs is provided, sends computed start/end (rolling window) instead of fixed params', async () => { const tBefore = Date.now() mockGet.mockResolvedValue({ data: { items: [] } }) const { Wrapper } = makeWrapper() const { useReadings } = await import('./hooks') const SPAN_MS = 60 * 60 * 1000 // 1 hour const { result } = renderHook( () => useReadings('test-uuid-1', { spanMs: SPAN_MS }), { wrapper: Wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) const tAfter = Date.now() // The GET should have been called with start and end computed from now(). expect(mockGet).toHaveBeenCalledWith( '/api/modbus/devices/{uuid}/readings', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ end: expect.any(String), start: expect.any(String), }), }), }), ) const call = mockGet.mock.calls[mockGet.mock.calls.length - 1] const query = call[1].params.query as { start: string; end: string } const endMs = new Date(query.end).getTime() const startMs = new Date(query.start).getTime() // end must be within the test window (tBefore..tAfter). expect(endMs).toBeGreaterThanOrEqual(tBefore) expect(endMs).toBeLessThanOrEqual(tAfter + 100) // small tolerance // start must be approximately end - spanMs. expect(endMs - startMs).toBeCloseTo(SPAN_MS, -2) // within 100ms }) it('when spanMs is provided, queryKey uses spanMs (not absolute timestamps) for stable caching', async () => { mockGet.mockResolvedValue({ data: { items: [] } }) const { Wrapper, qc } = makeWrapper() const { useReadings } = await import('./hooks') const SPAN_MS = 3600000 // 1 hour const { result } = renderHook( () => useReadings('test-uuid-1', { spanMs: SPAN_MS }), { wrapper: Wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) // The query cache should have a key containing spanMs, not an absolute timestamp. const queries = qc.getQueryCache().findAll({ predicate: (q) => { const key = q.queryKey as unknown[] return key[0] === 'modbus-readings' && key[1] === 'test-uuid-1' }, }) expect(queries).toHaveLength(1) const keyParams = queries[0].queryKey[2] as Record expect(keyParams).toHaveProperty('spanMs', SPAN_MS) expect(keyParams).not.toHaveProperty('start') expect(keyParams).not.toHaveProperty('end') }) })