- 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>
251 lines
13 KiB
PHP
251 lines
13 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Models\PressReleaseStatusLog;
|
|
use App\Models\User;
|
|
use App\Services\SearchConsole\IndexStatusChecker;
|
|
use App\Services\SearchConsole\IndexStatusReport;
|
|
use App\Services\SearchConsole\SearchConsoleClient;
|
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Indexierungsstatus aus der Search Console (Roadmap 1c.4): Befunde
|
|
* unterscheiden „noch nicht gecrawlt" von „gecrawlt, nicht indexiert",
|
|
* jede Antwort ist eine Momentaufnahme, die Auswahl folgt dem Alter, und
|
|
* der Kunde sieht nichts, solange E8 die Anzeige zu hält.
|
|
*/
|
|
function fakeServiceAccount(): string
|
|
{
|
|
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
|
openssl_pkey_export($key, $pem);
|
|
|
|
$file = tempnam(sys_get_temp_dir(), 'gsc');
|
|
file_put_contents($file, json_encode([
|
|
'client_email' => 'indexer@example.iam.gserviceaccount.com',
|
|
'private_key' => $pem,
|
|
'token_uri' => 'https://oauth2.googleapis.com/token',
|
|
]));
|
|
|
|
config()->set('search_console.enabled', true);
|
|
config()->set('search_console.credentials', $file);
|
|
|
|
return $file;
|
|
}
|
|
|
|
function publishedOn(Portal $portal, int $daysAgo, array $attributes = []): PressRelease
|
|
{
|
|
$company = Company::factory()->create(['portal' => $portal]);
|
|
|
|
return PressRelease::factory()
|
|
->published()
|
|
->forPortal($portal)
|
|
->for($company)
|
|
->create(array_merge(['published_at' => now()->subDays($daysAgo)], $attributes));
|
|
}
|
|
|
|
test('the index state distinguishes not crawled from crawled but not indexed', function (array $indexStatus, IndexState $expected) {
|
|
expect(IndexState::fromIndexStatus($indexStatus))->toBe($expected);
|
|
})->with([
|
|
'indexed' => [['verdict' => 'PASS', 'coverageState' => 'Submitted and indexed', 'lastCrawlTime' => '2026-09-10T08:00:00Z'], IndexState::Indexed],
|
|
'crawled, not indexed' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Crawled - currently not indexed', 'lastCrawlTime' => '2026-09-10T08:00:00Z', 'pageFetchState' => 'SUCCESSFUL'], IndexState::CrawledNotIndexed],
|
|
'discovered, not crawled' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Discovered - currently not indexed'], IndexState::NotCrawled],
|
|
'unknown to google' => [['verdict' => 'NEUTRAL', 'coverageState' => 'URL is unknown to Google'], IndexState::NotCrawled],
|
|
'duplicate canonical' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Duplicate, Google chose different canonical than user', 'lastCrawlTime' => '2026-09-10T08:00:00Z'], IndexState::Excluded],
|
|
'blocked by robots' => [['verdict' => 'FAIL', 'coverageState' => 'Blocked by robots.txt', 'robotsTxtState' => 'DISALLOWED'], IndexState::Excluded],
|
|
'empty' => [[], IndexState::Unknown],
|
|
]);
|
|
|
|
test('candidates are picked by age band, never-checked first, within the budget', function () {
|
|
/** @var TestCase $this */
|
|
config()->set('search_console.recheck_days', [14 => 3, 90 => 14, PHP_INT_MAX => 180]);
|
|
|
|
$fresh = publishedOn(Portal::Presseecho, 2);
|
|
$freshChecked = publishedOn(Portal::Presseecho, 5);
|
|
PressReleaseIndexCheck::factory()->create(['press_release_id' => $freshChecked->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]);
|
|
$freshDue = publishedOn(Portal::Presseecho, 6);
|
|
PressReleaseIndexCheck::factory()->create(['press_release_id' => $freshDue->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDays(4)]);
|
|
$middle = publishedOn(Portal::Presseecho, 40);
|
|
$old = publishedOn(Portal::Presseecho, 400);
|
|
$otherPortal = publishedOn(Portal::Businessportal24, 1);
|
|
$both = publishedOn(Portal::Both, 3);
|
|
|
|
$ids = app(IndexStatusChecker::class)->candidates(Portal::Presseecho, 10)->pluck('id')->all();
|
|
|
|
expect($ids)->toContain($fresh->id, $freshDue->id, $middle->id, $old->id, $both->id)
|
|
->and($ids)->not->toContain($freshChecked->id, $otherPortal->id)
|
|
// Jüngste zuerst, innerhalb der Stufe nie geprüfte vor geprüften.
|
|
->and(array_slice($ids, 0, 3))->toBe([$fresh->id, $both->id, $freshDue->id]);
|
|
|
|
expect(app(IndexStatusChecker::class)->candidates(Portal::Presseecho, 2)->pluck('id')->all())->toBe([$fresh->id, $both->id]);
|
|
});
|
|
|
|
test('the command inspects due releases via the API and stores each answer as a snapshot', function () {
|
|
/** @var TestCase $this */
|
|
$file = fakeServiceAccount();
|
|
config()->set('search_console.properties.presseecho', 'sc-domain:presseecho.test');
|
|
config()->set('search_console.properties.businessportal24', null);
|
|
|
|
$release = publishedOn(Portal::Presseecho, 2, ['slug' => 'index-pruefung-pm']);
|
|
|
|
Http::fake([
|
|
'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123', 'expires_in' => 3600]),
|
|
SearchConsoleClient::INSPECT_URL => Http::response(['inspectionResult' => [
|
|
'inspectionResultLink' => 'https://search.google.com/search-console/inspect?resource_id=sc-domain:presseecho.test',
|
|
'indexStatusResult' => [
|
|
'verdict' => 'NEUTRAL',
|
|
'coverageState' => 'Crawled - currently not indexed',
|
|
'robotsTxtState' => 'ALLOWED',
|
|
'indexingState' => 'INDEXING_ALLOWED',
|
|
'lastCrawlTime' => '2026-09-15T06:30:00Z',
|
|
'pageFetchState' => 'SUCCESSFUL',
|
|
'googleCanonical' => 'https://presseecho.test/de/press-release/index-pruefung-pm',
|
|
],
|
|
]]),
|
|
]);
|
|
|
|
$this->artisan(CheckIndexStatus::class, ['--budget' => 5])
|
|
->expectsOutputToContain('1 Prüfungen gespeichert, 0 fehlgeschlagen.')
|
|
->assertSuccessful();
|
|
|
|
Http::assertSent(fn ($request) => $request->url() === SearchConsoleClient::INSPECT_URL
|
|
&& $request['inspectionUrl'] === 'https://presseecho.test/de/press-release/index-pruefung-pm'
|
|
&& $request['siteUrl'] === 'sc-domain:presseecho.test'
|
|
&& $request->hasHeader('Authorization', 'Bearer token-123'));
|
|
|
|
$check = PressReleaseIndexCheck::query()->sole();
|
|
|
|
expect($check->press_release_id)->toBe($release->id)
|
|
->and($check->portal)->toBe(Portal::Presseecho)
|
|
->and($check->state)->toBe(IndexState::CrawledNotIndexed)
|
|
->and($check->coverage_state)->toBe('Crawled - currently not indexed')
|
|
->and($check->last_crawl_time?->toDateString())->toBe('2026-09-15')
|
|
->and($check->raw['verdict'])->toBe('NEUTRAL');
|
|
|
|
// Zweiter Lauf: die Meldung ist gerade geprüft, also nicht fällig — keine weitere Abfrage.
|
|
$this->artisan(CheckIndexStatus::class, ['--budget' => 5])->assertSuccessful();
|
|
expect(PressReleaseIndexCheck::query()->count())->toBe(1);
|
|
|
|
@unlink($file);
|
|
});
|
|
|
|
test('the command does nothing without configuration', function () {
|
|
/** @var TestCase $this */
|
|
config()->set('search_console.enabled', false);
|
|
publishedOn(Portal::Presseecho, 2);
|
|
|
|
Http::fake();
|
|
|
|
$this->artisan(CheckIndexStatus::class)
|
|
->expectsOutputToContain('nicht konfiguriert')
|
|
->assertSuccessful();
|
|
|
|
Http::assertNothingSent();
|
|
expect(PressReleaseIndexCheck::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('the admin report splits the distribution by origin and lists the latest checks', function () {
|
|
/** @var TestCase $this */
|
|
test()->seed(RolesAndPermissionsSeeder::class);
|
|
|
|
$direct = publishedOn(Portal::Presseecho, 10, ['title' => 'Direkt eingereichte Meldung']);
|
|
$api = publishedOn(Portal::Presseecho, 12, ['title' => 'Syndizierte Meldung']);
|
|
PressReleaseStatusLog::query()->create(['press_release_id' => $api->id, 'from_status' => 'draft', 'to_status' => 'review', 'source' => 'api']);
|
|
$unchecked = publishedOn(Portal::Presseecho, 20);
|
|
|
|
// Ältere Momentaufnahme zählt nicht — nur die jüngste je Meldung.
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::NotCrawled)->create(['press_release_id' => $direct->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDays(5)]);
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $direct->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay(), 'url' => 'https://presseecho.test/de/press-release/'.$direct->slug, 'google_canonical' => 'https://presseecho.test/de/press-release/'.$direct->slug]);
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::CrawledNotIndexed)->create(['press_release_id' => $api->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]);
|
|
|
|
// Kopie mit Verweis (1c.8): Google folgt dem Canonical → „ausgeschlossen" ist hier erwartet.
|
|
$copy = publishedOn(Portal::Presseecho, 11, ['title' => 'Kopie der Meldung', 'canonical_press_release_id' => $direct->id]);
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::Excluded)->create(['press_release_id' => $copy->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay(), 'google_canonical' => 'https://businessportal24.test/de/press-release/'.$direct->slug]);
|
|
|
|
$distribution = app(IndexStatusReport::class)->distribution();
|
|
|
|
expect($distribution['presseecho']['direct']['source']['total'])->toBe(2)
|
|
->and($distribution['presseecho']['direct']['source']['indexed'])->toBe(1)
|
|
->and($distribution['presseecho']['direct']['source']['not_crawled'])->toBe(0)
|
|
->and($distribution['presseecho']['direct']['source']['unchecked'])->toBe(1)
|
|
->and($distribution['presseecho']['direct']['copy']['total'])->toBe(1)
|
|
->and($distribution['presseecho']['direct']['copy']['excluded'])->toBe(1)
|
|
->and($distribution['presseecho']['api']['source']['total'])->toBe(1)
|
|
->and($distribution['presseecho']['api']['source']['crawled_not_indexed'])->toBe(1)
|
|
->and($distribution['businessportal24']['direct']['source']['total'])->toBe(0);
|
|
|
|
// Quellenbefund nur über Erstquellen: die Kopie zählt nicht mit.
|
|
$sources = app(IndexStatusReport::class)->sourceSummary(app(IndexStatusChecker::class));
|
|
|
|
expect($sources['presseecho']['total'])->toBe(2)
|
|
->and($sources['presseecho']['self'])->toBe(1)
|
|
->and($sources['presseecho']['unknown'])->toBe(1)
|
|
->and($sources['presseecho']['own_other'])->toBe(0);
|
|
|
|
$admin = User::factory()->create(['is_active' => true]);
|
|
$admin->assignRole('admin');
|
|
|
|
$this->actingAs($admin)
|
|
->get(route('admin.reports.index-status'))
|
|
->assertSuccessful()
|
|
->assertSeeText('Indexierung')
|
|
->assertSeeText('Direkt eingereicht')
|
|
->assertSeeText('Syndikation (API)')
|
|
->assertSeeText('Direkt eingereichte Meldung')
|
|
->assertSeeText('Syndizierte Meldung')
|
|
->assertSeeText('gecrawlt, nicht indexiert')
|
|
->assertSeeText('Kopie mit Verweis')
|
|
->assertSeeText('Quellenbefund')
|
|
->assertSeeText('Abrufe durch Such- und KI-Dienste')
|
|
->assertSeeText('Abrufe der RSS-Feeds')
|
|
->assertSeeText('Kundenansicht');
|
|
});
|
|
|
|
test('the distribution counts states over the same population as the total, one check per release', function () {
|
|
/** @var TestCase $this */
|
|
$checked = publishedOn(Portal::Presseecho, 10);
|
|
$depublished = publishedOn(Portal::Presseecho, 12);
|
|
$at = now()->subDay()->startOfSecond();
|
|
|
|
// Zwei Prüfungen in derselben Sekunde: nur die jüngere (höhere ID) zählt.
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $checked->id, 'portal' => Portal::Presseecho, 'checked_at' => $at]);
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::NotCrawled)->create(['press_release_id' => $checked->id, 'portal' => Portal::Presseecho, 'checked_at' => $at]);
|
|
|
|
// Geprüft, danach depubliziert: weder im Nenner noch als „indexed".
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $depublished->id, 'portal' => Portal::Presseecho, 'checked_at' => $at]);
|
|
$depublished->update(['status' => 'archived']);
|
|
|
|
$bucket = app(IndexStatusReport::class)->distribution()['presseecho']['direct']['source'];
|
|
$states = collect(IndexState::cases())->sum(fn (IndexState $state): int => $bucket[$state->value]);
|
|
|
|
expect($bucket['total'])->toBe(1)
|
|
->and($bucket['indexed'])->toBe(0)
|
|
->and($bucket['not_crawled'])->toBe(1)
|
|
->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);
|
|
});
|