128 lines
4.9 KiB
PHP
128 lines
4.9 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\File;
|
|
|
|
/**
|
|
* Times are stored in UTC and shown in the operator's zone.
|
|
*
|
|
* The bug this exists to prevent: a console announcing that an update would
|
|
* start "spätestens um 15:21" while the clock on the wall said 17:21. Two of
|
|
* the offending views even looked as though they had handled it — they called
|
|
*
|
|
* ->timezone(config('app.timezone'))
|
|
*
|
|
* which reads like "convert to local time" and, because app.timezone is the
|
|
* STORAGE zone and must stay UTC, converts to UTC: a no-op wearing the costume
|
|
* of a fix. The other twelve views simply printed the database value.
|
|
*
|
|
* So the rule is mechanical rather than remembered: anything absolute that a
|
|
* person will read goes through ->local() first.
|
|
*/
|
|
|
|
/** Absolute formats. diffForHumans() is relative and reads the same anywhere. */
|
|
function absoluteTimeCalls(string $source): array
|
|
{
|
|
$hits = [];
|
|
|
|
foreach (explode("\n", $source) as $n => $line) {
|
|
if (! preg_match('/->(isoFormat|translatedFormat)\(|->format\([\'"][^\'"]*[YmdHis][^\'"]*[\'"]\)/', $line)) {
|
|
continue;
|
|
}
|
|
|
|
// A line may format several times; one ->local() per formatted value.
|
|
$formats = preg_match_all('/->(isoFormat|translatedFormat|format)\(/', $line);
|
|
$locals = preg_match_all('/->local\(\)/', $line);
|
|
|
|
if ($locals < $formats) {
|
|
$hits[] = ($n + 1).': '.trim($line);
|
|
}
|
|
}
|
|
|
|
return $hits;
|
|
}
|
|
|
|
it('converts every displayed time to the display zone', function () {
|
|
$offenders = [];
|
|
|
|
$files = collect(File::allFiles(resource_path('views')))
|
|
->merge(File::allFiles(app_path('Livewire')))
|
|
->filter(fn ($f) => in_array($f->getExtension(), ['php'], true));
|
|
|
|
foreach ($files as $file) {
|
|
$hits = absoluteTimeCalls($file->getContents());
|
|
|
|
if ($hits !== []) {
|
|
$offenders[str_replace(base_path().'/', '', $file->getPathname())] = $hits;
|
|
}
|
|
}
|
|
|
|
expect($offenders)->toBe([]);
|
|
});
|
|
|
|
it('never dresses a UTC no-op up as a timezone conversion', function () {
|
|
// ->timezone(config('app.timezone')) is the specific trap: it looks like a
|
|
// conversion and does nothing, which is worse than no call at all because
|
|
// it stops anyone looking further.
|
|
$offenders = [];
|
|
|
|
foreach (collect(File::allFiles(resource_path('views')))->merge(File::allFiles(app_path())) as $file) {
|
|
if (str_contains($file->getContents(), "timezone(config('app.timezone'))")) {
|
|
$offenders[] = str_replace(base_path().'/', '', $file->getPathname());
|
|
}
|
|
}
|
|
|
|
expect($offenders)->toBe([]);
|
|
});
|
|
|
|
it('keeps storage in UTC', function () {
|
|
// The display zone must never leak into the storage zone. Comparable
|
|
// timestamps across hosts depend on this staying put.
|
|
expect(config('app.timezone'))->toBe('UTC')
|
|
->and(config('app.display_timezone'))->not->toBe('UTC');
|
|
});
|
|
|
|
it('shows a summer time and a winter time in the right zone', function () {
|
|
// Both sides of the DST boundary, because a fixed +1 offset would pass a
|
|
// January test and be an hour out all summer.
|
|
$winter = Carbon::parse('2026-01-15 14:00:00', 'UTC');
|
|
$summer = Carbon::parse('2026-07-15 14:00:00', 'UTC');
|
|
|
|
expect($winter->local()->format('H:i'))->toBe('15:00')
|
|
->and($summer->local()->format('H:i'))->toBe('16:00');
|
|
});
|
|
|
|
it('leaves the stored value untouched when showing it', function () {
|
|
// ->local() must not mutate the instance it was called on: the same object
|
|
// is often written back or compared after being rendered.
|
|
$stored = Carbon::parse('2026-07-15 14:00:00', 'UTC');
|
|
$stored->local()->format('H:i');
|
|
|
|
expect($stored->format('H:i T'))->toBe('14:00 UTC');
|
|
});
|
|
|
|
it('takes a wall-clock field back as the time the operator meant', function () {
|
|
// The half that is easy to forget. A datetime-local field carries no
|
|
// offset, so 21:00 typed by someone in Vienna is 19:00 UTC — parsed
|
|
// without a zone it would be stored as 21:00 UTC and the window would run
|
|
// two hours late.
|
|
expect(\App\Support\LocalTime::fromField('2026-07-15T21:00')->format('H:i T'))->toBe('19:00 UTC')
|
|
// Winter, where the offset is one hour, not two.
|
|
->and(\App\Support\LocalTime::fromField('2026-01-15T21:00')->format('H:i T'))->toBe('20:00 UTC');
|
|
});
|
|
|
|
it('survives the round trip through a form field', function () {
|
|
// What goes into the field must come back as the same instant.
|
|
$stored = Carbon::parse('2026-07-15 19:00:00', 'UTC');
|
|
$shown = \App\Support\LocalTime::toField($stored);
|
|
|
|
expect($shown)->toBe('2026-07-15T21:00')
|
|
->and(\App\Support\LocalTime::fromField($shown)->equalTo($stored))->toBeTrue();
|
|
});
|
|
|
|
it('treats an empty field as no time rather than as now', function () {
|
|
expect(\App\Support\LocalTime::fromField(''))->toBeNull()
|
|
->and(\App\Support\LocalTime::fromField(' '))->toBeNull()
|
|
->and(\App\Support\LocalTime::toField(null))->toBe('');
|
|
});
|