munin/internal/search/registry_test.go
2026-10-19 10:13:56 +02:00

234 lines
6.5 KiB
Go

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