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