/**
* Tests for CostView component.
*
* Coverage:
* 1. Loading state.
* 2. Empty state.
* 3. Renders costs table with data.
* 4. Shows summary values.
* 5. Recompute button triggers confirmation modal and then recompute call.
*/
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 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',
// Money totals and kWh totals are deliberately distinct so the assertions
// below prove the cards read the *_kwh fields, not the monetary ones.
metered_import: 10.5,
metered_export: 2.3,
metered_net: 8.2,
metered_import_kwh: 33.3,
metered_export_kwh: 44.4,
fixed_costs: 5.0,
credits: 50.0,
total_payable: 12.5,
}
const THERMAL_SUMMARY = {
currency: 'EUR', heating: '1.10', hot_water_heating: '2.20', hot_water: '3.30',
hot_water_tax: '0.40', variable_subtotal: '7.00', fixed_subtotal: '0.50', all_in: '7.50',
period_count: 4, degraded_count: 2,
fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' },
}
const THERMAL_VALUES = {
variable: {
heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001',
hot_water: '1.234567890123456789', hot_water_tax: '0.456789012345678901',
},
standing: {
heating_network: '100.000000000000000001', metering: '0', delivery_set: '20.2',
hot_water_network: '30.3', other: '40.4',
},
}
const THERMAL_ROW = {
commodity: 'heating', period_start: '2026-06-22T10:00:00Z', period_end: '2026-06-22T10:15:00Z',
meter_id: 1, source_binding_id: 2, contract_version_id: 99, quantity: '1.2', cost: '0.123456789', currency: 'EUR',
cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES,
quality: 'unverifiable', degraded: false, degraded_reason: null,
}
const THERMAL_ROW_OTHER_VERSION = {
...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z',
contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170',
cost_breakdown: { hot_water_heating: '1.2', hot_water: '2.8', hot_water_tax: '0.339506172839506170' },
pricing_snapshot: { ...THERMAL_VALUES, variable: { ...THERMAL_VALUES.variable, hot_water: '1.234567890123456789' } },
}
const THERMAL_DEGRADED_MISSING_CONTRACT = {
commodity: 'heating', period_start: '2026-06-22T10:30:00Z', period_end: '2026-06-22T10:45:00Z',
meter_id: 1, source_binding_id: 2, contract_version_id: null, quantity: '0', cost: '0', currency: 'EUR',
cost_breakdown: {}, pricing_snapshot: {}, quality: 'invalid', degraded: true, degraded_reason: 'missing_contract',
}
const THERMAL_DEGRADED_CROSS_EPOCH = {
commodity: 'hot_water', period_start: '2026-06-22T10:45:00Z', period_end: '2026-06-22T11:00:00Z',
meter_id: 2, source_binding_id: null, contract_version_id: null, quantity: '0', cost: '0', currency: 'EUR',
cost_breakdown: {}, pricing_snapshot: {}, quality: 'invalid', degraded: true, degraded_reason: 'cross_meter_epoch',
}
const ACTIVE_HEATING_METER = { id: 10, commodity: 'heating', ended_at: null }
const ACTIVE_HOT_WATER_METER = { id: 11, commodity: 'hot_water', ended_at: null }
const ENDED_HEATING_METER = { id: 9, commodity: 'heating', ended_at: '2026-06-01T00:00:00Z' }
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { CostView } from './CostView'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CostView', () => {
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders()
expect(screen.getByTestId('costs-loading')).toBeInTheDocument()
})
it('renders empty state when no cost periods available', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByTestId('costs-empty')).toBeInTheDocument()
})
})
it('renders costs table when data is available', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [COST_PERIOD], total: 1 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByTestId('costs-table')).toBeInTheDocument()
})
expect(screen.getByTestId('cost-row-0')).toBeInTheDocument()
})
it('shows summary values correctly', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByTestId('summary-import')).toBeInTheDocument()
})
// Main figure is energy (kWh), taken from the *_kwh fields.
expect(screen.getByTestId('summary-import')).toHaveTextContent('33.300')
expect(screen.getByTestId('summary-export')).toHaveTextContent('44.400')
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
// Sub-line carries the monetary equivalent, so money is still visible.
expect(screen.getByTestId('summary-import-sub')).toHaveTextContent('10.50 EUR')
expect(screen.getByTestId('summary-export-sub')).toHaveTextContent('2.30 EUR')
})
it('shows recompute confirmation modal on button click', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByTestId('cost-recompute-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('cost-recompute-button'))
await waitFor(() => {
expect(screen.getByTestId('recompute-confirm-modal')).toBeInTheDocument()
})
})
it('calls recompute mutation when confirmed', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
mockPost.mockResolvedValue({ data: { recomputed: 0 } })
renderWithProviders()
await waitFor(() => {
expect(screen.getByTestId('cost-recompute-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('cost-recompute-button'))
await waitFor(() => {
expect(screen.getByTestId('recompute-confirm')).toBeInTheDocument()
})
await user.click(screen.getByTestId('recompute-confirm'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/costs/recompute',
expect.any(Object),
)
})
})
it('audits thermal rows and recomputes only a closed UTC quarter through the typed client', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW, THERMAL_ROW_OTHER_VERSION, THERMAL_DEGRADED_MISSING_CONTRACT, THERMAL_DEGRADED_CROSS_EPOCH], total: 4 } })
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
if (path === '/api/energy/costs') return Promise.resolve({ data: { items: [], total: 0 } })
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: null })
})
mockPost.mockResolvedValue({ data: { processed: 1, normal: 0, degraded: 1 } })
renderWithProviders()
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
expect(screen.getByTestId('thermal-period-count')).toHaveTextContent('4 periods; 2 degraded')
expect(screen.getByTestId('thermal-summary-degraded')).toHaveTextContent('Expand a row to see its recorded reason')
expect(screen.queryByTestId('thermal-degraded-0')).not.toBeInTheDocument()
expect(screen.queryByTestId('thermal-degraded-1')).not.toBeInTheDocument()
expect(screen.getByTestId('thermal-degraded-2')).toHaveTextContent('missing_contract')
expect(screen.getByTestId('thermal-degraded-3')).toHaveTextContent('cross_meter_epoch')
await user.click(screen.getByTestId('thermal-cost-expand-0'))
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('Contract version: 99')
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('20.123456789123456789')
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('heating_network')
await user.click(screen.getByTestId('thermal-cost-expand-1'))
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('Contract version: 100')
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('1.234567890123456789')
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('hot_water_tax')
await user.click(screen.getByTestId('thermal-cost-expand-2'))
expect(screen.getByTestId('thermal-cost-audit-2')).toHaveTextContent('Contract version: none')
expect(screen.getByTestId('thermal-cost-audit-2')).toHaveTextContent('Pricing snapshot: {}')
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('summary only')
expect(screen.getAllByText(/Fixed subtotal|All-in total/)).toHaveLength(2)
expect(screen.getByTestId('thermal-costs-table')).not.toHaveTextContent('Fixed')
await user.click(screen.getByTestId('thermal-recompute-button'))
expect(screen.getByTestId('thermal-recompute-confirm-modal')).toHaveTextContent('closed 15-minute')
await user.click(screen.getByTestId('thermal-recompute-confirm'))
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meter-costs/recompute', expect.any(Object)))
const options = mockPost.mock.calls.find(([path]) => path === '/api/energy/meter-costs/recompute')?.[1] as { params: { query: { end: string } } }
expect(new Date(options.params.query.end).getTime()).toBeLessThanOrEqual(Date.now())
expect(new Date(options.params.query.end).getUTCMinutes() % 15).toBe(0)
})
it('keeps the thermal confirmation cancellable and surfaces a 422 recompute failure', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW], total: 1 } })
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
mockPost.mockRejectedValue(new Error('422'))
renderWithProviders()
await user.click(screen.getByTestId('costs-scope-selector')); await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-recompute-button')).toBeInTheDocument())
await user.click(screen.getByTestId('thermal-recompute-button')); await user.click(screen.getByTestId('thermal-recompute-cancel'))
expect(screen.queryByTestId('thermal-recompute-confirm-modal')).not.toBeInTheDocument()
await user.click(screen.getByTestId('thermal-recompute-button')); await user.click(screen.getByTestId('thermal-recompute-confirm'))
await waitFor(() => expect(screen.getByTestId('thermal-recompute-error')).toBeInTheDocument())
})
it.each([
['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'],
['only hot water', [ACTIVE_HOT_WATER_METER], 'heating meter is not configured', 'Not configured'],
['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } })
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW], total: 1 } })
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: { ...THERMAL_SUMMARY, heating: heatingValue === 'Not configured' ? '0' : THERMAL_SUMMARY.heating } })
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
const user = userEvent.setup()
renderWithProviders()
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
if (missingText) {
expect(screen.getByTestId('thermal-missing-current-meter')).toHaveTextContent(missingText)
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Not configured')
} else {
expect(screen.queryByTestId('thermal-missing-current-meter')).not.toBeInTheDocument()
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent(heatingValue)
}
})
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
const user = userEvent.setup()
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
mockGet.mockImplementation((path: string, options?: { params?: { query?: { offset?: number } } }) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
if (path === '/api/energy/meter-costs') {
const offset = options?.params?.query?.offset ?? 0
return Promise.resolve({ data: offset === 0 ? { items: Array.from({ length: 500 }, () => THERMAL_ROW), total: 501 } : { items: [lastRow], total: 501 } })
}
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
renderWithProviders()
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
expect(screen.getByTestId('thermal-ledger-prev')).toBeDisabled()
expect(screen.getByTestId('thermal-ledger-next')).toBeEnabled()
await user.click(screen.getByTestId('thermal-ledger-next'))
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 501-501 of 501'))
expect(screen.getByTestId('thermal-ledger-prev')).toBeEnabled()
expect(screen.getByTestId('thermal-ledger-next')).toBeDisabled()
expect(mockGet).toHaveBeenCalledWith('/api/energy/meter-costs', expect.objectContaining({ params: { query: expect.objectContaining({ scope: 'thermal', offset: 500, limit: 500 }) } }))
await user.click(screen.getByTestId('thermal-cost-range-control'))
await user.click(screen.getByText('This month'))
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Electricity'))
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
})
})