|
| 1 | +/** |
| 2 | + * This source file is available under the terms of the |
| 3 | + * Pimcore Open Core License (POCL) |
| 4 | + * Full copyright and license information is available in |
| 5 | + * LICENSE.md which is distributed with this source code. |
| 6 | + * |
| 7 | + * @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com) |
| 8 | + * @license Pimcore Open Core License (POCL) |
| 9 | + */ |
| 10 | + |
| 11 | +import dayjs from 'dayjs' |
| 12 | +import utc from 'dayjs/plugin/utc' |
| 13 | +import timezone from 'dayjs/plugin/timezone' |
| 14 | +import { toDayJs, fromDayJs, toServerWallClock, formatFilterDate, parseFilterDate } from './date-picker-utils' |
| 15 | + |
| 16 | +dayjs.extend(utc) |
| 17 | +dayjs.extend(timezone) |
| 18 | + |
| 19 | +// Pimcore date/datetime timezone semantics: respectTimezone=true means the value is an absolute |
| 20 | +// instant rendered in the browser timezone; respectTimezone=false means the value is a wall-clock |
| 21 | +// anchored to the server timezone (so the displayed wall-clock does not drift between browsers). |
| 22 | +// |
| 23 | +// All assertions are independent of the host (browser) timezone the test process runs under, |
| 24 | +// except where they intentionally compare against the host-local `dayjs.unix()` rendering (the |
| 25 | +// respectTimezone=true case), which is the no-regression property. |
| 26 | + |
| 27 | +// Two reference instants: winter (Vienna = UTC+1) and summer/DST (Vienna = UTC+2). |
| 28 | +const WINTER_UNIX = dayjs.utc('2024-01-15T12:00:00Z').unix() |
| 29 | +const SUMMER_UNIX = dayjs.utc('2024-07-15T12:00:00Z').unix() |
| 30 | + |
| 31 | +describe('toServerWallClock', () => { |
| 32 | + it('renders the instant as the wall-clock of the given server timezone (winter)', () => { |
| 33 | + expect(toServerWallClock(dayjs.unix(WINTER_UNIX), 'Europe/Vienna').format('YYYY-MM-DD HH:mm')) |
| 34 | + .toBe('2024-01-15 13:00') // UTC+1 |
| 35 | + expect(toServerWallClock(dayjs.unix(WINTER_UNIX), 'America/New_York').format('YYYY-MM-DD HH:mm')) |
| 36 | + .toBe('2024-01-15 07:00') // UTC-5 |
| 37 | + expect(toServerWallClock(dayjs.unix(WINTER_UNIX), 'UTC').format('YYYY-MM-DD HH:mm')) |
| 38 | + .toBe('2024-01-15 12:00') |
| 39 | + }) |
| 40 | + |
| 41 | + it('honours DST for the server timezone (summer)', () => { |
| 42 | + expect(toServerWallClock(dayjs.unix(SUMMER_UNIX), 'Europe/Vienna').format('YYYY-MM-DD HH:mm')) |
| 43 | + .toBe('2024-07-15 14:00') // UTC+2 in summer |
| 44 | + }) |
| 45 | +}) |
| 46 | + |
| 47 | +describe('toDayJs — respectTimezone === false (server-timezone wall-clock, no browser drift)', () => { |
| 48 | + it('anchors numeric values to the server timezone wall-clock', () => { |
| 49 | + const result = toDayJs(WINTER_UNIX, undefined, { respectTimezone: false, timezone: 'Europe/Vienna' }) |
| 50 | + expect(result?.format('YYYY-MM-DD HH:mm')).toBe('2024-01-15 13:00') |
| 51 | + }) |
| 52 | + |
| 53 | + it('round-trips stably: stored -> display -> naive string -> server-tz parse -> stored', () => { |
| 54 | + const serverTz = 'Europe/Vienna' |
| 55 | + // Read for display (what the picker shows / edits). |
| 56 | + const display = toDayJs(WINTER_UNIX, undefined, { respectTimezone: false, timezone: serverTz }) |
| 57 | + // Save: non-respect-timezone fields emit a naive wall-clock string. |
| 58 | + const naive = fromDayJs(display, 'dateString', 'YYYY-MM-DD HH:mm') |
| 59 | + expect(naive).toBe('2024-01-15 13:00') |
| 60 | + // Backend Carbon::parse interprets the naive string in the server timezone. |
| 61 | + const recovered = dayjs.tz(naive as string, serverTz).unix() |
| 62 | + expect(recovered).toBe(WINTER_UNIX) |
| 63 | + }) |
| 64 | + |
| 65 | + it('falls back to the absolute instant when no server timezone is configured', () => { |
| 66 | + const result = toDayJs(WINTER_UNIX, undefined, { respectTimezone: false, timezone: '' }) |
| 67 | + expect(result?.valueOf()).toBe(dayjs.unix(WINTER_UNIX).valueOf()) |
| 68 | + }) |
| 69 | +}) |
| 70 | + |
| 71 | +describe('toDayJs — respectTimezone !== false (absolute instant in browser timezone)', () => { |
| 72 | + it('returns the same instant as dayjs.unix (no regression) when respectTimezone is true', () => { |
| 73 | + const result = toDayJs(WINTER_UNIX, undefined, { respectTimezone: true, timezone: 'Europe/Vienna' }) |
| 74 | + expect(result?.valueOf()).toBe(dayjs.unix(WINTER_UNIX).valueOf()) |
| 75 | + }) |
| 76 | + |
| 77 | + it('does not apply the server timezone when respectTimezone is omitted', () => { |
| 78 | + expect(toDayJs(WINTER_UNIX)?.valueOf()).toBe(dayjs.unix(WINTER_UNIX).valueOf()) |
| 79 | + expect(toDayJs(WINTER_UNIX, undefined, { timezone: 'Europe/Vienna' })?.valueOf()) |
| 80 | + .toBe(dayjs.unix(WINTER_UNIX).valueOf()) |
| 81 | + }) |
| 82 | +}) |
| 83 | + |
| 84 | +describe('toDayJs — non-numeric inputs are unchanged', () => { |
| 85 | + it('passes through dayjs values', () => { |
| 86 | + const d = dayjs.unix(WINTER_UNIX) |
| 87 | + expect(toDayJs(d, undefined, { respectTimezone: false, timezone: 'Europe/Vienna' })).toBe(d) |
| 88 | + }) |
| 89 | + |
| 90 | + it('parses strings with the given format and returns null for nullish', () => { |
| 91 | + expect(toDayJs('2024-01-15', 'YYYY-MM-DD')?.format('YYYY-MM-DD')).toBe('2024-01-15') |
| 92 | + expect(toDayJs(null)).toBeNull() |
| 93 | + expect(toDayJs()).toBeNull() |
| 94 | + }) |
| 95 | +}) |
| 96 | + |
| 97 | +describe('formatFilterDate', () => { |
| 98 | + // The DatePicker filter component emits a browser-local timestamp (seconds) — i.e. browser-local |
| 99 | + // midnight of the day the user clicked, as produced by fromDayJs's 'timestamp' branch. Mock that |
| 100 | + // shape directly so the test is independent of the host tz the jest runtime uses. |
| 101 | + const tsBrowserLocalMidnight = new Date(2026, 2, 15).getTime() / 1000 |
| 102 | + |
| 103 | + it('returns null for null', () => { |
| 104 | + expect(formatFilterDate(null, true)).toBeNull() |
| 105 | + expect(formatFilterDate(null, false)).toBeNull() |
| 106 | + }) |
| 107 | + |
| 108 | + it('respectTimezone=false emits the picked calendar day as UTC ISO 8601', () => { |
| 109 | + expect(formatFilterDate(tsBrowserLocalMidnight, false)).toBe('2026-03-15T00:00:00Z') |
| 110 | + }) |
| 111 | + |
| 112 | + it('respectTimezone=true emits an ISO 8601 string with browser offset (instant)', () => { |
| 113 | + const out = formatFilterDate(tsBrowserLocalMidnight, true)! |
| 114 | + // ISO includes the date, "T", time, and a numeric offset or Z. |
| 115 | + expect(out).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)$/) |
| 116 | + // Round-trips back to the same instant when parsed. |
| 117 | + expect(dayjs(out).unix()).toBe(tsBrowserLocalMidnight) |
| 118 | + }) |
| 119 | +}) |
| 120 | + |
| 121 | +describe('parseFilterDate (read-back round-trip)', () => { |
| 122 | + // Pins the regression: stored UTC ISO must not shift the picker day in browsers west of UTC. |
| 123 | + const tsBrowserLocalMidnight = new Date(2026, 2, 15).getTime() / 1000 |
| 124 | + |
| 125 | + it('returns null for null', () => { |
| 126 | + expect(parseFilterDate(null)).toBeNull() |
| 127 | + }) |
| 128 | + |
| 129 | + it('round-trips a respect=false stored value back to the originally picked day', () => { |
| 130 | + const stored = formatFilterDate(tsBrowserLocalMidnight, false)! |
| 131 | + expect(stored).toBe('2026-03-15T00:00:00Z') |
| 132 | + const recovered = parseFilterDate(stored)! |
| 133 | + expect(dayjs.unix(recovered).format('YYYY-MM-DD')).toBe('2026-03-15') |
| 134 | + }) |
| 135 | + |
| 136 | + it('round-trips a respect=true stored value back to the originally picked day', () => { |
| 137 | + const stored = formatFilterDate(tsBrowserLocalMidnight, true)! |
| 138 | + const recovered = parseFilterDate(stored)! |
| 139 | + expect(dayjs.unix(recovered).format('YYYY-MM-DD')).toBe('2026-03-15') |
| 140 | + }) |
| 141 | + |
| 142 | + it('ignores any offset or Z marker on the stored value (calendar day only)', () => { |
| 143 | + expect(dayjs.unix(parseFilterDate('2026-03-15T00:00:00Z')!).format('YYYY-MM-DD')).toBe('2026-03-15') |
| 144 | + expect(dayjs.unix(parseFilterDate('2026-03-15T00:00:00-04:00')!).format('YYYY-MM-DD')).toBe('2026-03-15') |
| 145 | + expect(dayjs.unix(parseFilterDate('2026-03-15T00:00:00+09:00')!).format('YYYY-MM-DD')).toBe('2026-03-15') |
| 146 | + }) |
| 147 | +}) |
| 148 | + |
| 149 | +describe('fromDayJs — unchanged save behaviour', () => { |
| 150 | + it('formats a dateString with the supplied output format', () => { |
| 151 | + expect(fromDayJs(dayjs('2024-01-15 13:30'), 'dateString', 'YYYY-MM-DD HH:mm')).toBe('2024-01-15 13:30') |
| 152 | + }) |
| 153 | + |
| 154 | + it('returns null for null', () => { |
| 155 | + expect(fromDayJs(null, 'dateString', 'YYYY-MM-DD')).toBeNull() |
| 156 | + }) |
| 157 | + |
| 158 | + it('emits unix seconds (start of day) for the timestamp output type', () => { |
| 159 | + const value = dayjs('2024-01-15 13:30') |
| 160 | + const ts = fromDayJs(value, 'timestamp') as number |
| 161 | + expect(ts).toBe(new Date(2024, 0, 15).getTime() / 1000) |
| 162 | + }) |
| 163 | +}) |
0 commit comments