234 lines
6.5 KiB
Go
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))
|
|
}
|
|
}
|