- Additive: Query.IncludeFields, ExcludeFields and Highlight; left empty, the Typesense request is the same as before. - SearchResult.Hits holds one Hit per id, in step with IDs; SearchPage always fills it. - Snippets are plain-text Segment runs split at private-use highlight tags, never HTML; MaxSnippetsPerArray is applied client-side. - Optional HighlightSearcher capability, checked with SupportsHighlights (true for Typesense, false for null and third-party engines). - README and docs: Highlights sections with the rule that a caller drops the hit of every id failing the SQL re-check before showing a snippet. Targets framework v0.6.4 (additive, no breaking change).
200 lines
6.4 KiB
Go
200 lines
6.4 KiB
Go
package beachcomber
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
// idsOnlyEngine implements Engine without PageSearcher.
|
|
type idsOnlyEngine struct {
|
|
ids []string
|
|
err error
|
|
got Query
|
|
}
|
|
|
|
func (*idsOnlyEngine) Name() string { return "ids-only" }
|
|
func (*idsOnlyEngine) Configured() bool { return true }
|
|
func (*idsOnlyEngine) Upsert(context.Context, string, map[string]any, []map[string]any) error {
|
|
return nil
|
|
}
|
|
func (*idsOnlyEngine) Delete(context.Context, string, []string) error { return nil }
|
|
func (*idsOnlyEngine) Flush(context.Context, string) error { return nil }
|
|
|
|
func (e *idsOnlyEngine) SearchIDs(_ context.Context, _ string, q Query) ([]string, error) {
|
|
e.got = q
|
|
return e.ids, e.err
|
|
}
|
|
|
|
// pagedEngine implements PageSearcher.
|
|
type pagedEngine struct {
|
|
idsOnlyEngine
|
|
found int
|
|
}
|
|
|
|
func (e *pagedEngine) SearchPage(_ context.Context, _ string, q Query) (SearchResult, error) {
|
|
e.got = q
|
|
return SearchResult{IDs: e.ids, Found: e.found}, nil
|
|
}
|
|
|
|
func TestSearchPageFallsBackToSearchIDs(t *testing.T) {
|
|
e := &idsOnlyEngine{ids: []string{"3", "1"}}
|
|
q := Query{Q: "blue", QueryBy: []string{"name", "notes"}, QueryByWeights: []int{10, 1}, PerPage: 20}
|
|
res, err := SearchPage(context.Background(), e, "posts", q)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(res, SearchResult{IDs: []string{"3", "1"}, Found: 2, Hits: []Hit{{ID: "3"}, {ID: "1"}}}) {
|
|
t.Fatalf("res = %+v", res)
|
|
}
|
|
if !reflect.DeepEqual(e.got, q) {
|
|
t.Fatalf("query = %+v", e.got)
|
|
}
|
|
e.err = errors.New("down")
|
|
if _, err := SearchPage(context.Background(), e, "posts", q); err == nil {
|
|
t.Fatal("engine error must surface")
|
|
}
|
|
}
|
|
|
|
func TestSearchPageUsesPageSearcher(t *testing.T) {
|
|
e := &pagedEngine{idsOnlyEngine: idsOnlyEngine{ids: []string{"7"}}, found: 1234}
|
|
res, err := SearchPage(context.Background(), e, "posts", Query{Q: "x"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Found != 1234 || !reflect.DeepEqual(res.IDs, []string{"7"}) {
|
|
t.Fatalf("res = %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestSearchPageNullEngine(t *testing.T) {
|
|
var e Engine = nullEngine{}
|
|
if _, ok := e.(PageSearcher); !ok {
|
|
t.Fatal("the null engine implements PageSearcher")
|
|
}
|
|
res, err := SearchPage(context.Background(), e, "posts", Query{})
|
|
if err != nil || res.Found != 0 || res.IDs == nil || len(res.IDs) != 0 || res.Hits == nil || len(res.Hits) != 0 {
|
|
t.Fatalf("res = %+v err = %v", res, err)
|
|
}
|
|
}
|
|
|
|
// highlightEngine implements HighlightSearcher with a configurable answer
|
|
// and can return its own hits.
|
|
type highlightEngine struct {
|
|
pagedEngine
|
|
supports bool
|
|
hits []Hit
|
|
}
|
|
|
|
func (e *highlightEngine) SupportsHighlights() bool { return e.supports }
|
|
|
|
func (e *highlightEngine) SearchPage(_ context.Context, _ string, q Query) (SearchResult, error) {
|
|
e.got = q
|
|
return SearchResult{IDs: e.ids, Found: e.found, Hits: e.hits}, nil
|
|
}
|
|
|
|
func TestSupportsHighlights(t *testing.T) {
|
|
for name, e := range map[string]Engine{
|
|
"null": nullEngine{},
|
|
"ids-only": &idsOnlyEngine{},
|
|
"paged": &pagedEngine{},
|
|
"nil": nil,
|
|
} {
|
|
if SupportsHighlights(e) {
|
|
t.Fatalf("%s: SupportsHighlights = true", name)
|
|
}
|
|
}
|
|
if !SupportsHighlights(&highlightEngine{supports: true}) {
|
|
t.Fatal("an engine answering true supports highlights")
|
|
}
|
|
if SupportsHighlights(&highlightEngine{supports: false}) {
|
|
t.Fatal("an engine answering false does not support highlights")
|
|
}
|
|
}
|
|
|
|
func TestSearchPageFillsHits(t *testing.T) {
|
|
ctx := context.Background()
|
|
res, err := SearchPage(ctx, &pagedEngine{idsOnlyEngine: idsOnlyEngine{ids: []string{"7"}}, found: 1}, "posts", Query{})
|
|
if err != nil || !reflect.DeepEqual(res.Hits, []Hit{{ID: "7"}}) {
|
|
t.Fatalf("hits = %+v err = %v", res.Hits, err)
|
|
}
|
|
own := []Hit{{ID: "7", Highlights: []Snippet{{Field: "title", ArrayIndex: -1, Segments: []Segment{{Text: "x", Match: true}}}}}}
|
|
he := &highlightEngine{pagedEngine: pagedEngine{idsOnlyEngine: idsOnlyEngine{ids: []string{"7"}}}, supports: true, hits: own}
|
|
res, err = SearchPage(ctx, he, "posts", Query{Highlight: &Highlight{}})
|
|
if err != nil || !reflect.DeepEqual(res.Hits, own) {
|
|
t.Fatalf("own hits = %+v err = %v", res.Hits, err)
|
|
}
|
|
res, err = SearchPage(ctx, &pagedEngine{}, "posts", Query{})
|
|
if err != nil || res.Hits == nil || len(res.Hits) != 0 {
|
|
t.Fatalf("zero ids: hits = %#v err = %v", res.Hits, err)
|
|
}
|
|
}
|
|
|
|
// flushEngine records Flush calls and can fail them.
|
|
type flushEngine struct {
|
|
idsOnlyEngine
|
|
flushed []string
|
|
err error
|
|
}
|
|
|
|
func (e *flushEngine) Flush(_ context.Context, index string) error {
|
|
e.flushed = append(e.flushed, index)
|
|
return e.err
|
|
}
|
|
|
|
// dropperEngine implements IndexDropper and IndexEnsurer.
|
|
type dropperEngine struct {
|
|
flushEngine
|
|
existing map[string]bool
|
|
ensured []string
|
|
}
|
|
|
|
func (e *dropperEngine) DropIndex(_ context.Context, index string) (bool, error) {
|
|
existed := e.existing[index]
|
|
delete(e.existing, index)
|
|
return existed, nil
|
|
}
|
|
|
|
func (e *dropperEngine) EnsureIndex(_ context.Context, index string, _ map[string]any) error {
|
|
e.ensured = append(e.ensured, index)
|
|
e.existing[index] = true
|
|
return nil
|
|
}
|
|
|
|
func TestDropIndex(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
fallback := &flushEngine{}
|
|
existed, err := DropIndex(ctx, fallback, "albums")
|
|
if err != nil || !existed || !reflect.DeepEqual(fallback.flushed, []string{"albums"}) {
|
|
t.Fatalf("fallback: existed=%v err=%v flushed=%v", existed, err, fallback.flushed)
|
|
}
|
|
boom := errors.New("boom")
|
|
failing := &flushEngine{err: boom}
|
|
if existed, err := DropIndex(ctx, failing, "albums"); !errors.Is(err, boom) || existed {
|
|
t.Fatalf("fallback error: existed=%v err=%v", existed, err)
|
|
}
|
|
|
|
d := &dropperEngine{existing: map[string]bool{"albums": true}}
|
|
if existed, err := DropIndex(ctx, d, "albums"); err != nil || !existed {
|
|
t.Fatalf("dropper first: existed=%v err=%v", existed, err)
|
|
}
|
|
if existed, err := DropIndex(ctx, d, "albums"); err != nil || existed {
|
|
t.Fatalf("dropper second: existed=%v err=%v, want already absent", existed, err)
|
|
}
|
|
if len(d.flushed) != 0 {
|
|
t.Fatalf("dropper path must not call Flush: %v", d.flushed)
|
|
}
|
|
|
|
if err := EnsureIndex(ctx, d, "albums", map[string]any{"fields": []any{}}); err != nil || !reflect.DeepEqual(d.ensured, []string{"albums"}) {
|
|
t.Fatalf("EnsureIndex: err=%v ensured=%v", err, d.ensured)
|
|
}
|
|
if err := EnsureIndex(ctx, fallback, "albums", nil); err != nil {
|
|
t.Fatalf("EnsureIndex without IndexEnsurer must be a no-op: %v", err)
|
|
}
|
|
if err := EnsureIndex(ctx, nullEngine{}, "albums", nil); err != nil {
|
|
t.Fatalf("null engine: %v", err)
|
|
}
|
|
}
|