package search import ( "context" "path/filepath" "testing" "time" "munin/internal/store" ) // setupRegistry legt eine Datenbank mit einem Projekt und eine Registry mit // bereits aufgebautem Index an. func setupRegistry(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() project, 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, project.ID) if err != nil { t.Fatal(err) } return r, db, project, idx } func write(t *testing.T, db *store.Store, project store.Project, slug, content string) store.Entry { t.Helper() e, err := db.PutEntry(context.Background(), store.EntryWrite{ ProjectID: project.ID, Slug: slug, Title: "Eintrag", Content: content, Source: store.SourceAgent, }) if err != nil { t.Fatal(err) } return e } func finds(idx *Index, word, slug string) bool { for _, r := range idx.Search(word, 10) { if r.DocID == slug { return true } } return false } // TestStaleUpdateKeepsIndexCurrent 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 // TestConcurrentWritesKeepSearchIndexCurrent im Paket mcpserver), hier // wird die Reihenfolge deshalb von Hand vertauscht. func TestStaleUpdateKeepsIndexCurrent(t *testing.T) { r, db, project, idx := setupRegistry(t) write(t, db, project, "umkaempft", "Stand wortzwei") write(t, db, project, "umkaempft", "Stand wortdrei") r.Refresh(context.Background(), project.ID, "umkaempft") // Schreiber von Fassung 3 r.Refresh(context.Background(), project.ID, "umkaempft") // Schreiber von Fassung 2, verspätet if !finds(idx, "wortdrei", "umkaempft") { t.Error("der Index kennt den aktuellen Stand (Fassung 3) nicht") } if finds(idx, "wortzwei", "umkaempft") { t.Error("der Index hält einen überholten Stand (Fassung 2)") } } // TestLateUpdateAfterDeleteStaysGone: 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 TestLateUpdateAfterDeleteStaysGone(t *testing.T) { r, db, project, idx := setupRegistry(t) ctx := context.Background() write(t, db, project, "kurzlebig", "Stand wortgeloescht") if err := db.MoveEntryToTrash(ctx, project.ID, "kurzlebig", "test"); err != nil { t.Fatal(err) } r.Refresh(ctx, project.ID, "kurzlebig") // Löschender r.Refresh(ctx, project.ID, "kurzlebig") // Schreiber, verspätet if finds(idx, "wortgeloescht", "kurzlebig") { t.Error("ein gelöschter Eintrag ist wieder im Suchindex") } } // TestSlowBuildDoesNotBlockOtherProjects: 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 TestSlowBuildDoesNotBlockOtherProjects(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() big, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "") if err != nil { t.Fatal(err) } small, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "") if err != nil { t.Fatal(err) } r := NewRegistry(db) building := make(chan struct{}) release := make(chan struct{}) r.afterRead = func(projectID string) { if projectID == big.ID { close(building) <-release // Aufbau hängt, bis der Test ihn freigibt } } done := make(chan error) go func() { _, err := r.For(ctx, big.ID) done <- err }() <-building // Projekt B, während A noch aufgebaut wird. smallDone := make(chan error) go func() { _, err := r.For(ctx, small.ID) smallDone <- err }() select { case err := <-smallDone: if err != nil { t.Fatal(err) } case <-time.After(2 * time.Second): t.Fatal("Projekt B wartet auf den Aufbau von Projekt A") } close(release) if err := <-done; err != nil { t.Fatal(err) } } // TestChangeDuringBuildIsNotLost: 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 TestChangeDuringBuildIsNotLost(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() project, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") if err != nil { t.Fatal(err) } write(t, db, project, "eintrag", "Stand wortalt") r := NewRegistry(db) refreshDone := make(chan struct{}) r.afterRead = func(string) { // Die Einträge sind gelesen, der Index noch nicht befüllt. Jetzt // schreibt jemand und meldet es, wie es die Handler tun. write(t, db, project, "eintrag", "Stand wortneu") go func() { r.Refresh(ctx, project.ID, "eintrag") close(refreshDone) }() // Refresh darf nicht durchlaufen, solange der Aufbau läuft. select { case <-refreshDone: t.Error("Refresh lief während des Aufbaus durch") case <-time.After(50 * time.Millisecond): } } idx, err := r.For(ctx, project.ID) if err != nil { t.Fatal(err) } <-refreshDone if !finds(idx, "wortneu", "eintrag") { t.Error("die Änderung während des Aufbaus fehlt im Index") } if finds(idx, "wortalt", "eintrag") { t.Error("der Index hält den Stand von vor der Änderung") } } // TestRegistryPrefersCurated prüft, dass die Registry kuratierten // Einträgen den Suchfaktor mitgibt — beim Aufbau wie beim Nachziehen. func TestRegistryPrefersCurated(t *testing.T) { r, db, project, idx := setupRegistry(t) ctx := context.Background() for _, curated := range []bool{false, true} { slug := "ungeprueft" if curated { slug = "geprueft" } if _, err := db.PutEntry(ctx, store.EntryWrite{ ProjectID: project.ID, Slug: slug, Title: "Deployment", Content: "Ablauf.", Source: store.SourceHuman, Curated: curated, }); err != nil { t.Fatal(err) } r.Refresh(ctx, project.ID, slug) } hits := idx.Search("deployment", 10) if len(hits) != 2 || hits[0].DocID != "geprueft" { t.Errorf("Treffer = %v, erwartet den kuratierten zuerst", resultIDs(hits)) } }