search_test.go raw
1 package neo4j
2
3 import (
4 "context"
5 "testing"
6
7 "next.orly.dev/pkg/nostr/encoders/event"
8 "next.orly.dev/pkg/nostr/encoders/filter"
9 "next.orly.dev/pkg/nostr/encoders/hex"
10 "next.orly.dev/pkg/nostr/encoders/kind"
11 "next.orly.dev/pkg/nostr/encoders/tag"
12 "next.orly.dev/pkg/nostr/encoders/timestamp"
13 "next.orly.dev/pkg/nostr/interfaces/signer/p8k"
14 )
15
16 // createTestSigner creates a new signer for testing.
17 func createTestSigner(t *testing.T) *p8k.Signer {
18 t.Helper()
19 s, err := p8k.New()
20 if err != nil {
21 t.Fatalf("Failed to create signer: %v", err)
22 }
23 if err := s.Generate(); err != nil {
24 t.Fatalf("Failed to generate keypair: %v", err)
25 }
26 return s
27 }
28
29 // saveTestEvent creates, signs, and saves a test event, returning it.
30 func saveTestEvent(t *testing.T, signer *p8k.Signer, kindVal uint16, content string, tags *tag.S) *event.E {
31 t.Helper()
32 ctx := context.Background()
33
34 ev := event.New()
35 ev.Pubkey = signer.Pub()
36 ev.CreatedAt = timestamp.Now().V
37 ev.Kind = kindVal
38 ev.Content = []byte(content)
39 ev.Tags = tags
40
41 if err := ev.Sign(signer); err != nil {
42 t.Fatalf("Failed to sign event: %v", err)
43 }
44
45 exists, err := testDB.SaveEvent(ctx, ev)
46 if err != nil {
47 t.Fatalf("Failed to save event: %v", err)
48 }
49 if exists {
50 t.Fatalf("Event already exists")
51 }
52
53 return ev
54 }
55
56 // uintPtr returns a pointer to a uint value.
57 func uintPtr(v uint) *uint { return &v }
58
59 // TestSaveEvent_WordTokens verifies that saving an event creates Word nodes
60 // and HAS_WORD relationships for content words.
61 func TestSaveEvent_WordTokens(t *testing.T) {
62 cleanTestDatabase()
63 ctx := context.Background()
64 signer := createTestSigner(t)
65
66 // Save event with known content
67 saveTestEvent(t, signer, 1, "Bitcoin lightning network", nil)
68
69 // Verify Word nodes were created
70 wordCount := countNodes(t, "Word")
71 if wordCount == 0 {
72 t.Fatal("Expected Word nodes to be created, got 0")
73 }
74
75 // Verify specific words exist (bitcoin, lightning, network)
76 for _, word := range []string{"bitcoin", "lightning", "network"} {
77 cypher := `MATCH (w:Word) WHERE w.text = $text RETURN w.hash AS hash`
78 result, err := testDB.ExecuteRead(ctx, cypher, map[string]any{"text": word})
79 if err != nil {
80 t.Fatalf("Failed to query Word node for %q: %v", word, err)
81 }
82 if !result.Next(ctx) {
83 t.Errorf("Expected Word node for %q, not found", word)
84 }
85 }
86
87 // Verify HAS_WORD relationships exist
88 cypher := `MATCH (e:Event)-[:HAS_WORD]->(w:Word) RETURN count(w) AS count`
89 result, err := testDB.ExecuteRead(ctx, cypher, nil)
90 if err != nil {
91 t.Fatalf("Failed to count HAS_WORD relationships: %v", err)
92 }
93 if result.Next(ctx) {
94 count, _ := result.Record().Values[0].(int64)
95 if count < 3 {
96 t.Errorf("Expected at least 3 HAS_WORD relationships, got %d", count)
97 }
98 }
99 }
100
101 // TestSaveEvent_WordTokensFromTags verifies that tag field values are also tokenized.
102 func TestSaveEvent_WordTokensFromTags(t *testing.T) {
103 cleanTestDatabase()
104 ctx := context.Background()
105 signer := createTestSigner(t)
106
107 // Save event with content in tags but minimal body
108 tags := tag.NewS(
109 tag.NewFromAny("t", "cryptocurrency"),
110 tag.NewFromAny("subject", "decentralized finance"),
111 )
112 saveTestEvent(t, signer, 1, "hi", tags)
113
114 // Verify tag-sourced words exist
115 for _, word := range []string{"cryptocurrency", "decentralized", "finance"} {
116 cypher := `MATCH (w:Word) WHERE w.text = $text RETURN w.hash AS hash`
117 result, err := testDB.ExecuteRead(ctx, cypher, map[string]any{"text": word})
118 if err != nil {
119 t.Fatalf("Failed to query Word node for %q: %v", word, err)
120 }
121 if !result.Next(ctx) {
122 t.Errorf("Expected Word node for %q from tags, not found", word)
123 }
124 }
125 }
126
127 // TestSearchQuery_SingleTerm verifies basic single-term search returns matching events.
128 func TestSearchQuery_SingleTerm(t *testing.T) {
129 cleanTestDatabase()
130 signer := createTestSigner(t)
131
132 // Save events with distinct content
133 saveTestEvent(t, signer, 1, "Bitcoin is digital gold", nil)
134 saveTestEvent(t, signer, 1, "Ethereum smart contracts", nil)
135 saveTestEvent(t, signer, 1, "Hello world greeting", nil)
136
137 // Search for "bitcoin"
138 f := &filter.F{
139 Search: []byte("bitcoin"),
140 }
141
142 evs, err := testDB.QueryEvents(context.Background(), f)
143 if err != nil {
144 t.Fatalf("Search failed: %v", err)
145 }
146
147 if len(evs) != 1 {
148 t.Fatalf("Expected 1 result for 'bitcoin', got %d", len(evs))
149 }
150
151 if string(evs[0].Content) != "Bitcoin is digital gold" {
152 t.Errorf("Expected bitcoin event, got: %s", string(evs[0].Content))
153 }
154 }
155
156 // TestSearchQuery_MultiTerm verifies that events matching more search terms rank higher.
157 func TestSearchQuery_MultiTerm(t *testing.T) {
158 cleanTestDatabase()
159 signer := createTestSigner(t)
160
161 // Event matching one word
162 saveTestEvent(t, signer, 1, "Bitcoin price today", nil)
163 // Event matching two words
164 saveTestEvent(t, signer, 1, "Bitcoin lightning network payment", nil)
165 // Event matching no words (should not appear)
166 saveTestEvent(t, signer, 1, "Hello world greeting", nil)
167
168 // Search for "bitcoin lightning"
169 f := &filter.F{
170 Search: []byte("bitcoin lightning"),
171 }
172
173 evs, err := testDB.QueryEvents(context.Background(), f)
174 if err != nil {
175 t.Fatalf("Search failed: %v", err)
176 }
177
178 if len(evs) != 2 {
179 t.Fatalf("Expected 2 results for 'bitcoin lightning', got %d", len(evs))
180 }
181
182 // The event matching both words should rank first
183 if string(evs[0].Content) != "Bitcoin lightning network payment" {
184 t.Errorf("Expected 2-match event first, got: %s", string(evs[0].Content))
185 }
186 }
187
188 // TestSearchQuery_WithKindFilter verifies search combined with kinds filter.
189 func TestSearchQuery_WithKindFilter(t *testing.T) {
190 cleanTestDatabase()
191 signer := createTestSigner(t)
192
193 // Kind 1 (text note) with bitcoin
194 saveTestEvent(t, signer, 1, "Bitcoin price analysis", nil)
195 // Kind 30023 (long-form) with bitcoin
196 dTag := tag.NewS(tag.NewFromAny("d", "article-1"))
197 saveTestEvent(t, signer, 30023, "Bitcoin deep dive article", dTag)
198
199 // Search for "bitcoin" but only kind 1
200 f := &filter.F{
201 Search: []byte("bitcoin"),
202 Kinds: kind.NewS(kind.New(1)),
203 Limit: uintPtr(10),
204 }
205
206 evs, err := testDB.QueryEvents(context.Background(), f)
207 if err != nil {
208 t.Fatalf("Search failed: %v", err)
209 }
210
211 if len(evs) != 1 {
212 t.Fatalf("Expected 1 result for 'bitcoin' kind 1, got %d", len(evs))
213 }
214
215 if evs[0].Kind != 1 {
216 t.Errorf("Expected kind 1, got kind %d", evs[0].Kind)
217 }
218 }
219
220 // TestSearchQuery_WithAuthorFilter verifies search combined with authors filter.
221 func TestSearchQuery_WithAuthorFilter(t *testing.T) {
222 cleanTestDatabase()
223
224 alice := createTestSigner(t)
225 bob := createTestSigner(t)
226
227 // Both authors post about bitcoin
228 saveTestEvent(t, alice, 1, "Bitcoin from Alice perspective", nil)
229 saveTestEvent(t, bob, 1, "Bitcoin from Bob perspective", nil)
230
231 // Search for "bitcoin" but only from Alice
232 f := &filter.F{
233 Search: []byte("bitcoin"),
234 Authors: tag.NewFromBytesSlice(alice.Pub()),
235 Limit: uintPtr(10),
236 }
237
238 evs, err := testDB.QueryEvents(context.Background(), f)
239 if err != nil {
240 t.Fatalf("Search failed: %v", err)
241 }
242
243 if len(evs) != 1 {
244 t.Fatalf("Expected 1 result for alice's bitcoin, got %d", len(evs))
245 }
246
247 alicePub := hex.Enc(evs[0].Pubkey)
248 expectedPub := hex.Enc(alice.Pub())
249 if alicePub != expectedPub {
250 t.Errorf("Expected alice's pubkey, got different author")
251 }
252 }
253
254 // TestSearchQuery_URLsIgnored verifies that URLs in content are not indexed.
255 func TestSearchQuery_URLsIgnored(t *testing.T) {
256 cleanTestDatabase()
257 signer := createTestSigner(t)
258
259 // Content with URL — "example" should not be indexed from the URL
260 saveTestEvent(t, signer, 1, "Check https://example.com for details", nil)
261
262 // Search for "example" — should not match the URL
263 f := &filter.F{
264 Search: []byte("example"),
265 }
266
267 evs, err := testDB.QueryEvents(context.Background(), f)
268 if err != nil {
269 t.Fatalf("Search failed: %v", err)
270 }
271
272 // "example" only appears in the URL which should be skipped
273 if len(evs) != 0 {
274 t.Errorf("Expected 0 results (URL words should be ignored), got %d", len(evs))
275 }
276 }
277
278 // TestSearchQuery_CaseInsensitive verifies that search is case-insensitive.
279 func TestSearchQuery_CaseInsensitive(t *testing.T) {
280 cleanTestDatabase()
281 signer := createTestSigner(t)
282
283 saveTestEvent(t, signer, 1, "BITCOIN IS GREAT", nil)
284
285 // Search with lowercase
286 f := &filter.F{
287 Search: []byte("bitcoin"),
288 }
289
290 evs, err := testDB.QueryEvents(context.Background(), f)
291 if err != nil {
292 t.Fatalf("Search failed: %v", err)
293 }
294
295 if len(evs) != 1 {
296 t.Errorf("Expected 1 result for case-insensitive 'bitcoin', got %d", len(evs))
297 }
298 }
299
300 // TestSearchQuery_NoResults verifies empty result for unmatched terms.
301 func TestSearchQuery_NoResults(t *testing.T) {
302 cleanTestDatabase()
303 signer := createTestSigner(t)
304
305 saveTestEvent(t, signer, 1, "Hello world", nil)
306
307 // Search for a term that doesn't exist
308 f := &filter.F{
309 Search: []byte("xyznonexistent"),
310 }
311
312 evs, err := testDB.QueryEvents(context.Background(), f)
313 if err != nil {
314 t.Fatalf("Search failed: %v", err)
315 }
316
317 if len(evs) != 0 {
318 t.Errorf("Expected 0 results for non-existent term, got %d", len(evs))
319 }
320 }
321
322 // TestSearchQuery_UnicodeNormalization verifies that decorative unicode
323 // (small caps, fraktur) in event content matches plain ASCII search terms.
324 func TestSearchQuery_UnicodeNormalization(t *testing.T) {
325 cleanTestDatabase()
326 signer := createTestSigner(t)
327
328 // Save event with small caps content
329 saveTestEvent(t, signer, 1, "ᴅᴇᴀᴛʜ comes for everyone", nil)
330
331 // Search for the ASCII equivalent
332 f := &filter.F{
333 Search: []byte("death"),
334 }
335
336 evs, err := testDB.QueryEvents(context.Background(), f)
337 if err != nil {
338 t.Fatalf("Search failed: %v", err)
339 }
340
341 if len(evs) != 1 {
342 t.Fatalf("Expected 1 result for 'death' matching small caps, got %d", len(evs))
343 }
344 }
345
346 // TestSearchQuery_EmptyContentTagWords verifies that events with no content
347 // but with searchable words in tags are found by search.
348 func TestSearchQuery_EmptyContentTagWords(t *testing.T) {
349 cleanTestDatabase()
350 signer := createTestSigner(t)
351
352 // Event with empty content but words in tag values
353 tags := tag.NewS(
354 tag.NewFromAny("subject", "bitcoin trading strategies"),
355 )
356 saveTestEvent(t, signer, 1, "", tags)
357
358 // Search for a word that appears only in the tag value
359 f := &filter.F{
360 Search: []byte("strategies"),
361 }
362
363 evs, err := testDB.QueryEvents(context.Background(), f)
364 if err != nil {
365 t.Fatalf("Search failed: %v", err)
366 }
367
368 if len(evs) != 1 {
369 t.Fatalf("Expected 1 result for tag-only word 'strategies', got %d", len(evs))
370 }
371 }
372
373 // TestSearchQuery_MultiTermScoringRecency verifies that recency affects
374 // ranking when match counts are equal.
375 func TestSearchQuery_MultiTermScoringRecency(t *testing.T) {
376 cleanTestDatabase()
377 signer := createTestSigner(t)
378
379 // Save events with identical content but different timestamps.
380 // We set created_at manually via saveTestEvent ordering.
381 // ev1: old event matching "bitcoin lightning"
382 ev1 := event.New()
383 ev1.Pubkey = signer.Pub()
384 ev1.CreatedAt = 1000000
385 ev1.Kind = 1
386 ev1.Content = []byte("bitcoin lightning old post")
387 ev1.Tags = tag.NewS()
388 if err := ev1.Sign(signer); err != nil {
389 t.Fatalf("Failed to sign: %v", err)
390 }
391 if _, err := testDB.SaveEvent(context.Background(), ev1); err != nil {
392 t.Fatalf("Failed to save: %v", err)
393 }
394
395 // ev2: recent event matching "bitcoin lightning"
396 ev2 := event.New()
397 ev2.Pubkey = signer.Pub()
398 ev2.CreatedAt = 2000000
399 ev2.Kind = 1
400 ev2.Content = []byte("bitcoin lightning recent post")
401 ev2.Tags = tag.NewS()
402 if err := ev2.Sign(signer); err != nil {
403 t.Fatalf("Failed to sign: %v", err)
404 }
405 if _, err := testDB.SaveEvent(context.Background(), ev2); err != nil {
406 t.Fatalf("Failed to save: %v", err)
407 }
408
409 f := &filter.F{
410 Search: []byte("bitcoin lightning"),
411 }
412
413 evs, err := testDB.QueryEvents(context.Background(), f)
414 if err != nil {
415 t.Fatalf("Search failed: %v", err)
416 }
417
418 if len(evs) != 2 {
419 t.Fatalf("Expected 2 results, got %d", len(evs))
420 }
421
422 // Both match 2 terms, so recency should break the tie — recent event first
423 if evs[0].CreatedAt < evs[1].CreatedAt {
424 t.Errorf("Expected recent event first (created_at %d), got older event first (created_at %d)",
425 evs[1].CreatedAt, evs[0].CreatedAt)
426 }
427 }
428
429 // TestSearchQuery_LimitInteraction verifies that limit=1 returns exactly 1 result.
430 func TestSearchQuery_LimitInteraction(t *testing.T) {
431 cleanTestDatabase()
432 signer := createTestSigner(t)
433
434 saveTestEvent(t, signer, 1, "bitcoin price today", nil)
435 saveTestEvent(t, signer, 1, "bitcoin market analysis", nil)
436 saveTestEvent(t, signer, 1, "bitcoin lightning network", nil)
437
438 f := &filter.F{
439 Search: []byte("bitcoin"),
440 Limit: uintPtr(1),
441 }
442
443 evs, err := testDB.QueryEvents(context.Background(), f)
444 if err != nil {
445 t.Fatalf("Search failed: %v", err)
446 }
447
448 if len(evs) != 1 {
449 t.Errorf("Expected exactly 1 result with limit=1, got %d", len(evs))
450 }
451 }
452
453 // TestSearchQuery_StopWordsNotIndexed verifies that searching for a stop word
454 // returns no results because stop words are not indexed.
455 func TestSearchQuery_StopWordsNotIndexed(t *testing.T) {
456 cleanTestDatabase()
457 signer := createTestSigner(t)
458
459 saveTestEvent(t, signer, 1, "the quick brown fox", nil)
460
461 // "the" is a stop word — should not be indexed
462 f := &filter.F{
463 Search: []byte("the"),
464 }
465
466 evs, err := testDB.QueryEvents(context.Background(), f)
467 if err != nil {
468 t.Fatalf("Search failed: %v", err)
469 }
470
471 if len(evs) != 0 {
472 t.Errorf("Expected 0 results for stop word 'the', got %d", len(evs))
473 }
474 }
475
476 // TestSearchQuery_TagValueOnly verifies that words appearing only in tag values
477 // (not in content) are searchable.
478 func TestSearchQuery_TagValueOnly(t *testing.T) {
479 cleanTestDatabase()
480 signer := createTestSigner(t)
481
482 tags := tag.NewS(
483 tag.NewFromAny("t", "cryptocurrency"),
484 )
485 saveTestEvent(t, signer, 1, "hello world", tags)
486
487 f := &filter.F{
488 Search: []byte("cryptocurrency"),
489 }
490
491 evs, err := testDB.QueryEvents(context.Background(), f)
492 if err != nil {
493 t.Fatalf("Search failed: %v", err)
494 }
495
496 if len(evs) != 1 {
497 t.Errorf("Expected 1 result for tag-value word 'cryptocurrency', got %d", len(evs))
498 }
499 }
500