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', () => {
|
describe('MeterManager — edit meter', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user