M6-T10: frontend contract management + price/cost views + Tibber test

- 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.
This commit is contained in:
2026-06-23 23:32:39 +02:00
parent 57f2459f60
commit 96e88861d4
15 changed files with 4235 additions and 32 deletions
+293
View File
@@ -0,0 +1,293 @@
/**
* 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: {} },
})
})
})