thats-me/backend/tests/Feature/Api/EventAuditFixesTest.php
Kevin Adametz b262a6771f Backend: Medien-Typen, Sprachnotizen, Event-Felder und Sync-Härtung
- Event-Medien tragen Typ (image/video/audio), Dauer und Notiz; Videos
  bekommen ein Poster, Audio wird per ffmpeg nach AAC (audio/mp4)
  gewandelt, Originale werden mit Range-Unterstützung gestreamt.
- Upload nimmt eine Notiz (caption) direkt mit, PATCH ändert sie später.
- Neue Event-Felder: Zeitraum (end_date), Kategorie, Personen, Meilenstein.
  Regeln zentral in EventFields, damit CRUD und Sync gleich prüfen.
- Titel darf leer sein: ein Event entsteht mit dem ersten Eintrag.
- Artisan-Befehl media:transcode-voice-notes wandelt alte WebM/Ogg-Notizen,
  die Safari nicht abspielen kann.
- Login-Throttling, Settings-Validierung, Sync-Fehlercodes ohne Rohtext.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 07:00:35 +00:00

293 lines
9.6 KiB
PHP

<?php
use App\Models\Event;
use App\Models\EventMedia;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Passport\Passport;
beforeEach(function () {
$this->user = User::factory()->create();
Passport::actingAs($this->user);
});
/*
|--------------------------------------------------------------------------
| A05 / A19 — location, keyImageTitle and long image URLs
|--------------------------------------------------------------------------
*/
test('location and key image title survive a create, update and reload roundtrip', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events', [
'id' => $clientId,
'title' => 'Bergwanderung',
'date' => '2014-08-12',
'emotion' => 0.75,
'location' => 'Zugspitze',
'keyImageTitle' => 'Gipfelfoto',
])->assertCreated()
->assertJsonPath('data.location', 'Zugspitze')
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
$this->assertDatabaseHas('events', [
'client_id' => $clientId,
'location' => 'Zugspitze',
'key_image_title' => 'Gipfelfoto',
]);
$this->putJson("/api/events/{$clientId}", ['location' => 'Wank'])
->assertOk()
->assertJsonPath('data.location', 'Wank')
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
// Reload: the fields must come back, not be blanked out by the response.
$this->getJson("/api/events/{$clientId}")
->assertOk()
->assertJsonPath('data.location', 'Wank')
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
});
test('an update without location leaves the stored location untouched', function () {
$event = Event::factory()->create([
'user_id' => $this->user->id,
'location' => 'Zugspitze',
]);
$this->putJson("/api/events/{$event->client_id}", ['title' => 'Neuer Titel'])
->assertOk()
->assertJsonPath('data.location', 'Zugspitze');
});
test('an image url of the documented maximum length is accepted', function () {
// The column was 255 while the rule allowed 500. Asserted here at the
// contract boundary; SQLite does not enforce column width, so the MySQL
// side of this is covered by the widened migration, not by this test.
$url = 'https://example.com/'.str_repeat('a', 500 - 20);
expect(strlen($url))->toBe(500);
$clientId = Str::uuid()->toString();
$this->postJson('/api/events', [
'id' => $clientId,
'title' => 'Langer Link',
'date' => '2025-01-01',
'emotion' => 0,
'image' => $url,
])->assertCreated();
expect(Event::where('client_id', $clientId)->value('image'))->toBe($url);
});
test('an image url beyond the documented maximum is rejected', function () {
$this->postJson('/api/events', [
'id' => Str::uuid()->toString(),
'title' => 'Zu langer Link',
'date' => '2025-01-01',
'emotion' => 0,
'image' => 'https://example.com/'.str_repeat('a', 500),
])->assertStatus(422)->assertJsonValidationErrors('image');
});
/*
|--------------------------------------------------------------------------
| A08 — cursor pagination across equal dates
|--------------------------------------------------------------------------
*/
test('paginating through events sharing one date loses no records', function () {
$sameDay = '2025-05-01';
collect(range(1, 5))->each(fn (int $n) => Event::factory()->create([
'user_id' => $this->user->id,
'date' => $sameDay,
'title' => "Event {$n}",
]));
$seen = [];
$url = '/api/events?limit=1';
// Guard against an endless loop if the cursor ever stops advancing.
for ($page = 0; $page < 20 && $url !== null; $page++) {
$response = $this->getJson($url)->assertOk();
$payload = $response->json();
foreach ($payload['data'] as $event) {
$seen[] = $event['id'];
}
$next = $payload['links']['next'] ?? null;
if ($next === null) {
$url = null;
continue;
}
parse_str((string) parse_url($next, PHP_URL_QUERY), $query);
$url = '/api/events?'.http_build_query($query);
}
expect($seen)->toHaveCount(5)
->and(array_unique($seen))->toHaveCount(5);
});
test('the limit parameter is validated instead of silently coerced', function () {
$this->getJson('/api/events?limit=0')->assertStatus(422);
$this->getJson('/api/events?limit=500')->assertStatus(422);
$this->getJson('/api/events?limit=abc')->assertStatus(422);
$this->getJson('/api/events?since=not-a-date')->assertStatus(422);
});
/*
|--------------------------------------------------------------------------
| A09 — sync shares the CRUD validation rules
|--------------------------------------------------------------------------
*/
test('sync rejects an emotion that plain crud would reject', function () {
$clientId = Str::uuid()->toString();
$response = $this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'create',
'eventId' => $clientId,
'payload' => ['title' => 'Out of range', 'date' => '2025-01-01', 'emotion' => 2],
]],
])->assertOk();
expect($response->json('results.0.status'))->toBe('invalid');
$this->assertDatabaseMissing('events', ['client_id' => $clientId]);
});
test('sync rejects an out of range emotion on update too', function () {
$event = Event::factory()->create([
'user_id' => $this->user->id,
'emotion' => 0.5,
]);
$response = $this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'update',
'eventId' => $event->client_id,
'payload' => ['emotion' => -7],
]],
])->assertOk();
expect($response->json('results.0.status'))->toBe('invalid');
expect((float) $event->fresh()->emotion)->toBe(0.5);
});
test('sync reports a missing event instead of acknowledging it', function () {
$response = $this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'update',
'eventId' => Str::uuid()->toString(),
'payload' => ['title' => 'Ghost'],
]],
])->assertOk();
expect($response->json('results.0.status'))->toBe('not_found');
});
test('sync never returns raw exception text to the client', function () {
$event = Event::factory()->create(['user_id' => $this->user->id]);
$response = $this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'update',
'eventId' => $event->client_id,
'payload' => ['title' => str_repeat('x', 5000)],
]],
])->assertOk();
$result = $response->json('results.0');
expect($result['status'])->toBe('invalid')
->and($result)->not->toHaveKey('message');
});
test('sync writes location and key image title like crud does', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'create',
'eventId' => $clientId,
'payload' => [
'title' => 'Via sync',
'date' => '2025-01-01',
'emotion' => 0,
'location' => 'Berlin',
'keyImageTitle' => 'Titelbild',
],
]],
])->assertOk();
$this->assertDatabaseHas('events', [
'client_id' => $clientId,
'location' => 'Berlin',
'key_image_title' => 'Titelbild',
]);
});
/*
|--------------------------------------------------------------------------
| A13 — deleting an event removes its files
|--------------------------------------------------------------------------
*/
test('deleting an event removes its media files', function () {
Storage::fake('local');
$event = Event::factory()->create(['user_id' => $this->user->id]);
$media = EventMedia::factory()->create([
'user_id' => $this->user->id,
'event_id' => $event->id,
'path' => 'event-media/1/original.jpg',
'preview_path' => 'event-media/1/preview.jpg',
'thumbnail_path' => 'event-media/1/thumb.jpg',
]);
Storage::disk('local')->put($media->path, 'original');
Storage::disk('local')->put($media->preview_path, 'preview');
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
$this->deleteJson("/api/events/{$event->client_id}")->assertNoContent();
Storage::disk('local')->assertMissing($media->path);
Storage::disk('local')->assertMissing($media->preview_path);
Storage::disk('local')->assertMissing($media->thumbnail_path);
$this->assertDatabaseMissing('event_media', ['id' => $media->id]);
});
test('deleting an event through sync removes its media files', function () {
Storage::fake('local');
$event = Event::factory()->create(['user_id' => $this->user->id]);
$media = EventMedia::factory()->create([
'user_id' => $this->user->id,
'event_id' => $event->id,
'path' => 'event-media/2/original.jpg',
'preview_path' => 'event-media/2/preview.jpg',
'thumbnail_path' => 'event-media/2/thumb.jpg',
]);
Storage::disk('local')->put($media->path, 'original');
Storage::disk('local')->put($media->preview_path, 'preview');
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
$this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'delete',
'eventId' => $event->client_id,
'payload' => null,
]],
])->assertOk();
Storage::disk('local')->assertMissing($media->path);
Storage::disk('local')->assertMissing($media->thumbnail_path);
});