- MQTT import_cost_total / export_revenue_total now anchor fixed-fee / tax-credit accrual at max(contract start, first recorded period), so standing charges for untracked pre-recording days are no longer folded into the running total. - Add energy.import_cost_today / energy.export_revenue_today (monetary, total_increasing) that reset at server-local midnight, for per-day cost aggregation in Home Assistant alongside the running totals. - ContractForm add-version mode prefills the contract's open version values, so only the fields that actually change need editing.
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
/**
|
|
* Tests for ContractForm component.
|
|
*
|
|
* Coverage:
|
|
* 1. Renders profile fields dynamically from profile structure (not hardcoded)
|
|
* — verified by passing defaultKind so fields render automatically.
|
|
* 2. Submit in create mode calls POST /api/energy/contracts.
|
|
* 3. Submit in add-version mode calls POST /api/energy/contracts/{id}/versions.
|
|
* 4. Cancel closes modal.
|
|
*/
|
|
|
|
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()
|
|
|
|
vi.mock('../api/client', () => ({
|
|
default: {
|
|
GET: (...args: unknown[]) => mockGet(...args),
|
|
POST: (...args: unknown[]) => mockPost(...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 PROFILES_RESPONSE = {
|
|
profiles: [
|
|
{
|
|
kind: 'manual',
|
|
label: 'Fixed / Variable Rate (NL, dual-tariff)',
|
|
energy: {
|
|
dual_tariff: true,
|
|
buy: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
|
|
sell: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
|
|
energy_tax: { unit: 'EUR/kWh' },
|
|
ode: { unit: 'EUR/kWh', default: 0 },
|
|
},
|
|
standing: {
|
|
network_fee: { unit: 'EUR/month' },
|
|
management_fee: { unit: 'EUR/month' },
|
|
},
|
|
credits: {
|
|
heffingskorting: { unit: 'EUR/year' },
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
const CREATED_CONTRACT = {
|
|
id: 1,
|
|
name: 'Test Contract',
|
|
kind: 'manual',
|
|
active: false,
|
|
currency: 'EUR',
|
|
created_at: '2026-06-01T00:00:00Z',
|
|
updated_at: '2026-06-01T00:00:00Z',
|
|
versions: [],
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { ContractForm } from './ContractForm'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ContractForm', () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it('renders profile fields dynamically from profile structure (not hardcoded)', async () => {
|
|
mockGet.mockImplementation((path: string) => {
|
|
if (path === '/api/energy/profiles') {
|
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
})
|
|
|
|
const onClose = vi.fn()
|
|
const onSaved = vi.fn()
|
|
|
|
// Pass defaultKind so the profile fields render automatically in create mode
|
|
renderWithProviders(
|
|
<ContractForm defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
|
|
)
|
|
|
|
// Wait for profiles to load and fields to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
|
|
}, { timeout: 3000 })
|
|
|
|
// Verify sections are rendered from profile structure (not hardcoded)
|
|
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
|
|
expect(screen.getByTestId('contract-section-standing')).toBeInTheDocument()
|
|
expect(screen.getByTestId('contract-section-credits')).toBeInTheDocument()
|
|
|
|
// Verify specific fields exist from the profile structure
|
|
// "energy.buy.normal" → label "Buy Normal"
|
|
expect(screen.getByTestId('contract-field-energy.buy.normal')).toBeInTheDocument()
|
|
expect(screen.getByTestId('contract-field-energy.buy.dal')).toBeInTheDocument()
|
|
expect(screen.getByTestId('contract-field-standing.network_fee')).toBeInTheDocument()
|
|
})
|
|
|
|
it('calls POST /api/energy/contracts in create mode', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
if (path === '/api/energy/profiles') {
|
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
})
|
|
mockPost.mockResolvedValue({ data: CREATED_CONTRACT })
|
|
|
|
const onClose = vi.fn()
|
|
const onSaved = vi.fn()
|
|
|
|
// Use defaultKind to ensure fields load without needing to interact with Select
|
|
renderWithProviders(
|
|
<ContractForm defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
|
|
)
|
|
|
|
// Wait for form to render
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('contract-form')).toBeInTheDocument()
|
|
})
|
|
|
|
// Wait for profile fields to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('contract-name')).toBeInTheDocument()
|
|
})
|
|
|
|
// Fill in contract name
|
|
await user.type(screen.getByTestId('contract-name'), 'Test Contract')
|
|
|
|
// Submit form
|
|
await user.click(screen.getByTestId('contract-form-submit'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockPost).toHaveBeenCalledWith(
|
|
'/api/energy/contracts',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
name: 'Test Contract',
|
|
kind: 'manual',
|
|
currency: 'EUR',
|
|
}),
|
|
}),
|
|
)
|
|
}, { timeout: 3000 })
|
|
})
|
|
|
|
it('calls POST /api/energy/contracts/{id}/versions in add-version mode', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
if (path === '/api/energy/profiles') {
|
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
})
|
|
mockPost.mockResolvedValue({ data: CREATED_CONTRACT })
|
|
|
|
const onClose = vi.fn()
|
|
const onSaved = vi.fn()
|
|
|
|
renderWithProviders(
|
|
<ContractForm contractId={1} defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
|
|
)
|
|
|
|
// Wait for form to be ready (add-version mode)
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('contract-form')).toBeInTheDocument()
|
|
})
|
|
|
|
// In add-version mode, no name or kind fields shown
|
|
expect(screen.queryByTestId('contract-name')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('contract-kind')).not.toBeInTheDocument()
|
|
|
|
// Fill in effective_from (required in add-version mode)
|
|
const effectiveDateInput = screen.getByTestId('contract-effective-from')
|
|
await user.type(effectiveDateInput, '2026-07-01')
|
|
|
|
// Submit form
|
|
await user.click(screen.getByTestId('contract-form-submit'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockPost).toHaveBeenCalledWith(
|
|
'/api/energy/contracts/{contract_id}/versions',
|
|
expect.objectContaining({
|
|
params: { path: { contract_id: 1 } },
|
|
body: expect.objectContaining({
|
|
values: expect.any(Object),
|
|
}),
|
|
}),
|
|
)
|
|
}, { timeout: 3000 })
|
|
})
|
|
|
|
it('calls onClose when Cancel button is clicked', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
if (path === '/api/energy/profiles') {
|
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
})
|
|
|
|
const onClose = vi.fn()
|
|
const onSaved = vi.fn()
|
|
|
|
renderWithProviders(
|
|
<ContractForm onClose={onClose} onSaved={onSaved} />,
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('contract-form-cancel')).toBeInTheDocument()
|
|
})
|
|
|
|
await user.click(screen.getByTestId('contract-form-cancel'))
|
|
|
|
expect(onClose).toHaveBeenCalled()
|
|
})
|
|
|
|
it('prefills fields from the open version when in add-version mode', async () => {
|
|
// FU11: add-version mode should auto-prefill rate fields from the contract's
|
|
// current open version so the user only has to change what's different.
|
|
const CONTRACT_DETAIL = {
|
|
id: 42,
|
|
name: 'My Contract',
|
|
kind: 'manual',
|
|
active: true,
|
|
currency: 'EUR',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
versions: [
|
|
{
|
|
id: 1,
|
|
contract_id: 42,
|
|
effective_from: '2026-01-01T00:00:00Z',
|
|
effective_to: null, // open version
|
|
values: {
|
|
energy: {
|
|
buy: { normal: 0.133, dal: 0.127 },
|
|
sell: { normal: 0.05, dal: 0.05 },
|
|
energy_tax: 0.11,
|
|
ode: 0.0,
|
|
},
|
|
standing: {
|
|
network_fee: 30.0,
|
|
management_fee: 60.0,
|
|
},
|
|
credits: {
|
|
heffingskorting: 365.0,
|
|
},
|
|
},
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
},
|
|
],
|
|
}
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
if (path === '/api/energy/profiles') {
|
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
|
}
|
|
if (path === '/api/energy/contracts/{contract_id}') {
|
|
return Promise.resolve({ data: CONTRACT_DETAIL })
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
})
|
|
|
|
const onClose = vi.fn()
|
|
const onSaved = vi.fn()
|
|
|
|
renderWithProviders(
|
|
<ContractForm contractId={42} defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
|
|
)
|
|
|
|
const user = userEvent.setup()
|
|
|
|
// Wait for the form to render and profile fields to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
|
|
}, { timeout: 3000 })
|
|
|
|
// Wait for prefill to apply: both profile and contract detail queries must resolve.
|
|
// Fill in the required effective_from date and submit; the submitted values
|
|
// body.values should contain the prefilled rates from the open version.
|
|
mockPost.mockResolvedValue({ data: {} })
|
|
|
|
const effectiveInput = screen.getByTestId('contract-effective-from')
|
|
await user.type(effectiveInput, '2026-07-01')
|
|
|
|
await user.click(screen.getByTestId('contract-form-submit'))
|
|
|
|
// The submitted body should contain the prefilled values from CONTRACT_DETAIL
|
|
await waitFor(() => {
|
|
expect(mockPost).toHaveBeenCalledWith(
|
|
'/api/energy/contracts/{contract_id}/versions',
|
|
expect.objectContaining({
|
|
body: expect.objectContaining({
|
|
values: expect.objectContaining({
|
|
standing: expect.objectContaining({
|
|
network_fee: 30,
|
|
management_fee: 60,
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
)
|
|
}, { timeout: 3000 })
|
|
})
|
|
})
|