Some checks failed
Go / build-and-release (push) Has been cancelled
Introduce comprehensive integration tests for Neo4j bug fixes covering batching, event relationships, and processing logic. Add rate-limiting to Neo4j queries using semaphores and retry policies to prevent authentication rate limiting and connection exhaustion, ensuring system stability under load.
522 lines
15 KiB
Go
522 lines
15 KiB
Go
package neo4j
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.mleku.dev/mleku/nostr/encoders/event"
|
|
"git.mleku.dev/mleku/nostr/encoders/hex"
|
|
"git.mleku.dev/mleku/nostr/encoders/tag"
|
|
"git.mleku.dev/mleku/nostr/encoders/timestamp"
|
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
|
|
)
|
|
|
|
// TestBuildBaseEventCypher verifies the base event creation query generates correct Cypher.
|
|
// The new architecture separates event creation from tag processing to avoid stack overflow.
|
|
func TestBuildBaseEventCypher(t *testing.T) {
|
|
n := &N{}
|
|
|
|
signer, err := p8k.New()
|
|
if err != nil {
|
|
t.Fatalf("Failed to create signer: %v", err)
|
|
}
|
|
if err := signer.Generate(); err != nil {
|
|
t.Fatalf("Failed to generate keypair: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
tags *tag.S
|
|
description string
|
|
}{
|
|
{
|
|
name: "NoTags",
|
|
tags: nil,
|
|
description: "Event without tags",
|
|
},
|
|
{
|
|
name: "WithPTags",
|
|
tags: tag.NewS(
|
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000001"),
|
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000002"),
|
|
),
|
|
description: "Event with p-tags (stored in tags JSON, relationships added separately)",
|
|
},
|
|
{
|
|
name: "WithETags",
|
|
tags: tag.NewS(
|
|
tag.NewFromAny("e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
|
tag.NewFromAny("e", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
|
|
),
|
|
description: "Event with e-tags (stored in tags JSON, relationships added separately)",
|
|
},
|
|
{
|
|
name: "MixedTags",
|
|
tags: tag.NewS(
|
|
tag.NewFromAny("t", "nostr"),
|
|
tag.NewFromAny("e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
|
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000003"),
|
|
),
|
|
description: "Event with mixed tags",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ev := event.New()
|
|
ev.Pubkey = signer.Pub()
|
|
ev.CreatedAt = timestamp.Now().V
|
|
ev.Kind = 1
|
|
ev.Content = []byte(fmt.Sprintf("Test content for %s", tt.name))
|
|
ev.Tags = tt.tags
|
|
|
|
if err := ev.Sign(signer); err != nil {
|
|
t.Fatalf("Failed to sign event: %v", err)
|
|
}
|
|
|
|
cypher, params := n.buildBaseEventCypher(ev, 12345)
|
|
|
|
// Base event Cypher should NOT contain tag relationship clauses
|
|
// (tags are added separately via addTagsInBatches)
|
|
if strings.Contains(cypher, "OPTIONAL MATCH") {
|
|
t.Errorf("%s: buildBaseEventCypher should NOT contain OPTIONAL MATCH", tt.description)
|
|
}
|
|
if strings.Contains(cypher, "UNWIND") {
|
|
t.Errorf("%s: buildBaseEventCypher should NOT contain UNWIND", tt.description)
|
|
}
|
|
if strings.Contains(cypher, ":REFERENCES") {
|
|
t.Errorf("%s: buildBaseEventCypher should NOT contain :REFERENCES", tt.description)
|
|
}
|
|
if strings.Contains(cypher, ":MENTIONS") {
|
|
t.Errorf("%s: buildBaseEventCypher should NOT contain :MENTIONS", tt.description)
|
|
}
|
|
if strings.Contains(cypher, ":TAGGED_WITH") {
|
|
t.Errorf("%s: buildBaseEventCypher should NOT contain :TAGGED_WITH", tt.description)
|
|
}
|
|
|
|
// Should contain basic event creation elements
|
|
if !strings.Contains(cypher, "CREATE (e:Event") {
|
|
t.Errorf("%s: should CREATE Event node", tt.description)
|
|
}
|
|
if !strings.Contains(cypher, "MERGE (a:NostrUser") {
|
|
t.Errorf("%s: should MERGE NostrUser node", tt.description)
|
|
}
|
|
if !strings.Contains(cypher, ":AUTHORED_BY") {
|
|
t.Errorf("%s: should create AUTHORED_BY relationship", tt.description)
|
|
}
|
|
|
|
// Should have tags serialized in params
|
|
if _, ok := params["tags"]; !ok {
|
|
t.Errorf("%s: params should contain serialized tags", tt.description)
|
|
}
|
|
|
|
// Validate params have required fields
|
|
requiredParams := []string{"eventId", "serial", "kind", "createdAt", "content", "sig", "pubkey", "tags", "expiration"}
|
|
for _, p := range requiredParams {
|
|
if _, ok := params[p]; !ok {
|
|
t.Errorf("%s: missing required param: %s", tt.description, p)
|
|
}
|
|
}
|
|
|
|
t.Logf("✓ %s: base event Cypher is clean (no tag relationships)", tt.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSafePrefix validates the safePrefix helper function
|
|
func TestSafePrefix(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
n int
|
|
expected string
|
|
}{
|
|
{"hello world", 5, "hello"},
|
|
{"hi", 5, "hi"},
|
|
{"", 5, ""},
|
|
{"1234567890", 10, "1234567890"},
|
|
{"1234567890", 11, "1234567890"},
|
|
{"0123456789abcdef", 8, "01234567"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(fmt.Sprintf("%q[:%d]", tt.input, tt.n), func(t *testing.T) {
|
|
result := safePrefix(tt.input, tt.n)
|
|
if result != tt.expected {
|
|
t.Errorf("safePrefix(%q, %d) = %q; want %q", tt.input, tt.n, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSaveEvent_ETagReference tests that events with e-tags are saved correctly
|
|
// and the REFERENCES relationships are created when the referenced event exists.
|
|
// Uses shared testDB from testmain_test.go to avoid auth rate limiting.
|
|
func TestSaveEvent_ETagReference(t *testing.T) {
|
|
if testDB == nil {
|
|
t.Skip("Neo4j not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Clean up before test
|
|
cleanTestDatabase()
|
|
|
|
// Generate keypairs
|
|
alice, err := p8k.New()
|
|
if err != nil {
|
|
t.Fatalf("Failed to create signer: %v", err)
|
|
}
|
|
if err := alice.Generate(); err != nil {
|
|
t.Fatalf("Failed to generate keypair: %v", err)
|
|
}
|
|
|
|
bob, err := p8k.New()
|
|
if err != nil {
|
|
t.Fatalf("Failed to create signer: %v", err)
|
|
}
|
|
if err := bob.Generate(); err != nil {
|
|
t.Fatalf("Failed to generate keypair: %v", err)
|
|
}
|
|
|
|
// Create a root event from Alice
|
|
rootEvent := event.New()
|
|
rootEvent.Pubkey = alice.Pub()
|
|
rootEvent.CreatedAt = timestamp.Now().V
|
|
rootEvent.Kind = 1
|
|
rootEvent.Content = []byte("This is the root event")
|
|
|
|
if err := rootEvent.Sign(alice); err != nil {
|
|
t.Fatalf("Failed to sign root event: %v", err)
|
|
}
|
|
|
|
// Save root event
|
|
exists, err := testDB.SaveEvent(ctx, rootEvent)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save root event: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("Root event should not exist yet")
|
|
}
|
|
|
|
rootEventID := hex.Enc(rootEvent.ID[:])
|
|
|
|
// Create a reply from Bob that references the root event
|
|
replyEvent := event.New()
|
|
replyEvent.Pubkey = bob.Pub()
|
|
replyEvent.CreatedAt = timestamp.Now().V + 1
|
|
replyEvent.Kind = 1
|
|
replyEvent.Content = []byte("This is a reply to Alice")
|
|
replyEvent.Tags = tag.NewS(
|
|
tag.NewFromAny("e", rootEventID, "", "root"),
|
|
tag.NewFromAny("p", hex.Enc(alice.Pub())),
|
|
)
|
|
|
|
if err := replyEvent.Sign(bob); err != nil {
|
|
t.Fatalf("Failed to sign reply event: %v", err)
|
|
}
|
|
|
|
// Save reply event - this exercises the batched tag creation
|
|
exists, err = testDB.SaveEvent(ctx, replyEvent)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save reply event: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("Reply event should not exist yet")
|
|
}
|
|
|
|
// Verify REFERENCES relationship was created
|
|
cypher := `
|
|
MATCH (reply:Event {id: $replyId})-[:REFERENCES]->(root:Event {id: $rootId})
|
|
RETURN reply.id AS replyId, root.id AS rootId
|
|
`
|
|
params := map[string]any{
|
|
"replyId": hex.Enc(replyEvent.ID[:]),
|
|
"rootId": rootEventID,
|
|
}
|
|
|
|
result, err := testDB.ExecuteRead(ctx, cypher, params)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query REFERENCES relationship: %v", err)
|
|
}
|
|
|
|
if !result.Next(ctx) {
|
|
t.Error("Expected REFERENCES relationship between reply and root events")
|
|
} else {
|
|
record := result.Record()
|
|
returnedReplyId := record.Values[0].(string)
|
|
returnedRootId := record.Values[1].(string)
|
|
t.Logf("✓ REFERENCES relationship verified: %s -> %s", returnedReplyId[:8], returnedRootId[:8])
|
|
}
|
|
|
|
// Verify MENTIONS relationship was also created for the p-tag
|
|
mentionsCypher := `
|
|
MATCH (reply:Event {id: $replyId})-[:MENTIONS]->(author:NostrUser {pubkey: $authorPubkey})
|
|
RETURN author.pubkey AS pubkey
|
|
`
|
|
mentionsParams := map[string]any{
|
|
"replyId": hex.Enc(replyEvent.ID[:]),
|
|
"authorPubkey": hex.Enc(alice.Pub()),
|
|
}
|
|
|
|
mentionsResult, err := testDB.ExecuteRead(ctx, mentionsCypher, mentionsParams)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query MENTIONS relationship: %v", err)
|
|
}
|
|
|
|
if !mentionsResult.Next(ctx) {
|
|
t.Error("Expected MENTIONS relationship for p-tag")
|
|
} else {
|
|
t.Logf("✓ MENTIONS relationship verified")
|
|
}
|
|
}
|
|
|
|
// TestSaveEvent_ETagMissingReference tests that e-tags to non-existent events
|
|
// don't create broken relationships (batched processing handles this gracefully).
|
|
// Uses shared testDB from testmain_test.go to avoid auth rate limiting.
|
|
func TestSaveEvent_ETagMissingReference(t *testing.T) {
|
|
if testDB == nil {
|
|
t.Skip("Neo4j not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Clean up before test
|
|
cleanTestDatabase()
|
|
|
|
signer, err := p8k.New()
|
|
if err != nil {
|
|
t.Fatalf("Failed to create signer: %v", err)
|
|
}
|
|
if err := signer.Generate(); err != nil {
|
|
t.Fatalf("Failed to generate keypair: %v", err)
|
|
}
|
|
|
|
// Create an event that references a non-existent event
|
|
nonExistentEventID := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
|
|
|
ev := event.New()
|
|
ev.Pubkey = signer.Pub()
|
|
ev.CreatedAt = timestamp.Now().V
|
|
ev.Kind = 1
|
|
ev.Content = []byte("Reply to ghost event")
|
|
ev.Tags = tag.NewS(
|
|
tag.NewFromAny("e", nonExistentEventID, "", "reply"),
|
|
)
|
|
|
|
if err := ev.Sign(signer); err != nil {
|
|
t.Fatalf("Failed to sign event: %v", err)
|
|
}
|
|
|
|
// Save should succeed (batched e-tag processing handles missing reference)
|
|
exists, err := testDB.SaveEvent(ctx, ev)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save event with missing reference: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("Event should not exist yet")
|
|
}
|
|
|
|
// Verify event was saved
|
|
checkCypher := "MATCH (e:Event {id: $id}) RETURN e.id AS id"
|
|
checkParams := map[string]any{"id": hex.Enc(ev.ID[:])}
|
|
|
|
result, err := testDB.ExecuteRead(ctx, checkCypher, checkParams)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check event: %v", err)
|
|
}
|
|
|
|
if !result.Next(ctx) {
|
|
t.Error("Event should have been saved despite missing reference")
|
|
}
|
|
|
|
// Verify no REFERENCES relationship was created (as the target doesn't exist)
|
|
refCypher := `
|
|
MATCH (e:Event {id: $eventId})-[:REFERENCES]->(ref:Event)
|
|
RETURN count(ref) AS refCount
|
|
`
|
|
refParams := map[string]any{"eventId": hex.Enc(ev.ID[:])}
|
|
|
|
refResult, err := testDB.ExecuteRead(ctx, refCypher, refParams)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check references: %v", err)
|
|
}
|
|
|
|
if refResult.Next(ctx) {
|
|
count := refResult.Record().Values[0].(int64)
|
|
if count > 0 {
|
|
t.Errorf("Expected no REFERENCES relationship for non-existent event, got %d", count)
|
|
} else {
|
|
t.Logf("✓ Correctly handled missing reference (no relationship created)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSaveEvent_MultipleETags tests events with multiple e-tags.
|
|
// Uses shared testDB from testmain_test.go to avoid auth rate limiting.
|
|
func TestSaveEvent_MultipleETags(t *testing.T) {
|
|
if testDB == nil {
|
|
t.Skip("Neo4j not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Clean up before test
|
|
cleanTestDatabase()
|
|
|
|
signer, err := p8k.New()
|
|
if err != nil {
|
|
t.Fatalf("Failed to create signer: %v", err)
|
|
}
|
|
if err := signer.Generate(); err != nil {
|
|
t.Fatalf("Failed to generate keypair: %v", err)
|
|
}
|
|
|
|
// Create three events first
|
|
var eventIDs []string
|
|
for i := 0; i < 3; i++ {
|
|
ev := event.New()
|
|
ev.Pubkey = signer.Pub()
|
|
ev.CreatedAt = timestamp.Now().V + int64(i)
|
|
ev.Kind = 1
|
|
ev.Content = []byte(fmt.Sprintf("Event %d", i))
|
|
|
|
if err := ev.Sign(signer); err != nil {
|
|
t.Fatalf("Failed to sign event %d: %v", i, err)
|
|
}
|
|
|
|
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
|
|
t.Fatalf("Failed to save event %d: %v", i, err)
|
|
}
|
|
|
|
eventIDs = append(eventIDs, hex.Enc(ev.ID[:]))
|
|
}
|
|
|
|
// Create an event that references all three
|
|
replyEvent := event.New()
|
|
replyEvent.Pubkey = signer.Pub()
|
|
replyEvent.CreatedAt = timestamp.Now().V + 10
|
|
replyEvent.Kind = 1
|
|
replyEvent.Content = []byte("This references multiple events")
|
|
replyEvent.Tags = tag.NewS(
|
|
tag.NewFromAny("e", eventIDs[0], "", "root"),
|
|
tag.NewFromAny("e", eventIDs[1], "", "reply"),
|
|
tag.NewFromAny("e", eventIDs[2], "", "mention"),
|
|
)
|
|
|
|
if err := replyEvent.Sign(signer); err != nil {
|
|
t.Fatalf("Failed to sign reply event: %v", err)
|
|
}
|
|
|
|
// Save reply event - tests batched e-tag creation
|
|
exists, err := testDB.SaveEvent(ctx, replyEvent)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save multi-reference event: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("Reply event should not exist yet")
|
|
}
|
|
|
|
// Verify all REFERENCES relationships were created
|
|
cypher := `
|
|
MATCH (reply:Event {id: $replyId})-[:REFERENCES]->(ref:Event)
|
|
RETURN ref.id AS refId
|
|
`
|
|
params := map[string]any{"replyId": hex.Enc(replyEvent.ID[:])}
|
|
|
|
result, err := testDB.ExecuteRead(ctx, cypher, params)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query REFERENCES relationships: %v", err)
|
|
}
|
|
|
|
referencedIDs := make(map[string]bool)
|
|
for result.Next(ctx) {
|
|
refID := result.Record().Values[0].(string)
|
|
referencedIDs[refID] = true
|
|
}
|
|
|
|
if len(referencedIDs) != 3 {
|
|
t.Errorf("Expected 3 REFERENCES relationships, got %d", len(referencedIDs))
|
|
}
|
|
|
|
for i, id := range eventIDs {
|
|
if !referencedIDs[id] {
|
|
t.Errorf("Missing REFERENCES relationship to event %d (%s)", i, id[:8])
|
|
}
|
|
}
|
|
|
|
t.Logf("✓ All %d REFERENCES relationships created successfully", len(referencedIDs))
|
|
}
|
|
|
|
// TestSaveEvent_LargePTagBatch tests that events with many p-tags are saved correctly
|
|
// using batched processing to avoid Neo4j stack overflow.
|
|
// Uses shared testDB from testmain_test.go to avoid auth rate limiting.
|
|
func TestSaveEvent_LargePTagBatch(t *testing.T) {
|
|
if testDB == nil {
|
|
t.Skip("Neo4j not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Clean up before test
|
|
cleanTestDatabase()
|
|
|
|
signer, err := p8k.New()
|
|
if err != nil {
|
|
t.Fatalf("Failed to create signer: %v", err)
|
|
}
|
|
if err := signer.Generate(); err != nil {
|
|
t.Fatalf("Failed to generate keypair: %v", err)
|
|
}
|
|
|
|
// Create event with many p-tags (enough to require multiple batches)
|
|
// With tagBatchSize = 500, this will require 2 batches
|
|
numTags := 600
|
|
manyPTags := tag.NewS()
|
|
for i := 0; i < numTags; i++ {
|
|
manyPTags.Append(tag.NewFromAny("p", fmt.Sprintf("%064x", i)))
|
|
}
|
|
|
|
ev := event.New()
|
|
ev.Pubkey = signer.Pub()
|
|
ev.CreatedAt = timestamp.Now().V
|
|
ev.Kind = 3 // Contact list
|
|
ev.Content = []byte("")
|
|
ev.Tags = manyPTags
|
|
|
|
if err := ev.Sign(signer); err != nil {
|
|
t.Fatalf("Failed to sign event: %v", err)
|
|
}
|
|
|
|
// This should succeed with batched processing
|
|
exists, err := testDB.SaveEvent(ctx, ev)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save event with %d p-tags: %v", numTags, err)
|
|
}
|
|
if exists {
|
|
t.Fatal("Event should not exist yet")
|
|
}
|
|
|
|
// Verify all MENTIONS relationships were created
|
|
countCypher := `
|
|
MATCH (e:Event {id: $eventId})-[:MENTIONS]->(u:NostrUser)
|
|
RETURN count(u) AS mentionCount
|
|
`
|
|
countParams := map[string]any{"eventId": hex.Enc(ev.ID[:])}
|
|
|
|
result, err := testDB.ExecuteRead(ctx, countCypher, countParams)
|
|
if err != nil {
|
|
t.Fatalf("Failed to count MENTIONS: %v", err)
|
|
}
|
|
|
|
if result.Next(ctx) {
|
|
count := result.Record().Values[0].(int64)
|
|
if count != int64(numTags) {
|
|
t.Errorf("Expected %d MENTIONS relationships, got %d", numTags, count)
|
|
} else {
|
|
t.Logf("✓ All %d MENTIONS relationships created via batched processing", count)
|
|
}
|
|
}
|
|
}
|