create([ 'title' => 'Solarpark eröffnet', 'text' => 'Ein ausführlicher Text über den neuen Solarpark.', 'keywords' => 'solar, energie', ]); $index = PressReleaseSearchIndex::query()->find($release->id); expect($index)->not->toBeNull() ->and($index->title)->toBe('Solarpark eröffnet') ->and($index->text)->toBe('Ein ausführlicher Text über den neuen Solarpark.') ->and($index->keywords)->toBe('solar, energie') ->and($index->indexed_at)->not->toBeNull(); }); test('editing a searchable field updates the index', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(['title' => 'Alter Titel']); $release->update(['title' => 'Neuer Titel', 'text' => 'Neuer Inhalt.']); $index = PressReleaseSearchIndex::query()->find($release->id); expect($index->title)->toBe('Neuer Titel') ->and($index->text)->toBe('Neuer Inhalt.'); }); test('changing an unrelated field does not rewrite the index', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $indexedAt = PressReleaseSearchIndex::query()->find($release->id)->indexed_at; $this->travel(2)->minutes(); $release->update(['hits' => 999]); // Der Textkörper macht den Großteil der Datenmenge aus — ihn bei jedem // Statuswechsel neu zu schreiben wäre reine Verschwendung. expect(PressReleaseSearchIndex::query()->find($release->id)->indexed_at->timestamp) ->toBe($indexedAt->timestamp); }); test('force deleting a press release removes its index row', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $release->forceDelete(); expect(PressReleaseSearchIndex::query()->find($release->id))->toBeNull(); }); test('a soft deleted press release keeps its index row', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(); $release->delete(); // Die Suche filtert ohnehin über press_releases; dort ist die gelöschte PM // durch den SoftDeletes-Scope bereits ausgeschlossen. expect(PressReleaseSearchIndex::query()->find($release->id))->not->toBeNull(); }); test('the reindex command fills in rows that were written past eloquent', function () { /** @var TestCase $this */ $releases = PressRelease::factory()->count(3)->create(); // Massen-Update per Query-Builder löst keine Model-Events aus — genau der // Fall, für den der Reparatur-Lauf existiert. PressReleaseSearchIndex::query()->delete(); expect(PressReleaseSearchIndex::query()->count())->toBe(0); $this->artisan('press-releases:reindex-search')->assertSuccessful(); expect(PressReleaseSearchIndex::query()->count())->toBe(3) ->and(PressReleaseSearchIndex::query()->find($releases->first()->id)->title) ->toBe($releases->first()->title); }); test('the reindex command repairs stale content with --all', function () { /** @var TestCase $this */ $release = PressRelease::factory()->create(['title' => 'Richtiger Titel']); PressReleaseSearchIndex::query()->whereKey($release->id)->update(['title' => 'Veraltet']); // Ohne --all bleibt eine vorhandene Zeile unangetastet … $this->artisan('press-releases:reindex-search')->assertSuccessful(); expect(PressReleaseSearchIndex::query()->find($release->id)->title)->toBe('Veraltet'); // … erst der vollständige Lauf schreibt sie neu. $this->artisan('press-releases:reindex-search', ['--all' => true])->assertSuccessful(); expect(PressReleaseSearchIndex::query()->find($release->id)->title)->toBe('Richtiger Titel'); }); test('the fulltext constraint drives off the search table', function () { /** @var TestCase $this */ $query = PressRelease::query(); PressReleaseSearchIndex::constrain($query, ['title', 'keywords'], 'solar'); // Auf der Builder-Struktur geprüft statt auf dem fertigen SQL: SQLite // kann Volltext gar nicht übersetzen, die Form der Abfrage ist aber // treiberunabhängig — und genau sie entscheidet über die Laufzeit. $where = $query->getQuery()->wheres[0]; expect($where['type'])->toBe('InSub') // Die Suchtabelle ist die treibende Tabelle; der Volltext-Index wird // einmal ausgewertet, nicht pro Zeile der Ergebnismenge. ->and($where['column'])->toBe('press_releases.id') ->and($where['query']->from)->toBe('press_release_search') ->and(collect($where['query']->wheres)->pluck('type')->all())->toContain('Fulltext') // Unkorreliert: keine Verknüpfung auf die äußere Abfrage. ->and(collect($where['query']->wheres)->pluck('type')->all())->not->toContain('Column'); })->skip( fn (): bool => DB::connection()->getDriverName() !== 'mysql', 'Nur auf MySQL: whereIn() übersetzt die Unterabfrage sofort, und SQLite kennt keine Volltextsuche.', ); test('search falls back gracefully while the table is missing', function () { /** @var TestCase $this */ expect(PressReleaseSearchIndex::isAvailable())->toBeTrue(); Schema::drop('press_release_search'); PressReleaseSearchIndex::forgetAvailability(); // Zwischen Code-Deploy und Migration darf die Suche nicht mit einem // SQL-Fehler aussteigen, sondern weicht auf LIKE aus. expect(PressReleaseSearchIndex::isAvailable())->toBeFalse(); $results = PressRelease::query() ->withoutGlobalScopes() ->matchingSearchTerm('solar', 'de') ->get(); expect($results)->toBeCollection(); }); test('a negative switch check is cached only briefly', function () { /** @var TestCase $this */ Schema::drop('press_release_search'); PressReleaseSearchIndex::forgetAvailability(); expect(PressReleaseSearchIndex::isAvailable())->toBeFalse() ->and(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeFalse(); // `false` gilt nur während des Umstiegs und schickt die Suche so lange auf // den Rückfallpfad. Steht der Eintrag noch, NACHDEM die Migration die // Volltext-Indizes von press_releases entfernt hat, setzt der Rückfallpfad // sein MATCH() auf einen Index ab, den es nicht mehr gibt — MySQL antwortet // mit Fehler 1191 und jede Suche endet im 500er. $this->travel(10)->seconds(); expect(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeNull(); }); test('a positive switch check is cached for a minute', function () { /** @var TestCase $this */ expect(PressReleaseSearchIndex::isAvailable())->toBeTrue(); // Der Dauerzustand nach dem Umstieg — hier wäre ein SHOW INDEX pro Suche // reine Verschwendung. $this->travel(30)->seconds(); expect(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeTrue(); }); test('forgetting the switch check re-evaluates it', function () { /** @var TestCase $this */ expect(PressReleaseSearchIndex::isAvailable())->toBeTrue(); Schema::drop('press_release_search'); // Ohne das Verwerfen bliebe die veraltete Antwort stehen — genau deshalb // rufen die beiden Such-Migrationen forgetAvailability() auf. expect(PressReleaseSearchIndex::isAvailable())->toBeTrue(); PressReleaseSearchIndex::forgetAvailability(); expect(PressReleaseSearchIndex::isAvailable())->toBeFalse(); });