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>
This commit is contained in:
Kevin Adametz 2026-09-25 07:00:35 +00:00
parent f79dcb9928
commit b262a6771f
59 changed files with 11879 additions and 197 deletions

View file

@ -39,7 +39,7 @@ test('can filter events by since parameter', function () {
'updated_at' => now()->subHour(),
]);
$response = $this->getJson('/api/events?since=' . now()->subDay()->toISOString());
$response = $this->getJson('/api/events?since='.now()->subDay()->toISOString());
$response->assertOk()
->assertJsonCount(1, 'data');
@ -184,3 +184,99 @@ test('sync is idempotent for creates', function () {
expect(Event::where('client_id', $clientId)->count())->toBe(1);
});
test('can create an event with an empty title', function () {
// An event is created with its first entry, which may be a note or a
// photo. The title may therefore be empty, but it has to be sent.
$clientId = Str::uuid()->toString();
$this->postJson('/api/events', [
'id' => $clientId,
'title' => '',
'date' => '2024-06-15',
'emotion' => 0,
'note' => 'Nur eine Notiz',
])->assertCreated()
->assertJsonPath('data.title', '');
expect(Event::query()->where('client_id', $clientId)->value('title'))->toBe('');
});
test('title must still be sent when creating an event', function () {
$this->postJson('/api/events', [
'id' => Str::uuid()->toString(),
'date' => '2024-06-15',
'emotion' => 0,
])->assertUnprocessable()
->assertJsonValidationErrors(['title']);
});
test('stores category, people, a span and the milestone flag', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events', [
'id' => $clientId,
'title' => 'Umzug nach Hamburg',
'date' => '2019-04-01',
'endDate' => '2019-04-14',
'emotion' => 0.4,
'milestone' => true,
'category' => 'home',
'people' => ['Lena', ' Papa ', '', 'Lena'],
])
->assertCreated()
->assertJsonPath('data.endDate', '2019-04-14')
->assertJsonPath('data.milestone', true)
->assertJsonPath('data.category', 'home')
// Trimmed, blanks dropped, duplicates removed.
->assertJsonPath('data.people', ['Lena', 'Papa']);
// Clearing the span and the people list on update.
$this->putJson("/api/events/{$clientId}", [
'endDate' => null,
'people' => [],
'milestone' => false,
])
->assertOk()
->assertJsonPath('data.endDate', null)
->assertJsonPath('data.people', [])
->assertJsonPath('data.milestone', false);
});
test('rejects an unknown category and a span that ends before it starts', function () {
$base = ['id' => Str::uuid()->toString(), 'title' => 'x', 'date' => '2020-05-10', 'emotion' => 0];
$this->postJson('/api/events', [...$base, 'category' => 'aliens'])
->assertUnprocessable()
->assertJsonValidationErrors(['category']);
$this->postJson('/api/events', [...$base, 'endDate' => '2020-05-09'])
->assertUnprocessable()
->assertJsonValidationErrors(['endDate']);
$this->postJson('/api/events', [...$base, 'people' => [str_repeat('x', 81)]])
->assertUnprocessable()
->assertJsonValidationErrors(['people.0']);
});
test('sync writes the new fields like crud does', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events/sync', ['mutations' => [[
'action' => 'create',
'eventId' => $clientId,
'payload' => [
'title' => 'Abschluss',
'date' => '2015-07-01',
'emotion' => 0.9,
'milestone' => true,
'category' => 'education',
'people' => ['Prof. Weber'],
],
]]])->assertOk()->assertJsonPath('results.0.status', 'ok');
$event = Event::query()->where('client_id', $clientId)->firstOrFail();
expect($event->milestone)->toBeTrue()
->and($event->category)->toBe('education')
->and($event->people)->toBe(['Prof. Weber']);
});