From c2c9d64e1a9c006f624ce7ac37d89626eb8ff076 Mon Sep 17 00:00:00 2001 From: Suren Hakobyan Date: Wed, 6 May 2026 12:48:06 +0400 Subject: [PATCH 1/7] fix: use UTC getters in formatDate/formatDateTime to prevent timezone drift `formatDateTime` and `formatDate` use `new Date(input)` followed by local-time getters (`getFullYear`, `getMonth`, `getDate`, `getHours`, etc.). On CI runners or servers not in UTC, this produces shifted dates. For example, `formatDate("2023-01-01T00:30:00Z")` on a UTC+2 machine returns "2023-01-01" correctly, but on a UTC-5 machine it returns "2022-12-31" because 00:30 UTC is still Dec 31 in UTC-5. Since these functions serialize values for D1/SQLite storage where dates are inherently UTC, this commit: 1. Early-returns when input is already in canonical format (avoids needless round-trip through Date). 2. Normalizes space-separated datetime strings ("YYYY-MM-DD HH:mm:ss") to ISO format with Z suffix before parsing, ensuring UTC interpretation. 3. Replaces all local-time getters with UTC equivalents. Both copies (src/utils and src/runtime/internal/preview) are updated. Tests are updated to assert deterministic UTC output. --- src/runtime/internal/preview/utils.ts | 33 ++++++++++--- src/utils/content/transformers/utils.ts | 28 ++++++++--- test/unit/formatDate.test.ts | 65 +++++++++++++++---------- 3 files changed, 85 insertions(+), 41 deletions(-) diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index 11dd0a336..7a10aeb20 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -77,17 +77,26 @@ export function parseSourceBase(source: CollectionSource) { * Importing it from the preview runtime causes a broken path in the * published package. * + * Uses UTC getters to avoid timezone-dependent date shifts on non-UTC + * CI runners or servers. + * * @see https://github.com/nuxt/content/issues/3742 */ export const formatDate = (date: string): string => { - const d = new Date(date) + if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + return date + } + const normalized = typeof date === 'string' + ? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + : date + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } - const year = d.getFullYear() - const month = d.getMonth() + 1 - const day = d.getDate() + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` } @@ -95,18 +104,26 @@ export const formatDate = (date: string): string => { /** * Format a date string as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. * + * Uses UTC getters to avoid timezone-dependent shifts. + * * @see {@link formatDate} for why this is duplicated here. * @see https://github.com/nuxt/content/issues/3742 */ export const formatDateTime = (datetime: string): string => { - const d = new Date(datetime) + if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { + return datetime + } + const normalized = typeof datetime === 'string' + ? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + : datetime + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } - const hours = d.getHours() - const minutes = d.getMinutes() - const seconds = d.getSeconds() + const hours = d.getUTCHours() + const minutes = d.getUTCMinutes() + const seconds = d.getUTCSeconds() return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index 499b2dcda..a181ca4ab 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -5,27 +5,39 @@ export const defineTransformer = (transformer: ContentTransformer) => { } export const formatDateTime = (datetime: string): string => { - const d = new Date(datetime) + if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { + return datetime + } + const normalized = typeof datetime === 'string' + ? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + : datetime + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } - const hours = d.getHours() - const minutes = d.getMinutes() - const seconds = d.getSeconds() + const hours = d.getUTCHours() + const minutes = d.getUTCMinutes() + const seconds = d.getUTCSeconds() return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } export const formatDate = (date: string): string => { - const d = new Date(date) + if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + return date + } + const normalized = typeof date === 'string' + ? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + : date + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } - const year = d.getFullYear() - const month = d.getMonth() + 1 - const day = d.getDate() + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` } diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index be1b03cae..e5586e37f 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -2,27 +2,31 @@ import { describe, expect, it } from 'vitest' import { formatDate, formatDateTime } from '../../src/runtime/internal/preview/utils' describe('formatDate', () => { - it('formats a date string as YYYY-MM-DD', () => { - // formatDate uses local time (getFullYear/getMonth/getDate), so we - // construct expected values the same way to stay timezone-agnostic. - const input = '2022-06-15T12:00:00.000Z' - const d = new Date(input) - const expected = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` - expect(formatDate(input)).toBe(expected) + it('formats an ISO date string as YYYY-MM-DD using UTC', () => { + expect(formatDate('2022-06-15T12:00:00.000Z')).toBe('2022-06-15') + }) + + it('returns canonical date strings unchanged', () => { + expect(formatDate('2022-06-15')).toBe('2022-06-15') + expect(formatDate('2024-01-01')).toBe('2024-01-01') }) it('pads single-digit month and day', () => { - const input = '2022-01-05T12:00:00.000Z' - const result = formatDate(input) - // Format is always YYYY-MM-DD with zero-padded segments - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/) - expect(result).toContain('-05') + expect(formatDate('2022-01-05T00:00:00.000Z')).toBe('2022-01-05') }) - it('handles end-of-year dates', () => { - const input = '2022-12-31T12:00:00.000Z' - const result = formatDate(input) - expect(result).toMatch(/^\d{4}-12-31$/) + it('handles end-of-year dates consistently in UTC', () => { + // 2022-12-31T23:00:00Z is still Dec 31 in UTC even if it's Jan 1 locally + expect(formatDate('2022-12-31T23:00:00.000Z')).toBe('2022-12-31') + }) + + it('handles dates near midnight boundary in UTC', () => { + // This is Jan 1 00:30 UTC — should be 2023-01-01, not 2022-12-31 + expect(formatDate('2023-01-01T00:30:00.000Z')).toBe('2023-01-01') + }) + + it('parses space-separated datetime as UTC', () => { + expect(formatDate('2022-06-15 14:30:00')).toBe('2022-06-15') }) it('throws on invalid date', () => { @@ -31,7 +35,6 @@ describe('formatDate', () => { }) it('produces same output as the build-time copy', async () => { - // Guard against the two copies drifting apart. const buildTime = await import('../../src/utils/content/transformers/utils') const inputs = ['2022-06-15T12:00:00.000Z', '2023-01-01T00:00:00.000Z', '2024-12-31T23:59:59.000Z'] for (const input of inputs) { @@ -41,17 +44,29 @@ describe('formatDate', () => { }) describe('formatDateTime', () => { - it('formats a datetime string as YYYY-MM-DD HH:mm:ss', () => { - const input = '2022-06-15T14:30:45.000Z' - const result = formatDateTime(input) - expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) - // The date portion must match formatDate - expect(result.split(' ')[0]).toBe(formatDate(input)) + it('formats an ISO datetime string as YYYY-MM-DD HH:mm:ss using UTC', () => { + expect(formatDateTime('2022-06-15T14:30:45.000Z')).toBe('2022-06-15 14:30:45') + }) + + it('returns canonical datetime strings unchanged', () => { + expect(formatDateTime('2022-06-15 14:30:45')).toBe('2022-06-15 14:30:45') }) it('pads single-digit hours, minutes, and seconds', () => { - const result = formatDateTime('2022-01-01T01:02:03.000Z') - expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) + expect(formatDateTime('2022-01-01T01:02:03.000Z')).toBe('2022-01-01 01:02:03') + }) + + it('uses UTC time components regardless of system timezone', () => { + // Midnight UTC should always produce 00:00:00 + expect(formatDateTime('2022-06-15T00:00:00.000Z')).toBe('2022-06-15 00:00:00') + // 23:59:59 UTC should always produce that time, not shift to next day + expect(formatDateTime('2022-12-31T23:59:59.000Z')).toBe('2022-12-31 23:59:59') + }) + + it('the date portion matches formatDate output', () => { + const input = '2022-06-15T14:30:45.000Z' + const result = formatDateTime(input) + expect(result.split(' ')[0]).toBe(formatDate(input)) }) it('throws on invalid datetime', () => { From 00a1a92c897981956184e1c820d031925e1f29e5 Mon Sep 17 00:00:00 2001 From: Suren Hakobyan Date: Wed, 6 May 2026 13:12:36 +0400 Subject: [PATCH 2/7] refactor: remove redundant typeof guards and eliminate double-parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `typeof x === 'string'` checks — the parameter types already enforce string input at the TypeScript level - formatDateTime now extracts all date/time components from a single parsed Date object instead of delegating to formatDate(). This avoids parsing and normalizing the input twice and makes the data flow clearer. --- src/runtime/internal/preview/utils.ts | 17 ++++++++--------- src/utils/content/transformers/utils.ts | 17 ++++++++--------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index 7a10aeb20..a7e241411 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -83,12 +83,10 @@ export function parseSourceBase(source: CollectionSource) { * @see https://github.com/nuxt/content/issues/3742 */ export const formatDate = (date: string): string => { - if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { return date } - const normalized = typeof date === 'string' - ? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - : date + const normalized = date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) @@ -110,20 +108,21 @@ export const formatDate = (date: string): string => { * @see https://github.com/nuxt/content/issues/3742 */ export const formatDateTime = (datetime: string): string => { - if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { return datetime } - const normalized = typeof datetime === 'string' - ? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - : datetime + const normalized = datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() const hours = d.getUTCHours() const minutes = d.getUTCMinutes() const seconds = d.getUTCSeconds() - return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index a181ca4ab..d521d491e 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -5,31 +5,30 @@ export const defineTransformer = (transformer: ContentTransformer) => { } export const formatDateTime = (datetime: string): string => { - if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { return datetime } - const normalized = typeof datetime === 'string' - ? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - : datetime + const normalized = datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() const hours = d.getUTCHours() const minutes = d.getUTCMinutes() const seconds = d.getUTCSeconds() - return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } export const formatDate = (date: string): string => { - if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { return date } - const normalized = typeof date === 'string' - ? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - : date + const normalized = date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) From 1ce1618d9868b5fbbbf64380c00cff6f849408eb Mon Sep 17 00:00:00 2001 From: Suren Hakobyan Date: Wed, 6 May 2026 13:16:55 +0400 Subject: [PATCH 3/7] fix: accept Date objects in formatDate/formatDateTime for runtime callers The build-time collection utilities pass Date objects (cast with `as string`) to these functions. Rather than the previous `typeof` ternary pattern, explicitly widen the type to `string | Date` and normalize with `instanceof Date` at the entry point. This is cleaner than the previous approach because: - The type signature documents the actual runtime contract - A single coercion path at the top (no branching in the middle) - Date objects get proper UTC handling via `.toISOString()` --- src/runtime/internal/preview/utils.ts | 18 ++++++++++-------- src/utils/content/transformers/utils.ts | 18 ++++++++++-------- test/unit/formatDate.test.ts | 5 +++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index a7e241411..cd7f1a5f0 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -82,11 +82,12 @@ export function parseSourceBase(source: CollectionSource) { * * @see https://github.com/nuxt/content/issues/3742 */ -export const formatDate = (date: string): string => { - if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { - return date +export const formatDate = (date: string | Date): string => { + const input = date instanceof Date ? date.toISOString() : String(date) + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + return input } - const normalized = date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) @@ -107,11 +108,12 @@ export const formatDate = (date: string): string => { * @see {@link formatDate} for why this is duplicated here. * @see https://github.com/nuxt/content/issues/3742 */ -export const formatDateTime = (datetime: string): string => { - if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { - return datetime +export const formatDateTime = (datetime: string | Date): string => { + const input = datetime instanceof Date ? datetime.toISOString() : String(datetime) + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) { + return input } - const normalized = datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index d521d491e..bb2a496b0 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -4,11 +4,12 @@ export const defineTransformer = (transformer: ContentTransformer) => { return transformer } -export const formatDateTime = (datetime: string): string => { - if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { - return datetime +export const formatDateTime = (datetime: string | Date): string => { + const input = datetime instanceof Date ? datetime.toISOString() : String(datetime) + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) { + return input } - const normalized = datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) @@ -24,11 +25,12 @@ export const formatDateTime = (datetime: string): string => { return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } -export const formatDate = (date: string): string => { - if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { - return date +export const formatDate = (date: string | Date): string => { + const input = date instanceof Date ? date.toISOString() : String(date) + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + return input } - const normalized = date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index e5586e37f..6dfd8a4b1 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -29,6 +29,11 @@ describe('formatDate', () => { expect(formatDate('2022-06-15 14:30:00')).toBe('2022-06-15') }) + it('handles Date object input', () => { + const date = new Date('2022-06-15T14:30:00.000Z') + expect(formatDate(date as unknown as string)).toBe('2022-06-15') + }) + it('throws on invalid date', () => { expect(() => formatDate('not-a-date')).toThrow(TypeError) expect(() => formatDate('not-a-date')).toThrow('Invalid date value') From 3bce5acda0d7f6daa1b2efac944635bdb3ba100f Mon Sep 17 00:00:00 2001 From: Suren Hakobyan Date: Wed, 6 May 2026 13:26:50 +0400 Subject: [PATCH 4/7] test: remove unnecessary type cast now that signature accepts Date --- test/unit/formatDate.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index 6dfd8a4b1..461b0858e 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -30,8 +30,7 @@ describe('formatDate', () => { }) it('handles Date object input', () => { - const date = new Date('2022-06-15T14:30:00.000Z') - expect(formatDate(date as unknown as string)).toBe('2022-06-15') + expect(formatDate(new Date('2022-06-15T14:30:00.000Z'))).toBe('2022-06-15') }) it('throws on invalid date', () => { From dc6f5350b6016e49afe6fdf813c18124c327cdad Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 27 Aug 2026 13:14:00 +0200 Subject: [PATCH 5/7] fix: treat offset-less datetimes as UTC --- src/runtime/internal/preview/utils.ts | 61 ++++++++++++++------ src/utils/content/transformers/utils.ts | 75 +++++++++++++++++++------ test/unit/formatDate.test.ts | 63 ++++++++++++++++++--- 3 files changed, 155 insertions(+), 44 deletions(-) diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index cd7f1a5f0..c13e3ab1c 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -70,25 +70,19 @@ export function parseSourceBase(source: CollectionSource) { } /** - * Format a date string as `YYYY-MM-DD` for SQL DATE columns. + * Format a date value as `YYYY-MM-DD` for SQL DATE columns. * * Duplicated from `src/utils/content/transformers/utils.ts` because that * file lives outside the `runtime/` subtree and is not emitted to dist. * Importing it from the preview runtime causes a broken path in the * published package. * - * Uses UTC getters to avoid timezone-dependent date shifts on non-UTC - * CI runners or servers. + * Always uses UTC. Offset-less datetimes are treated as UTC. * * @see https://github.com/nuxt/content/issues/3742 */ export const formatDate = (date: string | Date): string => { - const input = date instanceof Date ? date.toISOString() : String(date) - if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { - return input - } - const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - const d = new Date(normalized) + const d = toUtcDate(date) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } @@ -101,20 +95,15 @@ export const formatDate = (date: string | Date): string => { } /** - * Format a date string as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. + * Format a datetime value as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. * - * Uses UTC getters to avoid timezone-dependent shifts. + * Always uses UTC. Offset-less datetimes are treated as UTC. * * @see {@link formatDate} for why this is duplicated here. * @see https://github.com/nuxt/content/issues/3742 */ export const formatDateTime = (datetime: string | Date): string => { - const input = datetime instanceof Date ? datetime.toISOString() : String(datetime) - if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) { - return input - } - const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - const d = new Date(normalized) + const d = toUtcDate(datetime) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } @@ -128,3 +117,41 @@ export const formatDateTime = (datetime: string | Date): string => { return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } + +/** + * Parse a date/datetime value as UTC. + * + * - Date objects are used as-is + * - Space-separated datetimes (`YYYY-MM-DD HH:mm:ss[.sss]`) become ISO + Z + * - Offset-less ISO datetimes (`YYYY-MM-DDTHH:mm:ss[.sss]`) get a Z suffix + * - Date-only (`YYYY-MM-DD`) and values that already include Z/offset pass through + */ +function toUtcDate(value: string | Date): Date { + if (value instanceof Date) { + return value + } + + const input = String(value).trim() + + // Already has an explicit offset or Z — Date parses correctly as absolute time + if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(input)) { + return new Date(input) + } + + // Space-separated SQL-style datetime → ISO + Z + const spaceSeparated = input.replace( + /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, + '$1T$2$3Z', + ) + if (spaceSeparated !== input) { + return new Date(spaceSeparated) + } + + // Offset-less ISO datetime (`2023-01-01T00:00:00`) → treat as UTC + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(input)) { + return new Date(`${input}Z`) + } + + // Date-only and everything else — Date-only is already UTC midnight per ES + return new Date(input) +} diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index bb2a496b0..1554b00a9 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -4,13 +4,32 @@ export const defineTransformer = (transformer: ContentTransformer) => { return transformer } -export const formatDateTime = (datetime: string | Date): string => { - const input = datetime instanceof Date ? datetime.toISOString() : String(datetime) - if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) { - return input +/** + * Format a date value as `YYYY-MM-DD` for SQL DATE columns. + * + * Always uses UTC. Offset-less datetimes (e.g. `2023-01-01T00:00:00`, + * `2023-01-01 00:00:00`) are treated as UTC rather than local time. + */ +export const formatDate = (date: string | Date): string => { + const d = toUtcDate(date) + if (Number.isNaN(d.getTime())) { + throw new TypeError(`Invalid date value: "${date}"`) } - const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - const d = new Date(normalized) + + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() + + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` +} + +/** + * Format a datetime value as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. + * + * Always uses UTC. Offset-less datetimes are treated as UTC. + */ +export const formatDateTime = (datetime: string | Date): string => { + const d = toUtcDate(datetime) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } @@ -25,20 +44,40 @@ export const formatDateTime = (datetime: string | Date): string => { return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } -export const formatDate = (date: string | Date): string => { - const input = date instanceof Date ? date.toISOString() : String(date) - if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { - return input +/** + * Parse a date/datetime value as UTC. + * + * - Date objects are used as-is + * - Space-separated datetimes (`YYYY-MM-DD HH:mm:ss[.sss]`) become ISO + Z + * - Offset-less ISO datetimes (`YYYY-MM-DDTHH:mm:ss[.sss]`) get a Z suffix + * - Date-only (`YYYY-MM-DD`) and values that already include Z/offset pass through + */ +function toUtcDate(value: string | Date): Date { + if (value instanceof Date) { + return value } - const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - const d = new Date(normalized) - if (Number.isNaN(d.getTime())) { - throw new TypeError(`Invalid date value: "${date}"`) + + const input = String(value).trim() + + // Already has an explicit offset or Z — Date parses correctly as absolute time + if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(input)) { + return new Date(input) } - const year = d.getUTCFullYear() - const month = d.getUTCMonth() + 1 - const day = d.getUTCDate() + // Space-separated SQL-style datetime → ISO + Z + const spaceSeparated = input.replace( + /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, + '$1T$2$3Z', + ) + if (spaceSeparated !== input) { + return new Date(spaceSeparated) + } - return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` + // Offset-less ISO datetime (`2023-01-01T00:00:00`) → treat as UTC + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(input)) { + return new Date(`${input}Z`) + } + + // Date-only and everything else — Date-only is already UTC midnight per ES + return new Date(input) } diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index 461b0858e..59e173273 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -16,21 +16,38 @@ describe('formatDate', () => { }) it('handles end-of-year dates consistently in UTC', () => { - // 2022-12-31T23:00:00Z is still Dec 31 in UTC even if it's Jan 1 locally + // Still Dec 31 in UTC even when local TZ has crossed into Jan 1 expect(formatDate('2022-12-31T23:00:00.000Z')).toBe('2022-12-31') }) it('handles dates near midnight boundary in UTC', () => { - // This is Jan 1 00:30 UTC — should be 2023-01-01, not 2022-12-31 expect(formatDate('2023-01-01T00:30:00.000Z')).toBe('2023-01-01') }) + it('treats date-only strings as UTC calendar dates', () => { + expect(formatDate('2023-01-01')).toBe('2023-01-01') + expect(formatDate('2024-12-31')).toBe('2024-12-31') + }) + + it('treats offset-less ISO datetimes as UTC', () => { + // ES would parse this as local time; we force UTC + expect(formatDate('2023-01-01T00:00:00')).toBe('2023-01-01') + expect(formatDate('2022-12-31T23:30:00')).toBe('2022-12-31') + }) + it('parses space-separated datetime as UTC', () => { expect(formatDate('2022-06-15 14:30:00')).toBe('2022-06-15') }) - it('handles Date object input', () => { + it('respects explicit non-UTC offsets', () => { + // 2023-01-01 00:30 in UTC+5:30 → 2022-12-31 19:00 UTC + expect(formatDate('2023-01-01T00:30:00+05:30')).toBe('2022-12-31') + }) + + it('handles Date object input via UTC components', () => { expect(formatDate(new Date('2022-06-15T14:30:00.000Z'))).toBe('2022-06-15') + // Near midnight UTC boundary + expect(formatDate(new Date('2022-12-31T23:00:00.000Z'))).toBe('2022-12-31') }) it('throws on invalid date', () => { @@ -40,7 +57,15 @@ describe('formatDate', () => { it('produces same output as the build-time copy', async () => { const buildTime = await import('../../src/utils/content/transformers/utils') - const inputs = ['2022-06-15T12:00:00.000Z', '2023-01-01T00:00:00.000Z', '2024-12-31T23:59:59.000Z'] + const inputs = [ + '2022-06-15T12:00:00.000Z', + '2023-01-01T00:00:00.000Z', + '2024-12-31T23:59:59.000Z', + '2023-01-01', + '2023-01-01T00:00:00', + '2022-06-15 14:30:00', + '2023-01-01T00:30:00+05:30', + ] for (const input of inputs) { expect(formatDate(input)).toBe(buildTime.formatDate(input)) } @@ -61,16 +86,30 @@ describe('formatDateTime', () => { }) it('uses UTC time components regardless of system timezone', () => { - // Midnight UTC should always produce 00:00:00 expect(formatDateTime('2022-06-15T00:00:00.000Z')).toBe('2022-06-15 00:00:00') - // 23:59:59 UTC should always produce that time, not shift to next day expect(formatDateTime('2022-12-31T23:59:59.000Z')).toBe('2022-12-31 23:59:59') }) + it('treats offset-less ISO datetimes as UTC', () => { + expect(formatDateTime('2022-06-15T14:30:45')).toBe('2022-06-15 14:30:45') + expect(formatDateTime('2022-12-31T23:00:00')).toBe('2022-12-31 23:00:00') + }) + + it('parses space-separated datetime as UTC', () => { + expect(formatDateTime('2022-06-15 14:30:45')).toBe('2022-06-15 14:30:45') + }) + + it('respects explicit non-UTC offsets', () => { + expect(formatDateTime('2022-06-15T14:30:45+02:00')).toBe('2022-06-15 12:30:45') + }) + + it('handles Date object input', () => { + expect(formatDateTime(new Date('2022-06-15T14:30:45.000Z'))).toBe('2022-06-15 14:30:45') + }) + it('the date portion matches formatDate output', () => { const input = '2022-06-15T14:30:45.000Z' - const result = formatDateTime(input) - expect(result.split(' ')[0]).toBe(formatDate(input)) + expect(formatDateTime(input).split(' ')[0]).toBe(formatDate(input)) }) it('throws on invalid datetime', () => { @@ -80,7 +119,13 @@ describe('formatDateTime', () => { it('produces same output as the build-time copy', async () => { const buildTime = await import('../../src/utils/content/transformers/utils') - const inputs = ['2022-06-15T14:30:45.000Z', '2023-01-01T00:00:00.000Z'] + const inputs = [ + '2022-06-15T14:30:45.000Z', + '2023-01-01T00:00:00.000Z', + '2022-06-15T14:30:45', + '2022-06-15 14:30:45', + '2022-06-15T14:30:45+02:00', + ] for (const input of inputs) { expect(formatDateTime(input)).toBe(buildTime.formatDateTime(input)) } From 2d7563cfc31fdd8576c10c07acbd356ce34d8dde Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 27 Aug 2026 13:46:02 +0200 Subject: [PATCH 6/7] Update formatDate.test.ts --- test/unit/formatDate.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index 59e173273..0c08a0adc 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -77,6 +77,10 @@ describe('formatDateTime', () => { expect(formatDateTime('2022-06-15T14:30:45.000Z')).toBe('2022-06-15 14:30:45') }) + it('handles Date object input', () => { + expect(formatDateTime(new Date('2022-06-15T14:30:45.000Z'))).toBe('2022-06-15 14:30:45') + }) + it('returns canonical datetime strings unchanged', () => { expect(formatDateTime('2022-06-15 14:30:45')).toBe('2022-06-15 14:30:45') }) From 22e3a2c92aaadcc45fcb86f7e6928703d6d9e85b Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 27 Aug 2026 14:12:15 +0200 Subject: [PATCH 7/7] fix: cover more formats and validate --- src/runtime/internal/preview/utils.ts | 68 +++++++++++------------ src/utils/content/transformers/utils.ts | 71 ++++++++++++------------- test/unit/formatDate.test.ts | 27 +++++++++- 3 files changed, 91 insertions(+), 75 deletions(-) diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index c13e3ab1c..0c1d3339a 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -86,12 +86,7 @@ export const formatDate = (date: string | Date): string => { if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } - - const year = d.getUTCFullYear() - const month = d.getUTCMonth() + 1 - const day = d.getUTCDate() - - return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` + return d.toISOString().slice(0, 10) } /** @@ -107,24 +102,15 @@ export const formatDateTime = (datetime: string | Date): string => { if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } - - const year = d.getUTCFullYear() - const month = d.getUTCMonth() + 1 - const day = d.getUTCDate() - const hours = d.getUTCHours() - const minutes = d.getUTCMinutes() - const seconds = d.getUTCSeconds() - - return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return d.toISOString().slice(0, 19).replace('T', ' ') } +/** Match structured date/datetime inputs we can validate as civil UTC components. */ +const STRUCTURED = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i + /** - * Parse a date/datetime value as UTC. - * - * - Date objects are used as-is - * - Space-separated datetimes (`YYYY-MM-DD HH:mm:ss[.sss]`) become ISO + Z - * - Offset-less ISO datetimes (`YYYY-MM-DDTHH:mm:ss[.sss]`) get a Z suffix - * - Date-only (`YYYY-MM-DD`) and values that already include Z/offset pass through + * Parse as UTC. Offset-less values are treated as UTC. + * Impossible civil dates (e.g. `2024-02-31`) are rejected via Date.UTC round-trip. */ function toUtcDate(value: string | Date): Date { if (value instanceof Date) { @@ -132,26 +118,36 @@ function toUtcDate(value: string | Date): Date { } const input = String(value).trim() - - // Already has an explicit offset or Z — Date parses correctly as absolute time - if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(input)) { + const match = STRUCTURED.exec(input) + if (!match) { return new Date(input) } - // Space-separated SQL-style datetime → ISO + Z - const spaceSeparated = input.replace( - /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, - '$1T$2$3Z', - ) - if (spaceSeparated !== input) { - return new Date(spaceSeparated) + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const hour = Number(match[4] || 0) + const minute = Number(match[5] || 0) + const second = Number(match[6] || 0) + const offset = match[7] + + // Round-trip through Date.UTC so Feb 31 / hour 25 stay invalid + const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second)) + if ( + utc.getUTCFullYear() !== year + || utc.getUTCMonth() + 1 !== month + || utc.getUTCDate() !== day + || utc.getUTCHours() !== hour + || utc.getUTCMinutes() !== minute + || utc.getUTCSeconds() !== second + ) { + return new Date(Number.NaN) } - // Offset-less ISO datetime (`2023-01-01T00:00:00`) → treat as UTC - if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(input)) { - return new Date(`${input}Z`) + // Explicit offset → absolute instant (civil parts already validated) + if (offset && offset.toUpperCase() !== 'Z') { + return new Date(input.includes('T') ? input : input.replace(' ', 'T')) } - // Date-only and everything else — Date-only is already UTC midnight per ES - return new Date(input) + return utc } diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index 1554b00a9..ff61b8423 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -7,20 +7,14 @@ export const defineTransformer = (transformer: ContentTransformer) => { /** * Format a date value as `YYYY-MM-DD` for SQL DATE columns. * - * Always uses UTC. Offset-less datetimes (e.g. `2023-01-01T00:00:00`, - * `2023-01-01 00:00:00`) are treated as UTC rather than local time. + * Always uses UTC. Offset-less datetimes are treated as UTC. */ export const formatDate = (date: string | Date): string => { const d = toUtcDate(date) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } - - const year = d.getUTCFullYear() - const month = d.getUTCMonth() + 1 - const day = d.getUTCDate() - - return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` + return d.toISOString().slice(0, 10) } /** @@ -33,24 +27,15 @@ export const formatDateTime = (datetime: string | Date): string => { if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } - - const year = d.getUTCFullYear() - const month = d.getUTCMonth() + 1 - const day = d.getUTCDate() - const hours = d.getUTCHours() - const minutes = d.getUTCMinutes() - const seconds = d.getUTCSeconds() - - return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return d.toISOString().slice(0, 19).replace('T', ' ') } +/** Match structured date/datetime inputs we can validate as civil UTC components. */ +const STRUCTURED = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i + /** - * Parse a date/datetime value as UTC. - * - * - Date objects are used as-is - * - Space-separated datetimes (`YYYY-MM-DD HH:mm:ss[.sss]`) become ISO + Z - * - Offset-less ISO datetimes (`YYYY-MM-DDTHH:mm:ss[.sss]`) get a Z suffix - * - Date-only (`YYYY-MM-DD`) and values that already include Z/offset pass through + * Parse as UTC. Offset-less values are treated as UTC. + * Impossible civil dates (e.g. `2024-02-31`) are rejected via Date.UTC round-trip. */ function toUtcDate(value: string | Date): Date { if (value instanceof Date) { @@ -58,26 +43,36 @@ function toUtcDate(value: string | Date): Date { } const input = String(value).trim() - - // Already has an explicit offset or Z — Date parses correctly as absolute time - if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(input)) { + const match = STRUCTURED.exec(input) + if (!match) { return new Date(input) } - // Space-separated SQL-style datetime → ISO + Z - const spaceSeparated = input.replace( - /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, - '$1T$2$3Z', - ) - if (spaceSeparated !== input) { - return new Date(spaceSeparated) + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const hour = Number(match[4] || 0) + const minute = Number(match[5] || 0) + const second = Number(match[6] || 0) + const offset = match[7] + + // Round-trip through Date.UTC so Feb 31 / hour 25 stay invalid + const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second)) + if ( + utc.getUTCFullYear() !== year + || utc.getUTCMonth() + 1 !== month + || utc.getUTCDate() !== day + || utc.getUTCHours() !== hour + || utc.getUTCMinutes() !== minute + || utc.getUTCSeconds() !== second + ) { + return new Date(Number.NaN) } - // Offset-less ISO datetime (`2023-01-01T00:00:00`) → treat as UTC - if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(input)) { - return new Date(`${input}Z`) + // Explicit offset → absolute instant (civil parts already validated) + if (offset && offset.toUpperCase() !== 'Z') { + return new Date(input.includes('T') ? input : input.replace(' ', 'T')) } - // Date-only and everything else — Date-only is already UTC midnight per ES - return new Date(input) + return utc } diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index 0c08a0adc..b7e927431 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -30,9 +30,12 @@ describe('formatDate', () => { }) it('treats offset-less ISO datetimes as UTC', () => { - // ES would parse this as local time; we force UTC + // ES would parse these as local time; we force UTC expect(formatDate('2023-01-01T00:00:00')).toBe('2023-01-01') expect(formatDate('2022-12-31T23:30:00')).toBe('2022-12-31') + // HH:mm form (no seconds) + expect(formatDate('2023-01-01T00:00')).toBe('2023-01-01') + expect(formatDate('2022-12-31T23:30')).toBe('2022-12-31') }) it('parses space-separated datetime as UTC', () => { @@ -55,6 +58,15 @@ describe('formatDate', () => { expect(() => formatDate('not-a-date')).toThrow('Invalid date value') }) + it('throws on impossible structured dates instead of normalizing them', () => { + // Date would roll Feb 31 → Mar 2/3; we reject + expect(() => formatDate('2024-02-31')).toThrow(TypeError) + expect(() => formatDate('2024-02-31T12:00:00')).toThrow(TypeError) + expect(() => formatDate('2024-02-31T12:00:00Z')).toThrow(TypeError) + expect(() => formatDate('2024-13-01')).toThrow(TypeError) + expect(() => formatDate('2024-04-31')).toThrow(TypeError) + }) + it('produces same output as the build-time copy', async () => { const buildTime = await import('../../src/utils/content/transformers/utils') const inputs = [ @@ -63,6 +75,7 @@ describe('formatDate', () => { '2024-12-31T23:59:59.000Z', '2023-01-01', '2023-01-01T00:00:00', + '2023-01-01T00:00', '2022-06-15 14:30:00', '2023-01-01T00:30:00+05:30', ] @@ -97,6 +110,9 @@ describe('formatDateTime', () => { it('treats offset-less ISO datetimes as UTC', () => { expect(formatDateTime('2022-06-15T14:30:45')).toBe('2022-06-15 14:30:45') expect(formatDateTime('2022-12-31T23:00:00')).toBe('2022-12-31 23:00:00') + // HH:mm form (no seconds) + expect(formatDateTime('2022-06-15T14:30')).toBe('2022-06-15 14:30:00') + expect(formatDateTime('2022-12-31T23:00')).toBe('2022-12-31 23:00:00') }) it('parses space-separated datetime as UTC', () => { @@ -121,13 +137,22 @@ describe('formatDateTime', () => { expect(() => formatDateTime('garbage')).toThrow('Invalid datetime value') }) + it('throws on impossible structured datetimes instead of normalizing them', () => { + expect(() => formatDateTime('2024-02-31 12:00:00')).toThrow(TypeError) + expect(() => formatDateTime('2024-02-31T12:00:00')).toThrow(TypeError) + expect(() => formatDateTime('2024-02-31T12:00:00Z')).toThrow(TypeError) + expect(() => formatDateTime('2024-01-01T25:00:00')).toThrow(TypeError) + }) + it('produces same output as the build-time copy', async () => { const buildTime = await import('../../src/utils/content/transformers/utils') const inputs = [ '2022-06-15T14:30:45.000Z', '2023-01-01T00:00:00.000Z', '2022-06-15T14:30:45', + '2022-06-15T14:30', '2022-06-15 14:30:45', + '2022-06-15 14:30', '2022-06-15T14:30:45+02:00', ] for (const input of inputs) {