thats-me/backend/tests/Feature/Api/ClientLogTest.php
Kevin Adametz ab23c9f4ef Client-Logging, getrennte Rate-Limits und Ladezustand für Bilder
Auf dem Testserver lief die App beim Scrollen langer Timelines in ein 429:
nach jeder Scroll-Pause ging sofort ein PUT /settings raus, und jedes
sichtbare Vorschaubild zählte auf dasselbe Limit von 300/min. Danach
blieben Bilder leer und "Einstellungen konnten nicht gespeichert werden"
erschien.

Backend: Medien-Reads bekommen einen eigenen Limiter (media, 1200/min),
POST /client-logs nimmt Warnungen und Fehler der App an (client-logs,
30/min) und schreibt sie in den täglichen Kanal `client`; Exceptions
tragen user_id, path und method im Kontext.

Frontend: Einstellungen gehen frühestens 1,5 s nach der letzten Änderung
und nur bei geändertem Inhalt zum Server, 429/Netzfehler werden still mit
Backoff wiederholt, nur eine Ablehnung wird gemeldet. Bilder laufen durch
ein Gate (6 parallel, Retry-After, 3 Versuche) und fallen auf einen
Platzhalter statt auf einen kaputten Pfad zurück. Neuer clientLog-Service
mit IndexedDB-Puffer, Dedupe und Batch-Versand, globaler Fehlerfang im
Boot, Protokoll im Entwickler-Tab. Drei pulsierende Punkte statt weißer
Fläche in Timeline-Punkt, Key Image und Medienkacheln.

Dokumentation in documentation/logging.md.

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

84 lines
3.1 KiB
PHP

<?php
use App\Http\Controllers\Api\ClientLogController;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Passport;
use Psr\Log\LoggerInterface;
test('client logs require a token', function () {
$this->postJson('/api/client-logs', ['entries' => []])
->assertUnauthorized();
});
test('client log entries are written to the client channel with the user attached', function () {
$user = User::factory()->create();
Passport::actingAs($user);
$logger = Mockery::mock(LoggerInterface::class);
Log::shouldReceive('channel')->once()->with(ClientLogController::CHANNEL)->andReturn($logger);
$logger->shouldReceive('log')->once()->withArgs(function (string $level, string $message, array $context) use ($user): bool {
return $level === 'error'
&& $message === '[api] PUT /settings failed'
&& $context['user_id'] === $user->id
&& $context['app_version'] === '0.0.1'
&& $context['url'] === '/#/'
&& $context['context'] === ['status' => 429, 'path' => '/settings'];
});
$logger->shouldReceive('log')->once()->withArgs(function (string $level, string $message, array $context): bool {
return $level === 'warning'
&& $message === '[image] Thumbnail load failed'
&& ! array_key_exists('context', $context);
});
$this->postJson('/api/client-logs', [
'appVersion' => '0.0.1',
'sessionId' => 'abc',
'userAgent' => 'Safari',
'entries' => [
[
'level' => 'error',
'scope' => 'api',
'message' => 'PUT /settings failed',
'url' => '/#/',
'occurredAt' => '2026-09-25T10:00:00.000Z',
'context' => ['status' => 429, 'path' => '/settings'],
],
[
'level' => 'warning',
'scope' => 'image',
'message' => 'Thumbnail load failed',
'occurredAt' => '2026-09-25T10:00:01.000Z',
],
],
])->assertNoContent();
});
test('client logs reject unknown levels, oversized batches and nested context', function () {
Passport::actingAs(User::factory()->create());
$entry = fn (array $overrides = []) => [
'level' => 'error',
'scope' => 'api',
'message' => 'x',
'occurredAt' => '2026-09-25T10:00:00.000Z',
...$overrides,
];
$this->postJson('/api/client-logs', ['entries' => [$entry(['level' => 'debug'])]])
->assertStatus(422)
->assertJsonValidationErrors(['entries.0.level']);
$this->postJson('/api/client-logs', ['entries' => array_fill(0, 51, $entry())])
->assertStatus(422)
->assertJsonValidationErrors(['entries']);
$this->postJson('/api/client-logs', ['entries' => [$entry(['context' => ['nested' => ['a' => 1]]])]])
->assertStatus(422)
->assertJsonValidationErrors(['entries.0.context.nested']);
$this->postJson('/api/client-logs', ['entries' => []])
->assertStatus(422)
->assertJsonValidationErrors(['entries']);
});