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
@@ -0,0 +1,212 @@
/**
* Tests for Tibber test button in ConfigPage.
*
* Coverage:
* 1. Tibber test button appears when Tibber section is in config.
* 2. Success tri-state shows green alert.
* 3. Config-error tri-state shows orange alert.
* 4. Failed tri-state shows red alert.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PUT: (...args: unknown[]) => mockPut(...args),
PATCH: vi.fn(),
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 CONFIG_WITH_TIBBER = {
sections: [
{
name: 'Tibber',
fields: [
{
env_name: 'TIBBER_TOKEN',
label: 'Tibber API Token',
secret: true,
input_type: 'text',
configured: true,
value: null,
},
],
},
],
}
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { ConfigPage } from './ConfigPage'
// ---------------------------------------------------------------------------
// Helper to suppress TOTP and Expose Settings sub-queries
// ---------------------------------------------------------------------------
function mockConfigDependencies() {
mockGet.mockImplementation((path: string) => {
if (path === '/api/config') {
return Promise.resolve({ data: CONFIG_WITH_TIBBER })
}
if (path === '/api/expose') {
return Promise.resolve({
data: {
catalog: [],
mqtt_status: { connected: false, broker: null },
},
})
}
if (path === '/api/auth/totp/status') {
return Promise.resolve({ data: { enabled: false } })
}
return Promise.resolve({ data: null })
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ConfigPage — Tibber test button', () => {
beforeEach(() => vi.clearAllMocks())
it('renders Tibber test button when Tibber section is present', async () => {
mockConfigDependencies()
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
})
it('shows success alert on successful Tibber test', async () => {
const user = userEvent.setup()
mockConfigDependencies()
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
return Promise.resolve({
data: {
result: 'success',
message: 'Connected to Tibber API',
price: {
starts_at: '2026-06-22T10:00:00Z',
total: 0.1337,
energy: 0.08,
tax: 0.0537,
currency: 'EUR',
level: 'NORMAL',
},
},
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-success')).toBeInTheDocument()
})
expect(screen.getByTestId('tibber-result-success')).toHaveTextContent(
'Tibber connection successful',
)
})
it('shows config-error alert when Tibber is misconfigured', async () => {
const user = userEvent.setup()
mockConfigDependencies()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ApiErrorClass = (await import('../api/client')).ApiError as any
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
throw new ApiErrorClass(400, {
result: 'config-error',
message: 'Tibber token not configured',
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-config-error')).toBeInTheDocument()
})
})
it('shows failed alert when Tibber test fails', async () => {
const user = userEvent.setup()
mockConfigDependencies()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ApiErrorClass = (await import('../api/client')).ApiError as any
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
throw new ApiErrorClass(500, {
result: 'failed',
message: 'Connection refused',
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-failed')).toBeInTheDocument()
})
})
})