package search import ( "context" "path/filepath" "testing" "time" "munin/internal/store" ) // aufbauRegistry legt eine Datenbank mit einem Projekt und eine Registry mit // bereits aufgebautem Index an. func aufbauRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Index) { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) ctx := context.Background() projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") if err != nil { t.Fatal(err) } r := NewRegistry(db) // Den Index vorher anlegen — sonst sind Aktualisierungen ein No-op. idx, err := r.For(ctx, projekt.ID) if err != nil { t.Fatal(err) } return r, db, projekt, idx } func schreibe(t *testing.T, db *store.Store, projekt store.Project, slug, inhalt string) store.Entry { t.Helper() e, err := db.PutEntry(context.Background(), store.EntryWrite{ ProjectID: projekt.ID, Slug: slug, Title: "Eintrag", Content: inhalt, Source: store.SourceAgent, }) if err != nil { t.Fatal(err) } return e } func findet(idx *Index, wort, slug string) bool { for _, r := range idx.Search(wort, 10) { if r.DocID == slug { return true } } return false } // TestUeberholteAktualisierungHaeltIndexAktuell stellt den Fall nach, dass sich // zwei Schreiber zwischen Datenbank und Index überholen: Fassung 2 und 3 sind // gespeichert, aber die Meldung für Fassung 3 kommt beim Index zuerst an. // // Unter Last lässt sich das kaum zuverlässig auslösen (siehe // TestGleichzeitigesSchreibenHaeltSuchindexAktuell im Paket mcpserver), hier // wird die Reihenfolge deshalb von Hand vertauscht. func TestUeberholteAktualisierungHaeltIndexAktuell(t *testing.T) { r, db, projekt, idx := aufbauRegistry(t) schreibe(t, db, projekt, "umkaempft", "Stand wortzwei") schreibe(t, db, projekt, "umkaempft", "Stand wortdrei") r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 3 r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 2, verspätet if !findet(idx, "wortdrei", "umkaempft") { t.Error("der Index kennt den aktuellen Stand (Fassung 3) nicht") } if findet(idx, "wortzwei", "umkaempft") { t.Error("der Index hält einen überholten Stand (Fassung 2)") } } // TestVerspaeteteAktualisierungNachLoeschenBleibtWeg: Ein Eintrag wird // geschrieben und gleich darauf gelöscht. Kommt die Meldung des Schreibers erst // nach der des Löschens beim Index an, darf der Eintrag nicht wieder auftauchen. func TestVerspaeteteAktualisierungNachLoeschenBleibtWeg(t *testing.T) { r, db, projekt, idx := aufbauRegistry(t) ctx := context.Background() schreibe(t, db, projekt, "kurzlebig", "Stand wortgeloescht") if err := db.MoveEntryToTrash(ctx, projekt.ID, "kurzlebig", "test"); err != nil { t.Fatal(err) } r.Refresh(ctx, projekt.ID, "kurzlebig") // Löschender r.Refresh(ctx, projekt.ID, "kurzlebig") // Schreiber, verspätet if findet(idx, "wortgeloescht", "kurzlebig") { t.Error("ein gelöschter Eintrag ist wieder im Suchindex") } } // TestLangsamerAufbauBremstAndereProjekteNicht: Während der Index von Projekt A // noch aufgebaut wird, muss Projekt B ohne Wartezeit durchkommen. Früher hielt // der Aufbau eine Sperre für den ganzen Workspace. func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) { db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) ctx := context.Background() gross, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "") if err != nil { t.Fatal(err) } klein, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "") if err != nil { t.Fatal(err) } r := NewRegistry(db) imAufbau := make(chan struct{}) weiter := make(chan struct{}) r.nachDemLesen = func(projectID string) { if projectID == gross.ID { close(imAufbau) <-weiter // Aufbau hängt, bis der Test ihn freigibt } } fertig := make(chan error) go func() { _, err := r.For(ctx, gross.ID) fertig <- err }() <-imAufbau // Projekt B, während A noch aufgebaut wird. klar := make(chan error) go func() { _, err := r.For(ctx, klein.ID) klar <- err }() select { case err := <-klar: if err != nil { t.Fatal(err) } case <-time.After(2 * time.Second): t.Fatal("Projekt B wartet auf den Aufbau von Projekt A") } close(weiter) if err := <-fertig; err != nil { t.Fatal(err) } } // TestAenderungWaehrendDesAufbausGehtNichtVerloren: Der Aufbau hat die // Einträge schon gelesen, da wird einer davon geändert. Das Refresh des // Schreibers muss warten und danach den neuen Stand nachtragen — sonst hielte // der fertige Index den Stand von vor der Änderung. func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) { db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) ctx := context.Background() projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") if err != nil { t.Fatal(err) } schreibe(t, db, projekt, "eintrag", "Stand wortalt") r := NewRegistry(db) refreshFertig := make(chan struct{}) r.nachDemLesen = func(string) { // Die Einträge sind gelesen, der Index noch nicht befüllt. Jetzt // schreibt jemand und meldet es, wie es die Handler tun. schreibe(t, db, projekt, "eintrag", "Stand wortneu") go func() { r.Refresh(ctx, projekt.ID, "eintrag") close(refreshFertig) }() // Refresh darf nicht durchlaufen, solange der Aufbau läuft. select { case <-refreshFertig: t.Error("Refresh lief während des Aufbaus durch") case <-time.After(50 * time.Millisecond): } } idx, err := r.For(ctx, projekt.ID) if err != nil { t.Fatal(err) } <-refreshFertig if !findet(idx, "wortneu", "eintrag") { t.Error("die Änderung während des Aufbaus fehlt im Index") } if findet(idx, "wortalt", "eintrag") { t.Error("der Index hält den Stand von vor der Änderung") } }