performance: Caching, deferred Tracking & externe Timeouts (H7, M12, M13)

- Navigations-Chrome (CategoryNavigation), Suche- und Newsroom-Seitendaten
  300s gecacht; gemeinsamer WebPageCache-Helper (eine TTL/Key-Konvention).
- Cache-Poisoning über ?page= geschlossen (Key an Paginator ausgerichtet,
  kein withQueryString im Cache); Newsrooms cachet nur den ungefilterten
  Fall und die ersten Seiten (kein unbegrenzter Key-Raum).
- Detailseiten schreiben Hits/View-Events deferred nach der Response
  (M12); OpenAI-/Markt-/USt-ID-Aufrufe mit Connect-Timeouts und
  begrenzten Retries; Admin-Memo-Caches konsistent invalidiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-14 13:49:47 +00:00
parent 1afcc364ac
commit 22975e762e
18 changed files with 419 additions and 39 deletions

View file

@ -91,4 +91,5 @@ test('an unreachable evatr api degrades to unverified', function () {
$result = vatIdService()->check('NL123456789B01', 'NL');
expect($result['status'])->toBe(VatIdCheckStatus::Unverified);
Http::assertSentCount(3);
});

View file

@ -116,6 +116,7 @@ test('score job falls back to the deterministic driver when openai fails', funct
->firstOrFail();
expect($audit->provider)->toBe('deterministic');
expect($pressRelease->fresh()->content_score)->toBeGreaterThan(0);
Http::assertSentCount(3);
});
test('submitForReview dispatches the scoring job onto the classification queue', function () {

View file

@ -49,6 +49,25 @@ test('once the free quota is empty the overflow draws one credit per check', fun
expect($this->wallet->balance($user))->toBe(4);
});
test('stale user instances cannot consume the same final free review check', function () {
$user = User::factory()->create();
$this->wallet->credit($user, 5);
for ($i = 0; $i < 3; $i++) {
$this->service->recordCheck($user);
}
$firstRequestUser = $user->fresh();
$secondRequestUser = $user->fresh();
$lastFreeCheck = $this->service->recordCheck($firstRequestUser);
$overflowCheck = $this->service->recordCheck($secondRequestUser);
expect($lastFreeCheck->source)->toBe(ReviewCheckSource::Free)
->and($overflowCheck->source)->toBe(ReviewCheckSource::Credit)
->and($this->wallet->balance($user))->toBe(4);
});
test('overflow without enough credits throws and records nothing', function () {
$user = User::factory()->create();
for ($i = 0; $i < 4; $i++) {

View file

@ -0,0 +1,40 @@
<?php
use App\Support\CategoryNavigation;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Regression zu Befund H7 (Audit 14.07.2026): Die Rubriken-Navigation rendert
* auf jeder Web-Seite und darf nicht pro Request neu über das Archiv aggregieren.
*/
test('category navigation is cached and not recomputed on the second call', function () {
/** @var TestCase $this */
config()->set('app.theme', 'presseecho');
app()->setLocale('de');
Cache::flush();
DB::enableQueryLog();
CategoryNavigation::items(5);
expect(DB::getQueryLog())->not->toBeEmpty();
DB::flushQueryLog();
CategoryNavigation::items(5);
expect(DB::getQueryLog())->toBeEmpty();
});
test('category navigation caches per portal, language and limit separately', function () {
/** @var TestCase $this */
config()->set('app.theme', 'presseecho');
app()->setLocale('de');
Cache::flush();
CategoryNavigation::items(5);
expect(Cache::has('web.category-nav.presseecho.de.5'))->toBeTrue()
->and(Cache::has('web.category-nav.presseecho.de.all'))->toBeFalse();
CategoryNavigation::items(null);
expect(Cache::has('web.category-nav.presseecho.de.all'))->toBeTrue();
});

View file

@ -57,6 +57,7 @@ it('behält den Cache-Stand, wenn der Abruf komplett fehlschlägt', function ()
expect($slides)->toHaveCount(1)
->and($slides[0]['value'])->toBe('24.150');
Http::assertSentCount(6);
});
it('liefert keine Slides, wenn Marktdaten deaktiviert sind', function () {

View file

@ -94,6 +94,117 @@ test('category page data is cached per portal, language and slug but 404s are no
expect(Cache::has('web.category.businessportal24.de.gibt-es-nicht'))->toBeFalse();
});
test('search sidebar data is cached per portal and language', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/de/suche')->assertSuccessful();
expect(Cache::has('web.search.most-read.businessportal24-both.de'))->toBeTrue();
});
test('newsroom detail data is cached per portal language slug and page', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create([
'slug' => 'cache-newsroom',
'is_active' => true,
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->create([
'company_id' => $company->id,
'language' => 'de',
'published_at' => now()->subDay(),
]);
$this->get('https://businessportal24.test/de/newsroom/cache-newsroom?page=2')
->assertSuccessful();
expect(Cache::has('web.newsroom.businessportal24-both.de.cache-newsroom.page.2'))->toBeTrue();
});
test('newsroom detail cache key aligns with the paginator so an invalid page cannot poison another page', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create([
'slug' => 'poison-newsroom',
'is_active' => true,
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->create([
'company_id' => $company->id,
'language' => 'de',
'published_at' => now()->subDay(),
]);
// Ungültige Seitennummer: der Paginator rendert Seite 1, also darf der
// Inhalt NICHT unter dem Key für Seite 2 landen (sonst Cache-Poisoning).
$this->get('https://businessportal24.test/de/newsroom/poison-newsroom?page=2.5')
->assertSuccessful();
expect(Cache::has('web.newsroom.businessportal24-both.de.poison-newsroom.page.1'))->toBeTrue();
expect(Cache::has('web.newsroom.businessportal24-both.de.poison-newsroom.page.2'))->toBeFalse();
});
test('newsrooms unfiltered index is cached but filtered searches are not', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create([
'name' => 'SolarTec Bestand',
'is_active' => true,
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($company)
->create(['language' => 'de', 'published_at' => now()->subDay()]);
// Ungefiltertes Verzeichnis (erste Seite) wird unter einem beschränkten Key gecacht.
$this->get('https://businessportal24.test/de/newsrooms')
->assertSuccessful()
->assertSeeText('SolarTec Bestand');
expect(Cache::has('web.newsrooms.businessportal24,both.de.page.1'))->toBeTrue();
// Gefilterte Suche wird NICHT gecacht (Schutz vor unbegrenztem Key-Raum):
// eine nach dem ersten Aufruf angelegte Firma erscheint sofort.
$newCompany = Company::factory()->businessportal24()->create([
'name' => 'SolarTec Neu',
'is_active' => true,
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($newCompany)
->create(['language' => 'de', 'published_at' => now()->subHour()]);
$this->get('https://businessportal24.test/de/newsrooms?q=solartec')
->assertSuccessful()
->assertSeeText('SolarTec Neu');
$this->get('https://businessportal24.test/de/newsrooms?q=solartec')
->assertSuccessful()
->assertSeeText('SolarTec Neu');
});
test('deep newsrooms pages are not cached', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/de/newsrooms?page=25')->assertSuccessful();
expect(Cache::has('web.newsrooms.businessportal24,both.de.page.25'))->toBeFalse();
});
test('newsrooms cache does not retain unrelated query parameters in pagination links', function () {
/** @var TestCase $this */
$response = $this->get('https://businessportal24.test/de/newsrooms?q=SolarTec&utm_source=poison')
->assertSuccessful();
$pageTwoUrl = $response->viewData('newsrooms')->url(2);
expect($pageTwoUrl)
->toContain('q=SolarTec')
->not->toContain('utm_source');
});
test('subcategory counts are aggregated correctly in a single grouped query', function () {
/** @var TestCase $this */
$parent = makeCategoryWithSlug(Portal::Both, 'Wirtschaft', 'wirtschaft');