thats-me/backend/tests/Feature/Api/EventMediaTest.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

387 lines
14 KiB
PHP

<?php
use App\Models\Event;
use App\Models\EventMedia;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Passport\Passport;
test('media endpoints require a token', function () {
$this->getJson('/api/events/event-id/media')
->assertUnauthorized();
});
test('can upload key image and receive protected urls', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$response = $this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'key_image',
'file' => UploadedFile::fake()->image('avatar.png', 1200, 800),
]);
$response->assertCreated()
->assertJsonPath('data.collection', 'key_image')
->assertJsonPath('data.type', 'image')
->assertJsonPath('data.thumbnailWidth', 320)
->assertJsonPath('data.thumbnailHeight', 320)
->assertJsonPath('data.previewWidth', 900);
$media = EventMedia::query()->firstOrFail();
Storage::disk('local')->assertExists($media->path);
Storage::disk('local')->assertExists($media->preview_path);
Storage::disk('local')->assertExists($media->thumbnail_path);
expect($event->fresh()->image)->toBe("/event-media/{$media->id}/thumb");
});
test('uploaded originals keep a4 quality maximum side', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'gallery',
'file' => UploadedFile::fake()->image('large.jpg', 5000, 2500),
])->assertCreated();
$media = EventMedia::query()->firstOrFail();
expect($media->width)->toBe(3508)
->and($media->height)->toBe(1754)
->and($media->preview_width)->toBe(900)
->and($media->preview_height)->toBe(450)
->and(max($media->width, $media->height))->toBe(3508);
});
test('can list media for own event', function () {
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'collection' => 'gallery',
]);
Passport::actingAs($user);
$this->getJson("/api/events/{$event->client_id}/media")
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.collection', 'gallery');
});
test('can stream thumbnail for own media', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'thumbnail_path' => 'event-media/test/thumb.jpg',
]);
Storage::disk('local')->put($media->thumbnail_path, 'fake-jpeg');
Passport::actingAs($user);
$this->get("/api/event-media/{$media->id}/thumb")
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
});
test('can stream preview for own media', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'preview_path' => 'event-media/test/preview.jpg',
]);
Storage::disk('local')->put($media->preview_path, 'fake-jpeg');
Passport::actingAs($user);
$this->get("/api/event-media/{$media->id}/preview")
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
});
test('preview falls back to thumbnail for legacy media', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'preview_path' => null,
'thumbnail_path' => 'event-media/test/thumb.jpg',
]);
Storage::disk('local')->put($media->thumbnail_path, 'legacy-thumb');
Passport::actingAs($user);
$this->get("/api/event-media/{$media->id}/preview")
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
});
test('cannot access another users media', function () {
Storage::fake('local');
$user = User::factory()->create();
$otherUser = User::factory()->create();
$event = Event::factory()->create(['user_id' => $otherUser->id]);
$media = EventMedia::factory()->create([
'user_id' => $otherUser->id,
'event_id' => $event->id,
'thumbnail_path' => 'event-media/test/thumb.jpg',
]);
Storage::disk('local')->put($media->thumbnail_path, 'fake-jpeg');
Passport::actingAs($user);
$this->getJson("/api/events/{$event->client_id}/media")
->assertNotFound();
$this->get("/api/event-media/{$media->id}/thumb")
->assertNotFound();
});
test('can delete own media and files', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'path' => 'event-media/test/original.jpg',
'preview_path' => null,
'thumbnail_path' => 'event-media/test/thumb.jpg',
]);
Storage::disk('local')->put($media->path, 'original');
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
Passport::actingAs($user);
$this->deleteJson("/api/events/{$event->client_id}/media/{$media->id}")
->assertNoContent();
$this->assertDatabaseMissing('event_media', ['id' => $media->id]);
Storage::disk('local')->assertMissing($media->path);
Storage::disk('local')->assertMissing($media->thumbnail_path);
});
/**
* Minimal MP4 container header. libmagic identifies the `ftyp` box as
* video/mp4, which is all the upload validation looks at.
*/
function fakeMp4(): string
{
return "\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2mp41".str_repeat("\0", 256);
}
/**
* Minimal WebM (EBML) header, as Chrome's MediaRecorder produces for audio.
* libmagic reports it as video/webm.
*/
function fakeWebm(): string
{
return "\x1A\x45\xDF\xA3\x9F\x42\x86\x81\x01\x42\xF7\x81\x01\x42\xF2\x81\x04\x42\xF3\x81\x08"
."\x42\x82\x84webm\x42\x87\x81\x02\x42\x85\x81\x02".str_repeat("\0", 256);
}
test('can upload a video to the gallery', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$response = $this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'gallery',
'file' => UploadedFile::fake()->createWithContent('clip.mp4', fakeMp4()),
]);
$response->assertCreated()
->assertJsonPath('data.collection', 'gallery')
->assertJsonPath('data.type', 'video')
->assertJsonPath('data.mimeType', 'video/mp4');
$media = EventMedia::query()->firstOrFail();
expect($media->type)->toBe('video')
->and($media->path)->toEndWith('.mp4');
Storage::disk('local')->assertExists($media->path);
// The header-only clip has no decodable frame: no poster, but the
// upload still succeeds and the client gets an explicit null.
expect($response->json('data.originalUrl'))->toBe("/event-media/{$media->id}/original");
if ($media->thumbnail_path === null) {
expect($response->json('data.thumbnailUrl'))->toBeNull()
->and($response->json('data.src'))->toBeNull();
}
});
test('key image rejects a video', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'key_image',
'file' => UploadedFile::fake()->createWithContent('clip.mp4', fakeMp4()),
])->assertUnprocessable()
->assertJsonValidationErrors(['file']);
expect(EventMedia::query()->count())->toBe(0);
});
test('an event can carry several voice notes', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$first = $this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
]);
$first->assertCreated()
->assertJsonPath('data.collection', 'voice_note')
->assertJsonPath('data.type', 'audio')
->assertJsonPath('data.caption', '')
->assertJsonPath('data.thumbnailUrl', null)
->assertJsonPath('data.previewUrl', null);
$firstMedia = EventMedia::query()->firstOrFail();
Storage::disk('local')->assertExists($firstMedia->path);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
])->assertCreated();
expect(EventMedia::query()->where('collection', 'voice_note')->count())->toBe(2);
$this->assertDatabaseHas('event_media', ['id' => $firstMedia->id]);
Storage::disk('local')->assertExists($firstMedia->path);
});
test('a voice note can be uploaded together with its caption', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
// Written offline, sent with the file once the connection was back.
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'caption' => 'Aufgenommen im Zug, ohne Netz',
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
])
->assertCreated()
->assertJsonPath('data.caption', 'Aufgenommen im Zug, ohne Netz');
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'caption' => str_repeat('x', 501),
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
])->assertUnprocessable();
});
test('can set and clear the caption of own media', function () {
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'collection' => 'voice_note',
'type' => 'audio',
]);
Passport::actingAs($user);
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", [
'caption' => 'Nachtrag: die Adresse war Hauptstraße 3',
])->assertOk()
->assertJsonPath('data.caption', 'Nachtrag: die Adresse war Hauptstraße 3');
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", ['caption' => ''])
->assertOk()
->assertJsonPath('data.caption', '');
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", [
'caption' => str_repeat('x', 501),
])->assertUnprocessable();
});
test('cannot caption media of another user or another event', function () {
$user = User::factory()->create();
$other = User::factory()->create();
$ownEvent = Event::factory()->create(['user_id' => $user->id]);
$otherEvent = Event::factory()->create(['user_id' => $other->id]);
$otherMedia = EventMedia::factory()->create(['user_id' => $other->id, 'event_id' => $otherEvent->id]);
Passport::actingAs($user);
$this->patchJson("/api/events/{$otherEvent->client_id}/media/{$otherMedia->id}", ['caption' => 'x'])
->assertNotFound();
// Own event, but the media hangs on someone else's event.
$this->patchJson("/api/events/{$ownEvent->client_id}/media/{$otherMedia->id}", ['caption' => 'x'])
->assertNotFound();
expect($otherMedia->fresh()->caption)->toBeNull();
});
test('voice note keeps the audio type the browser reported', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
// A real temp file with the browser's declared type, as a multipart
// upload from MediaRecorder arrives.
$path = tempnam(sys_get_temp_dir(), 'voice').'.webm';
file_put_contents($path, fakeWebm());
$file = new UploadedFile($path, 'sprachnotiz.webm', 'audio/webm', null, true);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'file' => $file,
])->assertCreated()
->assertJsonPath('data.mimeType', 'audio/webm');
});
test('voice note rejects an image', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'file' => UploadedFile::fake()->image('foto.jpg', 400, 300),
])->assertUnprocessable()
->assertJsonValidationErrors(['file']);
});
test('streams an original with its own mime type', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'type' => 'video',
'mime_type' => 'video/mp4',
'path' => 'event-media/test/clip.mp4',
'thumbnail_path' => null,
'preview_path' => null,
]);
Storage::disk('local')->put($media->path, fakeMp4());
Passport::actingAs($user);
$this->get("/api/event-media/{$media->id}/original")
->assertOk()
->assertHeader('Content-Type', 'video/mp4')
->assertHeader('Accept-Ranges', 'bytes');
// No poster frame: the image variants do not exist.
$this->get("/api/event-media/{$media->id}/thumb")->assertNotFound();
});