FUE-T02: fix EditMeterForm timestamp parsing to use shared parseBackendTimestamp (handles offset form)
This commit is contained in:
@@ -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', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type MeterReason,
|
||||
} from './hooks'
|
||||
import { ApiError } from '../api/client'
|
||||
import { formatLocalDate } from '../utils/datetime'
|
||||
import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -60,6 +60,19 @@ function toLocalMidnightNaive(dateStr: string): string {
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,19 +217,12 @@ interface EditMeterFormProps {
|
||||
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
|
||||
const [label, setLabel] = useState(meter.label)
|
||||
const [note, setNote] = useState(meter.note ?? '')
|
||||
// Convert UTC 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.
|
||||
const initialDateStr = (() => {
|
||||
const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/)
|
||||
? meter.started_at + 'Z'
|
||||
: 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}`
|
||||
})()
|
||||
// Convert started_at to a local date string for the <input type="date">.
|
||||
// parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC,
|
||||
// matching the backend's storage convention), Z-suffixed strings, and strings with
|
||||
// explicit offsets like +02:00. We then extract the local-timezone date components
|
||||
// so the displayed date matches the local wall-clock date of the meter start.
|
||||
const initialDateStr = toLocalDateInputString(parseBackendTimestamp(meter.started_at))
|
||||
const [dateStr, setDateStr] = useState(initialDateStr)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
||||
@@ -20,5 +20,13 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user