173 lines
6.4 KiB
TypeScript
173 lines
6.4 KiB
TypeScript
/**
|
|||
|
|
* Tests for src/utils/datetime.ts
|
||
|
|
*
|
||
|
|
* Key invariants verified (timezone-agnostic where possible):
|
||
|
|
* 1. A tz-less string is parsed identically to the same string with "Z" appended
|
||
|
|
* (i.e. treated as UTC regardless of the machine's local timezone).
|
||
|
|
* 2. Formatted output never contains AM / PM / am / pm (always 24-hour).
|
||
|
|
* 3. Strings that already carry a timezone marker are NOT double-offset.
|
||
|
|
* 4. Invalid / empty input does not throw — returns original string or safe fallback.
|
||
|
|
* 5. Pure date strings (no T) are handled without crashing.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect } from 'vitest'
|
||
|
|
import {
|
||
|
|
parseBackendTimestamp,
|
||
|
|
formatLocalDateTime,
|
||
|
|
formatLocalTime,
|
||
|
|
formatLocalDate,
|
||
|
|
} from './datetime'
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// parseBackendTimestamp — core UTC-fallback logic
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('parseBackendTimestamp', () => {
|
||
|
|
it('returns the same Date for tz-less and Z-suffixed strings (UTC equivalence)', () => {
|
||
|
|
const noTz = parseBackendTimestamp('2026-06-24T10:27:00')
|
||
|
|
const withZ = parseBackendTimestamp('2026-06-24T10:27:00Z')
|
||
|
|
// Both must represent the exact same UTC instant.
|
||
|
|
expect(noTz.getTime()).toBe(withZ.getTime())
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not double-offset a string that already has Z', () => {
|
||
|
|
const d1 = parseBackendTimestamp('2026-06-24T10:27:00Z')
|
||
|
|
const d2 = parseBackendTimestamp('2026-06-24T10:27:00')
|
||
|
|
expect(d1.getTime()).toBe(d2.getTime())
|
||
|
|
})
|
||
|
|
|
||
|
|
it('correctly parses a string with +HH:MM offset', () => {
|
||
|
|
// 2026-06-24T12:27:00+02:00 == 2026-06-24T10:27:00Z
|
||
|
|
const withOffset = parseBackendTimestamp('2026-06-24T12:27:00+02:00')
|
||
|
|
const utc = parseBackendTimestamp('2026-06-24T10:27:00Z')
|
||
|
|
expect(withOffset.getTime()).toBe(utc.getTime())
|
||
|
|
})
|
||
|
|
|
||
|
|
it('correctly parses a string with -HH:MM offset', () => {
|
||
|
|
// 2026-06-24T02:27:00-08:00 == 2026-06-24T10:27:00Z
|
||
|
|
const withOffset = parseBackendTimestamp('2026-06-24T02:27:00-08:00')
|
||
|
|
const utc = parseBackendTimestamp('2026-06-24T10:27:00Z')
|
||
|
|
expect(withOffset.getTime()).toBe(utc.getTime())
|
||
|
|
})
|
||
|
|
|
||
|
|
it('handles pure date strings (no T) without crashing', () => {
|
||
|
|
const d = parseBackendTimestamp('2026-06-24')
|
||
|
|
expect(isNaN(d.getTime())).toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns Invalid Date for empty string', () => {
|
||
|
|
const d = parseBackendTimestamp('')
|
||
|
|
expect(isNaN(d.getTime())).toBe(true)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns Invalid Date for garbage input', () => {
|
||
|
|
const d = parseBackendTimestamp('not-a-date')
|
||
|
|
expect(isNaN(d.getTime())).toBe(true)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// formatLocalDateTime — no AM/PM, 24-hour
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('formatLocalDateTime', () => {
|
||
|
|
it('produces the same output for tz-less and Z-suffixed strings (UTC equivalence)', () => {
|
||
|
|
const noTz = formatLocalDateTime('2026-06-24T10:27:00')
|
||
|
|
const withZ = formatLocalDateTime('2026-06-24T10:27:00Z')
|
||
|
|
// Both should format to the same local representation.
|
||
|
|
expect(noTz).toBe(withZ)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not contain AM or PM (24-hour enforcement)', () => {
|
||
|
|
// Use noon UTC — a time where AM/PM distinction matters.
|
||
|
|
const result = formatLocalDateTime('2026-06-24T14:30:00Z')
|
||
|
|
expect(result).not.toMatch(/[Aa][Mm]|[Pp][Mm]/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not throw or return undefined for empty string', () => {
|
||
|
|
const result = formatLocalDateTime('')
|
||
|
|
expect(result).toBe('')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not throw or return undefined for invalid string', () => {
|
||
|
|
const result = formatLocalDateTime('not-a-date')
|
||
|
|
// Should return the original string as a safe fallback.
|
||
|
|
expect(result).toBe('not-a-date')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns a non-empty string for a valid timestamp', () => {
|
||
|
|
const result = formatLocalDateTime('2026-06-24T10:27:00Z')
|
||
|
|
expect(typeof result).toBe('string')
|
||
|
|
expect(result.length).toBeGreaterThan(0)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// formatLocalTime — compact HH:mm, no AM/PM
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('formatLocalTime', () => {
|
||
|
|
it('produces the same output for tz-less and Z-suffixed strings', () => {
|
||
|
|
const noTz = formatLocalTime('2026-06-24T10:27:00')
|
||
|
|
const withZ = formatLocalTime('2026-06-24T10:27:00Z')
|
||
|
|
expect(noTz).toBe(withZ)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not contain AM or PM', () => {
|
||
|
|
const result = formatLocalTime('2026-06-24T14:30:00Z')
|
||
|
|
expect(result).not.toMatch(/[Aa][Mm]|[Pp][Mm]/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns a non-empty string for a valid timestamp', () => {
|
||
|
|
const result = formatLocalTime('2026-06-24T10:27:00Z')
|
||
|
|
expect(typeof result).toBe('string')
|
||
|
|
expect(result.length).toBeGreaterThan(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not throw for empty string', () => {
|
||
|
|
expect(() => formatLocalTime('')).not.toThrow()
|
||
|
|
expect(formatLocalTime('')).toBe('')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not throw for invalid string', () => {
|
||
|
|
expect(() => formatLocalTime('bad')).not.toThrow()
|
||
|
|
expect(formatLocalTime('bad')).toBe('bad')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// formatLocalDate — date only
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('formatLocalDate', () => {
|
||
|
|
it('produces the same output for tz-less and Z-suffixed strings', () => {
|
||
|
|
// Pick a time that won't cross a date boundary in any timezone
|
||
|
|
// (noon UTC — safe for UTC-12 through UTC+14).
|
||
|
|
const noTz = formatLocalDate('2026-06-24T12:00:00')
|
||
|
|
const withZ = formatLocalDate('2026-06-24T12:00:00Z')
|
||
|
|
expect(noTz).toBe(withZ)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('handles pure date strings without crashing', () => {
|
||
|
|
expect(() => formatLocalDate('2026-06-24')).not.toThrow()
|
||
|
|
const result = formatLocalDate('2026-06-24')
|
||
|
|
expect(typeof result).toBe('string')
|
||
|
|
expect(result.length).toBeGreaterThan(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not throw for empty string', () => {
|
||
|
|
expect(() => formatLocalDate('')).not.toThrow()
|
||
|
|
expect(formatLocalDate('')).toBe('')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not throw for invalid string', () => {
|
||
|
|
expect(() => formatLocalDate('not-a-date')).not.toThrow()
|
||
|
|
expect(formatLocalDate('not-a-date')).toBe('not-a-date')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns a non-empty string for a valid date', () => {
|
||
|
|
const result = formatLocalDate('2026-06-01T00:00:00Z')
|
||
|
|
expect(result.length).toBeGreaterThan(0)
|
||
|
|
})
|
||
|
|
})
|