- inline and signed uploads queue the text job in their transaction - the admin form queues it in the save transaction - detach resyncs the material (its text row goes by cascade) - bm:materials-reindex backfills missing PDF texts before each sync
184 lines
6.6 KiB
Go
184 lines
6.6 KiB
Go
package classes_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
|
"git.golem15.com/jakub/sm-bm-plugin/internal/searchtest"
|
|
"git.golem15.com/jakub/sm-bm-plugin/jobs"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
func TestMaterialQueueExtract(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// A nil indexer, or one without Enqueue, is a no-op.
|
|
var none *classes.MaterialIndexer
|
|
none.QueueExtract(ctx, nil, 3)
|
|
(&classes.MaterialIndexer{}).QueueExtract(ctx, nil, 3)
|
|
|
|
var logs bytes.Buffer
|
|
var got []pact.JobArgs
|
|
var txs []*gorm.DB
|
|
ix := &classes.MaterialIndexer{
|
|
Log: slog.New(slog.NewTextHandler(&logs, nil)),
|
|
Enqueue: func(_ context.Context, tx *gorm.DB, args pact.JobArgs) error {
|
|
got = append(got, args)
|
|
txs = append(txs, tx)
|
|
return nil
|
|
},
|
|
}
|
|
tx := &gorm.DB{}
|
|
ix.QueueExtract(ctx, tx, 12)
|
|
ix.QueueExtract(ctx, tx, 0)
|
|
if len(got) != 1 {
|
|
t.Fatalf("enqueued %d jobs, want 1 (id 0 queues nothing)", len(got))
|
|
}
|
|
args, ok := got[0].(jobs.ExtractMaterialTextArgs)
|
|
if !ok || args.MaterialID != 12 || args.Kind() != "jz.bm.extract_material_text" {
|
|
t.Errorf("args = %#v, want ExtractMaterialTextArgs{12} of kind jz.bm.extract_material_text", got[0])
|
|
}
|
|
if txs[0] != tx {
|
|
t.Error("the job was not enqueued in the given transaction")
|
|
}
|
|
if logs.Len() != 0 {
|
|
t.Errorf("a successful enqueue logged %q", logs.String())
|
|
}
|
|
|
|
// A failing Enqueue is logged with the material id and not returned.
|
|
ix.Enqueue = func(context.Context, *gorm.DB, pact.JobArgs) error { return errors.New("river is down") }
|
|
ix.QueueExtract(ctx, tx, 12)
|
|
if out := logs.String(); !strings.Contains(out, "could not queue the file text job") || !strings.Contains(out, "material_id=12") || !strings.Contains(out, "river is down") {
|
|
t.Errorf("log = %q, want the failure with the material id", out)
|
|
}
|
|
|
|
// The indexer of an app resolves conga at call time; without an app the
|
|
// enqueue fails and is only logged.
|
|
logs.Reset()
|
|
app := classes.NewMaterialIndexer(nil)
|
|
if app.Enqueue == nil {
|
|
t.Fatal("NewMaterialIndexer left Enqueue nil")
|
|
}
|
|
app.Log = slog.New(slog.NewTextHandler(&logs, nil))
|
|
app.QueueExtract(ctx, nil, 5)
|
|
if !strings.Contains(logs.String(), "material_id=5") {
|
|
t.Errorf("log = %q, want the failed enqueue logged", logs.String())
|
|
}
|
|
}
|