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>
This commit is contained in:
parent
1d417f87f7
commit
c2e0b85242
48 changed files with 1676 additions and 134 deletions
|
|
@ -4,7 +4,9 @@ use App\Enums\Portal;
|
|||
use App\Models\Category;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Support\WebPageCache;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -176,3 +178,21 @@ test('deactivating a category clears the cached public pages and its feed at onc
|
|||
// Nicht erst nach Ablauf der Stale-Grenze: sofort weg.
|
||||
$this->get('https://presseecho.test/de/category/handel/feed')->assertNotFound();
|
||||
});
|
||||
|
||||
test('creating an active category clears the cached public pages at once', function () {
|
||||
/** @var TestCase $this */
|
||||
$generation = WebPageCache::generation();
|
||||
Cache::put('web.llms.presseecho.test', 'alt', 3600);
|
||||
|
||||
LivewireVolt::test('admin.categories.create')
|
||||
->set('portal', Portal::Presseecho->value)
|
||||
->set('nameDe', 'Energie')
|
||||
->set('nameEn', 'Energy')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, 5.2-Rest: Navigation und llms.txt zeigen
|
||||
// die neue Branche sofort, nicht erst nach bis zu einer Stunde.
|
||||
expect(WebPageCache::generation())->not->toBe($generation)
|
||||
->and(Cache::has('web.llms.presseecho.test'))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -301,3 +301,21 @@ test('api submissions without press contact address the company by name', functi
|
|||
&& ! str_contains($html, 'Sebastian Einbock');
|
||||
});
|
||||
});
|
||||
|
||||
test('the legacy view url follows the language of the press release', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = parityApiUser();
|
||||
$company = Company::factory()->create(['portal' => Portal::Businessportal24]);
|
||||
$pressRelease = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'company_id' => $company->id,
|
||||
'portal' => Portal::Businessportal24,
|
||||
'language' => 'en',
|
||||
'status' => PressReleaseStatus::Published,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// Englische Meldungen liegen unter /en/ — /de/ antwortet für sie 404.
|
||||
expect($this->getJson("/service/pressreleases/{$pressRelease->id}")->assertOk()->json('view_url'))
|
||||
->toEndWith("/en/press-release/{$pressRelease->slug}");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use App\Enums\EmailBounceKind;
|
|||
use App\Models\ActivityEvent;
|
||||
use App\Models\EmailBounce;
|
||||
use App\Services\Mail\BounceMailProcessor;
|
||||
use App\Services\Mail\BounceReference;
|
||||
use App\Services\Mail\DsnParser;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -18,6 +19,19 @@ function bounceFixture(string $name): string
|
|||
return (string) file_get_contents(base_path("tests/Fixtures/bounces/{$name}.eml"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture, deren X-Pk-Ref wie beim echten Versand für `$recipient` signiert
|
||||
* ist (Umsetzungsaudit 29.09.2026, R-04).
|
||||
*/
|
||||
function signedBounceFixture(string $name, string $recipient): string
|
||||
{
|
||||
return (string) preg_replace_callback(
|
||||
'/^X-Pk-Ref: (.+)$/m',
|
||||
fn (array $m): string => 'X-Pk-Ref: '.app(BounceReference::class)->sign(trim($m[1]), [$recipient]),
|
||||
bounceFixture($name),
|
||||
);
|
||||
}
|
||||
|
||||
test('a postfix hard bounce yields recipient, status, diagnostic and our reference header', function () {
|
||||
/** @var TestCase $this */
|
||||
$report = app(DsnParser::class)->parse(bounceFixture('hard-5.1.1'));
|
||||
|
|
@ -35,7 +49,7 @@ test('a postfix hard bounce yields recipient, status, diagnostic and our referen
|
|||
|
||||
test('processing a hard bounce suppresses the address and keeps the reference', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and($result['bounces'])->toBe(1)
|
||||
|
|
@ -44,13 +58,13 @@ test('processing a hard bounce suppresses the address and keeps the reference',
|
|||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
|
||||
|
||||
// Zweiter Durchlauf derselben Mail (abgebrochener Lauf): nichts doppelt.
|
||||
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
|
||||
expect(EmailBounce::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a delayed report counts as a soft bounce and lower-case field names are fine', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
|
||||
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('delayed-4.4.1', 'voll@example.test'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft)
|
||||
|
|
@ -89,7 +103,7 @@ test('the utf-8 dsn variant of rfc 6533 is read like a classic report', function
|
|||
->and($report->recipients[0]['status'])->toBe('5.0.0')
|
||||
->and($report->reference)->toBe('user:4711');
|
||||
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('global-delivery-status'));
|
||||
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('global-delivery-status', 'weiterleitung@example.net'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
|
||||
|
|
@ -104,10 +118,23 @@ test('the free-text notice of our own relay is read and never mistaken for an au
|
|||
$result = app(BounceMailProcessor::class)->process(bounceFixture('kas-antispam'));
|
||||
$suppressions = app(EmailSuppressionService::class);
|
||||
|
||||
// Der Freitext trägt keine Original-Header, also keine signierte
|
||||
// Referenz: nur zur Sichtung protokolliert, zählt auf keinen
|
||||
// Schwellwert (R-04, Umsetzungsaudit 02.10.2026, O01).
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and($result['bounces'])->toBe(2)
|
||||
->and($suppressions->isSuppressed('presse@example.net'))->toBeTrue()
|
||||
->and($suppressions->isSuppressed('info@example.org'))->toBeTrue();
|
||||
->and(EmailBounce::query()->where('email', 'presse@example.net')->value('kind'))->toBe(EmailBounceKind::Policy)
|
||||
->and($suppressions->isSuppressed('presse@example.net'))->toBeFalse();
|
||||
|
||||
// Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture.
|
||||
$this->travelTo('2026-09-03 12:00:00');
|
||||
|
||||
foreach ([2, 3] as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('844FE5F41C65@', '844FE5F41C6'.$i.'@', bounceFixture('kas-antispam')));
|
||||
}
|
||||
|
||||
expect($suppressions->isSuppressed('presse@example.net'))->toBeFalse()
|
||||
->and($suppressions->isSuppressed('info@example.org'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('policy rejections and full mailboxes never count hard', function () {
|
||||
|
|
@ -124,7 +151,7 @@ test('policy rejections and full mailboxes never count hard', function () {
|
|||
$quota = str_replace(
|
||||
['Status: 5.7.1', '554 5.7.1 <pr@example.net>: Relay access denied', '<20260915120000.ABC@'],
|
||||
['Status: 5.2.2', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'],
|
||||
bounceFixture('policy-5.7.1'),
|
||||
signedBounceFixture('policy-5.7.1', 'pr@example.net'),
|
||||
);
|
||||
app(BounceMailProcessor::class)->process($quota);
|
||||
|
||||
|
|
@ -153,3 +180,110 @@ test('a policy rejection is logged but never counts toward suppression', functio
|
|||
expect(EmailBounce::query()->where('kind', EmailBounceKind::Policy->value)->count())->toBe(4)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a forged report without our signature never suppresses hard', function () {
|
||||
/** @var TestCase $this */
|
||||
// Jeder kann ein multipart/report ans Return-Path-Postfach schicken.
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy)
|
||||
->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (unsigniert)')
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a report with a wrong or foreign signature is parked for review', function () {
|
||||
/** @var TestCase $this */
|
||||
// Signatur einer echten Mail an den Angreifer selbst, auf die Opferadresse umgeschrieben.
|
||||
$replayed = signedBounceFixture('hard-5.1.1', 'angreifer@example.test');
|
||||
$result = app(BounceMailProcessor::class)->process($replayed);
|
||||
|
||||
expect($result['bounces'])->toBe(1)
|
||||
->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy)
|
||||
->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (Signatur ungültig)')
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
|
||||
|
||||
// Beliebig oft wiederholt: Richtlinie zählt auf keinen Schwellwert.
|
||||
foreach (range(1, 3) as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('<20260915071233.ABC123@', '<2026091507123'.$i.'.ABC123@', $replayed));
|
||||
}
|
||||
|
||||
expect(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('unsigned reports only count when the operator opts in, and then never hard', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('bounces.unsigned_kind', 'soft');
|
||||
|
||||
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
|
||||
expect(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Soft)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('repeated unsigned or wrongly signed soft reports never suppress an address', function (string $variant) {
|
||||
/** @var TestCase $this */
|
||||
// Umsetzungsaudit 02.10.2026, O01: Der weiche Weg (delayed, failed 4.x)
|
||||
// lief an der Signaturprüfung vorbei — drei gefälschte Berichte in
|
||||
// sieben Tagen sperrten jede beliebige Adresse.
|
||||
$raw = match ($variant) {
|
||||
'delayed, unsigned' => bounceFixture('delayed-4.4.1'),
|
||||
'delayed, foreign signature' => signedBounceFixture('delayed-4.4.1', 'angreifer@example.test'),
|
||||
'failed 4.x, unsigned' => str_replace(['Action: delayed', 'action: delayed'], ['Action: failed', 'action: failed'], bounceFixture('delayed-4.4.1')),
|
||||
};
|
||||
|
||||
$this->travelTo('2026-09-17 12:00:00');
|
||||
|
||||
foreach (range(1, 4) as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('<delay-1@', '<delay-'.$i.'0@', $raw));
|
||||
}
|
||||
|
||||
expect(EmailBounce::query()->count())->toBe(4)
|
||||
->and(EmailBounce::query()->where('kind', '!=', EmailBounceKind::Policy->value)->count())->toBe(0)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeFalse();
|
||||
})->with(['delayed, unsigned', 'delayed, foreign signature', 'failed 4.x, unsigned']);
|
||||
|
||||
test('validly signed soft reports still suppress at the threshold', function () {
|
||||
/** @var TestCase $this */
|
||||
// Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture.
|
||||
$this->travelTo('2026-09-17 12:00:00');
|
||||
|
||||
foreach (range(1, 3) as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('<delay-1@', '<delay-'.$i.'0@', signedBounceFixture('delayed-4.4.1', 'voll@example.test')));
|
||||
}
|
||||
|
||||
expect(EmailBounce::query()->where('kind', EmailBounceKind::Soft->value)->count())->toBe(3)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the reference signature is bound to the recipient', function () {
|
||||
$references = app(BounceReference::class);
|
||||
$header = $references->sign('user:5', ['A@Example.test', 'b@example.test']);
|
||||
|
||||
expect($header)->toStartWith('user:5; s=')
|
||||
->and($references->plain($header))->toBe('user:5')
|
||||
->and($references->verify($header, 'a@example.test'))->toBeTrue()
|
||||
->and($references->verify($header, 'b@example.test'))->toBeTrue()
|
||||
->and($references->verify($header, 'c@example.test'))->toBeFalse()
|
||||
->and($references->verify(str_replace('user:5', 'user:6', $header), 'a@example.test'))->toBeFalse()
|
||||
->and($references->verify('user:5', 'a@example.test'))->toBeFalse()
|
||||
->and($references->isSigned('user:5'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('bounce events are purged after the retention period while the suppression stays', function () {
|
||||
/** @var TestCase $this */
|
||||
app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
|
||||
app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
|
||||
EmailBounce::query()->where('email', 'presse@alte-firma.example')->update(['created_at' => now()->subDays(400)]);
|
||||
|
||||
$this->artisan('conversion:purge-events', ['--dry-run' => true])
|
||||
->expectsOutputToContain('[Dry-Run] 1 Rückläufer-Ereignisse')
|
||||
->assertSuccessful();
|
||||
expect(EmailBounce::query()->count())->toBe(2);
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, R-10: Adresse und Diagnosetext nach 395 Tagen weg.
|
||||
$this->artisan('conversion:purge-events')->assertSuccessful();
|
||||
|
||||
expect(EmailBounce::query()->pluck('email')->all())->toBe(['voll@example.test'])
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -287,3 +287,76 @@ test('a soft-deleted source is no canonical target even while the copy still poi
|
|||
->and(app(CanonicalAssigner::class)->canonicalUrlFor($pe, Portal::Presseecho))
|
||||
->toBe('https://presseecho.test/de/press-release/doppel-standort');
|
||||
});
|
||||
|
||||
test('assigning the backlog resets references that no longer form a pair', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('canonical.enabled', true);
|
||||
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
|
||||
|
||||
[$pe, $b24] = duplicatePair('it');
|
||||
[$pe2, $b242] = duplicatePair('it', ['slug' => 'zweites-paar', 'title' => 'Zweites Paar'], ['slug' => 'zweites-paar', 'title' => 'Zweites Paar']);
|
||||
$assigner = app(CanonicalAssigner::class);
|
||||
$assigner->assignAll();
|
||||
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id)
|
||||
->and($pe2->fresh()->canonical_press_release_id)->toBe($b242->id);
|
||||
|
||||
// Ohne Hook umgehängt (Query-Builder): die Kopie gehört jetzt einer anderen Firma.
|
||||
DB::table('press_releases')->where('id', $pe->id)->update(['company_id' => Company::factory()->create()->id]);
|
||||
// Zweites Paar: Text der Erstquelle geändert, ohne Neubewertung.
|
||||
DB::table('press_releases')->where('id', $b242->id)->update(['content_hash' => str_repeat('a', 64)]);
|
||||
|
||||
$dry = $assigner->assignAll(dryRun: true);
|
||||
expect($dry['reset'])->toBe(2)
|
||||
->and($pe->fresh()->canonical_press_release_id)->toBe($b24->id);
|
||||
|
||||
$result = $assigner->assignAll();
|
||||
|
||||
expect($result['reset'])->toBe(2)
|
||||
->and($pe->fresh()->canonical_press_release_id)->toBeNull()
|
||||
->and($pe2->fresh()->canonical_press_release_id)->toBeNull();
|
||||
|
||||
$this->artisan(AssignCanonicalPressReleases::class)->assertSuccessful();
|
||||
});
|
||||
|
||||
test('copies are re-evaluated when their source is archived by an admin (Audit N19-Rest)', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
config()->set('canonical.enabled', true);
|
||||
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
|
||||
|
||||
[$pe, $b24] = duplicatePair('it');
|
||||
$assigner = app(CanonicalAssigner::class);
|
||||
$assigner->assignFor($pe);
|
||||
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id);
|
||||
|
||||
app(PressReleaseService::class)->archive($b24->fresh());
|
||||
|
||||
expect($pe->fresh()->canonical_press_release_id)->toBeNull()
|
||||
->and($assigner->isCanonicalOn($pe->fresh(), Portal::Presseecho))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the mapping by category id survives a slug change and wins over the slug list', function () {
|
||||
config()->set('canonical.enabled', true);
|
||||
|
||||
[$pe, $b24, $category] = duplicatePair('it');
|
||||
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
|
||||
config()->set('canonical.portal_by_category_id', [$category->id => 'presseecho']);
|
||||
|
||||
$assigner = app(CanonicalAssigner::class);
|
||||
expect($assigner->preferredPortal($pe))->toBe(Portal::Presseecho);
|
||||
|
||||
// Slug im Admin umbenannt: die ID-Zuordnung greift weiter.
|
||||
$category->translations()->where('locale', 'de')->update(['slug' => 'informationstechnik']);
|
||||
$fresh = app()->make(CanonicalAssigner::class);
|
||||
expect($fresh->preferredPortal($pe))->toBe(Portal::Presseecho);
|
||||
|
||||
// Unterkategorien folgen der Hauptkategorie.
|
||||
$child = Category::factory()->create(['portal' => Portal::Both, 'parent_id' => $category->id]);
|
||||
expect($fresh->preferredPortalForCategory($child->id))->toBe(Portal::Presseecho)
|
||||
->and($fresh->categoryIdsPreferring(Portal::Presseecho))->toContain($category->id, $child->id);
|
||||
|
||||
// Ohne ID-Eintrag: rückwärtskompatibel über den DE-Slug.
|
||||
config()->set('canonical.portal_by_category_id', []);
|
||||
config()->set('canonical.portal_by_category', ['informationstechnik' => 'businessportal24']);
|
||||
expect(app()->make(CanonicalAssigner::class)->preferredPortal($pe))->toBe(Portal::Businessportal24);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Enums\CompanyAddressClass;
|
||||
use App\Enums\CompanyMergeSubject;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyEmailGroup;
|
||||
use App\Models\CompanyMerge;
|
||||
|
|
@ -12,6 +13,7 @@ use App\Models\User;
|
|||
use App\Services\Company\CompanyMergePlan;
|
||||
use App\Services\Company\CompanyMergeService;
|
||||
use App\Services\Company\CompanyMergeSkip;
|
||||
use App\Services\Company\MergedNewsroomLookup;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -345,3 +347,118 @@ test('the merge list shows survivor, merged companies and the URLs to check', fu
|
|||
->expectsOutputToContain('muster-gmbh')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('merge and unmerge leave updated_at of the moved releases untouched', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
mergeMember('info@muster.de', Portal::Presseecho, 3);
|
||||
$loser = mergeMember('info@muster.de', Portal::Businessportal24, 1);
|
||||
$release = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->sole();
|
||||
$before = $release->updated_at->toDateTimeString();
|
||||
|
||||
$this->travel(3)->days();
|
||||
$service = app(CompanyMergeService::class);
|
||||
$merge = $service->merge(planFor('info@muster.de'), 'b');
|
||||
expect(PressRelease::withoutGlobalScopes()->find($release->id)->updated_at->toDateTimeString())->toBe($before);
|
||||
|
||||
$service->revert($merge);
|
||||
expect(PressRelease::withoutGlobalScopes()->find($release->id))
|
||||
->company_id->toBe($loser->id)
|
||||
->updated_at->toDateTimeString()->toBe($before);
|
||||
});
|
||||
|
||||
test('unmerge keeps a field the customer changed after the merge', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'boilerplate' => null, 'phone' => null]);
|
||||
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'boilerplate' => 'Über Muster', 'phone' => '030 2']);
|
||||
|
||||
$service = app(CompanyMergeService::class);
|
||||
$merge = $service->merge(planFor('info@muster.de'), 'b');
|
||||
|
||||
expect($merge->moves()->where('field', 'boilerplate')->value('merged_value'))->toBe('Über Muster');
|
||||
|
||||
// Der Kunde beansprucht den Newsroom und schreibt den Text neu.
|
||||
$survivor->refresh()->update(['boilerplate' => 'Unser eigener Text']);
|
||||
|
||||
$service->revert($merge);
|
||||
$survivor->refresh();
|
||||
|
||||
expect($survivor->boilerplate)->toBe('Unser eigener Text')
|
||||
->and($survivor->website)->toBeNull()
|
||||
->and($survivor->phone)->toBeNull();
|
||||
});
|
||||
|
||||
test('unmerge of an older merge without stored fill value compares against the merged company', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'boilerplate' => null]);
|
||||
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'boilerplate' => 'Über Muster']);
|
||||
|
||||
$service = app(CompanyMergeService::class);
|
||||
$merge = $service->merge(planFor('info@muster.de'), 'b');
|
||||
// Stand vor der Migration: kein Füllwert in der Move-Zeile.
|
||||
$merge->moves()->update(['merged_value' => null]);
|
||||
|
||||
$survivor->refresh()->update(['boilerplate' => 'Unser eigener Text']);
|
||||
$service->revert($merge);
|
||||
$survivor->refresh();
|
||||
|
||||
expect($survivor->boilerplate)->toBe('Unser eigener Text')
|
||||
->and($survivor->website)->toBeNull();
|
||||
});
|
||||
|
||||
test('merge and unmerge re-evaluate the canonical assignment of the moved releases', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('canonical.enabled', true);
|
||||
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
|
||||
|
||||
$category = Category::factory()->create(['portal' => Portal::Both]);
|
||||
$category->translations()->create(['locale' => 'de', 'name' => 'IT', 'slug' => 'it']);
|
||||
|
||||
mergeGroup('info@muster.de');
|
||||
$survivor = mergeMember('info@muster.de', Portal::Businessportal24, 2);
|
||||
$loser = mergeMember('info@muster.de', Portal::Presseecho);
|
||||
|
||||
$source = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)
|
||||
->create(['company_id' => $survivor->id, 'title' => 'Neuer Standort', 'text' => '<p>Gleicher Text</p>']);
|
||||
$copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)
|
||||
->create(['company_id' => $loser->id, 'title' => 'Neuer Standort', 'text' => '<p>Gleicher Text</p>']);
|
||||
|
||||
// Verschiedene Firmen: kein Paar.
|
||||
expect($copy->fresh()->canonical_press_release_id)->toBeNull();
|
||||
|
||||
$service = app(CompanyMergeService::class);
|
||||
$merge = $service->merge(planFor('info@muster.de'), 'b');
|
||||
|
||||
expect($copy->fresh()->canonical_press_release_id)->toBe($source->id)
|
||||
->and($source->fresh()->canonical_press_release_id)->toBeNull();
|
||||
|
||||
$service->revert($merge);
|
||||
|
||||
// Wieder verschiedene Firmen: der Verweis auf die fremde Meldung fällt.
|
||||
expect($copy->fresh()->canonical_press_release_id)->toBeNull()
|
||||
->and($source->fresh()->canonical_press_release_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('an old newsroom slug follows a chain of merges to the current survivor', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$middle = mergeMember('info@muster.de', Portal::Presseecho, 2, ['slug' => 'muster-mitte']);
|
||||
mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster-alt']);
|
||||
|
||||
$service = app(CompanyMergeService::class);
|
||||
$service->merge(planFor('info@muster.de'), 'b1');
|
||||
|
||||
// Später: Die Mitte geht ihrerseits in einer weiteren Firma auf.
|
||||
$final = mergeMember('info@muster.de', Portal::Presseecho, 5, ['slug' => 'muster-neu']);
|
||||
PressRelease::withoutGlobalScopes()->where('company_id', $final->id)->update(['published_at' => now()->subDay()]);
|
||||
$service->merge(planFor('info@muster.de'), 'b2');
|
||||
|
||||
expect(Company::withTrashed()->find($middle->id)->trashed())->toBeTrue()
|
||||
->and(app(MergedNewsroomLookup::class)->survivorFor(['presseecho', 'both'], 'muster-alt')?->id)->toBe($final->id);
|
||||
|
||||
$this->get('https://presseecho.test/de/newsroom/muster-alt')
|
||||
->assertRedirect('https://presseecho.test/de/newsroom/muster-neu')
|
||||
->assertStatus(301);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -267,3 +267,33 @@ test('the ladder is complete and ordered', function () {
|
|||
expect($steps)->toBe([1, 2, 3, 4, 5])
|
||||
->and(ConversionEventType::NewsroomEdited->diagnosis())->toBe('Kein Anreiz, das Profil zu pflegen');
|
||||
});
|
||||
|
||||
test('mail link clicks are throttled per address and release and ignore link scanners', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->create();
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
|
||||
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
|
||||
$url = 'https://businessportal24.test/de/press-release/'.$release->slug.'?src='.ConversionSource::MailPublish->value.'&pr='.$release->id;
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, R-08: Link-Scanner der Mailserver holen
|
||||
// Links vorab — per HEAD oder mit eigener Kennung.
|
||||
$this->call('HEAD', $url)->assertOk();
|
||||
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Barracuda Sentinel)'])->get($url)->assertOk();
|
||||
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) BingPreview/1.0b'])->get($url)->assertOk();
|
||||
expect(ConversionEvent::query()->count())->toBe(0);
|
||||
|
||||
// Derselbe Klick mehrfach (Doppelklick, erneutes Öffnen): einmal gezählt.
|
||||
$browser = ['User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15'];
|
||||
$this->withHeaders($browser)->get($url)->assertOk();
|
||||
$this->withHeaders($browser)->get($url)->assertOk();
|
||||
$this->withHeaders($browser)->get($url)->assertOk();
|
||||
|
||||
expect(ConversionEvent::query()->where('type', ConversionEventType::MailLinkClicked->value)->count())->toBe(1);
|
||||
|
||||
// Eine andere Meldung aus derselben Mail zählt eigenständig.
|
||||
$other = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
|
||||
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
|
||||
$this->withHeaders($browser)->get('https://businessportal24.test/de/press-release/'.$other->slug.'?src='.ConversionSource::MailPublish->value.'&pr='.$other->id)->assertOk();
|
||||
|
||||
expect(ConversionEvent::query()->count())->toBe(2);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use App\Models\EmailSuppression;
|
|||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Mail\BounceReference;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\Mail\MailCircuitBreaker;
|
||||
use App\Services\Mail\SmtpFailureClassifier;
|
||||
|
|
@ -130,7 +131,22 @@ test('the gate keeps the other recipients and sets the return path', function ()
|
|||
expect($sent)->toHaveCount(1)
|
||||
->and(array_map(fn ($a) => $a->getAddress(), $email->getTo()))->toBe(['lebt@example.test'])
|
||||
->and($email->getReturnPath()?->getAddress())->toBe('bounce@pressekonto.test')
|
||||
->and($email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString())->toBe('user:'.$user->id);
|
||||
->and($email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString())->toStartWith('user:'.$user->id.'; s=');
|
||||
|
||||
// Signiert je verbliebenem Empfänger (Umsetzungsaudit 29.09.2026, R-04).
|
||||
$reference = $email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString();
|
||||
expect(app(BounceReference::class)->verify($reference, 'lebt@example.test'))->toBeTrue()
|
||||
->and(app(BounceReference::class)->verify($reference, 'tot@example.test'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the gate signs a reference on mails without their own', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::raw('Hallo', fn ($message) => $message->to('kunde@example.test')->subject('Test'));
|
||||
|
||||
$reference = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->getHeaders()->get('X-Pk-Ref')?->getBodyAsString();
|
||||
|
||||
expect($reference)->toStartWith('mail; s=')
|
||||
->and(app(BounceReference::class)->verify($reference, 'kunde@example.test'))->toBeTrue();
|
||||
});
|
||||
|
||||
// ------------------------------------------------- Pfad 2: SMTP-Ablehnung
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Console\Commands\CheckIndexStatus;
|
||||
use App\Enums\IndexState;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseIndexCheck;
|
||||
|
|
@ -230,3 +231,21 @@ test('the distribution counts states over the same population as the total, one
|
|||
->and($bucket['unchecked'])->toBe(0)
|
||||
->and($states + $bucket['unchecked'])->toBe($bucket['total']);
|
||||
});
|
||||
|
||||
test('a release on both portals counts as copy on the portal that does not lead, like the sitemap', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('canonical.enabled', true);
|
||||
config()->set('canonical.portal_by_category', ['handel' => 'businessportal24']);
|
||||
|
||||
$category = Category::factory()->create(['portal' => Portal::Both]);
|
||||
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
|
||||
publishedOn(Portal::Both, 5, ['category_id' => $category->id]);
|
||||
|
||||
$distribution = app(IndexStatusReport::class)->distribution();
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, N18-Rest: die Sitemap von presseecho führt sie nicht.
|
||||
expect($distribution['businessportal24']['direct']['source']['total'])->toBe(1)
|
||||
->and($distribution['businessportal24']['direct']['copy']['total'])->toBe(0)
|
||||
->and($distribution['presseecho']['direct']['source']['total'])->toBe(0)
|
||||
->and($distribution['presseecho']['direct']['copy']['total'])->toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\PressRelease;
|
|||
use App\Models\PressReleaseCrawlerHit;
|
||||
use App\Models\PressReleaseEvent;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
|
|
@ -125,3 +126,25 @@ test('the purge reads the watermark from the database, not from a stale cache (A
|
|||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -234,3 +234,26 @@ test('the release summary reports the sitemap role: the source is listed, a copy
|
|||
config()->set('canonical.enabled', false);
|
||||
expect($stats->releaseSummary($copy)['sitemap_role'])->toBe('source');
|
||||
});
|
||||
|
||||
test('the listing date is not claimed when the release has a counterpart on the other portal', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $company] = claimant();
|
||||
config()->set('canonical.enabled', true);
|
||||
|
||||
// Fassung auf dem anderen Portal, inzwischen depubliziert: Die Kopie ist
|
||||
// erst seitdem Erstquelle — wann genau, ist nicht gespeichert (N17-Rest).
|
||||
$former = PressRelease::factory()->forPortal(Portal::Presseecho)->create(['company_id' => $company->id, 'title' => 'Gleich', 'text' => 'Gleicher Text', 'status' => 'archived', 'published_at' => now()->subDays(20)]);
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $customer->id, 'title' => 'Gleich', 'text' => 'Gleicher Text', 'published_at' => now()->subDays(10)]);
|
||||
expect($former->content_hash)->toBe($release->content_hash);
|
||||
|
||||
$summary = app(VisibilityStats::class)->releaseSummary($release);
|
||||
|
||||
expect($summary['sitemap_role'])->toBe('source')
|
||||
->and($summary['in_sitemap_since'])->toBeNull()
|
||||
->and($summary['sitemap_portals'])->toBe([Portal::Businessportal24->label()]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
|
||||
->assertSeeText('In der Sitemap gelistet (Businessportal24)')
|
||||
->assertDontSeeText('Gelistet seit')
|
||||
->assertDontSeeText('Derzeit nicht gelistet');
|
||||
});
|
||||
|
|
|
|||
112
tests/Feature/Web/ContentChangedAtTest.php
Normal file
112
tests/Feature/Web/ContentChangedAtTest.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?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();
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ use App\Models\Category;
|
|||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseCrawlerHit;
|
||||
use App\Models\PressReleaseEvent;
|
||||
use App\Services\PressRelease\CrawlerHitReport;
|
||||
use App\Support\CrawlerIdentifier;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -124,3 +125,22 @@ test('the crawler report aggregates fetches by service, by day and by release',
|
|||
->and($top[0]['fetches'])->toBe(4)
|
||||
->and($top[0]['services'])->toBe(2);
|
||||
});
|
||||
|
||||
test('a fetch by a known crawler is not counted as a view', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = makeCrawlerLogRelease();
|
||||
|
||||
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'])
|
||||
->get('https://presseecho.test/de/press-release/crawler-protokoll-pm')
|
||||
->assertSuccessful();
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, R-07: Crawler-Abruf ja, Aufruf nein.
|
||||
expect(PressReleaseCrawlerHit::query()->count())->toBe(1)
|
||||
->and(PressReleaseEvent::query()->where('press_release_id', $release->id)->count())->toBe(0);
|
||||
|
||||
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36'])
|
||||
->get('https://presseecho.test/de/press-release/crawler-protokoll-pm')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(PressReleaseEvent::query()->where('press_release_id', $release->id)->count())->toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use App\Models\User;
|
|||
use App\Services\PressRelease\FeedFetchReport;
|
||||
use App\Services\PressRelease\VisibilityStats;
|
||||
use App\Support\FeedReaderIdentifier;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
|
|
@ -162,3 +163,42 @@ test('the reader summary keeps feeds apart by portal and language', function ()
|
|||
->and($feedly['feeds'])->toBe(3)
|
||||
->and($feedly['subscribers'])->toBe(35);
|
||||
});
|
||||
|
||||
test('the report groups and limits in sql: one query for top feeds and for the per-release counts', function () {
|
||||
/** @var TestCase $this */
|
||||
FeedFetch::factory()->category('handel')->reader('feedly', 12)->count(3)->create();
|
||||
FeedFetch::factory()->category('handel')->reader(null)->create();
|
||||
FeedFetch::factory()->newsroom('nordwind')->reader('feedly', 2)->create();
|
||||
FeedFetch::factory()->reader('inoreader', 5)->count(2)->create();
|
||||
FeedFetch::factory()->reader('inoreader', 5)->create(['language' => 'en']);
|
||||
FeedFetch::factory()->category('handel')->create(['created_at' => now()->subDays(40)]);
|
||||
|
||||
$report = app(FeedFetchReport::class);
|
||||
|
||||
DB::enableQueryLog();
|
||||
$top = $report->topFeeds(30, limit: 2);
|
||||
$feedQueries = collect(DB::getQueryLog())->pluck('query')->filter(fn (string $sql): bool => str_contains($sql, 'feed_fetches'));
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, OPT-1.
|
||||
expect($feedQueries)->toHaveCount(1)
|
||||
->and(strtolower((string) $feedQueries->first()))->toContain('limit')
|
||||
->and(collect($top)->pluck('fetches')->all())->toBe([4, 2])
|
||||
->and($top[0]['readers'])->toBe(2)
|
||||
->and($top[0]['subscribers'])->toBe(12);
|
||||
|
||||
DB::flushQueryLog();
|
||||
$counts = $report->fetchesFor([
|
||||
['feed' => FeedFetch::FEED_CATEGORY, 'feed_key' => 'handel', 'portal' => 'presseecho', 'language' => 'de'],
|
||||
['feed' => FeedFetch::FEED_NEWSROOM, 'feed_key' => 'nordwind', 'portal' => 'presseecho', 'language' => 'de'],
|
||||
['feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => 'presseecho', 'language' => 'de'],
|
||||
['feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => 'businessportal24', 'language' => 'de'],
|
||||
]);
|
||||
|
||||
expect(DB::getQueryLog())->toHaveCount(1)
|
||||
->and($counts)->toBe([
|
||||
'category:handel:presseecho:de' => 4,
|
||||
'newsroom:nordwind:presseecho:de' => 1,
|
||||
'portal::presseecho:de' => 2,
|
||||
'portal::businessportal24:de' => 0,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -225,3 +225,15 @@ test('the feed is cached with the page data and cleared with the public pages',
|
|||
|
||||
expect(Cache::has(WebPageCache::key('feed', ['presseecho', 'de', 'category', 'handel'])))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the feed teaser decodes stored entities before escaping them once', function () {
|
||||
/** @var TestCase $this */
|
||||
$category = feedCategory();
|
||||
feedRelease($category, 'entities', ['text' => '<p>Müller & Söhne "neu" – jetzt</p>']);
|
||||
|
||||
$xml = (string) $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful()->getContent();
|
||||
|
||||
expect($xml)->toContain('<description>Müller & Söhne "neu" – jetzt</description>')
|
||||
->not->toContain('&amp;')
|
||||
->not->toContain('&quot;');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
|
|
@ -43,3 +45,14 @@ test('the hub serves llms.txt that points to both portals', function () {
|
|||
|
||||
$this->assertStringNotContainsString('press-release/{slug}', $text);
|
||||
});
|
||||
|
||||
test('llms.txt is part of the public page caches that a content change clears', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://presseecho.test/llms.txt')->assertSuccessful();
|
||||
expect(Cache::has('web.llms.presseecho.test'))->toBeTrue();
|
||||
|
||||
WebCaches::forgetPublicPages();
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, R-11: sonst bis zu 1 h alte Kategorienliste.
|
||||
expect(Cache::has('web.llms.presseecho.test'))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -230,3 +230,25 @@ test('a claim from the publish mail lands on that release with its views, a fore
|
|||
$this->post(claimUrl(), ['pr' => $foreign->id])->assertSessionHas('newsroom-claim-status');
|
||||
expect(MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail()->payload)->toBe(['company_id' => $company->id]);
|
||||
});
|
||||
|
||||
test('attempts without a sent link do not use up the daily links of the newsroom', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = claimCompany(['email' => null]);
|
||||
RateLimiter::clear('newsroom-claim|company|'.$company->id);
|
||||
config()->set('newsroom.claim.links_per_day', 2);
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, R-09: Ohne Versand zählt der Firmenzähler
|
||||
// nicht — Dritte können den Inhaber so nicht aussperren.
|
||||
foreach (range(1, 3) as $i) {
|
||||
$this->post(claimUrl())->assertSessionHas('newsroom-claim-none')->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
$company->update(['email' => 'info@muster.de']);
|
||||
|
||||
$this->post(claimUrl())->assertSessionHas('newsroom-claim-status')->assertSessionHasNoErrors();
|
||||
Mail::assertQueuedCount(1);
|
||||
});
|
||||
|
||||
test('the default daily link budget per newsroom is moderate', function () {
|
||||
expect((int) config('newsroom.claim.links_per_day'))->toBe(5);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue