presseportale/tests/Feature/Web/ContentChangedAtTest.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

112 lines
4.8 KiB
PHP

<?php
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* Umsetzungsaudit 29.09.2026, R-02/K-03 und R-03: `updated_at` taugt nicht
* als Änderungsdatum (Aufrufaggregation, Merge). Sitemap-lastmod, JSON-LD
* dateModified und article:modified_time folgen `content_changed_at`, die
* Sitemap führt englische Meldungen unter /en/.
*/
function lastmodRelease(array $attributes = []): PressRelease
{
$category = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
$company = Company::factory()->presseecho()->create(['is_active' => true]);
return PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create(array_merge(['slug' => 'lastmod-pm', 'published_at' => Carbon::parse('2026-01-10 10:00:00')], $attributes));
}
test('view aggregation leaves updated_at untouched', function () {
/** @var TestCase $this */
$release = lastmodRelease(['hits' => 0]);
$before = PressRelease::withoutGlobalScopes()->find($release->id)->updated_at;
PressReleaseEvent::factory()->count(3)->create([
'press_release_id' => $release->id,
'type' => PressReleaseEventType::View,
]);
$this->travel(2)->days();
$this->artisan('press-releases:aggregate-views')->assertSuccessful();
$fresh = PressRelease::withoutGlobalScopes()->find($release->id);
expect($fresh->hits)->toBe(3)
->and($fresh->updated_at->equalTo($before))->toBeTrue();
});
test('a text change sets content_changed_at, other saves do not', function () {
$release = lastmodRelease();
expect($release->fresh()->content_changed_at)->toBeNull();
$this->travel(1)->days();
$release->update(['keywords' => 'nur, schlagworte']);
expect($release->fresh()->content_changed_at)->toBeNull();
$this->travel(1)->days();
$release->update(['text' => '<p>Neuer Text</p>']);
expect($release->fresh()->content_changed_at?->toDateTimeString())->toBe(now()->toDateTimeString());
});
test('sitemap lastmod follows content_changed_at and ignores updated_at', function () {
/** @var TestCase $this */
$release = lastmodRelease();
// Aufrufaggregation / Merge alter Art: updated_at springt, Inhalt nicht.
PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['updated_at' => '2026-09-20 12:00:00']);
Cache::flush();
$xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent();
expect($xml)->toContain('<lastmod>'.Carbon::parse('2026-01-10 10:00:00')->toAtomString().'</lastmod>')
->not->toContain('2026-09-20');
PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['content_changed_at' => '2026-03-05 08:00:00']);
Cache::flush();
$xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent();
expect($xml)->toContain('<lastmod>'.Carbon::parse('2026-03-05 08:00:00')->toAtomString().'</lastmod>');
});
test('the detail page reports content_changed_at as modification date', function () {
/** @var TestCase $this */
$release = lastmodRelease();
PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['updated_at' => '2026-09-20 12:00:00']);
$html = $this->get('https://presseecho.test/de/press-release/lastmod-pm')->assertSuccessful()->getContent();
expect($html)->not->toContain('article:modified_time')
->not->toContain('2026-09-20T12:00:00');
PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['content_changed_at' => '2026-03-05 08:00:00']);
Cache::flush();
$html = $this->get('https://presseecho.test/de/press-release/lastmod-pm')->assertSuccessful()->getContent();
$modified = Carbon::parse('2026-03-05 08:00:00')->toIso8601String();
expect($html)->toContain('<meta property="article:modified_time" content="'.$modified.'">')
->toContain('"dateModified":"'.str_replace('/', '\/', $modified).'"');
});
test('the sitemap lists english releases under the english edition', function () {
/** @var TestCase $this */
lastmodRelease(['slug' => 'english-release', 'language' => 'en']);
lastmodRelease(['slug' => 'deutsche-meldung', 'language' => 'de']);
$xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent();
expect($xml)->toContain('<loc>https://presseecho.test/en/press-release/english-release</loc>')
->toContain('<loc>https://presseecho.test/de/press-release/deutsche-meldung</loc>')
->not->toContain('/de/press-release/english-release');
$this->get('https://presseecho.test/en/press-release/english-release')->assertSuccessful();
});