/** * Tests for RecordsPage (M2-T10). * * Coverage: * 1. Poo list renders from mocked apiClient GET /api/poo. * 2. Poo pagination: page 2 requests offset=100. * 3. Edit poo: clicking Edit opens the modal; form submit calls PATCH with raw (un-encoded) * PK in the path params (openapi-fetch encodes once; we must not double-encode). * 4. Delete poo: clicking Delete opens confirmation; confirming calls DELETE and refreshes. * 5. Location list renders from mocked apiClient GET /api/locations. * 6. Location pagination: page 2 requests offset=100. * 7. Edit location: clicking Edit opens modal; form submit calls PATCH with raw PK params. * 8. Delete location: clicking Delete opens confirmation; confirming calls DELETE. * 9. Real-encoding regression: stub global fetch; verify actual URL uses single encoding * (%3A present, %253A absent) for PKs containing colons. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { screen, waitFor, fireEvent } from '@testing-library/react' import { renderWithProviders } from '../test-utils' import { RecordsPage } from './RecordsPage' import type { LocationRecord } from '../records' import type { ModbusDevice, MetricInfo } from '../energy/hooks' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const POO_RECORD = { timestamp: '2026-06-12T10:00:00Z', status: 'done', latitude: 51.5, longitude: -0.1, } const POO_RECORD_2 = { timestamp: '2026-06-12T11:00:00Z', status: 'pending', latitude: 51.6, longitude: -0.2, } const LOCATION_RECORD: LocationRecord = { person: 'alice', datetime: '2026-06-12T09:00:00Z', latitude: 52.0, longitude: 1.0, altitude: 10, } // Build a page of 100 items (all identical except for timestamp offset). function makePooPage(offset: number) { return Array.from({ length: 100 }, (_, i) => ({ timestamp: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`, status: 'done', latitude: 51.5, longitude: -0.1, })) } function makeLocationPage(offset: number): LocationRecord[] { return Array.from({ length: 100 }, (_, i) => ({ person: 'alice', datetime: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`, latitude: 52.0, longitude: 1.0, altitude: null, })) } // --------------------------------------------------------------------------- // Mock apiClient // --------------------------------------------------------------------------- const mockGet = vi.fn() const mockPatch = vi.fn() const mockDelete = vi.fn() vi.mock('../api/client', () => ({ default: { GET: (...args: unknown[]) => mockGet(...args), PATCH: (...args: unknown[]) => mockPatch(...args), DELETE: (...args: unknown[]) => mockDelete(...args), }, 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(), })) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function renderRecords() { return renderWithProviders(, { initialPath: '/records' }) } /** Make GET mock respond based on path. */ function setupGetMock({ pooItems = [POO_RECORD], locationItems = [LOCATION_RECORD], pooOffset = 0, locationOffset = 0, }: { pooItems?: typeof POO_RECORD[] locationItems?: typeof LOCATION_RECORD[] pooOffset?: number locationOffset?: number } = {}) { mockGet.mockImplementation((path: string, opts?: { params?: { query?: { offset?: number } } }) => { const offset = opts?.params?.query?.offset ?? 0 if (path === '/api/poo') { return Promise.resolve({ data: { items: pooItems, limit: 100, offset: pooOffset || offset }, response: { status: 200, ok: true }, }) } if (path === '/api/locations') { return Promise.resolve({ data: { items: locationItems, limit: 100, offset: locationOffset || offset }, response: { status: 200, ok: true }, }) } return Promise.resolve({ data: null }) }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('RecordsPage — Poo tab', () => { beforeEach(() => { vi.clearAllMocks() setupGetMock() }) // ------------------------------------------------------------------------- // 1. Poo list renders // ------------------------------------------------------------------------- it('renders poo records from GET /api/poo', async () => { renderRecords() await waitFor(() => { expect(screen.getByTestId('poo-table')).toBeInTheDocument() }) expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument() expect(screen.getByText('done')).toBeInTheDocument() }) // ------------------------------------------------------------------------- // 2. Poo pagination: page 2 sends offset=100 // ------------------------------------------------------------------------- it('requests offset=100 when page 2 is selected', async () => { // Return full page to trigger pagination display. const page1 = makePooPage(0) setupGetMock({ pooItems: page1 }) renderRecords() await waitFor(() => { expect(screen.getByTestId('poo-pagination')).toBeInTheDocument() }) // Click page 2. const page2Button = screen.getByRole('button', { name: '2' }) fireEvent.click(page2Button) await waitFor(() => { const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/poo') const page2Call = allCalls.find( (c) => (c[1]?.params?.query?.offset ?? 0) === 100, ) expect(page2Call).toBeDefined() }) }) // ------------------------------------------------------------------------- // 3. Edit poo: opens modal, submit calls PATCH with encoded PK // ------------------------------------------------------------------------- it('opens EditPooModal when Edit is clicked; submit calls PATCH with raw PK in path params and correct body', async () => { mockPatch.mockResolvedValue({ data: POO_RECORD, response: { status: 200, ok: true } }) renderRecords() await waitFor(() => { expect(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`)) // Modal appears await waitFor(() => { expect(screen.getByTestId('edit-poo-modal')).toBeInTheDocument() }) // Change status const statusInput = screen.getByTestId('poo-status-input') as HTMLInputElement fireEvent.change(statusInput, { target: { value: 'reviewed' } }) // Submit fireEvent.submit(screen.getByTestId('edit-poo-form')) await waitFor(() => { expect(mockPatch).toHaveBeenCalled() }) const patchCall = mockPatch.mock.calls[0] expect(patchCall[0]).toBe('/api/poo/{timestamp}') // PK must be the raw value — openapi-fetch encodes it once; hooks must not pre-encode. expect(patchCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp) // Body must include only non-PK fields expect(patchCall[1].body).toHaveProperty('status') expect(patchCall[1].body).not.toHaveProperty('timestamp') }) // ------------------------------------------------------------------------- // 4. Delete poo: confirmation then DELETE called; list refreshes // ------------------------------------------------------------------------- it('shows confirmation modal on Delete click; DELETE is called after confirmation', async () => { mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } }) renderRecords() await waitFor(() => { expect(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)).toBeInTheDocument() }) // Click Delete — confirmation modal appears fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)) await waitFor(() => { expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument() }) // The modal should show a helpful message expect(screen.getByTestId('confirm-delete-message')).toBeInTheDocument() // Cancel first — modal should close fireEvent.click(screen.getByTestId('confirm-delete-cancel')) await waitFor(() => { expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument() }) // Reopen and confirm fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)) await waitFor(() => { expect(screen.getByTestId('confirm-delete-confirm')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('confirm-delete-confirm')) await waitFor(() => { expect(mockDelete).toHaveBeenCalled() }) const deleteCall = mockDelete.mock.calls[0] expect(deleteCall[0]).toBe('/api/poo/{timestamp}') // PK must be the raw value — hooks must not pre-encode; openapi-fetch encodes once. expect(deleteCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp) }) }) describe('RecordsPage — Locations tab', () => { beforeEach(() => { vi.clearAllMocks() setupGetMock() }) // ------------------------------------------------------------------------- // 5. Location list renders // ------------------------------------------------------------------------- it('renders location records after switching to Locations tab', async () => { renderRecords() // Switch to Locations tab await waitFor(() => { expect(screen.getByTestId('tab-locations')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('tab-locations')) await waitFor(() => { expect(screen.getByTestId('location-table')).toBeInTheDocument() }) expect(screen.getByText('alice')).toBeInTheDocument() expect(screen.getByText('2026-06-12T09:00:00Z')).toBeInTheDocument() }) // ------------------------------------------------------------------------- // 6. Location pagination: page 2 sends offset=100 // ------------------------------------------------------------------------- it('requests offset=100 for locations when page 2 is selected', async () => { const page1 = makeLocationPage(0) setupGetMock({ locationItems: page1 }) renderRecords() // Switch to Locations tab await waitFor(() => { expect(screen.getByTestId('tab-locations')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('tab-locations')) await waitFor(() => { expect(screen.getByTestId('location-pagination')).toBeInTheDocument() }) const page2Button = screen.getByRole('button', { name: '2' }) fireEvent.click(page2Button) await waitFor(() => { const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/locations') const page2Call = allCalls.find( (c) => (c[1]?.params?.query?.offset ?? 0) === 100, ) expect(page2Call).toBeDefined() }) }) // ------------------------------------------------------------------------- // 7. Edit location: opens modal, submit calls PATCH with encoded PK // ------------------------------------------------------------------------- it('opens EditLocationModal; submit calls PATCH with raw person+datetime in path params', async () => { mockPatch.mockResolvedValue({ data: LOCATION_RECORD, response: { status: 200, ok: true } }) renderRecords() fireEvent.click(screen.getByTestId('tab-locations')) const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}` await waitFor(() => { expect(screen.getByTestId(`location-edit-${rowKey}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`location-edit-${rowKey}`)) await waitFor(() => { expect(screen.getByTestId('edit-location-modal')).toBeInTheDocument() }) // PK shown read-only in the modal (may appear more than once in the page: table + modal) const modalEl = screen.getByTestId('edit-location-modal') expect(modalEl).toBeInTheDocument() // 'alice' and datetime appear in modal read-only text expect(modalEl.textContent).toContain('alice') expect(modalEl.textContent).toContain('2026-06-12T09:00:00Z') // Submit fireEvent.submit(screen.getByTestId('edit-location-form')) await waitFor(() => { expect(mockPatch).toHaveBeenCalled() }) const patchCall = mockPatch.mock.calls[0] expect(patchCall[0]).toBe('/api/locations/{person}/{datetime}') // PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once. expect(patchCall[1].params.path.person).toBe(LOCATION_RECORD.person) expect(patchCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime) // Body must NOT contain PK fields expect(patchCall[1].body).not.toHaveProperty('person') expect(patchCall[1].body).not.toHaveProperty('datetime') expect(patchCall[1].body).toHaveProperty('latitude') expect(patchCall[1].body).toHaveProperty('longitude') }) // ------------------------------------------------------------------------- // 8. Delete location: confirmation then DELETE called // ------------------------------------------------------------------------- it('shows confirmation modal on Delete; DELETE is called with raw PK params', async () => { mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } }) renderRecords() fireEvent.click(screen.getByTestId('tab-locations')) const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}` await waitFor(() => { expect(screen.getByTestId(`location-delete-${rowKey}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`location-delete-${rowKey}`)) await waitFor(() => { expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('confirm-delete-confirm')) await waitFor(() => { expect(mockDelete).toHaveBeenCalled() }) const deleteCall = mockDelete.mock.calls[0] expect(deleteCall[0]).toBe('/api/locations/{person}/{datetime}') // PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once. expect(deleteCall[1].params.path.person).toBe(LOCATION_RECORD.person) expect(deleteCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime) }) }) // --------------------------------------------------------------------------- // Additional: multiple poo records with correct timestamps // --------------------------------------------------------------------------- describe('RecordsPage — multiple poo rows', () => { beforeEach(() => { vi.clearAllMocks() setupGetMock({ pooItems: [POO_RECORD, POO_RECORD_2] }) }) it('renders both rows', async () => { renderRecords() await waitFor(() => { expect(screen.getByTestId('poo-table')).toBeInTheDocument() }) expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument() expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // Meter readings tabs (M5) // --------------------------------------------------------------------------- const METER_DEVICE_1: ModbusDevice = { uuid: 'dev-uuid-1', friendly_name: 'Main Meter', transport: 'tcp', host: '192.168.1.10', port: 502, unit_id: 1, profile: 'sdm630', poll_interval_s: 5, enabled: true, last_poll_at: '2026-06-22T10:00:00Z', last_poll_ok: true, created_at: '2026-06-01T00:00:00Z', updated_at: '2026-06-22T10:00:00Z', } const METER_DEVICE_2: ModbusDevice = { uuid: 'dev-uuid-2', friendly_name: 'Sub Meter', transport: 'tcp', host: '192.168.1.11', port: 502, unit_id: 1, profile: 'sdm120', poll_interval_s: 5, enabled: true, last_poll_at: null, last_poll_ok: null, created_at: '2026-06-01T00:00:00Z', updated_at: '2026-06-22T10:00:00Z', } const METER_METRICS: MetricInfo[] = [ { key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' }, { key: 'energy_kwh', label: 'Energy', unit: 'kWh', device_class: 'energy' }, ] /** A single reading with an open-ended payload shape for test flexibility. */ type TestReading = { recorded_at: string; payload: Record } const METER_READINGS: TestReading[] = [ { recorded_at: '2026-06-22T09:00:00Z', payload: { voltage: 230.5, energy_kwh: 12.345 }, }, { recorded_at: '2026-06-22T09:05:00Z', payload: { voltage: 231.1, energy_kwh: 12.350 }, }, ] /** Extend the poo/location mock to also serve Modbus endpoints. */ function setupGetMockWithMeters({ devices = [METER_DEVICE_1], metricsMap = { 'dev-uuid-1': METER_METRICS }, readingsMap = { 'dev-uuid-1': METER_READINGS }, }: { devices?: ModbusDevice[] metricsMap?: Record readingsMap?: Record } = {}) { mockGet.mockImplementation((path: string, opts?: { params?: { path?: { uuid?: string }; query?: { offset?: number } } }) => { const offset = opts?.params?.query?.offset ?? 0 if (path === '/api/poo') { return Promise.resolve({ data: { items: [POO_RECORD], limit: 100, offset }, response: { status: 200, ok: true }, }) } if (path === '/api/locations') { return Promise.resolve({ data: { items: [LOCATION_RECORD], limit: 100, offset }, response: { status: 200, ok: true }, }) } if (path === '/api/modbus/devices') { return Promise.resolve({ data: { items: devices, total: devices.length }, response: { status: 200, ok: true }, }) } const uuid = opts?.params?.path?.uuid if (path === '/api/modbus/devices/{uuid}/metrics' && uuid) { return Promise.resolve({ data: { profile: 'sdm630', metrics: metricsMap[uuid] ?? [] }, response: { status: 200, ok: true }, }) } if (path === '/api/modbus/devices/{uuid}/readings' && uuid) { return Promise.resolve({ data: { items: readingsMap[uuid] ?? [] }, response: { status: 200, ok: true }, }) } return Promise.resolve({ data: null }) }) } describe('RecordsPage — meter tabs', () => { beforeEach(() => { vi.clearAllMocks() }) // ------------------------------------------------------------------------- // 0 devices: no meter tabs // ------------------------------------------------------------------------- it('shows no meter tabs when there are 0 devices', async () => { setupGetMockWithMeters({ devices: [] }) renderRecords() // Poo and Locations tabs must still be present await waitFor(() => { expect(screen.getByTestId('tab-poo')).toBeInTheDocument() expect(screen.getByTestId('tab-locations')).toBeInTheDocument() }) // No meter tab rendered expect(screen.queryByTestId('tab-meter-dev-uuid-1')).not.toBeInTheDocument() }) // ------------------------------------------------------------------------- // 1 device: 1 meter tab with friendly_name label // ------------------------------------------------------------------------- it('renders 1 meter tab with friendly_name for 1 device', async () => { setupGetMockWithMeters({ devices: [METER_DEVICE_1] }) renderRecords() await waitFor(() => { expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toHaveTextContent(METER_DEVICE_1.friendly_name) }) // ------------------------------------------------------------------------- // 2 devices: 2 meter tabs with correct friendly_names // ------------------------------------------------------------------------- it('renders 2 meter tabs for 2 devices, each with correct friendly_name', async () => { setupGetMockWithMeters({ devices: [METER_DEVICE_1, METER_DEVICE_2], metricsMap: { 'dev-uuid-1': METER_METRICS, 'dev-uuid-2': METER_METRICS }, readingsMap: { 'dev-uuid-1': METER_READINGS, 'dev-uuid-2': [] }, }) renderRecords() await waitFor(() => { expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() expect(screen.getByTestId(`tab-meter-${METER_DEVICE_2.uuid}`)).toBeInTheDocument() }) expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toHaveTextContent('Main Meter') expect(screen.getByTestId(`tab-meter-${METER_DEVICE_2.uuid}`)).toHaveTextContent('Sub Meter') }) // ------------------------------------------------------------------------- // Meter tab: switching to it shows the readings table with metric columns // ------------------------------------------------------------------------- it('switching to meter tab shows table with metric column headers and reading rows', async () => { setupGetMockWithMeters({ devices: [METER_DEVICE_1] }) renderRecords() // Wait for tab to appear then click it await waitFor(() => { expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)) // Table should be rendered await waitFor(() => { expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) // Metric column headers expect(screen.getByText(/Voltage/i)).toBeInTheDocument() expect(screen.getByText(/Energy/i)).toBeInTheDocument() // At least one reading row const rows = screen.getAllByTestId(`meter-row-${METER_DEVICE_1.uuid}`) expect(rows).toHaveLength(2) }) // ------------------------------------------------------------------------- // Meter tab: values formatted correctly (voltage 2 dp, energy 3 dp) // ------------------------------------------------------------------------- it('formats voltage with 2 dp and energy with 3 dp', async () => { setupGetMockWithMeters({ devices: [METER_DEVICE_1] }) renderRecords() await waitFor(() => { expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)) await waitFor(() => { expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) // voltage: 230.5 → "230.50" (2 dp, device_class != energy) expect(screen.getByText('230.50')).toBeInTheDocument() // energy_kwh: 12.345 → "12.345" (3 dp, device_class == energy) expect(screen.getByText('12.345')).toBeInTheDocument() }) // ------------------------------------------------------------------------- // Meter tab: missing key in payload → placeholder "—" // ------------------------------------------------------------------------- it('shows placeholder "—" for a missing key in payload', async () => { const readingsWithMissingKey = [ { recorded_at: '2026-06-22T09:00:00Z', payload: { voltage: 230.5 }, // energy_kwh missing }, ] setupGetMockWithMeters({ devices: [METER_DEVICE_1], readingsMap: { 'dev-uuid-1': readingsWithMissingKey }, }) renderRecords() await waitFor(() => { expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)) await waitFor(() => { expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) // The "—" placeholder must appear (for the missing energy_kwh key) expect(screen.getByText('—')).toBeInTheDocument() }) // ------------------------------------------------------------------------- // Meter tab: empty readings → empty state message // ------------------------------------------------------------------------- it('shows empty state when device has no readings', async () => { setupGetMockWithMeters({ devices: [METER_DEVICE_1], readingsMap: { 'dev-uuid-1': [] }, }) renderRecords() await waitFor(() => { expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)) await waitFor(() => { expect(screen.getByTestId(`meter-empty-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) }) // ------------------------------------------------------------------------- // Meter tab: 1000 readings → truncation notice shown // ------------------------------------------------------------------------- it('shows truncation notice when readings.length equals METER_READINGS_LIMIT (1000)', async () => { const truncatedReadings = Array.from({ length: 1000 }, (_, i) => ({ recorded_at: `2026-06-22T${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`, payload: { voltage: 230 + i * 0.01, energy_kwh: 10 + i * 0.001 }, })) setupGetMockWithMeters({ devices: [METER_DEVICE_1], readingsMap: { 'dev-uuid-1': truncatedReadings }, }) renderRecords() await waitFor(() => { expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)) await waitFor(() => { expect(screen.getByTestId(`meter-truncated-${METER_DEVICE_1.uuid}`)).toBeInTheDocument() }) }) })