- 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>
387 lines
14 KiB
PHP
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();
|
|
});
|