presseportale/tests/Feature/IndexStatusCheckTest.php
Kevin Adametz df557e419b search-console: vorübergehende Google-Fehler einmal wiederholt und nur als Warnung protokolliert, Portal-Abbruch nach fünf Fehlern in Folge, Zeitbudget 40 Minuten (GlitchTip server-pressekonto 11–14)
Am 06.10.2026 hing die URL Inspection API zeitweise: 28 Zeitüberschreitungen
und 4 × 500, jede einzeln als Fehler gemeldet, der Lauf zog sich über 1¾
Stunden. Neu: SearchConsoleUnavailable für Timeout/5xx/429 (Warnung, Meldung
bleibt fällig), echte Fehler (401/403/400) weiter über report(); FAILURE —
und damit ein Betriebs-Alert — nur bei Abbruch oder ≥ 50 % Fehlern.

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

371 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\Exceptions;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
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);
});
/**
* Robustheit des Laufs (06.10.2026: 28 Zeitüberschreitungen und 4 × 500 von
* Google, jede einzeln als Fehler gemeldet, Lauf über 1¾ Stunden).
*/
function inspectionOk(): array
{
return ['inspectionResult' => ['indexStatusResult' => [
'verdict' => 'PASS',
'coverageState' => 'Submitted and indexed',
'indexingState' => 'INDEXING_ALLOWED',
]]];
}
function robustIndexRun(): void
{
fakeServiceAccount();
config()->set('search_console.properties.presseecho', 'sc-domain:presseecho.test');
config()->set('search_console.properties.businessportal24', null);
config()->set('search_console.retry_sleep_ms', 0);
}
test('a google timeout is retried once and then counts as a normal check', function () {
/** @var TestCase $this */
robustIndexRun();
publishedOn(Portal::Presseecho, 2);
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123']),
SearchConsoleClient::INSPECT_URL => Http::sequence()
->pushFailedConnection('cURL error 28: Operation timed out after 20002 milliseconds')
->push(inspectionOk()),
]);
$this->artisan(CheckIndexStatus::class, ['--budget' => 5])
->expectsOutputToContain('1 Prüfungen gespeichert, 0 fehlgeschlagen.')
->assertSuccessful();
Http::assertSentCount(3);
expect(PressReleaseIndexCheck::query()->count())->toBe(1);
});
test('temporary google failures are logged as warnings, not reported, and stay due', function () {
/** @var TestCase $this */
robustIndexRun();
Exceptions::fake();
Log::spy();
publishedOn(Portal::Presseecho, 2);
publishedOn(Portal::Presseecho, 3);
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123']),
SearchConsoleClient::INSPECT_URL => Http::response(['error' => ['message' => 'Internal error encountered.']], 500),
]);
$this->artisan(CheckIndexStatus::class, ['--budget' => 5])
->expectsOutputToContain('0 Prüfungen gespeichert, 2 fehlgeschlagen.')
->assertSuccessful();
Exceptions::assertNothingReported();
Log::shouldHaveReceived('warning')->with('Search Console vorübergehend nicht verfügbar.', Mockery::any())->twice();
expect(PressReleaseIndexCheck::query()->count())->toBe(0)
->and(app(IndexStatusChecker::class)->candidates(Portal::Presseecho, 5))->toHaveCount(2);
});
test('a portal is aborted after five failures in a row and the run fails once', function () {
/** @var TestCase $this */
robustIndexRun();
Exceptions::fake();
foreach (range(1, 8) as $day) {
publishedOn(Portal::Presseecho, $day);
}
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123']),
SearchConsoleClient::INSPECT_URL => Http::failedConnection(),
]);
$this->artisan(CheckIndexStatus::class, ['--budget' => 10])
->expectsOutputToContain('Abbruch nach 5 Fehlern in Folge')
->assertFailed();
// Fünf Meldungen mit je einer Wiederholung plus Token — nicht alle acht.
Http::assertSentCount(1 + 5 * 2);
Exceptions::assertNothingReported();
});
test('a configuration error is reported and not retried', function () {
/** @var TestCase $this */
robustIndexRun();
Exceptions::fake();
publishedOn(Portal::Presseecho, 2);
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123']),
SearchConsoleClient::INSPECT_URL => Http::response(['error' => ['message' => 'User does not have sufficient permission for site.']], 403),
]);
$this->artisan(CheckIndexStatus::class, ['--budget' => 5])->assertSuccessful();
Http::assertSentCount(2);
Exceptions::assertReported(fn (RuntimeException $e) => str_contains($e->getMessage(), 'antwortet mit 403'));
});
test('the run stops at its time budget and leaves the rest for tomorrow', function () {
/** @var TestCase $this */
robustIndexRun();
config()->set('search_console.max_runtime_minutes', 0);
publishedOn(Portal::Presseecho, 2);
Http::fake();
$this->artisan(CheckIndexStatus::class, ['--budget' => 5])
->expectsOutputToContain('Zeitbudget erreicht')
->assertSuccessful();
Http::assertNothingSent();
});
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);
});