- EnergyPage: Mantine Tabs (Devices kept intact + Contracts/Prices/Costs). - ContractManager/ContractForm: list/activate/add-version + version history; form fields rendered dynamically from /api/energy/profiles structure. - TibberPrices + CostView: Recharts price curve, cost trend/detail/summary, recompute; window-bounded, currency/units from API, empty/error/loading states. - ConfigPage: tri-state Tibber test button (token never shown). - hooks.ts: typed energy hooks; schema.d.ts regenerated via codegen.
294 lines
8.7 KiB
TypeScript
294 lines
8.7 KiB
TypeScript
/**
|
|
* Tests for energy hooks in hooks.ts — energy pricing API wrappers.
|
|
*
|
|
* Coverage:
|
|
* 1. useContracts — GET /api/energy/contracts
|
|
* 2. useCreateContract — POST /api/energy/contracts
|
|
* 3. useEnergyPrices — GET /api/energy/prices
|
|
* 4. useEnergyCosts — GET /api/energy/costs
|
|
* 5. useEnergyCostSummary — GET /api/energy/costs/summary
|
|
* 6. useRecomputeCosts — POST /api/energy/costs/recompute
|
|
*/
|
|
|
|
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()
|
|
|
|
vi.mock('../api/client', () => ({
|
|
default: {
|
|
GET: (...args: unknown[]) => mockGet(...args),
|
|
POST: (...args: unknown[]) => mockPost(...args),
|
|
PATCH: (...args: unknown[]) => mockPatch(...args),
|
|
DELETE: vi.fn(),
|
|
},
|
|
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 CONTRACT = {
|
|
id: 1,
|
|
name: 'Test Contract',
|
|
kind: 'manual',
|
|
active: true,
|
|
currency: 'EUR',
|
|
created_at: '2026-06-01T00:00:00Z',
|
|
updated_at: '2026-06-01T00:00:00Z',
|
|
}
|
|
|
|
const PRICE_POINT = {
|
|
starts_at: '2026-06-22T10:00:00Z',
|
|
buy: 0.133,
|
|
sell: 0.09,
|
|
level: 'NORMAL',
|
|
}
|
|
|
|
const COST_PERIOD = {
|
|
period_start: '2026-06-22T10:00:00Z',
|
|
d1_kwh: 0.1,
|
|
d2_kwh: 0.2,
|
|
r1_kwh: 0.0,
|
|
r2_kwh: 0.05,
|
|
import_cost: 0.044,
|
|
export_revenue: 0.005,
|
|
net_cost: 0.039,
|
|
currency: 'EUR',
|
|
degraded: false,
|
|
contract_version_id: 1,
|
|
}
|
|
|
|
const SUMMARY = {
|
|
currency: 'EUR',
|
|
metered_import: 10.5,
|
|
metered_export: 2.3,
|
|
metered_net: 8.2,
|
|
fixed_costs: 5.0,
|
|
credits: 50.0,
|
|
total_payable: 12.5,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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('useContracts', () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it('calls GET /api/energy/contracts and returns contract list', async () => {
|
|
mockGet.mockResolvedValue({ data: { items: [CONTRACT], total: 1 } })
|
|
|
|
const { Wrapper } = makeWrapper()
|
|
const { useContracts } = await import('./hooks')
|
|
const { result } = renderHook(() => useContracts(), { wrapper: Wrapper })
|
|
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
|
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/contracts')
|
|
expect(result.current.data?.items).toHaveLength(1)
|
|
expect(result.current.data?.items[0].id).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('useCreateContract', () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it('calls POST /api/energy/contracts with the provided body', async () => {
|
|
mockPost.mockResolvedValue({ data: { ...CONTRACT, versions: [] } })
|
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
|
|
|
const { Wrapper } = makeWrapper()
|
|
const { useCreateContract } = await import('./hooks')
|
|
const { result } = renderHook(() => useCreateContract(), { wrapper: Wrapper })
|
|
|
|
const body = {
|
|
name: 'New Contract',
|
|
kind: 'manual',
|
|
currency: 'EUR',
|
|
values: { energy: { buy: { normal: 0.133 } } },
|
|
}
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(body)
|
|
})
|
|
|
|
expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', { body })
|
|
})
|
|
})
|
|
|
|
describe('useEnergyPrices', () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it('calls GET /api/energy/prices with start and end params', async () => {
|
|
mockGet.mockResolvedValue({
|
|
data: {
|
|
kind: 'tibber',
|
|
currency: 'EUR',
|
|
points: [PRICE_POINT],
|
|
tariff: null,
|
|
},
|
|
})
|
|
|
|
const { Wrapper } = makeWrapper()
|
|
const { useEnergyPrices } = await import('./hooks')
|
|
const start = '2026-06-22T00:00:00Z'
|
|
const end = '2026-06-23T00:00:00Z'
|
|
const { result } = renderHook(() => useEnergyPrices(start, end), { wrapper: Wrapper })
|
|
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
|
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', {
|
|
params: { query: { start, end } },
|
|
})
|
|
expect(result.current.data?.points).toHaveLength(1)
|
|
expect(result.current.data?.kind).toBe('tibber')
|
|
})
|
|
|
|
it('calls GET /api/energy/prices without params when none provided', async () => {
|
|
mockGet.mockResolvedValue({
|
|
data: { kind: 'manual', currency: 'EUR', points: [], tariff: null },
|
|
})
|
|
|
|
const { Wrapper } = makeWrapper()
|
|
const { useEnergyPrices } = await import('./hooks')
|
|
const { result } = renderHook(() => useEnergyPrices(), { wrapper: Wrapper })
|
|
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
|
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', {
|
|
params: { query: {} },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('useEnergyCosts', () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it('calls GET /api/energy/costs with start, end, and limit', async () => {
|
|
mockGet.mockResolvedValue({
|
|
data: { items: [COST_PERIOD], total: 1 },
|
|
})
|
|
|
|
const { Wrapper } = makeWrapper()
|
|
const { useEnergyCosts } = await import('./hooks')
|
|
const start = '2026-06-22T00:00:00Z'
|
|
const end = '2026-06-23T00:00:00Z'
|
|
const limit = 100
|
|
const { result } = renderHook(
|
|
() => useEnergyCosts(start, end, limit),
|
|
{ wrapper: Wrapper },
|
|
)
|
|
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
|
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/costs', {
|
|
params: { query: { start, end, limit } },
|
|
})
|
|
expect(result.current.data?.items).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('useEnergyCostSummary', () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it('calls GET /api/energy/costs/summary with date range', async () => {
|
|
mockGet.mockResolvedValue({ data: SUMMARY })
|
|
|
|
const { Wrapper } = makeWrapper()
|
|
const { useEnergyCostSummary } = await import('./hooks')
|
|
const start = '2026-06-01T00:00:00Z'
|
|
const end = '2026-06-30T23:59:59Z'
|
|
const { result } = renderHook(
|
|
() => useEnergyCostSummary(start, end),
|
|
{ wrapper: Wrapper },
|
|
)
|
|
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
|
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/costs/summary', {
|
|
params: { query: { start, end } },
|
|
})
|
|
expect(result.current.data?.total_payable).toBe(12.5)
|
|
})
|
|
})
|
|
|
|
describe('useRecomputeCosts', () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it('calls POST /api/energy/costs/recompute and invalidates cost queries', async () => {
|
|
mockPost.mockResolvedValue({ data: { recomputed: 10 } })
|
|
// Mock GET for invalidation queries (costs and summary)
|
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
|
|
|
const { Wrapper } = makeWrapper()
|
|
const { useRecomputeCosts } = await import('./hooks')
|
|
const { result } = renderHook(() => useRecomputeCosts(), { wrapper: Wrapper })
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
start: '2026-06-22T00:00:00Z',
|
|
end: '2026-06-23T00:00:00Z',
|
|
})
|
|
})
|
|
|
|
expect(mockPost).toHaveBeenCalledWith('/api/energy/costs/recompute', {
|
|
params: {
|
|
query: {
|
|
start: '2026-06-22T00:00:00Z',
|
|
end: '2026-06-23T00:00:00Z',
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
it('calls POST /api/energy/costs/recompute without params when none provided', async () => {
|
|
mockPost.mockResolvedValue({ data: { recomputed: 0 } })
|
|
|
|
const { Wrapper } = makeWrapper()
|
|
const { useRecomputeCosts } = await import('./hooks')
|
|
const { result } = renderHook(() => useRecomputeCosts(), { wrapper: Wrapper })
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({})
|
|
})
|
|
|
|
expect(mockPost).toHaveBeenCalledWith('/api/energy/costs/recompute', {
|
|
params: { query: {} },
|
|
})
|
|
})
|
|
})
|