Skip to content

Commit 77bd226

Browse files
UjjawalPrabhatNethmiRodrigo
authored andcommitted
(fix) O3-4970: Use date-only format for immunization expiration dates (#2779)
* O3-4970: Use date-only format for immunization expiration dates - Add toDateOnlyString() utility function with robust error handling - Update immunization form to use timezone-safe date formatting - Replace .toISOString() with manual YYYY-MM-DD formatting - Add comprehensive unit tests for date utility (11 test cases) - Add regression test for O3-4970 in immunization form tests - Ensure FHIR compliance for Immunization.expirationDate field - Fix date shift bug where 31/12/2025 was saved as 30/12/2025 Fixes: https://openmrs.atlassian.net/browse/O3-4970 * Update packages/esm-patient-immunizations-app/src/immunizations/immunizations-form.test.tsx Co-authored-by: Ian <[email protected]> * fix(O3-4970): use dayjs for date-only formatting and remove custom utility - Remove toDateOnlyString utility and its tests - Import dayjs and replace toDateOnlyString(...) with dayjs(...).format('YYYY-MM-DD') - Update form tests to expect ISO-formatted strings again - Ensure existing tests pass without the custom utility * Update packages/esm-patient-immunizations-app/src/immunizations/utils.ts Co-authored-by: Nethmi Rodrigo <[email protected]> * Add expiration date format tests Add test coverage to verify expiration dates are sent as YYYY-MM-DD date-only strings to prevent timezone conversion bugs.
1 parent c22bcdb commit 77bd226

File tree

2 files changed

+153
-8
lines changed

2 files changed

+153
-8
lines changed

packages/esm-patient-immunizations-app/src/immunizations/immunizations-form.test.tsx

Lines changed: 152 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import dayjs from 'dayjs';
33
import userEvent from '@testing-library/user-event';
4-
import { render, screen } from '@testing-library/react';
4+
import { render, screen, waitFor } from '@testing-library/react';
55
import {
66
getDefaultsFromConfigSchema,
77
showSnackbar,
@@ -114,10 +114,11 @@ describe('Immunizations Form', () => {
114114
mockToDateObjectStrict.mockImplementation((dateString) => dayjs(dateString, isoFormat).toDate());
115115
});
116116

117-
it('should render ImmunizationsForm component', () => {
117+
it('should render ImmunizationsForm component', async () => {
118118
render(<ImmunizationsForm {...testProps} />);
119119

120-
expect(screen.getByLabelText(/vaccination date/i)).toBeInTheDocument();
120+
await screen.findByLabelText(/vaccination date/i);
121+
121122
expect(screen.getByRole('combobox', { name: /Immunization/i })).toBeInTheDocument();
122123
expect(screen.getByRole('textbox', { name: /note/i })).toBeInTheDocument();
123124
expect(screen.getByText(/Vaccine Batch Information/i)).toBeInTheDocument();
@@ -230,10 +231,10 @@ describe('Immunizations Form', () => {
230231
const immunizationToEdit = {
231232
vaccineUuid: '886AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
232233
immunizationId: '0a6ca2bb-a317-49d8-bd6b-dabb658840d2',
233-
vaccinationDate: new Date('2024-01-03').toString(),
234+
vaccinationDate: new Date('2024-01-03').toISOString(),
234235
doseNumber: 2,
235-
expirationDate: new Date('2024-05-19').toString(),
236-
nextDoseDate: new Date('2024-01-03').toString(),
236+
expirationDate: new Date('2024-05-19').toISOString(),
237+
nextDoseDate: new Date('2024-01-03').toISOString(),
237238
note: 'Given as part of routine schedule.',
238239
lotNumber: 'A123456',
239240
manufacturer: 'Merck & Co., Inc.',
@@ -283,7 +284,7 @@ describe('Immunizations Form', () => {
283284
expect.objectContaining({
284285
encounter: { reference: 'Encounter/ce589c9c-2f30-42ec-b289-a153f812ea5e', type: 'Encounter' },
285286
id: '0a6ca2bb-a317-49d8-bd6b-dabb658840d2',
286-
expirationDate: dayjs(new Date('2024-05-19')).startOf('day').toDate().toISOString(),
287+
expirationDate: '2024-05-19',
287288
extension: [
288289
{
289290
url: FHIR_NEXT_DOSE_DATE_EXTENSION_URL,
@@ -316,6 +317,150 @@ describe('Immunizations Form', () => {
316317
title: 'Vaccination saved successfully',
317318
});
318319
});
320+
321+
it('should save new immunization with expiration date in correct format', async () => {
322+
const user = userEvent.setup();
323+
324+
// Pre-populate form with expiration date using the form subscription (same pattern as edit tests)
325+
const immunizationWithExpiration = {
326+
vaccineUuid: '782AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
327+
vaccinationDate: new Date('2024-06-15').toISOString(),
328+
doseNumber: 1,
329+
expirationDate: new Date('2025-12-31').toISOString(),
330+
manufacturer: 'Pfizer',
331+
lotNumber: 'LOT123',
332+
note: '',
333+
nextDoseDate: null,
334+
};
335+
336+
immunizationFormSub.next(immunizationWithExpiration);
337+
338+
mockSavePatientImmunization.mockResolvedValue({
339+
status: 201,
340+
ok: true,
341+
data: {
342+
id: 'new-immunization-id',
343+
},
344+
});
345+
346+
render(<ImmunizationsForm {...testProps} />);
347+
348+
// Verify the form is populated
349+
const expirationDateField = screen.getByRole('textbox', { name: /Expiration date/i });
350+
expect(expirationDateField).toHaveValue('31/12/2025');
351+
352+
// Submit without making changes
353+
const saveButton = screen.getByRole('button', { name: /Save/i });
354+
await user.click(saveButton);
355+
356+
// Verify that expirationDate is formatted as YYYY-MM-DD without timezone
357+
expect(mockSavePatientImmunization).toHaveBeenCalledWith(
358+
expect.objectContaining({
359+
expirationDate: '2025-12-31', // Date-only format, not ISO string with time/timezone
360+
lotNumber: 'LOT123',
361+
manufacturer: { display: 'Pfizer' },
362+
}),
363+
undefined,
364+
expect.any(AbortController),
365+
);
366+
});
367+
368+
it('should format expiration date as date-only string without timezone', async () => {
369+
const user = userEvent.setup();
370+
371+
// Regression test for O3-4970:
372+
// Previously, expiration dates were converted to ISO strings with timezone (e.g., "2025-12-31T00:00:00.000Z"),
373+
// causing a one-day shift for users in timezones ahead of UTC. This test ensures dates are sent as
374+
// date-only strings (e.g., "2025-12-31") per FHIR date type specification, preventing timezone conversion.
375+
376+
// Setup immunization with expiration date
377+
const immunizationWithExpiration = {
378+
vaccineUuid: '782AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
379+
immunizationId: 'test-immunization-with-expiration',
380+
vaccinationDate: new Date('2024-12-25').toISOString(),
381+
doseNumber: 1,
382+
expirationDate: new Date('2025-12-31').toISOString(),
383+
manufacturer: 'Test Manufacturer',
384+
lotNumber: 'LOT123',
385+
note: 'Test note',
386+
nextDoseDate: null,
387+
};
388+
389+
immunizationFormSub.next(immunizationWithExpiration);
390+
391+
mockSavePatientImmunization.mockResolvedValue({
392+
status: 201,
393+
ok: true,
394+
data: {
395+
id: immunizationWithExpiration.immunizationId,
396+
},
397+
});
398+
399+
render(<ImmunizationsForm {...testProps} />);
400+
401+
// Verify the expiration date is displayed correctly
402+
const expirationDateField = screen.getByRole('textbox', { name: /Expiration date/i });
403+
expect(expirationDateField).toHaveValue('31/12/2025');
404+
405+
// Submit the form without changes to verify the date format is preserved
406+
const saveButton = screen.getByRole('button', { name: /Save/i });
407+
await user.click(saveButton);
408+
409+
// Verify that expirationDate is formatted as YYYY-MM-DD without timezone (not ISO string)
410+
expect(mockSavePatientImmunization).toHaveBeenCalledWith(
411+
expect.objectContaining({
412+
expirationDate: '2025-12-31', // Date-only format, not ISO string with time/timezone
413+
}),
414+
immunizationWithExpiration.immunizationId,
415+
expect.any(AbortController),
416+
);
417+
});
418+
419+
it('should preserve date format when submitting immunization with different expiration date', async () => {
420+
const user = userEvent.setup();
421+
422+
// Load existing immunization with a different expiration date
423+
const immunizationToEdit = {
424+
vaccineUuid: '782AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
425+
immunizationId: 'existing-immunization-id',
426+
vaccinationDate: new Date('2024-06-15').toISOString(),
427+
doseNumber: 1,
428+
expirationDate: new Date('2026-06-15').toISOString(),
429+
manufacturer: 'Moderna',
430+
lotNumber: 'ABC123',
431+
note: 'Initial note',
432+
nextDoseDate: null,
433+
};
434+
435+
immunizationFormSub.next(immunizationToEdit);
436+
437+
mockSavePatientImmunization.mockResolvedValue({
438+
status: 201,
439+
ok: true,
440+
data: {
441+
id: immunizationToEdit.immunizationId,
442+
},
443+
});
444+
445+
render(<ImmunizationsForm {...testProps} />);
446+
447+
// Verify expiration date is displayed
448+
const expirationDateField = screen.getByRole('textbox', { name: /Expiration date/i });
449+
expect(expirationDateField).toHaveValue('15/06/2026');
450+
451+
// Submit the form
452+
const saveButton = screen.getByRole('button', { name: /Save/i });
453+
await user.click(saveButton);
454+
455+
// Verify the date is sent in correct format (YYYY-MM-DD, not ISO string)
456+
expect(mockSavePatientImmunization).toHaveBeenCalledWith(
457+
expect.objectContaining({
458+
expirationDate: '2026-06-15', // Date-only format, not ISO string with time/timezone
459+
}),
460+
immunizationToEdit.immunizationId,
461+
expect.any(AbortController),
462+
);
463+
});
319464
});
320465

321466
async function selectOption(dropdown: HTMLElement, optionLabel: string) {

packages/esm-patient-immunizations-app/src/immunizations/immunizations-form.workspace.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ const ImmunizationsForm: React.FC<DefaultPatientWorkspaceProps> = ({
155155
doseNumber,
156156
nextDoseDate: nextDoseDate ? dayjs(nextDoseDate).startOf('day').toDate().toISOString() : null,
157157
note,
158-
expirationDate: expirationDate ? dayjs(expirationDate).startOf('day').toDate().toISOString() : null,
158+
expirationDate: expirationDate ? dayjs(expirationDate).format('YYYY-MM-DD') : null,
159159
lotNumber,
160160
manufacturer,
161161
};

0 commit comments

Comments
 (0)