Give an API expiry date the same meaning the web gives it

FilesController::expiryInstant exists because a calendar day ends where
the person naming it lives: the web form posts a bare YYYY-MM-DD, and
storing that as it arrives would cut a file off at midnight UTC -- "expires
on the 12th" ending partway through the 11th for anyone in the Americas.

The API takes the same field, validates it as a date, and stores it raw:

  web  → 2026-09-12T23:59:59+00:00   (end of the day, as the docblock means)
  API  → 2026-09-12T00:00:00+00:00   (raw)

Same value, same field, same file, two meanings -- and the earlier of the
two is a file that dies at the start of the day it was promised.

A bare date now means the end of that day in the caller's timezone, as it
does on the web. A value carrying a time is unchanged: it is an instant
the caller named on purpose, the API can express one and a date input
cannot. The endpoint's docblock says both, so the OpenAPI document does
too.

Three tests: the day, the timestamp, and clearing. Without the fix the
first goes red.
This commit is contained in:
denkfabrik-li
2026-08-28 02:56:53 +02:00
parent 06c364d29a
commit e1cd010f9d
3 changed files with 78 additions and 2 deletions
+41
View File
@@ -124,6 +124,47 @@ test('a file can be updated field by field', function () {
->and($file->description)->toBe($file->getOriginal('description'));
});
test('a calendar day means the end of that day where the caller lives', function () {
// The same value on the web means the end of the 12th (LocalDay::end via
// FilesController::expiryInstant). Stored as it arrives it is midnight
// UTC, so the file would die at the *start* of the 12th instead.
$this->admin->update(['timezone' => 'Europe/Berlin']);
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
'expires_at' => '2026-09-12',
])->assertOk();
// 23:59:59 on the 12th in Berlin is 21:59:59Z.
expect($file->refresh()->expires_at?->toIso8601String())->toBe('2026-09-12T21:59:59+00:00');
});
test('a timestamp is stored as the instant it names', function () {
// The half that must not change: an API caller can name a moment, and
// naming one is not the same as naming a day.
$this->admin->update(['timezone' => 'Europe/Berlin']);
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
'expires_at' => '2026-09-12T08:30:00+00:00',
])->assertOk();
expect($file->refresh()->expires_at?->toIso8601String())->toBe('2026-09-12T08:30:00+00:00');
});
test('clearing the expiry still clears it', function () {
$file = File::factory()->create([
'uploaded_by' => $this->admin->id,
'expires_at' => now()->addWeek(),
]);
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
'expires_at' => null,
])->assertOk();
expect($file->refresh()->expires_at)->toBeNull();
});
test('fields the caller lacks permission for are left alone rather than refused', function () {
// Mirrors the web controller: a user who may edit a file but not set
// expiry dates still gets to rename it.