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:
@@ -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: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user