/** * 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) }) })