Files
sm-bm-plugin/classes/material_index_test.go
Jakub Zych 4ffce6e6ac feat: search materials through beachcomber
- Material is a beachcomber Searchable (index jz_bm_materials) in the
  hand-written models/material_search.go; jz.bm requires summercms v0.6.4
- classes.MaterialIndexer asks Typesense for up to 250 candidates with
  body highlights; the app-wide Gate is on only for jz.bm-marked contexts
- GET /_bm/api/v1/materials takes q: candidates are re-gated, counted and
  paged in SQL in rank order, each row with at most one plain snippet
- search failure answers 503 Search is unavailable.; without q the list
  is unchanged
2026-11-08 19:45:46 +01:00

122 lines
4.4 KiB
Go

package classes_test
import (
"context"
"errors"
"net/http"
"slices"
"strings"
"testing"
"git.golem15.com/jakub/sm-bm-plugin/classes"
"git.golem15.com/jakub/sm-bm-plugin/internal/searchtest"
)
func TestMaterialSearchGate(t *testing.T) {
gate := classes.MaterialSearchGate()
if !gate.Enabled(classes.MarkMaterialSearch(context.Background()), nil) {
t.Error("a marked context is off")
}
if gate.Enabled(context.Background(), nil) {
t.Error("an unmarked context (a chat message sync) is on")
}
}
func TestMaterialSearchRequest(t *testing.T) {
t.Run("unavailable", materialSearchUnavailable)
stub := &searchtest.Stub{Hits: []searchtest.Hit{
{ID: "12", Field: "body", Snippet: "o " + searchtest.HighlightStart + "globalizacji" + searchtest.HighlightEnd + " dziś"},
{ID: "abc"},
{ID: "0"},
{ID: "5"},
}}
ix := classes.NewMaterialIndexer(searchtest.App(t, searchtest.Start(t, stub)...))
got, err := ix.Search(context.Background(), classes.MaterialSearch{Q: "globalizacja", CourseID: 7, MeetingID: 3, Type: "pdf", Tag: "globalizacja"})
if err != nil {
t.Fatalf("Search: %v", err)
}
searches := stub.Searches()
if len(searches) != 1 {
t.Fatalf("searches = %d, want 1", len(searches))
}
q := searches[0].Query
want := map[string]string{
"q": "globalizacja",
"query_by": "title,tags,source,course,url,body,file_texts",
"query_by_weights": "6,4,3,3,1,2,1",
"filter_by": "course_id:=7 && meeting_id:=3 && type:=`pdf` && tag_slugs:=`globalizacja`",
"sort_by": "_text_match:desc,published_at:desc",
"page": "1",
"per_page": "250",
"exclude_fields": "body,file_texts",
"highlight_fields": "body,file_texts",
}
for k, v := range want {
if q.Get(k) != v {
t.Errorf("%s = %q, want %q", k, q.Get(k), v)
}
}
if !slices.Equal(got.IDs, []uint{12, 5}) {
t.Errorf("ids = %v, want [12 5]", got.IDs)
}
if !got.Highlights {
t.Error("Highlights is false against the typesense engine")
}
if h := got.Hits[12]; len(h.Highlights) != 1 || h.Highlights[0].Field != "body" {
t.Errorf("hit 12 = %+v", h)
} else if segs := h.Highlights[0].Segments; len(segs) != 3 || segs[1].Text != "globalizacji" || !segs[1].Match {
t.Errorf("hit 12 segments = %+v", segs)
}
if _, ok := got.Hits[5]; !ok {
t.Error("hit 5 is missing")
}
if len(got.Hits) != 2 {
t.Errorf("hits = %v, want only 12 and 5", got.Hits)
}
// A tag that is not a plain slug and an unknown type stay out of the
// engine filter; SQL applies them.
if _, err := ix.Search(context.Background(), classes.MaterialSearch{Q: "xx", Tag: "x`y", Type: "exe"}); err != nil {
t.Fatal(err)
}
searches = stub.Searches()
if f := searches[len(searches)-1].Query.Get("filter_by"); f != "" {
t.Errorf("filter_by = %q, want empty", f)
}
if _, err := ix.Search(context.Background(), classes.MaterialSearch{Q: "xx", Tag: "zła-Wielka"}); err != nil {
t.Fatal(err)
}
searches = stub.Searches()
if f := searches[len(searches)-1].Query.Get("filter_by"); strings.Contains(f, "tag_slugs") {
t.Errorf("filter_by = %q carries a tag that is not a slug", f)
}
}
// materialSearchUnavailable runs under TestMaterialSearchRequest.
func materialSearchUnavailable(t *testing.T) {
// No key: the engine is not configured and nothing is sent.
stub := &searchtest.Stub{}
env := searchtest.Start(t, stub)
noKey := slices.DeleteFunc(slices.Clone(env), func(s string) bool { return strings.Contains(s, "API_KEY") })
ix := classes.NewMaterialIndexer(searchtest.App(t, noKey...))
if _, err := ix.Search(context.Background(), classes.MaterialSearch{Q: "xx"}); !errors.Is(err, classes.ErrSearchUnavailable) {
t.Errorf("unconfigured engine: err = %v, want ErrSearchUnavailable", err)
}
if n := len(stub.Requests()); n != 0 {
t.Errorf("unconfigured engine sent %d requests", n)
}
failing := &searchtest.Stub{Status: http.StatusInternalServerError}
ix = classes.NewMaterialIndexer(searchtest.App(t, searchtest.Start(t, failing)...))
if _, err := ix.Search(context.Background(), classes.MaterialSearch{Q: "xx"}); !errors.Is(err, classes.ErrSearchUnavailable) {
t.Errorf("failing engine: err = %v, want ErrSearchUnavailable", err)
}
var none *classes.MaterialIndexer
if _, err := none.Search(context.Background(), classes.MaterialSearch{Q: "xx"}); !errors.Is(err, classes.ErrSearchUnavailable) {
t.Errorf("nil indexer: err = %v, want ErrSearchUnavailable", err)
}
none.Sync(context.Background(), nil, 1)
none.RemoveAfterCommit(context.Background(), nil, 1)
}