bugfix_test.go raw

   1  //go:build integration
   2  // +build integration
   3  
   4  // Integration tests for Neo4j bug fixes.
   5  // These tests require a running Neo4j instance and are not run by default.
   6  //
   7  // To run these tests:
   8  //   1. Start Neo4j: docker compose -f pkg/neo4j/docker-compose.yaml up -d
   9  //   2. Run tests: go test -tags=integration ./pkg/neo4j/... -v
  10  //   3. Stop Neo4j: docker compose -f pkg/neo4j/docker-compose.yaml down
  11  //
  12  // Or use the helper script:
  13  //   ./scripts/test-neo4j-integration.sh
  14  
  15  package neo4j
  16  
  17  import (
  18  	"context"
  19  	"crypto/rand"
  20  	"encoding/hex"
  21  	"testing"
  22  	"time"
  23  
  24  	"next.orly.dev/pkg/nostr/encoders/event"
  25  	"next.orly.dev/pkg/nostr/encoders/tag"
  26  )
  27  
  28  // TestLargeContactListBatching tests that kind 3 events with many follows
  29  // don't cause OOM errors by verifying batched processing works correctly.
  30  // This tests the fix for: "java out of memory error broadcasting a kind 3 event"
  31  func TestLargeContactListBatching(t *testing.T) {
  32  	if testDB == nil {
  33  		t.Skip("Neo4j not available")
  34  	}
  35  
  36  	ctx := context.Background()
  37  
  38  	// Clean up before test
  39  	cleanTestDatabase()
  40  
  41  	// Generate a test pubkey for the author
  42  	authorPubkey := generateTestPubkey()
  43  
  44  	// Create a kind 3 event with 2000 follows (enough to require multiple batches)
  45  	// With contactListBatchSize = 1000, this will require 2 batches
  46  	numFollows := 2000
  47  	followPubkeys := make([]string, numFollows)
  48  	tagsList := tag.NewS()
  49  
  50  	for i := 0; i < numFollows; i++ {
  51  		followPubkeys[i] = generateTestPubkey()
  52  		tagsList.Append(tag.NewFromAny("p", followPubkeys[i]))
  53  	}
  54  
  55  	// Create the kind 3 event
  56  	ev := createTestEvent(t, authorPubkey, 3, tagsList, "")
  57  
  58  	// Save the event - this should NOT cause OOM with batching
  59  	exists, err := testDB.SaveEvent(ctx, ev)
  60  	if err != nil {
  61  		t.Fatalf("Failed to save large contact list event: %v", err)
  62  	}
  63  	if exists {
  64  		t.Fatal("Event unexpectedly already exists")
  65  	}
  66  
  67  	// Verify the event was saved
  68  	eventID := hex.EncodeToString(ev.ID[:])
  69  	checkCypher := "MATCH (e:Event {id: $id}) RETURN e.id AS id"
  70  	result, err := testDB.ExecuteRead(ctx, checkCypher, map[string]any{"id": eventID})
  71  	if err != nil {
  72  		t.Fatalf("Failed to check event existence: %v", err)
  73  	}
  74  	if !result.Next(ctx) {
  75  		t.Fatal("Event was not saved")
  76  	}
  77  
  78  	// Verify FOLLOWS relationships were created
  79  	followsCypher := `
  80  		MATCH (author:NostrUser {pubkey: $pubkey})-[:FOLLOWS]->(followed:NostrUser)
  81  		RETURN count(followed) AS count
  82  	`
  83  	result, err = testDB.ExecuteRead(ctx, followsCypher, map[string]any{"pubkey": authorPubkey})
  84  	if err != nil {
  85  		t.Fatalf("Failed to count follows: %v", err)
  86  	}
  87  
  88  	if result.Next(ctx) {
  89  		count := result.Record().Values[0].(int64)
  90  		if count != int64(numFollows) {
  91  			t.Errorf("Expected %d follows, got %d", numFollows, count)
  92  		}
  93  		t.Logf("Successfully created %d FOLLOWS relationships in batches", count)
  94  	} else {
  95  		t.Fatal("No follow count returned")
  96  	}
  97  
  98  	// Verify ProcessedSocialEvent was created with correct relationship_count
  99  	psCypher := `
 100  		MATCH (ps:ProcessedSocialEvent {pubkey: $pubkey, event_kind: 3})
 101  		RETURN ps.relationship_count AS count
 102  	`
 103  	result, err = testDB.ExecuteRead(ctx, psCypher, map[string]any{"pubkey": authorPubkey})
 104  	if err != nil {
 105  		t.Fatalf("Failed to check ProcessedSocialEvent: %v", err)
 106  	}
 107  
 108  	if result.Next(ctx) {
 109  		count := result.Record().Values[0].(int64)
 110  		if count != int64(numFollows) {
 111  			t.Errorf("ProcessedSocialEvent.relationship_count: expected %d, got %d", numFollows, count)
 112  		}
 113  	} else {
 114  		t.Fatal("ProcessedSocialEvent not created")
 115  	}
 116  }
 117  
 118  // TestMultipleETagsWithClause tests that events with multiple e-tags
 119  // generate valid Cypher (WITH between FOREACH and OPTIONAL MATCH).
 120  // This tests the fix for: "WITH is required between FOREACH and MATCH"
 121  func TestMultipleETagsWithClause(t *testing.T) {
 122  	if testDB == nil {
 123  		t.Skip("Neo4j not available")
 124  	}
 125  
 126  	ctx := context.Background()
 127  
 128  	// Clean up before test
 129  	cleanTestDatabase()
 130  
 131  	// First, create some events that will be referenced
 132  	refEventIDs := make([]string, 5)
 133  	for i := 0; i < 5; i++ {
 134  		refPubkey := generateTestPubkey()
 135  		refTags := tag.NewS()
 136  		refEv := createTestEvent(t, refPubkey, 1, refTags, "referenced event")
 137  		exists, err := testDB.SaveEvent(ctx, refEv)
 138  		if err != nil {
 139  			t.Fatalf("Failed to save reference event %d: %v", i, err)
 140  		}
 141  		if exists {
 142  			t.Fatalf("Reference event %d unexpectedly exists", i)
 143  		}
 144  		refEventIDs[i] = hex.EncodeToString(refEv.ID[:])
 145  	}
 146  
 147  	// Create a kind 5 delete event that references multiple events (multiple e-tags)
 148  	authorPubkey := generateTestPubkey()
 149  	tagsList := tag.NewS()
 150  	for _, refID := range refEventIDs {
 151  		tagsList.Append(tag.NewFromAny("e", refID))
 152  	}
 153  
 154  	// Create the kind 5 event with multiple e-tags
 155  	ev := createTestEvent(t, authorPubkey, 5, tagsList, "")
 156  
 157  	// Save the event - this should NOT fail with Cypher syntax error
 158  	exists, err := testDB.SaveEvent(ctx, ev)
 159  	if err != nil {
 160  		t.Fatalf("Failed to save event with multiple e-tags: %v\n"+
 161  			"This indicates the WITH clause fix is not working", err)
 162  	}
 163  	if exists {
 164  		t.Fatal("Event unexpectedly already exists")
 165  	}
 166  
 167  	// Verify the event was saved
 168  	eventID := hex.EncodeToString(ev.ID[:])
 169  	checkCypher := "MATCH (e:Event {id: $id}) RETURN e.id AS id"
 170  	result, err := testDB.ExecuteRead(ctx, checkCypher, map[string]any{"id": eventID})
 171  	if err != nil {
 172  		t.Fatalf("Failed to check event existence: %v", err)
 173  	}
 174  	if !result.Next(ctx) {
 175  		t.Fatal("Event was not saved")
 176  	}
 177  
 178  	// Verify REFERENCES relationships were created
 179  	refCypher := `
 180  		MATCH (e:Event {id: $id})-[:REFERENCES]->(ref:Event)
 181  		RETURN count(ref) AS count
 182  	`
 183  	result, err = testDB.ExecuteRead(ctx, refCypher, map[string]any{"id": eventID})
 184  	if err != nil {
 185  		t.Fatalf("Failed to count references: %v", err)
 186  	}
 187  
 188  	if result.Next(ctx) {
 189  		count := result.Record().Values[0].(int64)
 190  		if count != int64(len(refEventIDs)) {
 191  			t.Errorf("Expected %d REFERENCES relationships, got %d", len(refEventIDs), count)
 192  		}
 193  		t.Logf("Successfully created %d REFERENCES relationships", count)
 194  	} else {
 195  		t.Fatal("No reference count returned")
 196  	}
 197  }
 198  
 199  // TestLargeMuteListBatching tests that kind 10000 events with many mutes
 200  // don't cause OOM errors by verifying batched processing works correctly.
 201  func TestLargeMuteListBatching(t *testing.T) {
 202  	if testDB == nil {
 203  		t.Skip("Neo4j not available")
 204  	}
 205  
 206  	ctx := context.Background()
 207  
 208  	// Clean up before test
 209  	cleanTestDatabase()
 210  
 211  	// Generate a test pubkey for the author
 212  	authorPubkey := generateTestPubkey()
 213  
 214  	// Create a kind 10000 event with 1500 mutes (enough to require 2 batches)
 215  	numMutes := 1500
 216  	tagsList := tag.NewS()
 217  
 218  	for i := 0; i < numMutes; i++ {
 219  		mutePubkey := generateTestPubkey()
 220  		tagsList.Append(tag.NewFromAny("p", mutePubkey))
 221  	}
 222  
 223  	// Create the kind 10000 event
 224  	ev := createTestEvent(t, authorPubkey, 10000, tagsList, "")
 225  
 226  	// Save the event - this should NOT cause OOM with batching
 227  	exists, err := testDB.SaveEvent(ctx, ev)
 228  	if err != nil {
 229  		t.Fatalf("Failed to save large mute list event: %v", err)
 230  	}
 231  	if exists {
 232  		t.Fatal("Event unexpectedly already exists")
 233  	}
 234  
 235  	// Verify MUTES relationships were created
 236  	mutesCypher := `
 237  		MATCH (author:NostrUser {pubkey: $pubkey})-[:MUTES]->(muted:NostrUser)
 238  		RETURN count(muted) AS count
 239  	`
 240  	result, err := testDB.ExecuteRead(ctx, mutesCypher, map[string]any{"pubkey": authorPubkey})
 241  	if err != nil {
 242  		t.Fatalf("Failed to count mutes: %v", err)
 243  	}
 244  
 245  	if result.Next(ctx) {
 246  		count := result.Record().Values[0].(int64)
 247  		if count != int64(numMutes) {
 248  			t.Errorf("Expected %d mutes, got %d", numMutes, count)
 249  		}
 250  		t.Logf("Successfully created %d MUTES relationships in batches", count)
 251  	} else {
 252  		t.Fatal("No mute count returned")
 253  	}
 254  }
 255  
 256  // TestContactListUpdate tests that updating a contact list (replacing one kind 3 with another)
 257  // correctly handles the diff and batching.
 258  func TestContactListUpdate(t *testing.T) {
 259  	if testDB == nil {
 260  		t.Skip("Neo4j not available")
 261  	}
 262  
 263  	ctx := context.Background()
 264  
 265  	// Clean up before test
 266  	cleanTestDatabase()
 267  
 268  	authorPubkey := generateTestPubkey()
 269  
 270  	// Create initial contact list with 500 follows
 271  	initialFollows := make([]string, 500)
 272  	tagsList1 := tag.NewS()
 273  	for i := 0; i < 500; i++ {
 274  		initialFollows[i] = generateTestPubkey()
 275  		tagsList1.Append(tag.NewFromAny("p", initialFollows[i]))
 276  	}
 277  
 278  	ev1 := createTestEventWithTimestamp(t, authorPubkey, 3, tagsList1, "", time.Now().Unix()-100)
 279  	_, err := testDB.SaveEvent(ctx, ev1)
 280  	if err != nil {
 281  		t.Fatalf("Failed to save initial contact list: %v", err)
 282  	}
 283  
 284  	// Verify initial follows count
 285  	countCypher := `
 286  		MATCH (author:NostrUser {pubkey: $pubkey})-[:FOLLOWS]->(followed:NostrUser)
 287  		RETURN count(followed) AS count
 288  	`
 289  	result, err := testDB.ExecuteRead(ctx, countCypher, map[string]any{"pubkey": authorPubkey})
 290  	if err != nil {
 291  		t.Fatalf("Failed to count initial follows: %v", err)
 292  	}
 293  	if result.Next(ctx) {
 294  		count := result.Record().Values[0].(int64)
 295  		if count != 500 {
 296  			t.Errorf("Initial follows: expected 500, got %d", count)
 297  		}
 298  	}
 299  
 300  	// Create updated contact list: remove 100 old follows, add 200 new ones
 301  	tagsList2 := tag.NewS()
 302  	// Keep first 400 of the original follows
 303  	for i := 0; i < 400; i++ {
 304  		tagsList2.Append(tag.NewFromAny("p", initialFollows[i]))
 305  	}
 306  	// Add 200 new follows
 307  	for i := 0; i < 200; i++ {
 308  		tagsList2.Append(tag.NewFromAny("p", generateTestPubkey()))
 309  	}
 310  
 311  	ev2 := createTestEventWithTimestamp(t, authorPubkey, 3, tagsList2, "", time.Now().Unix())
 312  	_, err = testDB.SaveEvent(ctx, ev2)
 313  	if err != nil {
 314  		t.Fatalf("Failed to save updated contact list: %v", err)
 315  	}
 316  
 317  	// Verify final follows count (should be 600)
 318  	result, err = testDB.ExecuteRead(ctx, countCypher, map[string]any{"pubkey": authorPubkey})
 319  	if err != nil {
 320  		t.Fatalf("Failed to count final follows: %v", err)
 321  	}
 322  	if result.Next(ctx) {
 323  		count := result.Record().Values[0].(int64)
 324  		if count != 600 {
 325  			t.Errorf("Final follows: expected 600, got %d", count)
 326  		}
 327  		t.Logf("Contact list update successful: 500 -> 600 follows (removed 100, added 200)")
 328  	}
 329  
 330  	// Verify old ProcessedSocialEvent is marked as superseded
 331  	supersededCypher := `
 332  		MATCH (ps:ProcessedSocialEvent {pubkey: $pubkey, event_kind: 3})
 333  		WHERE ps.superseded_by IS NOT NULL
 334  		RETURN count(ps) AS count
 335  	`
 336  	result, err = testDB.ExecuteRead(ctx, supersededCypher, map[string]any{"pubkey": authorPubkey})
 337  	if err != nil {
 338  		t.Fatalf("Failed to check superseded events: %v", err)
 339  	}
 340  	if result.Next(ctx) {
 341  		count := result.Record().Values[0].(int64)
 342  		if count != 1 {
 343  			t.Errorf("Expected 1 superseded ProcessedSocialEvent, got %d", count)
 344  		}
 345  	}
 346  }
 347  
 348  // TestMixedTagsEvent tests that events with e-tags, p-tags, and other tags
 349  // all generate valid Cypher with proper WITH clauses.
 350  func TestMixedTagsEvent(t *testing.T) {
 351  	if testDB == nil {
 352  		t.Skip("Neo4j not available")
 353  	}
 354  
 355  	ctx := context.Background()
 356  
 357  	// Clean up before test
 358  	cleanTestDatabase()
 359  
 360  	// Create some referenced events
 361  	refEventIDs := make([]string, 3)
 362  	for i := 0; i < 3; i++ {
 363  		refPubkey := generateTestPubkey()
 364  		refTags := tag.NewS()
 365  		refEv := createTestEvent(t, refPubkey, 1, refTags, "ref")
 366  		testDB.SaveEvent(ctx, refEv)
 367  		refEventIDs[i] = hex.EncodeToString(refEv.ID[:])
 368  	}
 369  
 370  	// Create an event with mixed tags: e-tags, p-tags, and other tags
 371  	authorPubkey := generateTestPubkey()
 372  	tagsList := tag.NewS(
 373  		// e-tags (event references)
 374  		tag.NewFromAny("e", refEventIDs[0]),
 375  		tag.NewFromAny("e", refEventIDs[1]),
 376  		tag.NewFromAny("e", refEventIDs[2]),
 377  		// p-tags (pubkey mentions)
 378  		tag.NewFromAny("p", generateTestPubkey()),
 379  		tag.NewFromAny("p", generateTestPubkey()),
 380  		// other tags
 381  		tag.NewFromAny("t", "nostr"),
 382  		tag.NewFromAny("t", "test"),
 383  		tag.NewFromAny("subject", "Test Subject"),
 384  	)
 385  
 386  	ev := createTestEvent(t, authorPubkey, 1, tagsList, "Mixed tags test")
 387  
 388  	// Save the event - should not fail with Cypher syntax errors
 389  	exists, err := testDB.SaveEvent(ctx, ev)
 390  	if err != nil {
 391  		t.Fatalf("Failed to save event with mixed tags: %v", err)
 392  	}
 393  	if exists {
 394  		t.Fatal("Event unexpectedly already exists")
 395  	}
 396  
 397  	eventID := hex.EncodeToString(ev.ID[:])
 398  
 399  	// Verify REFERENCES relationships
 400  	refCypher := `MATCH (e:Event {id: $id})-[:REFERENCES]->(ref:Event) RETURN count(ref) AS count`
 401  	result, err := testDB.ExecuteRead(ctx, refCypher, map[string]any{"id": eventID})
 402  	if err != nil {
 403  		t.Fatalf("Failed to count references: %v", err)
 404  	}
 405  	if result.Next(ctx) {
 406  		count := result.Record().Values[0].(int64)
 407  		if count != 3 {
 408  			t.Errorf("Expected 3 REFERENCES, got %d", count)
 409  		}
 410  	}
 411  
 412  	// Verify MENTIONS relationships
 413  	mentionsCypher := `MATCH (e:Event {id: $id})-[:MENTIONS]->(u:NostrUser) RETURN count(u) AS count`
 414  	result, err = testDB.ExecuteRead(ctx, mentionsCypher, map[string]any{"id": eventID})
 415  	if err != nil {
 416  		t.Fatalf("Failed to count mentions: %v", err)
 417  	}
 418  	if result.Next(ctx) {
 419  		count := result.Record().Values[0].(int64)
 420  		if count != 2 {
 421  			t.Errorf("Expected 2 MENTIONS, got %d", count)
 422  		}
 423  	}
 424  
 425  	// Verify TAGGED_WITH relationships
 426  	taggedCypher := `MATCH (e:Event {id: $id})-[:TAGGED_WITH]->(t:Tag) RETURN count(t) AS count`
 427  	result, err = testDB.ExecuteRead(ctx, taggedCypher, map[string]any{"id": eventID})
 428  	if err != nil {
 429  		t.Fatalf("Failed to count tags: %v", err)
 430  	}
 431  	if result.Next(ctx) {
 432  		count := result.Record().Values[0].(int64)
 433  		if count != 3 {
 434  			t.Errorf("Expected 3 TAGGED_WITH, got %d", count)
 435  		}
 436  	}
 437  
 438  	t.Log("Mixed tags event saved successfully with all relationship types")
 439  }
 440  
 441  // Helper functions
 442  
 443  func generateTestPubkey() string {
 444  	b := make([]byte, 32)
 445  	rand.Read(b)
 446  	return hex.EncodeToString(b)
 447  }
 448  
 449  func createTestEvent(t *testing.T, pubkey string, kind uint16, tagsList *tag.S, content string) *event.E {
 450  	t.Helper()
 451  	return createTestEventWithTimestamp(t, pubkey, kind, tagsList, content, time.Now().Unix())
 452  }
 453  
 454  func createTestEventWithTimestamp(t *testing.T, pubkey string, kind uint16, tagsList *tag.S, content string, timestamp int64) *event.E {
 455  	t.Helper()
 456  
 457  	// Decode pubkey
 458  	pubkeyBytes, err := hex.DecodeString(pubkey)
 459  	if err != nil {
 460  		t.Fatalf("Invalid pubkey: %v", err)
 461  	}
 462  
 463  	// Generate random ID and signature (for testing purposes)
 464  	idBytes := make([]byte, 32)
 465  	rand.Read(idBytes)
 466  	sigBytes := make([]byte, 64)
 467  	rand.Read(sigBytes)
 468  
 469  	// event.E uses []byte slices, not [32]byte arrays, so we need to assign directly
 470  	ev := &event.E{
 471  		Kind:      kind,
 472  		Tags:      tagsList,
 473  		Content:   []byte(content),
 474  		CreatedAt: timestamp,
 475  		Pubkey:    pubkeyBytes,
 476  		ID:        idBytes,
 477  		Sig:       sigBytes,
 478  	}
 479  
 480  	return ev
 481  }
 482