You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
824 lines
24 KiB
824 lines
24 KiB
package neo4j |
|
|
|
import ( |
|
"context" |
|
"fmt" |
|
"os" |
|
"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" |
|
) |
|
|
|
// TestCypherQueryGeneration_WithClause is a unit test that validates the WITH clause fix |
|
// without requiring a Neo4j instance. This test verifies the generated Cypher string |
|
// has correct syntax for different tag combinations. |
|
func TestCypherQueryGeneration_WithClause(t *testing.T) { |
|
// Create a mock N struct - we only need it to call buildEventCreationCypher |
|
// No actual Neo4j connection is needed for this unit test |
|
n := &N{} |
|
|
|
// Generate test keypair |
|
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 |
|
expectWithClause bool |
|
expectOptionalMatch bool |
|
description string |
|
}{ |
|
{ |
|
name: "NoTags", |
|
tags: nil, |
|
expectWithClause: false, |
|
expectOptionalMatch: false, |
|
description: "Event without tags", |
|
}, |
|
{ |
|
name: "OnlyPTags_NoWithNeeded", |
|
tags: tag.NewS( |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000001"), |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000002"), |
|
), |
|
expectWithClause: false, |
|
expectOptionalMatch: false, |
|
description: "p-tags use MERGE (not OPTIONAL MATCH), no WITH needed", |
|
}, |
|
{ |
|
name: "OnlyETags_WithRequired", |
|
tags: tag.NewS( |
|
tag.NewFromAny("e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), |
|
tag.NewFromAny("e", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), |
|
), |
|
expectWithClause: true, |
|
expectOptionalMatch: true, |
|
description: "e-tags use OPTIONAL MATCH which requires WITH clause after CREATE", |
|
}, |
|
{ |
|
name: "ETagBeforePTag", |
|
tags: tag.NewS( |
|
tag.NewFromAny("e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000003"), |
|
), |
|
expectWithClause: true, |
|
expectOptionalMatch: true, |
|
description: "e-tag appearing first triggers WITH clause", |
|
}, |
|
{ |
|
name: "PTagBeforeETag", |
|
tags: tag.NewS( |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000004"), |
|
tag.NewFromAny("e", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"), |
|
), |
|
expectWithClause: true, |
|
expectOptionalMatch: true, |
|
description: "WITH clause needed even when p-tag comes before e-tag", |
|
}, |
|
{ |
|
name: "GenericTagsBeforeETag", |
|
tags: tag.NewS( |
|
tag.NewFromAny("t", "nostr"), |
|
tag.NewFromAny("r", "https://example.com"), |
|
tag.NewFromAny("e", "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), |
|
), |
|
expectWithClause: true, |
|
expectOptionalMatch: true, |
|
description: "WITH clause needed when e-tag follows generic tags", |
|
}, |
|
{ |
|
name: "OnlyGenericTags", |
|
tags: tag.NewS( |
|
tag.NewFromAny("t", "bitcoin"), |
|
tag.NewFromAny("d", "identifier"), |
|
tag.NewFromAny("r", "wss://relay.example.com"), |
|
), |
|
expectWithClause: false, |
|
expectOptionalMatch: false, |
|
description: "Generic tags use MERGE, no WITH needed", |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
// Create test event |
|
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) |
|
} |
|
|
|
// Generate Cypher query |
|
cypher, params := n.buildEventCreationCypher(ev, 12345) |
|
|
|
// Validate WITH clause presence |
|
hasWithClause := strings.Contains(cypher, "WITH e, a") |
|
if tt.expectWithClause && !hasWithClause { |
|
t.Errorf("%s: expected WITH clause but none found in Cypher:\n%s", tt.description, cypher) |
|
} |
|
if !tt.expectWithClause && hasWithClause { |
|
t.Errorf("%s: unexpected WITH clause in Cypher:\n%s", tt.description, cypher) |
|
} |
|
|
|
// Validate OPTIONAL MATCH presence |
|
hasOptionalMatch := strings.Contains(cypher, "OPTIONAL MATCH") |
|
if tt.expectOptionalMatch && !hasOptionalMatch { |
|
t.Errorf("%s: expected OPTIONAL MATCH but none found", tt.description) |
|
} |
|
if !tt.expectOptionalMatch && hasOptionalMatch { |
|
t.Errorf("%s: unexpected OPTIONAL MATCH found", tt.description) |
|
} |
|
|
|
// Validate WITH clause comes BEFORE first OPTIONAL MATCH (if both present) |
|
if hasWithClause && hasOptionalMatch { |
|
withIndex := strings.Index(cypher, "WITH e, a") |
|
optionalIndex := strings.Index(cypher, "OPTIONAL MATCH") |
|
if withIndex > optionalIndex { |
|
t.Errorf("%s: WITH clause must come BEFORE OPTIONAL MATCH.\nWITH at %d, OPTIONAL MATCH at %d\nCypher:\n%s", |
|
tt.description, withIndex, optionalIndex, cypher) |
|
} |
|
} |
|
|
|
// Validate parameters are set |
|
if params == nil { |
|
t.Error("params should not be nil") |
|
} |
|
|
|
// Validate basic required params exist |
|
if _, ok := params["eventId"]; !ok { |
|
t.Error("params should contain eventId") |
|
} |
|
if _, ok := params["serial"]; !ok { |
|
t.Error("params should contain serial") |
|
} |
|
|
|
t.Logf("✓ %s: WITH=%v, OPTIONAL_MATCH=%v", tt.name, hasWithClause, hasOptionalMatch) |
|
}) |
|
} |
|
} |
|
|
|
// TestCypherQueryGeneration_MultipleETags verifies WITH clause is added exactly once |
|
// even with multiple e-tags. |
|
func TestCypherQueryGeneration_MultipleETags(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) |
|
} |
|
|
|
// Create event with many e-tags |
|
manyETags := tag.NewS() |
|
for i := 0; i < 10; i++ { |
|
manyETags.Append(tag.NewFromAny("e", fmt.Sprintf("%064x", i))) |
|
} |
|
|
|
ev := event.New() |
|
ev.Pubkey = signer.Pub() |
|
ev.CreatedAt = timestamp.Now().V |
|
ev.Kind = 1 |
|
ev.Content = []byte("Event with many e-tags") |
|
ev.Tags = manyETags |
|
|
|
if err := ev.Sign(signer); err != nil { |
|
t.Fatalf("Failed to sign event: %v", err) |
|
} |
|
|
|
cypher, _ := n.buildEventCreationCypher(ev, 1) |
|
|
|
// Count WITH clauses - should be exactly 1 |
|
withCount := strings.Count(cypher, "WITH e, a") |
|
if withCount != 1 { |
|
t.Errorf("Expected exactly 1 WITH clause, found %d\nCypher:\n%s", withCount, cypher) |
|
} |
|
|
|
// Count OPTIONAL MATCH - should match number of e-tags |
|
optionalMatchCount := strings.Count(cypher, "OPTIONAL MATCH") |
|
if optionalMatchCount != 10 { |
|
t.Errorf("Expected 10 OPTIONAL MATCH statements (one per e-tag), found %d", optionalMatchCount) |
|
} |
|
|
|
// Count FOREACH (which wraps the conditional relationship creation) |
|
foreachCount := strings.Count(cypher, "FOREACH") |
|
if foreachCount != 10 { |
|
t.Errorf("Expected 10 FOREACH blocks, found %d", foreachCount) |
|
} |
|
|
|
t.Logf("✓ WITH clause added once, followed by %d OPTIONAL MATCH + FOREACH pairs", optionalMatchCount) |
|
} |
|
|
|
// TestCypherQueryGeneration_CriticalBugScenario reproduces the exact bug scenario |
|
// that was fixed: CREATE followed by OPTIONAL MATCH without WITH clause. |
|
func TestCypherQueryGeneration_CriticalBugScenario(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) |
|
} |
|
|
|
// This is the exact scenario that caused the bug: |
|
// An event with just one e-tag should have: |
|
// 1. CREATE clause for the event |
|
// 2. WITH clause to carry forward variables |
|
// 3. OPTIONAL MATCH for the referenced event |
|
ev := event.New() |
|
ev.Pubkey = signer.Pub() |
|
ev.CreatedAt = timestamp.Now().V |
|
ev.Kind = 1 |
|
ev.Content = []byte("Reply to an event") |
|
ev.Tags = tag.NewS( |
|
tag.NewFromAny("e", "1234567890123456789012345678901234567890123456789012345678901234"), |
|
) |
|
|
|
if err := ev.Sign(signer); err != nil { |
|
t.Fatalf("Failed to sign event: %v", err) |
|
} |
|
|
|
cypher, _ := n.buildEventCreationCypher(ev, 1) |
|
|
|
// The critical validation: WITH must appear between CREATE and OPTIONAL MATCH |
|
createIndex := strings.Index(cypher, "CREATE (e)-[:AUTHORED_BY]->(a)") |
|
withIndex := strings.Index(cypher, "WITH e, a") |
|
optionalMatchIndex := strings.Index(cypher, "OPTIONAL MATCH") |
|
|
|
if createIndex == -1 { |
|
t.Fatal("CREATE clause not found in Cypher") |
|
} |
|
if withIndex == -1 { |
|
t.Fatal("WITH clause not found in Cypher - THIS IS THE BUG!") |
|
} |
|
if optionalMatchIndex == -1 { |
|
t.Fatal("OPTIONAL MATCH not found in Cypher") |
|
} |
|
|
|
// Validate order: CREATE < WITH < OPTIONAL MATCH |
|
if !(createIndex < withIndex && withIndex < optionalMatchIndex) { |
|
t.Errorf("Invalid clause ordering. Expected: CREATE (%d) < WITH (%d) < OPTIONAL MATCH (%d)\nCypher:\n%s", |
|
createIndex, withIndex, optionalMatchIndex, cypher) |
|
} |
|
|
|
t.Log("✓ Critical bug scenario validated: WITH clause correctly placed between CREATE and OPTIONAL MATCH") |
|
} |
|
|
|
// TestBuildEventCreationCypher_WithClause validates the WITH clause fix for Cypher queries. |
|
// The bug was that OPTIONAL MATCH cannot directly follow CREATE in Cypher - a WITH clause |
|
// is required to carry forward bound variables (e, a) from the CREATE to the MATCH. |
|
func TestBuildEventCreationCypher_WithClause(t *testing.T) { |
|
// Skip if Neo4j is not available |
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
|
if neo4jURI == "" { |
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
|
} |
|
|
|
// Create test database |
|
ctx, cancel := context.WithCancel(context.Background()) |
|
defer cancel() |
|
|
|
tempDir := t.TempDir() |
|
db, err := New(ctx, cancel, tempDir, "debug") |
|
if err != nil { |
|
t.Fatalf("Failed to create database: %v", err) |
|
} |
|
defer db.Close() |
|
|
|
// Wait for database to be ready |
|
<-db.Ready() |
|
|
|
// Wipe database to ensure clean state |
|
if err := db.Wipe(); err != nil { |
|
t.Fatalf("Failed to wipe database: %v", err) |
|
} |
|
|
|
// Generate test keypair |
|
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) |
|
} |
|
|
|
// Test cases for different tag combinations |
|
tests := []struct { |
|
name string |
|
tags *tag.S |
|
wantWithClause bool |
|
description string |
|
}{ |
|
{ |
|
name: "NoTags", |
|
tags: nil, |
|
wantWithClause: false, |
|
description: "Event without tags should not have WITH clause", |
|
}, |
|
{ |
|
name: "OnlyPTags", |
|
tags: tag.NewS( |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000001"), |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000002"), |
|
), |
|
wantWithClause: false, |
|
description: "Event with only p-tags (MERGE) should not have WITH clause", |
|
}, |
|
{ |
|
name: "OnlyETags", |
|
tags: tag.NewS( |
|
tag.NewFromAny("e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), |
|
tag.NewFromAny("e", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), |
|
), |
|
wantWithClause: true, |
|
description: "Event with e-tags (OPTIONAL MATCH) MUST have WITH clause", |
|
}, |
|
{ |
|
name: "ETagFirst", |
|
tags: tag.NewS( |
|
tag.NewFromAny("e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000003"), |
|
), |
|
wantWithClause: true, |
|
description: "Event with e-tag first MUST have WITH clause before OPTIONAL MATCH", |
|
}, |
|
{ |
|
name: "PTagFirst", |
|
tags: tag.NewS( |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000004"), |
|
tag.NewFromAny("e", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"), |
|
), |
|
wantWithClause: true, |
|
description: "Event with p-tag first still needs WITH clause before e-tag's OPTIONAL MATCH", |
|
}, |
|
{ |
|
name: "MixedTags", |
|
tags: tag.NewS( |
|
tag.NewFromAny("t", "nostr"), |
|
tag.NewFromAny("e", "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), |
|
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000005"), |
|
tag.NewFromAny("r", "https://example.com"), |
|
), |
|
wantWithClause: true, |
|
description: "Mixed tags with e-tag requires WITH clause", |
|
}, |
|
{ |
|
name: "OnlyGenericTags", |
|
tags: tag.NewS( |
|
tag.NewFromAny("t", "bitcoin"), |
|
tag.NewFromAny("r", "wss://relay.example.com"), |
|
tag.NewFromAny("d", "identifier"), |
|
), |
|
wantWithClause: false, |
|
description: "Generic tags (MERGE) don't require WITH clause", |
|
}, |
|
} |
|
|
|
for _, tt := range tests { |
|
t.Run(tt.name, func(t *testing.T) { |
|
// Create event |
|
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) |
|
} |
|
|
|
// Build Cypher query |
|
cypher, params := db.buildEventCreationCypher(ev, 1) |
|
|
|
// Check if WITH clause is present |
|
hasWithClause := strings.Contains(cypher, "WITH e, a") |
|
|
|
if tt.wantWithClause && !hasWithClause { |
|
t.Errorf("%s: expected WITH clause but none found.\nCypher:\n%s", tt.description, cypher) |
|
} |
|
if !tt.wantWithClause && hasWithClause { |
|
t.Errorf("%s: unexpected WITH clause found.\nCypher:\n%s", tt.description, cypher) |
|
} |
|
|
|
// Verify Cypher syntax by executing it against Neo4j |
|
// This is the key test - invalid Cypher will fail here |
|
_, err := db.ExecuteWrite(ctx, cypher, params) |
|
if err != nil { |
|
t.Errorf("%s: Cypher query failed (invalid syntax): %v\nCypher:\n%s", tt.description, err, cypher) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
// TestSaveEvent_ETagReference tests that events with e-tags are saved correctly |
|
// and the REFERENCES relationships are created when the referenced event exists. |
|
func TestSaveEvent_ETagReference(t *testing.T) { |
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
|
if neo4jURI == "" { |
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
|
} |
|
|
|
ctx, cancel := context.WithCancel(context.Background()) |
|
defer cancel() |
|
|
|
tempDir := t.TempDir() |
|
db, err := New(ctx, cancel, tempDir, "debug") |
|
if err != nil { |
|
t.Fatalf("Failed to create database: %v", err) |
|
} |
|
defer db.Close() |
|
|
|
<-db.Ready() |
|
|
|
if err := db.Wipe(); err != nil { |
|
t.Fatalf("Failed to wipe database: %v", err) |
|
} |
|
|
|
// 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 := db.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 WITH clause fix |
|
exists, err = db.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 := db.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:Author {pubkey: $authorPubkey}) |
|
RETURN author.pubkey AS pubkey |
|
` |
|
mentionsParams := map[string]any{ |
|
"replyId": hex.Enc(replyEvent.ID[:]), |
|
"authorPubkey": hex.Enc(alice.Pub()), |
|
} |
|
|
|
mentionsResult, err := db.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 (OPTIONAL MATCH handles this gracefully). |
|
func TestSaveEvent_ETagMissingReference(t *testing.T) { |
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
|
if neo4jURI == "" { |
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
|
} |
|
|
|
ctx, cancel := context.WithCancel(context.Background()) |
|
defer cancel() |
|
|
|
tempDir := t.TempDir() |
|
db, err := New(ctx, cancel, tempDir, "debug") |
|
if err != nil { |
|
t.Fatalf("Failed to create database: %v", err) |
|
} |
|
defer db.Close() |
|
|
|
<-db.Ready() |
|
|
|
if err := db.Wipe(); err != nil { |
|
t.Fatalf("Failed to wipe database: %v", err) |
|
} |
|
|
|
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 (OPTIONAL MATCH handles missing reference) |
|
exists, err := db.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 := db.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 := db.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. |
|
func TestSaveEvent_MultipleETags(t *testing.T) { |
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
|
if neo4jURI == "" { |
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
|
} |
|
|
|
ctx, cancel := context.WithCancel(context.Background()) |
|
defer cancel() |
|
|
|
tempDir := t.TempDir() |
|
db, err := New(ctx, cancel, tempDir, "debug") |
|
if err != nil { |
|
t.Fatalf("Failed to create database: %v", err) |
|
} |
|
defer db.Close() |
|
|
|
<-db.Ready() |
|
|
|
if err := db.Wipe(); err != nil { |
|
t.Fatalf("Failed to wipe database: %v", err) |
|
} |
|
|
|
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 := db.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 multiple OPTIONAL MATCH statements after WITH |
|
exists, err := db.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 := db.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)) |
|
} |
|
|
|
// TestBuildEventCreationCypher_CypherSyntaxValidation validates the generated Cypher |
|
// is syntactically correct for all edge cases. |
|
func TestBuildEventCreationCypher_CypherSyntaxValidation(t *testing.T) { |
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
|
if neo4jURI == "" { |
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
|
} |
|
|
|
ctx, cancel := context.WithCancel(context.Background()) |
|
defer cancel() |
|
|
|
tempDir := t.TempDir() |
|
db, err := New(ctx, cancel, tempDir, "debug") |
|
if err != nil { |
|
t.Fatalf("Failed to create database: %v", err) |
|
} |
|
defer db.Close() |
|
|
|
<-db.Ready() |
|
|
|
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) |
|
} |
|
|
|
// Test many e-tags to ensure WITH clause is added only once |
|
manyETags := tag.NewS() |
|
for i := 0; i < 10; i++ { |
|
manyETags.Append(tag.NewFromAny("e", fmt.Sprintf("%064x", i))) |
|
} |
|
|
|
ev := event.New() |
|
ev.Pubkey = signer.Pub() |
|
ev.CreatedAt = timestamp.Now().V |
|
ev.Kind = 1 |
|
ev.Content = []byte("Event with many e-tags") |
|
ev.Tags = manyETags |
|
|
|
if err := ev.Sign(signer); err != nil { |
|
t.Fatalf("Failed to sign event: %v", err) |
|
} |
|
|
|
cypher, _ := db.buildEventCreationCypher(ev, 1) |
|
|
|
// Count occurrences of WITH clause - should be exactly 1 |
|
withCount := strings.Count(cypher, "WITH e, a") |
|
if withCount != 1 { |
|
t.Errorf("Expected exactly 1 WITH clause, found %d\nCypher:\n%s", withCount, cypher) |
|
} |
|
|
|
// Count OPTIONAL MATCH statements - should equal number of e-tags |
|
optionalMatchCount := strings.Count(cypher, "OPTIONAL MATCH") |
|
if optionalMatchCount != 10 { |
|
t.Errorf("Expected 10 OPTIONAL MATCH statements, found %d", optionalMatchCount) |
|
} |
|
|
|
t.Logf("✓ WITH clause correctly added once, followed by %d OPTIONAL MATCH statements", optionalMatchCount) |
|
} |