Files
summercms/modules/beachcomber/example_test.go
Jakub Zych 44bd1446f5 feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links
- docs/backend: admin controllers, forms, lists and filters, relation
  manager, users and permissions, settings, partials and widgets, admin SPA
- docs/services: storage, outbound HTTP, realtime, Web Push, search, parity
  testing and the Frontend and AJAX (not provided) page
- Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and
  its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse
  and beachcomber TestDocs* regions run on their Postgres harnesses
- concept map rows link their guide pages and the not-provided rows the
  Frontend and AJAX page; index lists Backend, Database and Services
- TestDocsRequiredPages asserts the D-08 section order
2026-09-30 23:18:35 +02:00

259 lines
8.0 KiB
Go

package beachcomber_test
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// Post is the acme.blog post model. It implements beachcomber.Searchable
// without importing beachcomber.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
BlogID uint `gorm:"column:blog_id"`
Title string `gorm:"column:title"`
Published bool `gorm:"column:published"`
}
func (Post) TableName() string { return "acme_blog_posts" }
// SearchableAs is the index name, before search.prefix.
func (Post) SearchableAs() string { return "acme_blog_posts" }
// ShouldBeSearchable keeps drafts out of the index.
func (p *Post) ShouldBeSearchable() bool { return p.Published }
// ToSearchableArray builds the document from the committed row.
func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) {
return map[string]any{
"id": strconv.FormatUint(uint64(p.ID), 10),
"blog_id": int64(p.BlogID),
"title": p.Title,
}, nil
}
// memoryEngine is a toy engine: it matches Q against the title and ignores
// FilterBy, so its answers are loose candidates, as a stale index's are.
type memoryEngine struct {
mu sync.Mutex
docs map[string]map[string]map[string]any // index -> id -> document
}
func (e *memoryEngine) Name() string { return "acme-memory" }
func (e *memoryEngine) Configured() bool { return true }
func (e *memoryEngine) Upsert(ctx context.Context, index string, schema map[string]any, docs []map[string]any) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.docs[index] == nil {
e.docs[index] = map[string]map[string]any{}
}
for _, d := range docs {
e.docs[index][fmt.Sprint(d["id"])] = d
}
return nil
}
func (e *memoryEngine) Delete(ctx context.Context, index string, ids []string) error {
e.mu.Lock()
defer e.mu.Unlock()
for _, id := range ids {
delete(e.docs[index], id)
}
return nil
}
func (e *memoryEngine) Flush(ctx context.Context, index string) error {
e.mu.Lock()
defer e.mu.Unlock()
delete(e.docs, index)
return nil
}
func (e *memoryEngine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) {
e.mu.Lock()
defer e.mu.Unlock()
ids := []string{}
for id, d := range e.docs[index] {
if strings.Contains(strings.ToLower(fmt.Sprint(d["title"])), strings.ToLower(q.Q)) {
ids = append(ids, id)
}
}
slices.Sort(ids)
return ids, nil
}
var memory = &memoryEngine{docs: map[string]map[string]map[string]any{}}
func ExampleFrom() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("search.prefix", "staging_")
svc, err := beachcomber.From(backpack.New(cfg)) // search.driver defaults to null
if err != nil {
fmt.Println(err)
return
}
fmt.Println(svc.Engine().Name(), svc.Engine().Configured(), svc.IndexName(&Post{}))
ids, err := svc.Engine().SearchIDs(context.Background(), svc.IndexName(&Post{}), beachcomber.Query{Q: "go"})
fmt.Println(ids, err)
_ = cfg.Set("search.driver", "elastic")
_, err = beachcomber.From(backpack.New(cfg))
fmt.Println(err != nil)
// Output:
// null false staging_acme_blog_posts
// [] <nil>
// true
}
// installGate turns search sync on only while the blog's search setting is
// on; a failed read counts as off.
func installGate(svc *beachcomber.Service) {
// docs:start gate
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
var enabled bool
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
return err == nil && enabled
}))
// docs:end gate
}
// searchPosts answers a search in one blog.
func searchPosts(ctx context.Context, svc *beachcomber.Service, db *gorm.DB, blogID uint, term string) ([]Post, error) {
// docs:start search
ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&Post{}), beachcomber.Query{
Q: term,
QueryBy: []string{"title"},
FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10),
})
if err != nil {
return nil, err
}
// The ids are candidates from an index that may be stale or loosely
// filtered: re-check every one in SQL before exposing a row.
var posts []Post
err = db.WithContext(ctx).
Where("id IN ? AND blog_id = ? AND published", ids, blogID).
Order("id").
Find(&posts).Error
return posts, err
// docs:end search
}
// TestDocsSearch runs the gate and search regions of the Search page on the
// package's Postgres harness.
func TestDocsSearch(t *testing.T) {
app, db := beachcomber.DocsApp(t, nil)
for _, stmt := range []string{
`CREATE TABLE acme_blog_posts (id SERIAL PRIMARY KEY, blog_id INTEGER NOT NULL, title TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT FALSE)`,
`CREATE TABLE acme_blog_settings (id INTEGER PRIMARY KEY, search_enabled BOOLEAN NOT NULL)`,
`INSERT INTO acme_blog_settings VALUES (1, TRUE)`,
} {
if err := db.Exec(stmt).Error; err != nil {
t.Fatal(err)
}
}
svc, err := beachcomber.From(app)
if err != nil {
t.Fatal(err)
}
// An application selects a registered engine with search.driver; the
// test installs the toy engine directly, so the global engine list the
// package's own tests check stays unchanged.
beachcomber.DocsUseEngine(svc, memory)
installGate(svc)
ctx := t.Context()
indexed := func() int {
memory.mu.Lock()
defer memory.mu.Unlock()
return len(memory.docs[svc.IndexName(&Post{})])
}
// A committed write is indexed after commit; a rolled-back one never is.
create := func(p *Post, fail bool) error {
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Create(p).Error; err != nil {
return err
}
if fail {
return fmt.Errorf("rolled back")
}
return nil
})
}
hello := &Post{BlogID: 7, Title: "Hello Go", Published: true}
if err := create(hello, false); err != nil {
t.Fatal(err)
}
if err := create(&Post{BlogID: 7, Title: "Go rolled back", Published: true}, true); err == nil {
t.Fatal("rolled-back create succeeded")
}
if err := create(&Post{BlogID: 8, Title: "Go elsewhere", Published: true}, false); err != nil {
t.Fatal(err)
}
if n := indexed(); n != 2 {
t.Fatalf("indexed %d documents, want 2", n)
}
// The engine answers both blogs; SQL keeps blog 7's post only.
posts, err := searchPosts(ctx, svc, db, 7, "go")
if err != nil {
t.Fatal(err)
}
if len(posts) != 1 || posts[0].ID != hello.ID {
t.Fatalf("search = %+v", posts)
}
// A write in a plain GORM transaction is not synced, so the index keeps
// the stale document; the SQL re-check still hides the draft.
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Model(hello).Update("published", false).Error
}); err != nil {
t.Fatal(err)
}
if n := indexed(); n != 2 {
t.Fatalf("indexed %d documents after an unsynced write, want 2", n)
}
if posts, err = searchPosts(ctx, svc, db, 7, "go"); err != nil || len(posts) != 0 {
t.Fatalf("search after unpublish = %+v, %v", posts, err)
}
// With the gate off nothing is sent.
if err := db.Exec(`UPDATE acme_blog_settings SET search_enabled = FALSE`).Error; err != nil {
t.Fatal(err)
}
if err := create(&Post{BlogID: 7, Title: "Go quietly", Published: true}, false); err != nil {
t.Fatal(err)
}
if n := indexed(); n != 2 {
t.Fatalf("indexed %d documents with the gate off, want 2", n)
}
}
// TestDocsDeclarations checks the Searchable methods the Search page shows.
func TestDocsDeclarations(t *testing.T) {
var _ beachcomber.Searchable = &Post{}
p := &Post{ID: 5, BlogID: 7, Title: "Hello", Published: true}
if p.SearchableAs() != "acme_blog_posts" || !p.ShouldBeSearchable() {
t.Fatalf("SearchableAs %q, ShouldBeSearchable %v", p.SearchableAs(), p.ShouldBeSearchable())
}
doc, err := p.ToSearchableArray(t.Context(), nil)
if err != nil || doc["id"] != "5" || doc["blog_id"] != int64(7) {
t.Fatalf("ToSearchableArray = %v, %v", doc, err)
}
}