- 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>
293 lines
9.6 KiB
PHP
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);
|
|
});
|