/** * 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')) }) })