FUE-T02: fix EditMeterForm timestamp parsing to use shared parseBackendTimestamp (handles offset form)

This commit is contained in:
2026-06-25 18:33:42 +02:00
parent f1e7ce3133
commit 188168b16a
3 changed files with 117 additions and 14 deletions
+89
View File
@@ -258,6 +258,95 @@ describe('MeterManager — declare new meter', () => {
}) })
}) })
describe('MeterManager — edit meter date initialisation', () => {
beforeEach(() => vi.clearAllMocks())
it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => {
// ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight).
// The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so
// the local date is deterministically '2024-01-15' on any CI runner.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-01-15')
})
it('initialises date input from started_at (naive, no tz marker)', async () => {
// A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z'
// so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date
// equals the UTC date exactly.
const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' }
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-03-20')
})
it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => {
// The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00'
// suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date.
// The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex
// and handles explicit offsets properly.
const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' }
// UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15'
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
// Must not be empty (which would indicate Invalid Date from the old buggy path)
expect(dateInput.value).not.toBe('')
expect(dateInput.value).toBe('2024-01-15')
})
it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => {
// Open the edit form and immediately submit without changing any fields except label.
// The date should be considered unchanged → no started_at in the PATCH body.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change only label; leave date untouched
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'New label')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchBody = mockPatch.mock.calls[0][1].body
expect(patchBody).not.toHaveProperty('started_at')
})
})
describe('MeterManager — edit meter', () => { describe('MeterManager — edit meter', () => {
beforeEach(() => vi.clearAllMocks()) beforeEach(() => vi.clearAllMocks())
+20 -14
View File
@@ -38,7 +38,7 @@ import {
type MeterReason, type MeterReason,
} from './hooks' } from './hooks'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { formatLocalDate } from '../utils/datetime' import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -60,6 +60,19 @@ function toLocalMidnightNaive(dateStr: string): string {
return `${dateStr}T00:00:00` return `${dateStr}T00:00:00`
} }
/**
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
* Used to populate <input type="date"> fields.
* Returns '' if the Date is invalid.
*/
function toLocalDateInputString(d: Date): string {
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Declare meter form (modal) // Declare meter form (modal)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -204,19 +217,12 @@ interface EditMeterFormProps {
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) { function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
const [label, setLabel] = useState(meter.label) const [label, setLabel] = useState(meter.label)
const [note, setNote] = useState(meter.note ?? '') const [note, setNote] = useState(meter.note ?? '')
// Convert UTC started_at to a local date string for the <input type="date">. // Convert started_at to a local date string for the <input type="date">.
// We parse as UTC (appending Z if needed) and format to "YYYY-MM-DD" in local tz. // parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC,
const initialDateStr = (() => { // matching the backend's storage convention), Z-suffixed strings, and strings with
const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/) // explicit offsets like +02:00. We then extract the local-timezone date components
? meter.started_at + 'Z' // so the displayed date matches the local wall-clock date of the meter start.
: meter.started_at const initialDateStr = toLocalDateInputString(parseBackendTimestamp(meter.started_at))
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
})()
const [dateStr, setDateStr] = useState(initialDateStr) const [dateStr, setDateStr] = useState(initialDateStr)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
+8
View File
@@ -20,5 +20,13 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: ['./src/test-setup.ts'], setupFiles: ['./src/test-setup.ts'],
env: {
// Lock the test timezone to UTC so that date-formatting assertions
// (which rely on toLocalDateInputString / getFullYear etc.) produce
// deterministic results regardless of the CI runner's local timezone.
// All fixture timestamps are UTC midnight, so the expected YYYY-MM-DD
// values in MeterManager.test.tsx are correct under UTC.
TZ: 'UTC',
},
}, },
}) })