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); });