save-event_test.go raw
1 package neo4j
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8
9 "next.orly.dev/pkg/nostr/encoders/event"
10 "next.orly.dev/pkg/nostr/encoders/hex"
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 // TestBuildBaseEventCypher verifies the base event creation query generates correct Cypher.
17 // The new architecture separates event creation from tag processing to avoid stack overflow.
18 func TestBuildBaseEventCypher(t *testing.T) {
19 n := &N{}
20
21 signer, err := p8k.New()
22 if err != nil {
23 t.Fatalf("Failed to create signer: %v", err)
24 }
25 if err := signer.Generate(); err != nil {
26 t.Fatalf("Failed to generate keypair: %v", err)
27 }
28
29 tests := []struct {
30 name string
31 tags *tag.S
32 description string
33 }{
34 {
35 name: "NoTags",
36 tags: nil,
37 description: "Event without tags",
38 },
39 {
40 name: "WithPTags",
41 tags: tag.NewS(
42 tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000001"),
43 tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000002"),
44 ),
45 description: "Event with p-tags (stored in tags JSON, relationships added separately)",
46 },
47 {
48 name: "WithETags",
49 tags: tag.NewS(
50 tag.NewFromAny("e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
51 tag.NewFromAny("e", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
52 ),
53 description: "Event with e-tags (stored in tags JSON, relationships added separately)",
54 },
55 {
56 name: "MixedTags",
57 tags: tag.NewS(
58 tag.NewFromAny("t", "nostr"),
59 tag.NewFromAny("e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
60 tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000003"),
61 ),
62 description: "Event with mixed tags",
63 },
64 }
65
66 for _, tt := range tests {
67 t.Run(tt.name, func(t *testing.T) {
68 ev := event.New()
69 ev.Pubkey = signer.Pub()
70 ev.CreatedAt = timestamp.Now().V
71 ev.Kind = 1
72 ev.Content = []byte(fmt.Sprintf("Test content for %s", tt.name))
73 ev.Tags = tt.tags
74
75 if err := ev.Sign(signer); err != nil {
76 t.Fatalf("Failed to sign event: %v", err)
77 }
78
79 cypher, params := n.buildBaseEventCypher(ev, 12345)
80
81 // Base event Cypher should NOT contain tag relationship clauses
82 // (tags are added separately via addTagsInBatches)
83 if strings.Contains(cypher, "OPTIONAL MATCH") {
84 t.Errorf("%s: buildBaseEventCypher should NOT contain OPTIONAL MATCH", tt.description)
85 }
86 if strings.Contains(cypher, "UNWIND") {
87 t.Errorf("%s: buildBaseEventCypher should NOT contain UNWIND", tt.description)
88 }
89 if strings.Contains(cypher, ":REFERENCES") {
90 t.Errorf("%s: buildBaseEventCypher should NOT contain :REFERENCES", tt.description)
91 }
92 if strings.Contains(cypher, ":MENTIONS") {
93 t.Errorf("%s: buildBaseEventCypher should NOT contain :MENTIONS", tt.description)
94 }
95 if strings.Contains(cypher, ":TAGGED_WITH") {
96 t.Errorf("%s: buildBaseEventCypher should NOT contain :TAGGED_WITH", tt.description)
97 }
98
99 // Should contain basic event creation elements
100 if !strings.Contains(cypher, "CREATE (e:Event") {
101 t.Errorf("%s: should CREATE Event node", tt.description)
102 }
103 if !strings.Contains(cypher, "MERGE (a:NostrUser") {
104 t.Errorf("%s: should MERGE NostrUser node", tt.description)
105 }
106 if !strings.Contains(cypher, ":AUTHORED_BY") {
107 t.Errorf("%s: should create AUTHORED_BY relationship", tt.description)
108 }
109
110 // Should have tags serialized in params
111 if _, ok := params["tags"]; !ok {
112 t.Errorf("%s: params should contain serialized tags", tt.description)
113 }
114
115 // Validate params have required fields
116 requiredParams := []string{"eventId", "serial", "kind", "createdAt", "content", "sig", "pubkey", "tags", "expiration"}
117 for _, p := range requiredParams {
118 if _, ok := params[p]; !ok {
119 t.Errorf("%s: missing required param: %s", tt.description, p)
120 }
121 }
122
123 t.Logf("✓ %s: base event Cypher is clean (no tag relationships)", tt.name)
124 })
125 }
126 }
127
128 // TestSafePrefix validates the safePrefix helper function
129 func TestSafePrefix(t *testing.T) {
130 tests := []struct {
131 input string
132 n int
133 expected string
134 }{
135 {"hello world", 5, "hello"},
136 {"hi", 5, "hi"},
137 {"", 5, ""},
138 {"1234567890", 10, "1234567890"},
139 {"1234567890", 11, "1234567890"},
140 {"0123456789abcdef", 8, "01234567"},
141 }
142
143 for _, tt := range tests {
144 t.Run(fmt.Sprintf("%q[:%d]", tt.input, tt.n), func(t *testing.T) {
145 result := safePrefix(tt.input, tt.n)
146 if result != tt.expected {
147 t.Errorf("safePrefix(%q, %d) = %q; want %q", tt.input, tt.n, result, tt.expected)
148 }
149 })
150 }
151 }
152
153 // TestSaveEvent_ETagReference tests that events with e-tags are saved correctly
154 // using the Tag-based model: Event-[:TAGGED_WITH]->Tag-[:REFERENCES]->Event.
155 // Uses shared testDB from testmain_test.go to avoid auth rate limiting.
156 func TestSaveEvent_ETagReference(t *testing.T) {
157 if testDB == nil {
158 t.Skip("Neo4j not available")
159 }
160
161 ctx := context.Background()
162
163 // Clean up before test
164 cleanTestDatabase()
165
166 // Generate keypairs
167 alice, err := p8k.New()
168 if err != nil {
169 t.Fatalf("Failed to create signer: %v", err)
170 }
171 if err := alice.Generate(); err != nil {
172 t.Fatalf("Failed to generate keypair: %v", err)
173 }
174
175 bob, err := p8k.New()
176 if err != nil {
177 t.Fatalf("Failed to create signer: %v", err)
178 }
179 if err := bob.Generate(); err != nil {
180 t.Fatalf("Failed to generate keypair: %v", err)
181 }
182
183 // Create a root event from Alice
184 rootEvent := event.New()
185 rootEvent.Pubkey = alice.Pub()
186 rootEvent.CreatedAt = timestamp.Now().V
187 rootEvent.Kind = 1
188 rootEvent.Content = []byte("This is the root event")
189
190 if err := rootEvent.Sign(alice); err != nil {
191 t.Fatalf("Failed to sign root event: %v", err)
192 }
193
194 // Save root event
195 exists, err := testDB.SaveEvent(ctx, rootEvent)
196 if err != nil {
197 t.Fatalf("Failed to save root event: %v", err)
198 }
199 if exists {
200 t.Fatal("Root event should not exist yet")
201 }
202
203 rootEventID := hex.Enc(rootEvent.ID[:])
204
205 // Create a reply from Bob that references the root event
206 replyEvent := event.New()
207 replyEvent.Pubkey = bob.Pub()
208 replyEvent.CreatedAt = timestamp.Now().V + 1
209 replyEvent.Kind = 1
210 replyEvent.Content = []byte("This is a reply to Alice")
211 replyEvent.Tags = tag.NewS(
212 tag.NewFromAny("e", rootEventID, "", "root"),
213 tag.NewFromAny("p", hex.Enc(alice.Pub())),
214 )
215
216 if err := replyEvent.Sign(bob); err != nil {
217 t.Fatalf("Failed to sign reply event: %v", err)
218 }
219
220 // Save reply event - this exercises the batched tag creation
221 exists, err = testDB.SaveEvent(ctx, replyEvent)
222 if err != nil {
223 t.Fatalf("Failed to save reply event: %v", err)
224 }
225 if exists {
226 t.Fatal("Reply event should not exist yet")
227 }
228
229 // Verify Tag-based e-tag model: Event-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->Event
230 cypher := `
231 MATCH (reply:Event {id: $replyId})-[:TAGGED_WITH]->(t:Tag {type: 'e', value: $rootId})-[:REFERENCES]->(root:Event {id: $rootId})
232 RETURN reply.id AS replyId, t.value AS tagValue, root.id AS rootId
233 `
234 params := map[string]any{
235 "replyId": hex.Enc(replyEvent.ID[:]),
236 "rootId": rootEventID,
237 }
238
239 result, err := testDB.ExecuteRead(ctx, cypher, params)
240 if err != nil {
241 t.Fatalf("Failed to query Tag-based REFERENCES: %v", err)
242 }
243
244 if !result.Next(ctx) {
245 t.Error("Expected Tag-based REFERENCES relationship between reply and root events")
246 } else {
247 record := result.Record()
248 returnedReplyId := record.Values[0].(string)
249 tagValue := record.Values[1].(string)
250 returnedRootId := record.Values[2].(string)
251 t.Logf("✓ Tag-based REFERENCES verified: Event(%s) -> Tag{e:%s} -> Event(%s)", returnedReplyId[:8], tagValue[:8], returnedRootId[:8])
252 }
253
254 // Verify Tag-based p-tag model: Event-[:TAGGED_WITH]->Tag{type:'p'}-[:REFERENCES]->NostrUser
255 pTagCypher := `
256 MATCH (reply:Event {id: $replyId})-[:TAGGED_WITH]->(t:Tag {type: 'p', value: $authorPubkey})-[:REFERENCES]->(author:NostrUser {pubkey: $authorPubkey})
257 RETURN author.pubkey AS pubkey, t.value AS tagValue
258 `
259 pTagParams := map[string]any{
260 "replyId": hex.Enc(replyEvent.ID[:]),
261 "authorPubkey": hex.Enc(alice.Pub()),
262 }
263
264 pTagResult, err := testDB.ExecuteRead(ctx, pTagCypher, pTagParams)
265 if err != nil {
266 t.Fatalf("Failed to query Tag-based p-tag: %v", err)
267 }
268
269 if !pTagResult.Next(ctx) {
270 t.Error("Expected Tag-based p-tag relationship")
271 } else {
272 t.Logf("✓ Tag-based p-tag relationship verified")
273 }
274 }
275
276 // TestSaveEvent_ETagMissingReference tests that e-tags to non-existent events
277 // create Tag nodes but don't create REFERENCES relationships to missing events.
278 // Uses shared testDB from testmain_test.go to avoid auth rate limiting.
279 func TestSaveEvent_ETagMissingReference(t *testing.T) {
280 if testDB == nil {
281 t.Skip("Neo4j not available")
282 }
283
284 ctx := context.Background()
285
286 // Clean up before test
287 cleanTestDatabase()
288
289 signer, err := p8k.New()
290 if err != nil {
291 t.Fatalf("Failed to create signer: %v", err)
292 }
293 if err := signer.Generate(); err != nil {
294 t.Fatalf("Failed to generate keypair: %v", err)
295 }
296
297 // Create an event that references a non-existent event
298 nonExistentEventID := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
299
300 ev := event.New()
301 ev.Pubkey = signer.Pub()
302 ev.CreatedAt = timestamp.Now().V
303 ev.Kind = 1
304 ev.Content = []byte("Reply to ghost event")
305 ev.Tags = tag.NewS(
306 tag.NewFromAny("e", nonExistentEventID, "", "reply"),
307 )
308
309 if err := ev.Sign(signer); err != nil {
310 t.Fatalf("Failed to sign event: %v", err)
311 }
312
313 // Save should succeed (batched e-tag processing handles missing reference)
314 exists, err := testDB.SaveEvent(ctx, ev)
315 if err != nil {
316 t.Fatalf("Failed to save event with missing reference: %v", err)
317 }
318 if exists {
319 t.Fatal("Event should not exist yet")
320 }
321
322 // Verify event was saved
323 checkCypher := "MATCH (e:Event {id: $id}) RETURN e.id AS id"
324 checkParams := map[string]any{"id": hex.Enc(ev.ID[:])}
325
326 result, err := testDB.ExecuteRead(ctx, checkCypher, checkParams)
327 if err != nil {
328 t.Fatalf("Failed to check event: %v", err)
329 }
330
331 if !result.Next(ctx) {
332 t.Error("Event should have been saved despite missing reference")
333 }
334
335 // Verify Tag node was created with TAGGED_WITH relationship
336 tagCypher := `
337 MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'e', value: $refId})
338 RETURN t.value AS tagValue
339 `
340 tagParams := map[string]any{
341 "eventId": hex.Enc(ev.ID[:]),
342 "refId": nonExistentEventID,
343 }
344
345 tagResult, err := testDB.ExecuteRead(ctx, tagCypher, tagParams)
346 if err != nil {
347 t.Fatalf("Failed to check Tag node: %v", err)
348 }
349
350 if !tagResult.Next(ctx) {
351 t.Error("Expected Tag node to be created for e-tag even when target doesn't exist")
352 } else {
353 t.Logf("✓ Tag node created for missing reference")
354 }
355
356 // Verify no REFERENCES relationship was created from Tag (as the target Event doesn't exist)
357 refCypher := `
358 MATCH (t:Tag {type: 'e', value: $refId})-[:REFERENCES]->(ref:Event)
359 RETURN count(ref) AS refCount
360 `
361 refParams := map[string]any{"refId": nonExistentEventID}
362
363 refResult, err := testDB.ExecuteRead(ctx, refCypher, refParams)
364 if err != nil {
365 t.Fatalf("Failed to check REFERENCES from Tag: %v", err)
366 }
367
368 if refResult.Next(ctx) {
369 count := refResult.Record().Values[0].(int64)
370 if count > 0 {
371 t.Errorf("Expected no REFERENCES from Tag for non-existent event, got %d", count)
372 } else {
373 t.Logf("✓ Correctly handled missing reference (no REFERENCES from Tag)")
374 }
375 }
376 }
377
378 // TestSaveEvent_MultipleETags tests events with multiple e-tags using Tag-based model.
379 // Uses shared testDB from testmain_test.go to avoid auth rate limiting.
380 func TestSaveEvent_MultipleETags(t *testing.T) {
381 if testDB == nil {
382 t.Skip("Neo4j not available")
383 }
384
385 ctx := context.Background()
386
387 // Clean up before test
388 cleanTestDatabase()
389
390 signer, err := p8k.New()
391 if err != nil {
392 t.Fatalf("Failed to create signer: %v", err)
393 }
394 if err := signer.Generate(); err != nil {
395 t.Fatalf("Failed to generate keypair: %v", err)
396 }
397
398 // Create three events first
399 var eventIDs []string
400 for i := 0; i < 3; i++ {
401 ev := event.New()
402 ev.Pubkey = signer.Pub()
403 ev.CreatedAt = timestamp.Now().V + int64(i)
404 ev.Kind = 1
405 ev.Content = []byte(fmt.Sprintf("Event %d", i))
406
407 if err := ev.Sign(signer); err != nil {
408 t.Fatalf("Failed to sign event %d: %v", i, err)
409 }
410
411 if _, err := testDB.SaveEvent(ctx, ev); err != nil {
412 t.Fatalf("Failed to save event %d: %v", i, err)
413 }
414
415 eventIDs = append(eventIDs, hex.Enc(ev.ID[:]))
416 }
417
418 // Create an event that references all three
419 replyEvent := event.New()
420 replyEvent.Pubkey = signer.Pub()
421 replyEvent.CreatedAt = timestamp.Now().V + 10
422 replyEvent.Kind = 1
423 replyEvent.Content = []byte("This references multiple events")
424 replyEvent.Tags = tag.NewS(
425 tag.NewFromAny("e", eventIDs[0], "", "root"),
426 tag.NewFromAny("e", eventIDs[1], "", "reply"),
427 tag.NewFromAny("e", eventIDs[2], "", "mention"),
428 )
429
430 if err := replyEvent.Sign(signer); err != nil {
431 t.Fatalf("Failed to sign reply event: %v", err)
432 }
433
434 // Save reply event - tests batched e-tag creation with Tag nodes
435 exists, err := testDB.SaveEvent(ctx, replyEvent)
436 if err != nil {
437 t.Fatalf("Failed to save multi-reference event: %v", err)
438 }
439 if exists {
440 t.Fatal("Reply event should not exist yet")
441 }
442
443 // Verify all Tag-based REFERENCES relationships were created
444 // Event-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->Event
445 cypher := `
446 MATCH (reply:Event {id: $replyId})-[:TAGGED_WITH]->(t:Tag {type: 'e'})-[:REFERENCES]->(ref:Event)
447 RETURN ref.id AS refId
448 `
449 params := map[string]any{"replyId": hex.Enc(replyEvent.ID[:])}
450
451 result, err := testDB.ExecuteRead(ctx, cypher, params)
452 if err != nil {
453 t.Fatalf("Failed to query Tag-based REFERENCES: %v", err)
454 }
455
456 referencedIDs := make(map[string]bool)
457 for result.Next(ctx) {
458 refID := result.Record().Values[0].(string)
459 referencedIDs[refID] = true
460 }
461
462 if len(referencedIDs) != 3 {
463 t.Errorf("Expected 3 Tag-based REFERENCES, got %d", len(referencedIDs))
464 }
465
466 for i, id := range eventIDs {
467 if !referencedIDs[id] {
468 t.Errorf("Missing Tag-based REFERENCES to event %d (%s)", i, id[:8])
469 }
470 }
471
472 t.Logf("✓ All %d Tag-based REFERENCES created successfully", len(referencedIDs))
473 }
474
475 // TestSaveEvent_LargePTagBatch tests that events with many p-tags are saved correctly
476 // using batched Tag-based processing to avoid Neo4j stack overflow.
477 // Uses shared testDB from testmain_test.go to avoid auth rate limiting.
478 func TestSaveEvent_LargePTagBatch(t *testing.T) {
479 if testDB == nil {
480 t.Skip("Neo4j not available")
481 }
482
483 ctx := context.Background()
484
485 // Clean up before test
486 cleanTestDatabase()
487
488 signer, err := p8k.New()
489 if err != nil {
490 t.Fatalf("Failed to create signer: %v", err)
491 }
492 if err := signer.Generate(); err != nil {
493 t.Fatalf("Failed to generate keypair: %v", err)
494 }
495
496 // Create event with many p-tags (enough to require multiple batches)
497 // With tagBatchSize = 500, this will require 2 batches
498 numTags := 600
499 manyPTags := tag.NewS()
500 for i := 0; i < numTags; i++ {
501 manyPTags.Append(tag.NewFromAny("p", fmt.Sprintf("%064x", i)))
502 }
503
504 ev := event.New()
505 ev.Pubkey = signer.Pub()
506 ev.CreatedAt = timestamp.Now().V
507 ev.Kind = 3 // Contact list
508 ev.Content = []byte("")
509 ev.Tags = manyPTags
510
511 if err := ev.Sign(signer); err != nil {
512 t.Fatalf("Failed to sign event: %v", err)
513 }
514
515 // This should succeed with batched processing
516 exists, err := testDB.SaveEvent(ctx, ev)
517 if err != nil {
518 t.Fatalf("Failed to save event with %d p-tags: %v", numTags, err)
519 }
520 if exists {
521 t.Fatal("Event should not exist yet")
522 }
523
524 // Verify all Tag nodes were created with TAGGED_WITH relationships
525 tagCountCypher := `
526 MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'p'})
527 RETURN count(t) AS tagCount
528 `
529 tagCountParams := map[string]any{"eventId": hex.Enc(ev.ID[:])}
530
531 tagResult, err := testDB.ExecuteRead(ctx, tagCountCypher, tagCountParams)
532 if err != nil {
533 t.Fatalf("Failed to count p-tag Tag nodes: %v", err)
534 }
535
536 if tagResult.Next(ctx) {
537 count := tagResult.Record().Values[0].(int64)
538 if count != int64(numTags) {
539 t.Errorf("Expected %d Tag nodes, got %d", numTags, count)
540 } else {
541 t.Logf("✓ All %d p-tag Tag nodes created via batched processing", count)
542 }
543 }
544
545 // Verify all REFERENCES relationships to NostrUser were created
546 refCountCypher := `
547 MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'p'})-[:REFERENCES]->(u:NostrUser)
548 RETURN count(u) AS refCount
549 `
550 refCountParams := map[string]any{"eventId": hex.Enc(ev.ID[:])}
551
552 refResult, err := testDB.ExecuteRead(ctx, refCountCypher, refCountParams)
553 if err != nil {
554 t.Fatalf("Failed to count Tag-based REFERENCES to NostrUser: %v", err)
555 }
556
557 if refResult.Next(ctx) {
558 count := refResult.Record().Values[0].(int64)
559 if count != int64(numTags) {
560 t.Errorf("Expected %d REFERENCES to NostrUser, got %d", numTags, count)
561 } else {
562 t.Logf("✓ All %d Tag-based REFERENCES to NostrUser created via batched processing", count)
563 }
564 }
565 }
566