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