2026-06-22 13:52:33 +02:00
|
|
|
/**
|
|
|
|
|
* 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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
|
|
|
}
|
|
|
|
|
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}', 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('test-uuid-1')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
|
|
|
|
params: { path: { uuid: 'test-uuid-1' } },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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' } },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-06-22 14:17:59 +02:00
|
|
|
|
|
|
|
|
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('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() }),
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|