query-events-search_test.go raw

   1  package database
   2  
   3  import (
   4  	"context"
   5  	"os"
   6  	"testing"
   7  	"time"
   8  
   9  	"next.orly.dev/pkg/lol/chk"
  10  	"next.orly.dev/pkg/nostr/interfaces/signer/p8k"
  11  	"next.orly.dev/pkg/nostr/encoders/event"
  12  	"next.orly.dev/pkg/nostr/encoders/filter"
  13  	"next.orly.dev/pkg/nostr/encoders/kind"
  14  	"next.orly.dev/pkg/nostr/encoders/tag"
  15  	"next.orly.dev/pkg/nostr/encoders/timestamp"
  16  )
  17  
  18  // helper to create a fresh DB
  19  func newTestDB(t *testing.T) (*D, context.Context, context.CancelFunc, string) {
  20  	t.Helper()
  21  	tempDir, err := os.MkdirTemp("", "search-db-*")
  22  	if err != nil {
  23  		t.Fatalf("Failed to create temp dir: %v", err)
  24  	}
  25  	ctx, cancel := context.WithCancel(context.Background())
  26  	db, err := New(ctx, cancel, tempDir, "error")
  27  	if err != nil {
  28  		cancel()
  29  		os.RemoveAll(tempDir)
  30  		t.Fatalf("Failed to init DB: %v", err)
  31  	}
  32  	return db, ctx, cancel, tempDir
  33  }
  34  
  35  // TestQueryEventsBySearchTerms creates a small set of events with content and tags,
  36  // saves them, then queries using filter.Search to ensure the word index works.
  37  func TestQueryEventsBySearchTerms(t *testing.T) {
  38  	db, ctx, cancel, tempDir := newTestDB(t)
  39  	defer func() {
  40  		// cancel context first to stop background routines cleanly
  41  		cancel()
  42  		db.Close()
  43  		os.RemoveAll(tempDir)
  44  	}()
  45  
  46  	// signer for all events
  47  	sign := p8k.MustNew()
  48  	if err := sign.Generate(); chk.E(err) {
  49  		t.Fatalf("signer generate: %v", err)
  50  	}
  51  
  52  	now := timestamp.Now().V
  53  
  54  	// Events to cover tokenizer rules:
  55  	// - regular words
  56  	// - URLs ignored
  57  	// - 64-char hex ignored
  58  	// - nostr: URIs ignored
  59  	// - #[n] mentions ignored
  60  	// - tag fields included in search
  61  
  62  	// 1. Contains words: "alpha beta", plus URL and hex (ignored)
  63  	ev1 := event.New()
  64  	ev1.Kind = kind.TextNote.K
  65  	ev1.Pubkey = sign.Pub()
  66  	ev1.CreatedAt = now - 5
  67  	ev1.Content = []byte("Alpha beta visit https://example.com deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
  68  	ev1.Tags = tag.NewS()
  69  	ev1.Sign(sign)
  70  	if _, err := db.SaveEvent(ctx, ev1); err != nil {
  71  		t.Fatalf("save ev1: %v", err)
  72  	}
  73  
  74  	// 2. Contains overlap word "beta" and unique "gamma" and nostr: URI ignored
  75  	ev2 := event.New()
  76  	ev2.Kind = kind.TextNote.K
  77  	ev2.Pubkey = sign.Pub()
  78  	ev2.CreatedAt = now - 4
  79  	ev2.Content = []byte("beta and GAMMA with nostr:nevent1qqqqq")
  80  	ev2.Tags = tag.NewS()
  81  	ev2.Sign(sign)
  82  	if _, err := db.SaveEvent(ctx, ev2); err != nil {
  83  		t.Fatalf("save ev2: %v", err)
  84  	}
  85  
  86  	// 3. Contains only a URL (should not create word tokens) and mention #[1] (ignored)
  87  	ev3 := event.New()
  88  	ev3.Kind = kind.TextNote.K
  89  	ev3.Pubkey = sign.Pub()
  90  	ev3.CreatedAt = now - 3
  91  	ev3.Content = []byte("see www.example.org #[1]")
  92  	ev3.Tags = tag.NewS()
  93  	ev3.Sign(sign)
  94  	if _, err := db.SaveEvent(ctx, ev3); err != nil {
  95  		t.Fatalf("save ev3: %v", err)
  96  	}
  97  
  98  	// 4. No content words, but tag value has searchable words: "delta epsilon"
  99  	ev4 := event.New()
 100  	ev4.Kind = kind.TextNote.K
 101  	ev4.Pubkey = sign.Pub()
 102  	ev4.CreatedAt = now - 2
 103  	ev4.Content = []byte("")
 104  	ev4.Tags = tag.NewS()
 105  	*ev4.Tags = append(*ev4.Tags, tag.NewFromAny("t", "delta epsilon"))
 106  	ev4.Sign(sign)
 107  	if _, err := db.SaveEvent(ctx, ev4); err != nil {
 108  		t.Fatalf("save ev4: %v", err)
 109  	}
 110  
 111  	// 5. Another event with both content and tag tokens for ordering checks
 112  	ev5 := event.New()
 113  	ev5.Kind = kind.TextNote.K
 114  	ev5.Pubkey = sign.Pub()
 115  	ev5.CreatedAt = now - 1
 116  	ev5.Content = []byte("alpha DELTA mixed-case and link http://foo.bar")
 117  	ev5.Tags = tag.NewS()
 118  	*ev5.Tags = append(*ev5.Tags, tag.NewFromAny("t", "zeta"))
 119  	ev5.Sign(sign)
 120  	if _, err := db.SaveEvent(ctx, ev5); err != nil {
 121  		t.Fatalf("save ev5: %v", err)
 122  	}
 123  
 124  	// Small sleep to ensure created_at ordering is the only factor
 125  	time.Sleep(5 * time.Millisecond)
 126  
 127  	// Helper to run a search and return IDs
 128  	run := func(q string) ([]*event.E, error) {
 129  		f := &filter.F{Search: []byte(q)}
 130  		return db.QueryEvents(ctx, f)
 131  	}
 132  
 133  	// Single-term search: alpha -> should match ev1 and ev5 ordered by created_at desc (ev5 newer)
 134  	if evs, err := run("alpha"); err != nil {
 135  		t.Fatalf("search alpha: %v", err)
 136  	} else {
 137  		if len(evs) != 2 {
 138  			t.Fatalf("alpha expected 2 results, got %d", len(evs))
 139  		}
 140  		if !(evs[0].CreatedAt >= evs[1].CreatedAt) {
 141  			t.Fatalf("results not ordered by created_at desc")
 142  		}
 143  	}
 144  
 145  	// Overlap term beta -> ev1 and ev2
 146  	if evs, err := run("beta"); err != nil {
 147  		t.Fatalf("search beta: %v", err)
 148  	} else if len(evs) != 2 {
 149  		t.Fatalf("beta expected 2 results, got %d", len(evs))
 150  	}
 151  
 152  	// Unique term gamma -> only ev2
 153  	if evs, err := run("gamma"); err != nil {
 154  		t.Fatalf("search gamma: %v", err)
 155  	} else if len(evs) != 1 {
 156  		t.Fatalf("gamma expected 1 result, got %d", len(evs))
 157  	}
 158  
 159  	// URL terms should be ignored: example -> appears only as URL in ev1/ev3/ev5; tokenizer ignores URLs so expect 0
 160  	if evs, err := run("example"); err != nil {
 161  		t.Fatalf("search example: %v", err)
 162  	} else if len(evs) != 0 {
 163  		t.Fatalf(
 164  			"example expected 0 results (URL tokens ignored), got %d", len(evs),
 165  		)
 166  	}
 167  
 168  	// Tag words searchable: delta should match ev4 and ev5 (delta in tag for ev4, in content for ev5)
 169  	if evs, err := run("delta"); err != nil {
 170  		t.Fatalf("search delta: %v", err)
 171  	} else if len(evs) != 2 {
 172  		t.Fatalf("delta expected 2 results, got %d", len(evs))
 173  	}
 174  
 175  	// Very short token ignored: single-letter should yield 0
 176  	if evs, err := run("a"); err != nil {
 177  		t.Fatalf("search short token: %v", err)
 178  	} else if len(evs) != 0 {
 179  		t.Fatalf("single-letter expected 0 results, got %d", len(evs))
 180  	}
 181  
 182  	// 64-char hex should be ignored
 183  	hex64 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
 184  	if evs, err := run(hex64); err != nil {
 185  		t.Fatalf("search hex64: %v", err)
 186  	} else if len(evs) != 0 {
 187  		t.Fatalf("hex64 expected 0 results, got %d", len(evs))
 188  	}
 189  
 190  	// nostr: scheme ignored
 191  	if evs, err := run("nostr:nevent1qqqqq"); err != nil {
 192  		t.Fatalf("search nostr: %v", err)
 193  	} else if len(evs) != 0 {
 194  		t.Fatalf("nostr: expected 0 results, got %d", len(evs))
 195  	}
 196  }
 197