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