Der Livewire-Feed lief auf jeder Startseiten-Impression ungecacht; seine ORDER-BY-published_at-Query kann wegen portal IN (…) keinen bestehenden Index zur Sortierung nutzen und filesortete ~10-20k Zeilen (live gemessen 2,3 s pro Aufruf — die Startseite blieb dadurch trotz Seitendaten-Cache bei 6-9 s TTFB). Zwei Maßnahmen: - Neuer Index (language, status, deleted_at, published_at): Backward Index Scan statt Filesort, Query 2 ms statt 2,3 s — greift auch für ungecachte Livewire-Interaktionen (Zeitraum-Filter, tiefe Seiten). - Feed-Seiten 1-3 laufen über WebPageCache (SWR wie die übrigen Seitendaten); gecacht werden nur items + total, der Paginator wird pro Request neu gebaut (keine Host-URLs des Cache-Erzeugers im Cache). Unbekannte timeframe-Werte aus der URL fallen auf 'all' zurück und erreichen weder Query noch Cache-Key. Der doppelte Gesamt-COUNT pro Request entfällt; der 'Mehr laden'-Zähler nutzt jetzt den gefilterten Total des Paginators. Lokal mit Live-Datenbestand: Startseite kalt 875 ms (vorher 2,5 s), warm 11 ms (vorher 72 ms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
340 lines
12 KiB
PHP
340 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Enums\Portal;
|
|
use App\Models\Category;
|
|
use App\Models\Company;
|
|
use App\Models\PressRelease;
|
|
use App\Support\WebPageCache;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Livewire\Volt\Volt as LivewireVolt;
|
|
use Tests\TestCase;
|
|
|
|
function makeCategoryWithSlug(Portal $portal, string $name, string $slug, ?int $parentId = null): Category
|
|
{
|
|
$category = Category::factory()->create(['portal' => $portal, 'parent_id' => $parentId]);
|
|
$category->translations()->create([
|
|
'locale' => 'de',
|
|
'name' => $name,
|
|
'slug' => $slug,
|
|
]);
|
|
|
|
return $category;
|
|
}
|
|
|
|
test('homepage data is cached per portal and language', function () {
|
|
/** @var TestCase $this */
|
|
$category = makeCategoryWithSlug(Portal::Presseecho, 'Energie & Klima', 'energie-klima');
|
|
$company = Company::factory()->presseecho()->create();
|
|
|
|
PressRelease::factory()
|
|
->published()
|
|
->forPortal(Portal::Presseecho)
|
|
->for($category)
|
|
->for($company)
|
|
->create([
|
|
'title' => 'Erste Meldung im Cache',
|
|
'slug' => 'erste-meldung-im-cache',
|
|
'published_at' => now()->subHour(),
|
|
]);
|
|
|
|
$this->get('https://presseecho.test/de')
|
|
->assertSuccessful()
|
|
->assertSeeText('Erste Meldung im Cache');
|
|
|
|
expect(Cache::has('web.home.presseecho.de'))->toBeTrue();
|
|
|
|
PressRelease::factory()
|
|
->published()
|
|
->forPortal(Portal::Presseecho)
|
|
->for($category)
|
|
->for($company)
|
|
->create([
|
|
'title' => 'Neue Meldung nach Cache-Aufbau',
|
|
'slug' => 'neue-meldung-nach-cache-aufbau',
|
|
'published_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
// Innerhalb der TTL liefert HomePageData den gecachten Stand: Lead bleibt
|
|
// die alte Meldung. (Auch der Livewire-Feed cached seine vorderen Seiten
|
|
// über WebPageCache — neue Meldungen erscheinen nach Ablauf der Frische-TTL.)
|
|
$cachedLead = $this->get('https://presseecho.test/de')
|
|
->assertSuccessful()
|
|
->viewData('leadRelease');
|
|
|
|
expect($cachedLead->title)->toBe('Erste Meldung im Cache');
|
|
|
|
Cache::flush();
|
|
|
|
$freshLead = $this->get('https://presseecho.test/de')
|
|
->assertSuccessful()
|
|
->viewData('leadRelease');
|
|
|
|
expect($freshLead->title)->toBe('Neue Meldung nach Cache-Aufbau');
|
|
});
|
|
|
|
test('web page cache serves stale data instantly instead of rebuilding in the request', function () {
|
|
/** @var TestCase $this */
|
|
$calls = 0;
|
|
$build = function () use (&$calls): string {
|
|
$calls++;
|
|
|
|
return 'version-'.$calls;
|
|
};
|
|
|
|
expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-1');
|
|
expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-1');
|
|
expect($calls)->toBe(1);
|
|
|
|
// Frische-TTL (300s) überschritten, Stale-Grenze (3600s) nicht: Der
|
|
// Besucher bekommt sofort den alten Stand; der Neuaufbau läuft deferred
|
|
// nach der Response, nicht synchron im Request.
|
|
$this->travel(10)->minutes();
|
|
|
|
expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-1');
|
|
expect($calls)->toBe(1);
|
|
|
|
// Nach Ablauf der Stale-Grenze baut der Request wieder synchron.
|
|
$this->travel(2)->hours();
|
|
|
|
expect(WebPageCache::remember('web.swr-test', $build))->toBe('version-2');
|
|
});
|
|
|
|
test('the press release feed caches its front pages but not deep pages or junk timeframes', function () {
|
|
/** @var TestCase $this */
|
|
$category = makeCategoryWithSlug(Portal::Presseecho, 'Feed-Rubrik', 'feed-rubrik');
|
|
$company = Company::factory()->presseecho()->create();
|
|
|
|
PressRelease::factory()
|
|
->published()
|
|
->forPortal(Portal::Presseecho)
|
|
->for($category)
|
|
->for($company)
|
|
->create([
|
|
'title' => 'Feed-Meldung eins',
|
|
'slug' => 'feed-meldung-eins',
|
|
'published_at' => now()->subHour(),
|
|
]);
|
|
|
|
$this->get('https://presseecho.test/de')
|
|
->assertSuccessful()
|
|
->assertSeeText('Feed-Meldung eins');
|
|
|
|
expect(Cache::has('web.feed.presseecho.de.all.page.1'))->toBeTrue();
|
|
|
|
// Tiefe Seiten bleiben ungecacht (Schutz vor unbegrenztem Key-Raum).
|
|
$this->get('https://presseecho.test/de?page=25')->assertSuccessful();
|
|
|
|
expect(Cache::has('web.feed.presseecho.de.all.page.25'))->toBeFalse();
|
|
|
|
// Unbekannte timeframe-Werte aus der URL fallen auf "all" zurück und
|
|
// erzeugen keinen eigenen (poisonbaren) Cache-Key.
|
|
$this->get('https://presseecho.test/de?timeframe=boesewert')->assertSuccessful();
|
|
|
|
expect(Cache::has('web.feed.presseecho.de.boesewert.page.1'))->toBeFalse();
|
|
|
|
// Der Cache hält nur items + total — der Paginator (inkl. Host-URLs der
|
|
// Pagination-Links) wird pro Request neu gebaut, damit der gecachte Feed
|
|
// nicht den Host des Cache-Erzeugers an andere Besucher ausliefert.
|
|
expect(Cache::get('web.feed.presseecho.de.all.page.1'))
|
|
->toBeArray()
|
|
->toHaveKeys(['items', 'total']);
|
|
});
|
|
|
|
test('category page data is cached per portal, language and slug but 404s are not cached', function () {
|
|
/** @var TestCase $this */
|
|
$category = makeCategoryWithSlug(Portal::Businessportal24, 'Mobilität', 'mobilitaet');
|
|
|
|
PressRelease::factory()
|
|
->published()
|
|
->forPortal(Portal::Businessportal24)
|
|
->for($category)
|
|
->for(Company::factory()->businessportal24())
|
|
->create([
|
|
'slug' => 'mobilitaets-meldung',
|
|
'published_at' => now()->subDay(),
|
|
]);
|
|
|
|
$this->get('https://businessportal24.test/de/category/mobilitaet')->assertSuccessful();
|
|
|
|
expect(Cache::has('web.category.businessportal24.de.mobilitaet'))->toBeTrue();
|
|
|
|
$this->get('https://businessportal24.test/de/category/gibt-es-nicht')->assertNotFound();
|
|
|
|
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');
|
|
$child = makeCategoryWithSlug(Portal::Both, 'Handel', 'handel', $parent->id);
|
|
|
|
$company = Company::factory()->businessportal24()->create();
|
|
|
|
// Stichtag (referenceNow) = jüngste Meldung: 2 im 7-Tage-Fenster, 1 älter (>14 Tage).
|
|
foreach ([1, 2, 20] as $daysAgo) {
|
|
PressRelease::factory()
|
|
->published()
|
|
->forPortal(Portal::Businessportal24)
|
|
->for($child)
|
|
->for($company)
|
|
->create([
|
|
'slug' => "handel-meldung-{$daysAgo}",
|
|
'published_at' => now()->subDays($daysAgo),
|
|
]);
|
|
}
|
|
|
|
$response = $this->get('https://businessportal24.test/de/category/wirtschaft')->assertSuccessful();
|
|
|
|
$subCategories = $response->viewData('subCategories');
|
|
|
|
expect($subCategories)->toHaveCount(1)
|
|
->and($subCategories[0]['name'])->toBe('Handel')
|
|
->and($subCategories[0]['total'])->toBe(3)
|
|
->and($subCategories[0]['recent'])->toBe(2)
|
|
->and($subCategories[0]['delta'])->toBe(2);
|
|
});
|
|
|
|
test('the press release feed today filter matches the reference day via range comparison', function () {
|
|
/** @var TestCase $this */
|
|
$category = makeCategoryWithSlug(Portal::Presseecho, 'Technologie', 'technologie');
|
|
$company = Company::factory()->presseecho()->create();
|
|
|
|
// Historisches Archiv: Stichtag = jüngste Meldung (vor 10 Tagen).
|
|
PressRelease::factory()
|
|
->published()
|
|
->forPortal(Portal::Presseecho)
|
|
->for($category)
|
|
->for($company)
|
|
->create([
|
|
'title' => 'Meldung am Stichtag',
|
|
'slug' => 'meldung-am-stichtag',
|
|
'published_at' => now()->subDays(10)->setTime(9, 0),
|
|
]);
|
|
|
|
PressRelease::factory()
|
|
->published()
|
|
->forPortal(Portal::Presseecho)
|
|
->for($category)
|
|
->for($company)
|
|
->create([
|
|
'title' => 'Meldung vom Vortag',
|
|
'slug' => 'meldung-vom-vortag',
|
|
'published_at' => now()->subDays(11)->setTime(9, 0),
|
|
]);
|
|
|
|
LivewireVolt::test('web.press-release-feed', ['portal' => Portal::Presseecho->value, 'language' => 'de'])
|
|
->call('setTimeframe', 'today')
|
|
->assertSee('Meldung am Stichtag')
|
|
->assertDontSee('Meldung vom Vortag');
|
|
});
|