$release->getKey(), 'type' => PressReleaseEventType::View, ]); $event->forceFill(['created_at' => $createdAt])->save(); return $event; } test('the purge drops aggregated events past the retention window', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $old = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); $recent = purgeTestEvent($release, now()->subDays(10)->toDateTimeString()); AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $recent->id); $this->artisan('press-releases:purge-events')->assertSuccessful(); expect(PressReleaseEvent::query()->find($old->id))->toBeNull() ->and(PressReleaseEvent::query()->find($recent->id))->not->toBeNull(); }); test('the purge never touches events above the watermark', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $aggregated = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); $notAggregated = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); // Nur das erste Ereignis steckt in `hits`. Fiele das zweite hier weg, wäre // dieser Aufruf dauerhaft verloren — es ist nirgends sonst gezählt. AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $aggregated->id); $this->artisan('press-releases:purge-events')->assertSuccessful(); expect(PressReleaseEvent::query()->find($aggregated->id))->toBeNull() ->and(PressReleaseEvent::query()->find($notAggregated->id))->not->toBeNull(); }); test('the purge refuses to run without a watermark', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $event = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); // Die Marke setzt die Migration beim Deploy. Ist sie weg (von Hand // gelöscht, Seed übersprungen), ist unbekannt, was bereits in `hits` // steckt — raten wäre Datenverlust. AppSetting::query()->whereKey(AggregatePressReleaseViews::WatermarkKey)->delete(); AppSetting::forgetCached(AggregatePressReleaseViews::WatermarkKey); $this->artisan('press-releases:purge-events')->assertExitCode(1); expect(PressReleaseEvent::query()->find($event->id))->not->toBeNull(); }); test('the purge dry run counts without deleting', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $event = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $event->id); // Der echte Lauf räumt auch die Abruf-Protokolle — der Probelauf nennt sie mit. PressReleaseCrawlerHit::factory()->create(['press_release_id' => $release->id, 'created_at' => now()->subDays(400)]); PressReleaseCrawlerHit::factory()->create(['press_release_id' => $release->id]); FeedFetch::factory()->create(['created_at' => now()->subDays(400)]); FeedFetch::factory()->create(); $this->artisan('press-releases:purge-events', ['--dry-run' => true]) ->expectsOutputToContain('[Dry-Run] 1 Ereignisse') ->expectsOutputToContain('[Dry-Run] 1 Einträge im Crawler-Abruf-Protokoll und 1 im Feed-Abruf-Protokoll') ->assertSuccessful(); expect(PressReleaseEvent::query()->find($event->id))->not->toBeNull() ->and(PressReleaseCrawlerHit::query()->count())->toBe(2) ->and(FeedFetch::query()->count())->toBe(2); }); test('the retention window is configurable', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $event = purgeTestEvent($release, now()->subDays(40)->toDateTimeString()); AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $event->id); $this->artisan('press-releases:purge-events', ['--days' => 30])->assertSuccessful(); expect(PressReleaseEvent::query()->find($event->id))->toBeNull(); }); test('the purge reads the watermark from the database, not from a stale cache (Audit N21)', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $old = purgeTestEvent($release, now()->subDays(400)->toDateTimeString()); AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $old->id); // Veralteter Cachewert: mit ihm dürfte nichts gelöscht werden — und er // darf vor allem nicht wieder in den Cache zurückwandern. Cache::forever('app-setting.'.AggregatePressReleaseViews::WatermarkKey, 0); $this->artisan('press-releases:purge-events')->assertSuccessful(); expect(PressReleaseEvent::query()->find($old->id))->toBeNull() ->and(Cache::get('app-setting.'.AggregatePressReleaseViews::WatermarkKey))->toBe(0); }); test('the purge deletes by id range below a boundary instead of scanning created_at', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $old = collect(range(1, 3))->map(fn () => purgeTestEvent($release, now()->subDays(400)->toDateTimeString())); $recent = purgeTestEvent($release, now()->subDays(10)->toDateTimeString()); AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $recent->id); DB::enableQueryLog(); $this->artisan('press-releases:purge-events', ['--chunk' => 100])->assertSuccessful(); // Umsetzungsaudit 29.09.2026, OPT-2: press_release_events hat keinen // Index auf created_at — gelöscht wird nach Primärschlüssel. $deletes = collect(DB::getQueryLog())->pluck('query') ->filter(fn (string $sql): bool => str_starts_with(strtolower($sql), 'delete') && str_contains($sql, 'press_release_events')); expect($deletes)->not->toBeEmpty() ->and($deletes->every(fn (string $sql): bool => ! str_contains($sql, 'created_at')))->toBeTrue() ->and(PressReleaseEvent::query()->whereIn('id', $old->pluck('id'))->count())->toBe(0) ->and(PressReleaseEvent::query()->find($recent->id))->not->toBeNull(); });