M5-T06: add Energy device management UI and sidebar entry
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user