Files
sm-journal-plugin/search_engine_test.go
Jakub Zych 08e3d82ecd feat: relevance-ordered search, suggest rows and journal:reindex
- Engine search asks Typesense for the first 250 candidates, re-gates them
  in SQL, counts the gated rows and pages over them in rank order; pages
  after the first are no longer empty. An explicit sort overrides the rank.
- The settings row gates engine search, so it works straight after boot.
- ?fields=suggest serves id, title, slug, a plain-text excerpt,
  featured_image_url and published_at.
- Content is indexed as plain text; Post declares SearchIndexSchema.
- journal:reindex [--fresh] syncs every post into the index.
2026-10-30 17:59:57 +01:00

348 lines
12 KiB
Go

package journal_test
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/console"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
const searchIndex = "golem15_journal_posts"
// typesenseStub stands in for a Typesense server: one collection that GET,
// POST and DELETE /collections create and drop, imports answered with one
// success line per document, and a search that answers with hits, the ids
// in rank order.
type typesenseStub struct {
mu sync.Mutex
exists bool
hits []string
requests []string
schema map[string]any
imported []string
removed []string
}
func (s *typesenseStub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
s.requests = append(s.requests, r.Method+" "+r.URL.Path)
coll := "/collections/" + searchIndex
switch {
case r.Method == http.MethodGet && r.URL.Path == coll:
if !s.exists {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodPost && r.URL.Path == "/collections":
_ = json.NewDecoder(r.Body).Decode(&s.schema)
s.exists = true
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodDelete && r.URL.Path == coll:
if !s.exists {
w.WriteHeader(http.StatusNotFound)
return
}
s.exists = false
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodPost && r.URL.Path == coll+"/documents/import":
sc := bufio.NewScanner(r.Body)
var answer []string
for sc.Scan() {
var doc map[string]any
if err := json.Unmarshal(sc.Bytes(), &doc); err == nil {
s.imported = append(s.imported, fmt.Sprint(doc["id"]))
}
answer = append(answer, `{"success":true}`)
}
_, _ = io.WriteString(w, strings.Join(answer, "\n"))
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, coll+"/documents/"):
s.removed = append(s.removed, strings.TrimPrefix(r.URL.Path, coll+"/documents/"))
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodGet && r.URL.Path == coll+"/documents/search":
hits := make([]string, len(s.hits))
for i, id := range s.hits {
hits[i] = `{"document":{"id":"` + id + `"}}`
}
_, _ = fmt.Fprintf(w, `{"found":%d,"hits":[%s]}`, len(s.hits), strings.Join(hits, ","))
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (s *typesenseStub) sent() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.requests...)
}
func newSearchEnv(t *testing.T) (*adminEnv, *typesenseStub) {
t.Helper()
stub := &typesenseStub{}
srv := httptest.NewServer(stub)
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
env := newPostsEnv(t,
"SUMMER_SEARCH__DRIVER=typesense",
"SUMMER_SEARCH__TYPESENSE__API_KEY=test-only-typesense-key",
"SUMMER_SEARCH__TYPESENSE__HOST="+u.Hostname(),
"SUMMER_SEARCH__TYPESENSE__PORT="+u.Port(),
"SUMMER_SEARCH__TYPESENSE__PROTOCOL="+u.Scheme,
)
t.Cleanup(func() { models.SetSearchGate(false) })
return env, stub
}
func setSearchGate(t *testing.T, env *adminEnv, on bool) {
t.Helper()
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET search_use_typesense = ? WHERE id = 1`, on).Error; err != nil {
t.Fatal(err)
}
}
// seedSearchPosts creates n published posts titled "Grzyb N" and one draft,
// with the gate off so the writes send nothing, and returns their ids.
func seedSearchPosts(t *testing.T, env *adminEnv, n int) (published []uint, draft uint) {
t.Helper()
past := time.Now().UTC().Add(-time.Hour)
for i := 1; i <= n; i++ {
at := past.Add(-time.Duration(i) * time.Minute)
p := models.Post{
Title: fmt.Sprintf("Grzyb %02d", i),
Slug: fmt.Sprintf("grzyb-%02d", i),
Content: "o grzybach",
Published: true,
PublishedAt: &at,
}
if err := env.gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
published = append(published, p.ID)
}
d := models.Post{Title: "Grzyb szkic", Slug: "grzyb-szkic", Content: "o grzybach", Published: false}
if err := env.gdb.Create(&d).Error; err != nil {
t.Fatal(err)
}
return published, d.ID
}
type listBody struct {
Data []map[string]any `json:"data"`
Meta struct {
CurrentPage int `json:"current_page"`
LastPage int `json:"last_page"`
Total int64 `json:"total"`
} `json:"meta"`
}
func getList(t *testing.T, env *adminEnv, path string) listBody {
t.Helper()
rec := env.call(t, http.MethodGet, path, "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", path, rec.Code, rec.Body.String())
}
var body listBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("GET %s body: %v", path, err)
}
return body
}
func slugsOf(rows []map[string]any) []string {
out := make([]string, len(rows))
for i, row := range rows {
out[i], _ = row["slug"].(string)
}
return out
}
// TestEngineSearchPages: the engine ranks 25 published posts and a draft;
// the list re-gates them in SQL, keeps the engine's order across pages and
// counts the gated rows only.
func TestEngineSearchPages(t *testing.T) {
env, stub := newSearchEnv(t)
published, draft := seedSearchPosts(t, env, 25)
// Rank: oldest first (the reverse of the date order), the draft in the
// middle.
var rank []string
for i := len(published) - 1; i >= 0; i-- {
rank = append(rank, strconv.FormatUint(uint64(published[i]), 10))
if i == 12 {
rank = append(rank, strconv.FormatUint(uint64(draft), 10))
}
}
stub.hits = rank
setSearchGate(t, env, true)
want := make([]string, 0, 25)
for i := 25; i >= 1; i-- {
want = append(want, fmt.Sprintf("grzyb-%02d", i))
}
for page, slice := range map[int][]string{1: want[:10], 2: want[10:20], 3: want[20:]} {
body := getList(t, env, fmt.Sprintf("/_journal/api/v1/posts?search=grzyb&per_page=10&page=%d", page))
if body.Meta.Total != 25 {
t.Fatalf("page %d total = %d, want 25 (draft re-gated out)", page, body.Meta.Total)
}
if got := slugsOf(body.Data); strings.Join(got, ",") != strings.Join(slice, ",") {
t.Fatalf("page %d = %v, want %v", page, got, slice)
}
}
for _, req := range stub.sent() {
if strings.HasSuffix(req, "/documents/search") {
continue
}
t.Fatalf("unexpected Typesense request %q", req)
}
// An explicit sort overrides relevance.
body := getList(t, env, "/_journal/api/v1/posts?search=grzyb&per_page=3&sort=title+asc")
if got := strings.Join(slugsOf(body.Data), ","); got != "grzyb-01,grzyb-02,grzyb-03" {
t.Fatalf("sort=title asc = %s", got)
}
}
// TestEngineSearchGateOff: with Typesense configured but the gate off, the
// list searches in SQL and sends nothing.
func TestEngineSearchGateOff(t *testing.T) {
env, stub := newSearchEnv(t)
seedSearchPosts(t, env, 3)
stub.hits = []string{"999"}
body := getList(t, env, "/_journal/api/v1/posts?search=grzyb")
if body.Meta.Total != 3 || len(body.Data) != 3 {
t.Fatalf("total=%d rows=%d, want the 3 SQL matches", body.Meta.Total, len(body.Data))
}
if got := strings.Join(slugsOf(body.Data), ","); got != "grzyb-01,grzyb-02,grzyb-03" {
t.Fatalf("SQL order = %s, want the date order", got)
}
if n := len(stub.sent()); n != 0 {
t.Fatalf("Typesense requests = %v, want none", stub.sent())
}
}
// TestSuggestRows: ?fields=suggest serves only the suggestion keys, with a
// plain-text excerpt cut on a word boundary or taken from the content.
func TestSuggestRows(t *testing.T) {
env := newPostsEnv(t)
past := time.Now().UTC().Add(-time.Hour)
long := strings.Repeat("borowik szlachetny ", 20)
for _, p := range []models.Post{
{Title: "Z zajawką", Slug: "z-zajawka", Excerpt: "<p>Krótka <b>zajawka</b></p>", Content: "treść", Published: true, PublishedAt: &past},
{Title: "Długa", Slug: "dluga", Excerpt: long, Content: "treść", Published: true, PublishedAt: &past},
{Title: "Bez zajawki", Slug: "bez-zajawki", Content: "x", ContentHTML: "<h2>Kurki</h2><p>rosną &amp; pachną</p>", Published: true, PublishedAt: &past},
} {
if err := env.gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
}
body := getList(t, env, "/_journal/api/v1/posts?fields=suggest&per_page=5&sort=title+asc")
if len(body.Data) != 3 || body.Meta.Total != 3 {
t.Fatalf("rows=%d total=%d", len(body.Data), body.Meta.Total)
}
wantKeys := "excerpt,featured_image_url,id,published_at,slug,title"
excerpts := map[string]string{}
for _, row := range body.Data {
keys := make([]string, 0, len(row))
for k := range row {
keys = append(keys, k)
}
sort.Strings(keys)
if got := strings.Join(keys, ","); got != wantKeys {
t.Fatalf("suggest keys = %s, want %s", got, wantKeys)
}
excerpts[row["slug"].(string)] = row["excerpt"].(string)
}
if got := excerpts["z-zajawka"]; got != "Krótka zajawka" {
t.Fatalf("excerpt = %q", got)
}
if got := excerpts["bez-zajawki"]; got != "Kurki rosną & pachną" {
t.Fatalf("content fallback = %q", got)
}
got := excerpts["dluga"]
cut, ok := strings.CutSuffix(got, "…")
if !ok || len([]rune(cut)) > 160 || !strings.HasPrefix(long, cut+" ") {
t.Fatalf("long excerpt = %q, want under 160 runes cut on a word boundary", got)
}
// Without fields=suggest the rows are the full ones.
full := getList(t, env, "/_journal/api/v1/posts?per_page=1")
if _, ok := full.Data[0]["content"]; !ok {
t.Fatal("full rows lost content")
}
}
// TestReindexFresh: --fresh drops the collection, recreates it from the
// schema, upserts the published posts and removes the draft's document.
func TestReindexFresh(t *testing.T) {
env, stub := newSearchEnv(t)
published, draft := seedSearchPosts(t, env, 3)
stub.exists = true
setSearchGate(t, env, true)
var buf strings.Builder
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
res, err := console.ReindexPosts(t.Context(), env.app, true, out)
if err != nil {
t.Fatalf("reindex: %v (%s)", err, buf.String())
}
if res.Synced != 3 || res.Removed != 1 || res.Failed != 0 {
t.Fatalf("result = %+v, want 3 synced, 1 removed", res)
}
sent := stub.sent()
if len(sent) < 3 || sent[0] != "DELETE /collections/"+searchIndex ||
sent[1] != "GET /collections/"+searchIndex || sent[2] != "POST /collections" {
t.Fatalf("requests = %v, want DELETE, GET, POST /collections first", sent)
}
fields, _ := stub.schema["fields"].([]any)
if stub.schema["name"] != searchIndex || len(fields) != len(models.Post{}.SearchIndexSchema()["fields"].([]map[string]any)) {
t.Fatalf("created schema = %v", stub.schema)
}
if len(stub.imported) != 3 {
t.Fatalf("imported = %v, want the 3 published posts", stub.imported)
}
for i, id := range published {
if stub.imported[i] != strconv.FormatUint(uint64(id), 10) {
t.Fatalf("imported = %v, want %v", stub.imported, published)
}
}
if len(stub.removed) != 1 || stub.removed[0] != strconv.FormatUint(uint64(draft), 10) {
t.Fatalf("removed = %v, want the draft %d", stub.removed, draft)
}
}
// TestReindexRefuses: with the gate off, or no engine configured, the
// command errors and sends nothing.
func TestReindexRefuses(t *testing.T) {
env, stub := newSearchEnv(t)
seedSearchPosts(t, env, 1)
if _, err := console.ReindexPosts(t.Context(), env.app, true, nil); err == nil || !strings.Contains(err.Error(), "search_use_typesense") {
t.Fatalf("gate off: err = %v", err)
}
if n := len(stub.sent()); n != 0 {
t.Fatalf("gate off sent %v", stub.sent())
}
plain := newPostsEnv(t)
setSearchGate(t, plain, true)
if _, err := console.ReindexPosts(t.Context(), plain.app, false, nil); err == nil || !strings.Contains(err.Error(), "not configured") {
t.Fatalf("null driver: err = %v", err)
}
}