Add Neo4j integration tests and query rate-limiting logic
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.
This commit is contained in:
2025-12-07 00:07:25 +00:00
parent 8ea91e39d8
commit 95271cbc81
21 changed files with 1925 additions and 1594 deletions

View File

@@ -18,7 +18,46 @@
"Bash(bun update:*)",
"Bash(cat:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"Bash(apt list:*)",
"Bash(dpkg:*)",
"Bash(find:*)",
"Bash(metaflac --list --block-type=VORBIS_COMMENT:*)",
"Bash(python3:*)",
"Bash(pip3 show:*)",
"Bash(pip3 install:*)",
"Bash(lsusb:*)",
"Bash(dmesg:*)",
"Bash(adb devices:*)",
"Bash(adb kill-server:*)",
"Bash(adb start-server:*)",
"Bash(adb shell:*)",
"Bash(adb push:*)",
"WebSearch",
"WebFetch(domain:krosbits.in)",
"WebFetch(domain:github.com)",
"Bash(curl:*)",
"Bash(adb install:*)",
"WebFetch(domain:signal.org)",
"WebFetch(domain:www.vet.minpolj.gov.rs)",
"WebFetch(domain:www.mfa.gov.rs)",
"Bash(adb uninstall:*)",
"WebFetch(domain:apkpure.com)",
"WebFetch(domain:claude.en.uptodown.com)",
"WebFetch(domain:www.apkmirror.com)",
"Bash(chmod:*)",
"Bash(done)",
"Bash(/home/mleku/src/next.orly.dev/scripts/test-neo4j-integration.sh:*)",
"Bash(echo:*)",
"Bash(go doc:*)",
"Bash(git checkout:*)",
"Bash(grep:*)",
"Bash(lsblk:*)",
"Bash(update-grub:*)",
"Bash(go clean:*)",
"Bash(go mod tidy:*)",
"Bash(./scripts/test-neo4j-integration.sh:*)",
"Bash(docker compose:*)"
],
"deny": [],
"ask": []

481
pkg/neo4j/bugfix_test.go Normal file
View File

@@ -0,0 +1,481 @@
//go:build integration
// +build integration
// Integration tests for Neo4j bug fixes.
// These tests require a running Neo4j instance and are not run by default.
//
// To run these tests:
// 1. Start Neo4j: docker compose -f pkg/neo4j/docker-compose.yaml up -d
// 2. Run tests: go test -tags=integration ./pkg/neo4j/... -v
// 3. Stop Neo4j: docker compose -f pkg/neo4j/docker-compose.yaml down
//
// Or use the helper script:
// ./scripts/test-neo4j-integration.sh
package neo4j
import (
"context"
"crypto/rand"
"encoding/hex"
"testing"
"time"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/tag"
)
// TestLargeContactListBatching tests that kind 3 events with many follows
// don't cause OOM errors by verifying batched processing works correctly.
// This tests the fix for: "java out of memory error broadcasting a kind 3 event"
func TestLargeContactListBatching(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx := context.Background()
// Clean up before test
cleanTestDatabase()
// Generate a test pubkey for the author
authorPubkey := generateTestPubkey()
// Create a kind 3 event with 2000 follows (enough to require multiple batches)
// With contactListBatchSize = 1000, this will require 2 batches
numFollows := 2000
followPubkeys := make([]string, numFollows)
tagsList := tag.NewS()
for i := 0; i < numFollows; i++ {
followPubkeys[i] = generateTestPubkey()
tagsList.Append(tag.NewFromAny("p", followPubkeys[i]))
}
// Create the kind 3 event
ev := createTestEvent(t, authorPubkey, 3, tagsList, "")
// Save the event - this should NOT cause OOM with batching
exists, err := testDB.SaveEvent(ctx, ev)
if err != nil {
t.Fatalf("Failed to save large contact list event: %v", err)
}
if exists {
t.Fatal("Event unexpectedly already exists")
}
// Verify the event was saved
eventID := hex.EncodeToString(ev.ID[:])
checkCypher := "MATCH (e:Event {id: $id}) RETURN e.id AS id"
result, err := testDB.ExecuteRead(ctx, checkCypher, map[string]any{"id": eventID})
if err != nil {
t.Fatalf("Failed to check event existence: %v", err)
}
if !result.Next(ctx) {
t.Fatal("Event was not saved")
}
// Verify FOLLOWS relationships were created
followsCypher := `
MATCH (author:NostrUser {pubkey: $pubkey})-[:FOLLOWS]->(followed:NostrUser)
RETURN count(followed) AS count
`
result, err = testDB.ExecuteRead(ctx, followsCypher, map[string]any{"pubkey": authorPubkey})
if err != nil {
t.Fatalf("Failed to count follows: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != int64(numFollows) {
t.Errorf("Expected %d follows, got %d", numFollows, count)
}
t.Logf("Successfully created %d FOLLOWS relationships in batches", count)
} else {
t.Fatal("No follow count returned")
}
// Verify ProcessedSocialEvent was created with correct relationship_count
psCypher := `
MATCH (ps:ProcessedSocialEvent {pubkey: $pubkey, event_kind: 3})
RETURN ps.relationship_count AS count
`
result, err = testDB.ExecuteRead(ctx, psCypher, map[string]any{"pubkey": authorPubkey})
if err != nil {
t.Fatalf("Failed to check ProcessedSocialEvent: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != int64(numFollows) {
t.Errorf("ProcessedSocialEvent.relationship_count: expected %d, got %d", numFollows, count)
}
} else {
t.Fatal("ProcessedSocialEvent not created")
}
}
// TestMultipleETagsWithClause tests that events with multiple e-tags
// generate valid Cypher (WITH between FOREACH and OPTIONAL MATCH).
// This tests the fix for: "WITH is required between FOREACH and MATCH"
func TestMultipleETagsWithClause(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx := context.Background()
// Clean up before test
cleanTestDatabase()
// First, create some events that will be referenced
refEventIDs := make([]string, 5)
for i := 0; i < 5; i++ {
refPubkey := generateTestPubkey()
refTags := tag.NewS()
refEv := createTestEvent(t, refPubkey, 1, refTags, "referenced event")
exists, err := testDB.SaveEvent(ctx, refEv)
if err != nil {
t.Fatalf("Failed to save reference event %d: %v", i, err)
}
if exists {
t.Fatalf("Reference event %d unexpectedly exists", i)
}
refEventIDs[i] = hex.EncodeToString(refEv.ID[:])
}
// Create a kind 5 delete event that references multiple events (multiple e-tags)
authorPubkey := generateTestPubkey()
tagsList := tag.NewS()
for _, refID := range refEventIDs {
tagsList.Append(tag.NewFromAny("e", refID))
}
// Create the kind 5 event with multiple e-tags
ev := createTestEvent(t, authorPubkey, 5, tagsList, "")
// Save the event - this should NOT fail with Cypher syntax error
exists, err := testDB.SaveEvent(ctx, ev)
if err != nil {
t.Fatalf("Failed to save event with multiple e-tags: %v\n"+
"This indicates the WITH clause fix is not working", err)
}
if exists {
t.Fatal("Event unexpectedly already exists")
}
// Verify the event was saved
eventID := hex.EncodeToString(ev.ID[:])
checkCypher := "MATCH (e:Event {id: $id}) RETURN e.id AS id"
result, err := testDB.ExecuteRead(ctx, checkCypher, map[string]any{"id": eventID})
if err != nil {
t.Fatalf("Failed to check event existence: %v", err)
}
if !result.Next(ctx) {
t.Fatal("Event was not saved")
}
// Verify REFERENCES relationships were created
refCypher := `
MATCH (e:Event {id: $id})-[:REFERENCES]->(ref:Event)
RETURN count(ref) AS count
`
result, err = testDB.ExecuteRead(ctx, refCypher, map[string]any{"id": eventID})
if err != nil {
t.Fatalf("Failed to count references: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != int64(len(refEventIDs)) {
t.Errorf("Expected %d REFERENCES relationships, got %d", len(refEventIDs), count)
}
t.Logf("Successfully created %d REFERENCES relationships", count)
} else {
t.Fatal("No reference count returned")
}
}
// TestLargeMuteListBatching tests that kind 10000 events with many mutes
// don't cause OOM errors by verifying batched processing works correctly.
func TestLargeMuteListBatching(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx := context.Background()
// Clean up before test
cleanTestDatabase()
// Generate a test pubkey for the author
authorPubkey := generateTestPubkey()
// Create a kind 10000 event with 1500 mutes (enough to require 2 batches)
numMutes := 1500
tagsList := tag.NewS()
for i := 0; i < numMutes; i++ {
mutePubkey := generateTestPubkey()
tagsList.Append(tag.NewFromAny("p", mutePubkey))
}
// Create the kind 10000 event
ev := createTestEvent(t, authorPubkey, 10000, tagsList, "")
// Save the event - this should NOT cause OOM with batching
exists, err := testDB.SaveEvent(ctx, ev)
if err != nil {
t.Fatalf("Failed to save large mute list event: %v", err)
}
if exists {
t.Fatal("Event unexpectedly already exists")
}
// Verify MUTES relationships were created
mutesCypher := `
MATCH (author:NostrUser {pubkey: $pubkey})-[:MUTES]->(muted:NostrUser)
RETURN count(muted) AS count
`
result, err := testDB.ExecuteRead(ctx, mutesCypher, map[string]any{"pubkey": authorPubkey})
if err != nil {
t.Fatalf("Failed to count mutes: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != int64(numMutes) {
t.Errorf("Expected %d mutes, got %d", numMutes, count)
}
t.Logf("Successfully created %d MUTES relationships in batches", count)
} else {
t.Fatal("No mute count returned")
}
}
// TestContactListUpdate tests that updating a contact list (replacing one kind 3 with another)
// correctly handles the diff and batching.
func TestContactListUpdate(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx := context.Background()
// Clean up before test
cleanTestDatabase()
authorPubkey := generateTestPubkey()
// Create initial contact list with 500 follows
initialFollows := make([]string, 500)
tagsList1 := tag.NewS()
for i := 0; i < 500; i++ {
initialFollows[i] = generateTestPubkey()
tagsList1.Append(tag.NewFromAny("p", initialFollows[i]))
}
ev1 := createTestEventWithTimestamp(t, authorPubkey, 3, tagsList1, "", time.Now().Unix()-100)
_, err := testDB.SaveEvent(ctx, ev1)
if err != nil {
t.Fatalf("Failed to save initial contact list: %v", err)
}
// Verify initial follows count
countCypher := `
MATCH (author:NostrUser {pubkey: $pubkey})-[:FOLLOWS]->(followed:NostrUser)
RETURN count(followed) AS count
`
result, err := testDB.ExecuteRead(ctx, countCypher, map[string]any{"pubkey": authorPubkey})
if err != nil {
t.Fatalf("Failed to count initial follows: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != 500 {
t.Errorf("Initial follows: expected 500, got %d", count)
}
}
// Create updated contact list: remove 100 old follows, add 200 new ones
tagsList2 := tag.NewS()
// Keep first 400 of the original follows
for i := 0; i < 400; i++ {
tagsList2.Append(tag.NewFromAny("p", initialFollows[i]))
}
// Add 200 new follows
for i := 0; i < 200; i++ {
tagsList2.Append(tag.NewFromAny("p", generateTestPubkey()))
}
ev2 := createTestEventWithTimestamp(t, authorPubkey, 3, tagsList2, "", time.Now().Unix())
_, err = testDB.SaveEvent(ctx, ev2)
if err != nil {
t.Fatalf("Failed to save updated contact list: %v", err)
}
// Verify final follows count (should be 600)
result, err = testDB.ExecuteRead(ctx, countCypher, map[string]any{"pubkey": authorPubkey})
if err != nil {
t.Fatalf("Failed to count final follows: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != 600 {
t.Errorf("Final follows: expected 600, got %d", count)
}
t.Logf("Contact list update successful: 500 -> 600 follows (removed 100, added 200)")
}
// Verify old ProcessedSocialEvent is marked as superseded
supersededCypher := `
MATCH (ps:ProcessedSocialEvent {pubkey: $pubkey, event_kind: 3})
WHERE ps.superseded_by IS NOT NULL
RETURN count(ps) AS count
`
result, err = testDB.ExecuteRead(ctx, supersededCypher, map[string]any{"pubkey": authorPubkey})
if err != nil {
t.Fatalf("Failed to check superseded events: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != 1 {
t.Errorf("Expected 1 superseded ProcessedSocialEvent, got %d", count)
}
}
}
// TestMixedTagsEvent tests that events with e-tags, p-tags, and other tags
// all generate valid Cypher with proper WITH clauses.
func TestMixedTagsEvent(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx := context.Background()
// Clean up before test
cleanTestDatabase()
// Create some referenced events
refEventIDs := make([]string, 3)
for i := 0; i < 3; i++ {
refPubkey := generateTestPubkey()
refTags := tag.NewS()
refEv := createTestEvent(t, refPubkey, 1, refTags, "ref")
testDB.SaveEvent(ctx, refEv)
refEventIDs[i] = hex.EncodeToString(refEv.ID[:])
}
// Create an event with mixed tags: e-tags, p-tags, and other tags
authorPubkey := generateTestPubkey()
tagsList := tag.NewS(
// e-tags (event references)
tag.NewFromAny("e", refEventIDs[0]),
tag.NewFromAny("e", refEventIDs[1]),
tag.NewFromAny("e", refEventIDs[2]),
// p-tags (pubkey mentions)
tag.NewFromAny("p", generateTestPubkey()),
tag.NewFromAny("p", generateTestPubkey()),
// other tags
tag.NewFromAny("t", "nostr"),
tag.NewFromAny("t", "test"),
tag.NewFromAny("subject", "Test Subject"),
)
ev := createTestEvent(t, authorPubkey, 1, tagsList, "Mixed tags test")
// Save the event - should not fail with Cypher syntax errors
exists, err := testDB.SaveEvent(ctx, ev)
if err != nil {
t.Fatalf("Failed to save event with mixed tags: %v", err)
}
if exists {
t.Fatal("Event unexpectedly already exists")
}
eventID := hex.EncodeToString(ev.ID[:])
// Verify REFERENCES relationships
refCypher := `MATCH (e:Event {id: $id})-[:REFERENCES]->(ref:Event) RETURN count(ref) AS count`
result, err := testDB.ExecuteRead(ctx, refCypher, map[string]any{"id": eventID})
if err != nil {
t.Fatalf("Failed to count references: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != 3 {
t.Errorf("Expected 3 REFERENCES, got %d", count)
}
}
// Verify MENTIONS relationships
mentionsCypher := `MATCH (e:Event {id: $id})-[:MENTIONS]->(u:NostrUser) RETURN count(u) AS count`
result, err = testDB.ExecuteRead(ctx, mentionsCypher, map[string]any{"id": eventID})
if err != nil {
t.Fatalf("Failed to count mentions: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != 2 {
t.Errorf("Expected 2 MENTIONS, got %d", count)
}
}
// Verify TAGGED_WITH relationships
taggedCypher := `MATCH (e:Event {id: $id})-[:TAGGED_WITH]->(t:Tag) RETURN count(t) AS count`
result, err = testDB.ExecuteRead(ctx, taggedCypher, map[string]any{"id": eventID})
if err != nil {
t.Fatalf("Failed to count tags: %v", err)
}
if result.Next(ctx) {
count := result.Record().Values[0].(int64)
if count != 3 {
t.Errorf("Expected 3 TAGGED_WITH, got %d", count)
}
}
t.Log("Mixed tags event saved successfully with all relationship types")
}
// Helper functions
func generateTestPubkey() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
func createTestEvent(t *testing.T, pubkey string, kind uint16, tagsList *tag.S, content string) *event.E {
t.Helper()
return createTestEventWithTimestamp(t, pubkey, kind, tagsList, content, time.Now().Unix())
}
func createTestEventWithTimestamp(t *testing.T, pubkey string, kind uint16, tagsList *tag.S, content string, timestamp int64) *event.E {
t.Helper()
// Decode pubkey
pubkeyBytes, err := hex.DecodeString(pubkey)
if err != nil {
t.Fatalf("Invalid pubkey: %v", err)
}
// Generate random ID and signature (for testing purposes)
idBytes := make([]byte, 32)
rand.Read(idBytes)
sigBytes := make([]byte, 64)
rand.Read(sigBytes)
// event.E uses []byte slices, not [32]byte arrays, so we need to assign directly
ev := &event.E{
Kind: kind,
Tags: tagsList,
Content: []byte(content),
CreatedAt: timestamp,
Pubkey: pubkeyBytes,
ID: idBytes,
Sig: sigBytes,
}
return ev
}

View File

@@ -84,7 +84,7 @@ LIMIT 1000`
deleteParams := map[string]any{"id": idStr}
if _, err := n.ExecuteWrite(ctx, deleteCypher, deleteParams); err != nil {
n.Logger.Warningf("failed to delete expired event %s: %v", idStr[:16], err)
n.Logger.Warningf("failed to delete expired event %s: %v", safePrefix(idStr, 16), err)
continue
}
@@ -117,7 +117,7 @@ func (n *N) ProcessDelete(ev *event.E, admins [][]byte) error {
// Check if author is an admin
for _, adminPk := range admins {
if string(ev.Pubkey[:]) == string(adminPk) {
if string(ev.Pubkey) == string(adminPk) {
isAdmin = true
break
}
@@ -157,7 +157,7 @@ func (n *N) ProcessDelete(ev *event.E, admins [][]byte) error {
}
// Check if deletion is allowed (same author or admin)
canDelete := isAdmin || string(ev.Pubkey[:]) == string(pubkey)
canDelete := isAdmin || string(ev.Pubkey) == string(pubkey)
if canDelete {
// Delete the event
if err := n.DeleteEvent(ctx, eventID); err != nil {

View File

@@ -1,8 +1,10 @@
//go:build integration
// +build integration
package neo4j
import (
"context"
"os"
"testing"
"git.mleku.dev/mleku/nostr/encoders/event"
@@ -14,27 +16,17 @@ import (
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
// All tests in this file use the shared testDB instance from testmain_test.go
// to avoid Neo4j authentication rate limiting from too many connections.
func TestDeleteEvent(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -55,12 +47,12 @@ func TestDeleteEvent(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Verify event exists
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(ev.ID),
})
if err != nil {
@@ -71,12 +63,12 @@ func TestDeleteEvent(t *testing.T) {
}
// Delete the event
if err := db.DeleteEvent(ctx, ev.ID[:]); err != nil {
if err := testDB.DeleteEvent(ctx, ev.ID[:]); err != nil {
t.Fatalf("Failed to delete event: %v", err)
}
// Verify event is deleted
evs, err = db.QueryEvents(ctx, &filter.F{
evs, err = testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(ev.ID),
})
if err != nil {
@@ -90,26 +82,13 @@ func TestDeleteEvent(t *testing.T) {
}
func TestDeleteEventBySerial(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -130,23 +109,23 @@ func TestDeleteEventBySerial(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Get serial
serial, err := db.GetSerialById(ev.ID[:])
serial, err := testDB.GetSerialById(ev.ID[:])
if err != nil {
t.Fatalf("Failed to get serial: %v", err)
}
// Delete by serial
if err := db.DeleteEventBySerial(ctx, serial, ev); err != nil {
if err := testDB.DeleteEventBySerial(ctx, serial, ev); err != nil {
t.Fatalf("Failed to delete event by serial: %v", err)
}
// Verify event is deleted
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(ev.ID),
})
if err != nil {
@@ -160,26 +139,13 @@ func TestDeleteEventBySerial(t *testing.T) {
}
func TestProcessDelete_AuthorCanDeleteOwnEvent(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -200,7 +166,7 @@ func TestProcessDelete_AuthorCanDeleteOwnEvent(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, originalEvent); err != nil {
if _, err := testDB.SaveEvent(ctx, originalEvent); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
@@ -219,12 +185,12 @@ func TestProcessDelete_AuthorCanDeleteOwnEvent(t *testing.T) {
}
// Process deletion (no admins)
if err := db.ProcessDelete(deleteEvent, nil); err != nil {
if err := testDB.ProcessDelete(deleteEvent, nil); err != nil {
t.Fatalf("Failed to process delete: %v", err)
}
// Verify original event is deleted
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(originalEvent.ID),
})
if err != nil {
@@ -238,26 +204,13 @@ func TestProcessDelete_AuthorCanDeleteOwnEvent(t *testing.T) {
}
func TestProcessDelete_OtherUserCannotDelete(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
alice, _ := p8k.New()
alice.Generate()
@@ -276,7 +229,7 @@ func TestProcessDelete_OtherUserCannotDelete(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, aliceEvent); err != nil {
if _, err := testDB.SaveEvent(ctx, aliceEvent); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
@@ -294,10 +247,10 @@ func TestProcessDelete_OtherUserCannotDelete(t *testing.T) {
}
// Process deletion (Bob is not an admin)
_ = db.ProcessDelete(deleteEvent, nil)
_ = testDB.ProcessDelete(deleteEvent, nil)
// Verify Alice's event still exists
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(aliceEvent.ID),
})
if err != nil {
@@ -311,26 +264,13 @@ func TestProcessDelete_OtherUserCannotDelete(t *testing.T) {
}
func TestProcessDelete_AdminCanDeleteAnyEvent(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
alice, _ := p8k.New()
alice.Generate()
@@ -349,7 +289,7 @@ func TestProcessDelete_AdminCanDeleteAnyEvent(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, aliceEvent); err != nil {
if _, err := testDB.SaveEvent(ctx, aliceEvent); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
@@ -368,12 +308,12 @@ func TestProcessDelete_AdminCanDeleteAnyEvent(t *testing.T) {
// Process deletion with admin pubkey
adminPubkeys := [][]byte{admin.Pub()}
if err := db.ProcessDelete(deleteEvent, adminPubkeys); err != nil {
if err := testDB.ProcessDelete(deleteEvent, adminPubkeys); err != nil {
t.Fatalf("Failed to process delete: %v", err)
}
// Verify Alice's event is deleted
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(aliceEvent.ID),
})
if err != nil {
@@ -387,26 +327,13 @@ func TestProcessDelete_AdminCanDeleteAnyEvent(t *testing.T) {
}
func TestCheckForDeleted(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -427,12 +354,12 @@ func TestCheckForDeleted(t *testing.T) {
t.Fatalf("Failed to sign target event: %v", err)
}
if _, err := db.SaveEvent(ctx, targetEvent); err != nil {
if _, err := testDB.SaveEvent(ctx, targetEvent); err != nil {
t.Fatalf("Failed to save target event: %v", err)
}
// Check that event is not deleted (no deletion event exists)
err = db.CheckForDeleted(targetEvent, nil)
err = testDB.CheckForDeleted(targetEvent, nil)
if err != nil {
t.Fatalf("Expected no error for non-deleted event, got: %v", err)
}
@@ -450,12 +377,12 @@ func TestCheckForDeleted(t *testing.T) {
t.Fatalf("Failed to sign delete event: %v", err)
}
if _, err := db.SaveEvent(ctx, deleteEvent); err != nil {
if _, err := testDB.SaveEvent(ctx, deleteEvent); err != nil {
t.Fatalf("Failed to save delete event: %v", err)
}
// Now check should return error (event has been deleted)
err = db.CheckForDeleted(targetEvent, nil)
err = testDB.CheckForDeleted(targetEvent, nil)
if err == nil {
t.Fatal("Expected error for deleted event")
}
@@ -464,26 +391,13 @@ func TestCheckForDeleted(t *testing.T) {
}
func TestReplaceableEventDeletion(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -504,12 +418,12 @@ func TestReplaceableEventDeletion(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, profileEvent); err != nil {
if _, err := testDB.SaveEvent(ctx, profileEvent); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Verify event exists
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(0)),
Authors: tag.NewFromBytesSlice(signer.Pub()),
})
@@ -531,12 +445,12 @@ func TestReplaceableEventDeletion(t *testing.T) {
t.Fatalf("Failed to sign newer event: %v", err)
}
if _, err := db.SaveEvent(ctx, newerProfileEvent); err != nil {
if _, err := testDB.SaveEvent(ctx, newerProfileEvent); err != nil {
t.Fatalf("Failed to save newer event: %v", err)
}
// Query should return only the newer event
evs, err = db.QueryEvents(ctx, &filter.F{
evs, err = testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(0)),
Authors: tag.NewFromBytesSlice(signer.Pub()),
})

View File

@@ -1,10 +1,12 @@
//go:build integration
// +build integration
package neo4j
import (
"bytes"
"context"
"encoding/json"
"os"
"testing"
"time"
@@ -17,27 +19,17 @@ import (
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
// All tests in this file use the shared testDB instance from testmain_test.go
// to avoid Neo4j authentication rate limiting from too many connections.
func TestExpiration_SaveEventWithExpiration(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -61,12 +53,12 @@ func TestExpiration_SaveEventWithExpiration(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Query the event to verify it was saved
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(ev.ID),
})
if err != nil {
@@ -81,26 +73,13 @@ func TestExpiration_SaveEventWithExpiration(t *testing.T) {
}
func TestExpiration_DeleteExpiredEvents(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -124,7 +103,7 @@ func TestExpiration_DeleteExpiredEvents(t *testing.T) {
t.Fatalf("Failed to sign expired event: %v", err)
}
if _, err := db.SaveEvent(ctx, expiredEv); err != nil {
if _, err := testDB.SaveEvent(ctx, expiredEv); err != nil {
t.Fatalf("Failed to save expired event: %v", err)
}
@@ -142,7 +121,7 @@ func TestExpiration_DeleteExpiredEvents(t *testing.T) {
t.Fatalf("Failed to sign valid event: %v", err)
}
if _, err := db.SaveEvent(ctx, validEv); err != nil {
if _, err := testDB.SaveEvent(ctx, validEv); err != nil {
t.Fatalf("Failed to save valid event: %v", err)
}
@@ -157,12 +136,12 @@ func TestExpiration_DeleteExpiredEvents(t *testing.T) {
t.Fatalf("Failed to sign permanent event: %v", err)
}
if _, err := db.SaveEvent(ctx, permanentEv); err != nil {
if _, err := testDB.SaveEvent(ctx, permanentEv); err != nil {
t.Fatalf("Failed to save permanent event: %v", err)
}
// Verify all 3 events exist
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Authors: tag.NewFromBytesSlice(signer.Pub()),
})
if err != nil {
@@ -173,10 +152,10 @@ func TestExpiration_DeleteExpiredEvents(t *testing.T) {
}
// Run DeleteExpired
db.DeleteExpired()
testDB.DeleteExpired()
// Verify only expired event was deleted
evs, err = db.QueryEvents(ctx, &filter.F{
evs, err = testDB.QueryEvents(ctx, &filter.F{
Authors: tag.NewFromBytesSlice(signer.Pub()),
})
if err != nil {
@@ -210,26 +189,13 @@ func TestExpiration_DeleteExpiredEvents(t *testing.T) {
}
func TestExpiration_NoExpirationTag(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -250,15 +216,15 @@ func TestExpiration_NoExpirationTag(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Run DeleteExpired - event should not be deleted
db.DeleteExpired()
testDB.DeleteExpired()
// Verify event still exists
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(ev.ID),
})
if err != nil {
@@ -273,26 +239,13 @@ func TestExpiration_NoExpirationTag(t *testing.T) {
}
func TestExport_AllEvents(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -315,14 +268,14 @@ func TestExport_AllEvents(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
}
// Export all events
var buf bytes.Buffer
db.Export(ctx, &buf)
testDB.Export(ctx, &buf)
// Parse the exported JSONL
lines := bytes.Split(buf.Bytes(), []byte("\n"))
@@ -346,26 +299,13 @@ func TestExport_AllEvents(t *testing.T) {
}
func TestExport_FilterByPubkey(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
// Create two signers
alice, _ := p8k.New()
@@ -388,7 +328,7 @@ func TestExport_FilterByPubkey(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
}
@@ -405,14 +345,14 @@ func TestExport_FilterByPubkey(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
}
// Export only Alice's events
var buf bytes.Buffer
db.Export(ctx, &buf, alice.Pub())
testDB.Export(ctx, &buf, alice.Pub())
// Parse the exported JSONL
lines := bytes.Split(buf.Bytes(), []byte("\n"))
@@ -440,30 +380,17 @@ func TestExport_FilterByPubkey(t *testing.T) {
}
func TestExport_Empty(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
// Export from empty database
var buf bytes.Buffer
db.Export(ctx, &buf)
testDB.Export(ctx, &buf)
// Should be empty or just whitespace
content := bytes.TrimSpace(buf.Bytes())
@@ -475,26 +402,13 @@ func TestExport_Empty(t *testing.T) {
}
func TestImportExport_RoundTrip(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, _ := p8k.New()
signer.Generate()
@@ -513,7 +427,7 @@ func TestImportExport_RoundTrip(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
originalEvents[i] = ev
@@ -521,15 +435,15 @@ func TestImportExport_RoundTrip(t *testing.T) {
// Export events
var buf bytes.Buffer
db.Export(ctx, &buf)
testDB.Export(ctx, &buf)
// Wipe database
if err := db.Wipe(); err != nil {
if err := testDB.Wipe(); err != nil {
t.Fatalf("Failed to wipe database: %v", err)
}
// Verify database is empty
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
})
if err != nil {
@@ -540,10 +454,10 @@ func TestImportExport_RoundTrip(t *testing.T) {
}
// Import events
db.Import(bytes.NewReader(buf.Bytes()))
testDB.Import(bytes.NewReader(buf.Bytes()))
// Verify events were restored
evs, err = db.QueryEvents(ctx, &filter.F{
evs, err = testDB.QueryEvents(ctx, &filter.F{
Authors: tag.NewFromBytesSlice(signer.Pub()),
})
if err != nil {

View File

@@ -1,8 +1,10 @@
//go:build integration
// +build integration
package neo4j
import (
"context"
"os"
"testing"
"git.mleku.dev/mleku/nostr/encoders/event"
@@ -14,27 +16,17 @@ import (
"next.orly.dev/pkg/database/indexes/types"
)
// All tests in this file use the shared testDB instance from testmain_test.go
// to avoid Neo4j authentication rate limiting from too many connections.
func TestFetchEventBySerial(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -55,18 +47,18 @@ func TestFetchEventBySerial(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Get the serial for this event
serial, err := db.GetSerialById(ev.ID[:])
serial, err := testDB.GetSerialById(ev.ID[:])
if err != nil {
t.Fatalf("Failed to get serial by ID: %v", err)
}
// Fetch event by serial
fetchedEvent, err := db.FetchEventBySerial(serial)
fetchedEvent, err := testDB.FetchEventBySerial(serial)
if err != nil {
t.Fatalf("Failed to fetch event by serial: %v", err)
}
@@ -98,28 +90,15 @@ func TestFetchEventBySerial(t *testing.T) {
}
func TestFetchEventBySerial_NonExistent(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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()
// Try to fetch with non-existent serial
nonExistentSerial := &types.Uint40{}
nonExistentSerial.Set(0xFFFFFFFFFF) // Max value
_, err = db.FetchEventBySerial(nonExistentSerial)
_, err := testDB.FetchEventBySerial(nonExistentSerial)
if err == nil {
t.Fatal("Expected error for non-existent serial")
}
@@ -128,26 +107,13 @@ func TestFetchEventBySerial_NonExistent(t *testing.T) {
}
func TestFetchEventsBySerials(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -172,11 +138,11 @@ func TestFetchEventsBySerials(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
serial, err := db.GetSerialById(ev.ID[:])
serial, err := testDB.GetSerialById(ev.ID[:])
if err != nil {
t.Fatalf("Failed to get serial: %v", err)
}
@@ -186,7 +152,7 @@ func TestFetchEventsBySerials(t *testing.T) {
}
// Fetch all events by serials
events, err := db.FetchEventsBySerials(serials)
events, err := testDB.FetchEventsBySerials(serials)
if err != nil {
t.Fatalf("Failed to fetch events by serials: %v", err)
}
@@ -210,26 +176,13 @@ func TestFetchEventsBySerials(t *testing.T) {
}
func TestGetSerialById(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -250,12 +203,12 @@ func TestGetSerialById(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Get serial by ID
serial, err := db.GetSerialById(ev.ID[:])
serial, err := testDB.GetSerialById(ev.ID[:])
if err != nil {
t.Fatalf("Failed to get serial by ID: %v", err)
}
@@ -272,27 +225,14 @@ func TestGetSerialById(t *testing.T) {
}
func TestGetSerialById_NonExistent(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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()
// Try to get serial for non-existent event
fakeID, _ := hex.Dec("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
_, err = db.GetSerialById(fakeID)
_, err := testDB.GetSerialById(fakeID)
if err == nil {
t.Fatal("Expected error for non-existent event ID")
}
@@ -301,26 +241,13 @@ func TestGetSerialById_NonExistent(t *testing.T) {
}
func TestGetSerialsByIds(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -343,7 +270,7 @@ func TestGetSerialsByIds(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
@@ -352,7 +279,7 @@ func TestGetSerialsByIds(t *testing.T) {
}
// Get serials by IDs
serials, err := db.GetSerialsByIds(ids)
serials, err := testDB.GetSerialsByIds(ids)
if err != nil {
t.Fatalf("Failed to get serials by IDs: %v", err)
}
@@ -365,26 +292,13 @@ func TestGetSerialsByIds(t *testing.T) {
}
func TestGetFullIdPubkeyBySerial(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -405,18 +319,18 @@ func TestGetFullIdPubkeyBySerial(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
// Get serial
serial, err := db.GetSerialById(ev.ID[:])
serial, err := testDB.GetSerialById(ev.ID[:])
if err != nil {
t.Fatalf("Failed to get serial: %v", err)
}
// Get full ID and pubkey
idPkTs, err := db.GetFullIdPubkeyBySerial(serial)
idPkTs, err := testDB.GetFullIdPubkeyBySerial(serial)
if err != nil {
t.Fatalf("Failed to get full ID and pubkey: %v", err)
}
@@ -441,26 +355,13 @@ func TestGetFullIdPubkeyBySerial(t *testing.T) {
}
func TestQueryForSerials(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleanTestDatabase()
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)
}
ctx := context.Background()
signer, err := p8k.New()
if err != nil {
@@ -482,13 +383,13 @@ func TestQueryForSerials(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
}
// Query for serials
serials, err := db.QueryForSerials(ctx, &filter.F{
serials, err := testDB.QueryForSerials(ctx, &filter.F{
Authors: tag.NewFromBytesSlice(signer.Pub()),
})
if err != nil {

View File

@@ -31,18 +31,25 @@ func IsBinaryEncoded(val []byte) bool {
// NormalizePubkeyHex ensures a pubkey/event ID is in lowercase hex format.
// It handles:
// - Binary-encoded values (33 bytes with null terminator) -> converts to lowercase hex
// - Raw binary values (32 bytes) -> converts to lowercase hex
// - Uppercase hex strings -> converts to lowercase
// - Already lowercase hex -> returns as-is
//
// This should be used for all pubkeys and event IDs before storing in Neo4j
// to prevent duplicate nodes due to case differences.
func NormalizePubkeyHex(val []byte) string {
// Handle binary-encoded values from the nostr library
// Handle binary-encoded values from the nostr library (33 bytes with null terminator)
if IsBinaryEncoded(val) {
// Convert binary to lowercase hex
return hex.Enc(val[:HashLen])
}
// Handle raw binary values (32 bytes) - common when passing ev.ID or ev.Pubkey directly
if len(val) == HashLen {
// Convert binary to lowercase hex
return hex.Enc(val)
}
// Handle hex strings (may be uppercase from external sources)
if len(val) == HexEncodedLen {
return strings.ToLower(string(val))

View File

@@ -74,6 +74,11 @@ func TestNormalizePubkeyHex(t *testing.T) {
input: binaryEncoded,
expected: "0000000000000000000000000000000000000000000000000000000000000001",
},
{
name: "Raw 32-byte binary to hex",
input: testBytes,
expected: "0000000000000000000000000000000000000000000000000000000000000001",
},
{
name: "Lowercase hex passthrough",
input: []byte("0000000000000000000000000000000000000000000000000000000000000001"),

View File

@@ -8,6 +8,8 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
"lol.mleku.dev"
@@ -18,6 +20,16 @@ import (
"next.orly.dev/pkg/utils/apputil"
)
// maxConcurrentQueries limits the number of concurrent Neo4j queries to prevent
// authentication rate limiting and connection exhaustion
const maxConcurrentQueries = 10
// maxRetryAttempts is the maximum number of times to retry a query on rate limit
const maxRetryAttempts = 3
// retryBaseDelay is the base delay for exponential backoff
const retryBaseDelay = 500 * time.Millisecond
// N implements the database.Database interface using Neo4j as the storage backend
type N struct {
ctx context.Context
@@ -34,6 +46,9 @@ type N struct {
neo4jPassword string
ready chan struct{} // Closed when database is ready to serve requests
// querySem limits concurrent queries to prevent rate limiting
querySem chan struct{}
}
// Ensure N implements database.Database interface at compile time
@@ -112,6 +127,7 @@ func NewWithConfig(
neo4jUser: neo4jUser,
neo4jPassword: neo4jPassword,
ready: make(chan struct{}),
querySem: make(chan struct{}, maxConcurrentQueries),
}
// Ensure the data directory exists
@@ -199,42 +215,139 @@ func (n *N) initNeo4jClient() error {
}
// ExecuteRead executes a read query against Neo4j
// isRateLimitError checks if an error is due to authentication rate limiting
func isRateLimitError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "AuthenticationRateLimit") ||
strings.Contains(errStr, "Too many failed authentication attempts")
}
// acquireQuerySlot acquires a slot from the query semaphore
func (n *N) acquireQuerySlot(ctx context.Context) error {
select {
case n.querySem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// releaseQuerySlot releases a slot back to the query semaphore
func (n *N) releaseQuerySlot() {
<-n.querySem
}
// ExecuteRead executes a read query against Neo4j with rate limiting and retry
// Returns a collected result that can be iterated after the session closes
func (n *N) ExecuteRead(ctx context.Context, cypher string, params map[string]any) (*CollectedResult, error) {
session := n.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
// Acquire semaphore slot to limit concurrent queries
if err := n.acquireQuerySlot(ctx); err != nil {
return nil, fmt.Errorf("failed to acquire query slot: %w", err)
}
defer n.releaseQuerySlot()
result, err := session.Run(ctx, cypher, params)
if err != nil {
return nil, fmt.Errorf("neo4j read query failed: %w", err)
var lastErr error
for attempt := 0; attempt < maxRetryAttempts; attempt++ {
if attempt > 0 {
// Exponential backoff
delay := retryBaseDelay * time.Duration(1<<uint(attempt-1))
n.Logger.Warningf("retrying read query after %v (attempt %d/%d)", delay, attempt+1, maxRetryAttempts)
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
session := n.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
result, err := session.Run(ctx, cypher, params)
if err != nil {
session.Close(ctx)
lastErr = err
if isRateLimitError(err) {
continue // Retry on rate limit
}
return nil, fmt.Errorf("neo4j read query failed: %w", err)
}
// Collect all records before the session closes
// (Neo4j results are lazy and need an open session for iteration)
records, err := result.Collect(ctx)
session.Close(ctx)
if err != nil {
lastErr = err
if isRateLimitError(err) {
continue // Retry on rate limit
}
return nil, fmt.Errorf("neo4j result collect failed: %w", err)
}
return &CollectedResult{records: records, index: -1}, nil
}
// Collect all records before the session closes
// (Neo4j results are lazy and need an open session for iteration)
records, err := result.Collect(ctx)
if err != nil {
return nil, fmt.Errorf("neo4j result collect failed: %w", err)
}
return &CollectedResult{records: records, index: -1}, nil
return nil, fmt.Errorf("neo4j read query failed after %d attempts: %w", maxRetryAttempts, lastErr)
}
// ExecuteWrite executes a write query against Neo4j
// ExecuteWrite executes a write query against Neo4j with rate limiting and retry
func (n *N) ExecuteWrite(ctx context.Context, cypher string, params map[string]any) (neo4j.ResultWithContext, error) {
session := n.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(ctx)
// Acquire semaphore slot to limit concurrent queries
if err := n.acquireQuerySlot(ctx); err != nil {
return nil, fmt.Errorf("failed to acquire query slot: %w", err)
}
defer n.releaseQuerySlot()
result, err := session.Run(ctx, cypher, params)
if err != nil {
return nil, fmt.Errorf("neo4j write query failed: %w", err)
var lastErr error
for attempt := 0; attempt < maxRetryAttempts; attempt++ {
if attempt > 0 {
// Exponential backoff
delay := retryBaseDelay * time.Duration(1<<uint(attempt-1))
n.Logger.Warningf("retrying write query after %v (attempt %d/%d)", delay, attempt+1, maxRetryAttempts)
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
session := n.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
result, err := session.Run(ctx, cypher, params)
if err != nil {
session.Close(ctx)
lastErr = err
if isRateLimitError(err) {
continue // Retry on rate limit
}
return nil, fmt.Errorf("neo4j write query failed: %w", err)
}
// Consume the result to ensure the query completes before closing session
_, err = result.Consume(ctx)
session.Close(ctx)
if err != nil {
lastErr = err
if isRateLimitError(err) {
continue // Retry on rate limit
}
return nil, fmt.Errorf("neo4j write consume failed: %w", err)
}
return result, nil
}
return result, nil
return nil, fmt.Errorf("neo4j write query failed after %d attempts: %w", maxRetryAttempts, lastErr)
}
// ExecuteWriteTransaction executes a transactional write operation
// ExecuteWriteTransaction executes a transactional write operation with rate limiting
func (n *N) ExecuteWriteTransaction(ctx context.Context, work func(tx neo4j.ManagedTransaction) (any, error)) (any, error) {
// Acquire semaphore slot to limit concurrent queries
if err := n.acquireQuerySlot(ctx); err != nil {
return nil, fmt.Errorf("failed to acquire query slot: %w", err)
}
defer n.releaseQuerySlot()
session := n.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(ctx)

View File

@@ -1,8 +1,9 @@
//go:build integration
// +build integration
package neo4j
import (
"context"
"os"
"testing"
"time"
@@ -10,27 +11,15 @@ import (
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
// All tests in this file use the shared testDB instance from testmain_test.go
// to avoid Neo4j authentication rate limiting from too many connections.
func TestNIP43_AddAndRemoveMember(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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)
}
cleanTestDatabase()
signer, _ := p8k.New()
signer.Generate()
@@ -38,12 +27,12 @@ func TestNIP43_AddAndRemoveMember(t *testing.T) {
// Add member
inviteCode := "test-invite-123"
if err := db.AddNIP43Member(pubkey, inviteCode); err != nil {
if err := testDB.AddNIP43Member(pubkey, inviteCode); err != nil {
t.Fatalf("Failed to add NIP-43 member: %v", err)
}
// Check membership
isMember, err := db.IsNIP43Member(pubkey)
isMember, err := testDB.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("Failed to check membership: %v", err)
}
@@ -52,7 +41,7 @@ func TestNIP43_AddAndRemoveMember(t *testing.T) {
}
// Get membership details
membership, err := db.GetNIP43Membership(pubkey)
membership, err := testDB.GetNIP43Membership(pubkey)
if err != nil {
t.Fatalf("Failed to get membership: %v", err)
}
@@ -61,12 +50,12 @@ func TestNIP43_AddAndRemoveMember(t *testing.T) {
}
// Remove member
if err := db.RemoveNIP43Member(pubkey); err != nil {
if err := testDB.RemoveNIP43Member(pubkey); err != nil {
t.Fatalf("Failed to remove member: %v", err)
}
// Verify no longer a member
isMember, _ = db.IsNIP43Member(pubkey)
isMember, _ = testDB.IsNIP43Member(pubkey)
if isMember {
t.Fatal("Expected pubkey to not be a member after removal")
}
@@ -75,26 +64,11 @@ func TestNIP43_AddAndRemoveMember(t *testing.T) {
}
func TestNIP43_GetAllMembers(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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)
}
cleanTestDatabase()
// Add multiple members
var pubkeys [][]byte
@@ -104,13 +78,13 @@ func TestNIP43_GetAllMembers(t *testing.T) {
pubkey := signer.Pub()
pubkeys = append(pubkeys, pubkey)
if err := db.AddNIP43Member(pubkey, "invite"+string(rune('A'+i))); err != nil {
if err := testDB.AddNIP43Member(pubkey, "invite"+string(rune('A'+i))); err != nil {
t.Fatalf("Failed to add member %d: %v", i, err)
}
}
// Get all members
members, err := db.GetAllNIP43Members()
members, err := testDB.GetAllNIP43Members()
if err != nil {
t.Fatalf("Failed to get all members: %v", err)
}
@@ -135,36 +109,21 @@ func TestNIP43_GetAllMembers(t *testing.T) {
}
func TestNIP43_InviteCode(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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)
}
cleanTestDatabase()
// Store valid invite code (expires in 1 hour)
validCode := "valid-code-123"
expiresAt := time.Now().Add(1 * time.Hour)
if err := db.StoreInviteCode(validCode, expiresAt); err != nil {
if err := testDB.StoreInviteCode(validCode, expiresAt); err != nil {
t.Fatalf("Failed to store invite code: %v", err)
}
// Validate the code
isValid, err := db.ValidateInviteCode(validCode)
isValid, err := testDB.ValidateInviteCode(validCode)
if err != nil {
t.Fatalf("Failed to validate invite code: %v", err)
}
@@ -173,7 +132,7 @@ func TestNIP43_InviteCode(t *testing.T) {
}
// Test non-existent code
isValid, err = db.ValidateInviteCode("non-existent-code")
isValid, err = testDB.ValidateInviteCode("non-existent-code")
if err != nil {
t.Fatalf("Failed to validate non-existent code: %v", err)
}
@@ -182,12 +141,12 @@ func TestNIP43_InviteCode(t *testing.T) {
}
// Delete the invite code
if err := db.DeleteInviteCode(validCode); err != nil {
if err := testDB.DeleteInviteCode(validCode); err != nil {
t.Fatalf("Failed to delete invite code: %v", err)
}
// Verify code is no longer valid
isValid, _ = db.ValidateInviteCode(validCode)
isValid, _ = testDB.ValidateInviteCode(validCode)
if isValid {
t.Fatal("Expected deleted code to be invalid")
}
@@ -196,36 +155,21 @@ func TestNIP43_InviteCode(t *testing.T) {
}
func TestNIP43_ExpiredInviteCode(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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)
}
cleanTestDatabase()
// Store expired invite code (expired 1 hour ago)
expiredCode := "expired-code-123"
expiresAt := time.Now().Add(-1 * time.Hour)
if err := db.StoreInviteCode(expiredCode, expiresAt); err != nil {
if err := testDB.StoreInviteCode(expiredCode, expiresAt); err != nil {
t.Fatalf("Failed to store expired invite code: %v", err)
}
// Validate should return false for expired code
isValid, err := db.ValidateInviteCode(expiredCode)
isValid, err := testDB.ValidateInviteCode(expiredCode)
if err != nil {
t.Fatalf("Failed to validate expired code: %v", err)
}
@@ -237,49 +181,34 @@ func TestNIP43_ExpiredInviteCode(t *testing.T) {
}
func TestNIP43_DuplicateMember(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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)
}
cleanTestDatabase()
signer, _ := p8k.New()
signer.Generate()
pubkey := signer.Pub()
// Add member first time
if err := db.AddNIP43Member(pubkey, "invite1"); err != nil {
if err := testDB.AddNIP43Member(pubkey, "invite1"); err != nil {
t.Fatalf("Failed to add member: %v", err)
}
// Add same member again (should not error, just update)
if err := db.AddNIP43Member(pubkey, "invite2"); err != nil {
if err := testDB.AddNIP43Member(pubkey, "invite2"); err != nil {
t.Fatalf("Failed to re-add member: %v", err)
}
// Check membership still exists
isMember, _ := db.IsNIP43Member(pubkey)
isMember, _ := testDB.IsNIP43Member(pubkey)
if !isMember {
t.Fatal("Expected pubkey to still be a member")
}
// Get all members should have only 1 entry
members, _ := db.GetAllNIP43Members()
members, _ := testDB.GetAllNIP43Members()
if len(members) != 1 {
t.Fatalf("Expected 1 member, got %d", len(members))
}
@@ -288,26 +217,11 @@ func TestNIP43_DuplicateMember(t *testing.T) {
}
func TestNIP43_MembershipPersistence(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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)
}
cleanTestDatabase()
signer, _ := p8k.New()
signer.Generate()
@@ -315,12 +229,12 @@ func TestNIP43_MembershipPersistence(t *testing.T) {
// Add member
inviteCode := "persistence-test"
if err := db.AddNIP43Member(pubkey, inviteCode); err != nil {
if err := testDB.AddNIP43Member(pubkey, inviteCode); err != nil {
t.Fatalf("Failed to add member: %v", err)
}
// Get membership and verify all fields
membership, err := db.GetNIP43Membership(pubkey)
membership, err := testDB.GetNIP43Membership(pubkey)
if err != nil {
t.Fatalf("Failed to get membership: %v", err)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
@@ -192,6 +193,16 @@ func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map
whereClauses = append(whereClauses, "e.kind <> 5")
}
// Filter out expired events (NIP-40) unless querying by explicit IDs
// Events with expiration > 0 that have passed are hidden from results
// EXCEPT when the query includes specific event IDs (allowing explicit lookup)
hasExplicitIds := f.Ids != nil && len(f.Ids.T) > 0
if !hasExplicitIds {
params["now"] = time.Now().Unix()
// Show events where either: no expiration (expiration = 0) OR expiration hasn't passed yet
whereClauses = append(whereClauses, "(e.expiration = 0 OR e.expiration > $now)")
}
// Build WHERE clause
whereClause := ""
if len(whereClauses) > 0 {

View File

@@ -1,8 +1,10 @@
//go:build integration
// +build integration
package neo4j
import (
"context"
"os"
"testing"
"git.mleku.dev/mleku/nostr/encoders/event"
@@ -14,37 +16,11 @@ import (
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
// setupTestDatabase creates a fresh Neo4j database connection for testing
func setupTestDatabase(t *testing.T) (*N, context.Context, context.CancelFunc) {
t.Helper()
// All tests in this file use the shared testDB instance from testmain_test.go
// to avoid Neo4j authentication rate limiting from too many connections.
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
}
ctx, cancel := context.WithCancel(context.Background())
tempDir := t.TempDir()
db, err := New(ctx, cancel, tempDir, "debug")
if err != nil {
cancel()
t.Fatalf("Failed to create database: %v", err)
}
<-db.Ready()
if err := db.Wipe(); err != nil {
db.Close()
cancel()
t.Fatalf("Failed to wipe database: %v", err)
}
return db, ctx, cancel
}
// createTestSigner creates a new signer for test events
func createTestSigner(t *testing.T) *p8k.Signer {
// createTestSignerLocal creates a new signer for test events
func createTestSignerLocal(t *testing.T) *p8k.Signer {
t.Helper()
signer, err := p8k.New()
@@ -57,8 +33,8 @@ func createTestSigner(t *testing.T) *p8k.Signer {
return signer
}
// createAndSaveEvent creates a signed event and saves it to the database
func createAndSaveEvent(t *testing.T, ctx context.Context, db *N, signer *p8k.Signer, k uint16, content string, tags *tag.S, ts int64) *event.E {
// createAndSaveEventLocal creates a signed event and saves it to the database
func createAndSaveEventLocal(t *testing.T, ctx context.Context, signer *p8k.Signer, k uint16, content string, tags *tag.S, ts int64) *event.E {
t.Helper()
ev := event.New()
@@ -72,7 +48,7 @@ func createAndSaveEvent(t *testing.T, ctx context.Context, db *N, signer *p8k.Si
t.Fatalf("Failed to sign event: %v", err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event: %v", err)
}
@@ -80,17 +56,20 @@ func createAndSaveEvent(t *testing.T, ctx context.Context, db *N, signer *p8k.Si
}
func TestQueryEventsByID(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
signer := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
// Create and save a test event
ev := createAndSaveEvent(t, ctx, db, signer, 1, "Test event for ID query", nil, timestamp.Now().V)
ev := createAndSaveEventLocal(t, ctx, signer, 1, "Test event for ID query", nil, timestamp.Now().V)
// Query by ID
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Ids: tag.NewFromBytesSlice(ev.ID),
})
if err != nil {
@@ -110,21 +89,24 @@ func TestQueryEventsByID(t *testing.T) {
}
func TestQueryEventsByKind(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
signer := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events of different kinds
createAndSaveEvent(t, ctx, db, signer, 1, "Kind 1 event A", nil, baseTs)
createAndSaveEvent(t, ctx, db, signer, 1, "Kind 1 event B", nil, baseTs+1)
createAndSaveEvent(t, ctx, db, signer, 7, "Kind 7 reaction", nil, baseTs+2)
createAndSaveEvent(t, ctx, db, signer, 30023, "Kind 30023 article", nil, baseTs+3)
createAndSaveEventLocal(t, ctx, signer, 1, "Kind 1 event A", nil, baseTs)
createAndSaveEventLocal(t, ctx, signer, 1, "Kind 1 event B", nil, baseTs+1)
createAndSaveEventLocal(t, ctx, signer, 7, "Kind 7 reaction", nil, baseTs+2)
createAndSaveEventLocal(t, ctx, signer, 30023, "Kind 30023 article", nil, baseTs+3)
// Query for kind 1
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
})
if err != nil {
@@ -145,21 +127,24 @@ func TestQueryEventsByKind(t *testing.T) {
}
func TestQueryEventsByAuthor(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
alice := createTestSigner(t)
bob := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
alice := createTestSignerLocal(t)
bob := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events from different authors
createAndSaveEvent(t, ctx, db, alice, 1, "Alice's event 1", nil, baseTs)
createAndSaveEvent(t, ctx, db, alice, 1, "Alice's event 2", nil, baseTs+1)
createAndSaveEvent(t, ctx, db, bob, 1, "Bob's event", nil, baseTs+2)
createAndSaveEventLocal(t, ctx, alice, 1, "Alice's event 1", nil, baseTs)
createAndSaveEventLocal(t, ctx, alice, 1, "Alice's event 2", nil, baseTs+1)
createAndSaveEventLocal(t, ctx, bob, 1, "Bob's event", nil, baseTs+2)
// Query for Alice's events
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Authors: tag.NewFromBytesSlice(alice.Pub()),
})
if err != nil {
@@ -181,21 +166,24 @@ func TestQueryEventsByAuthor(t *testing.T) {
}
func TestQueryEventsByTimeRange(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
signer := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events at different times
createAndSaveEvent(t, ctx, db, signer, 1, "Old event", nil, baseTs-7200) // 2 hours ago
createAndSaveEvent(t, ctx, db, signer, 1, "Recent event", nil, baseTs-1800) // 30 min ago
createAndSaveEvent(t, ctx, db, signer, 1, "Current event", nil, baseTs)
createAndSaveEventLocal(t, ctx, signer, 1, "Old event", nil, baseTs-7200) // 2 hours ago
createAndSaveEventLocal(t, ctx, signer, 1, "Recent event", nil, baseTs-1800) // 30 min ago
createAndSaveEventLocal(t, ctx, signer, 1, "Current event", nil, baseTs)
// Query for events in the last hour
since := &timestamp.T{V: baseTs - 3600}
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Since: since,
})
if err != nil {
@@ -216,23 +204,26 @@ func TestQueryEventsByTimeRange(t *testing.T) {
}
func TestQueryEventsByTag(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
signer := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events with tags
createAndSaveEvent(t, ctx, db, signer, 1, "Bitcoin post",
createAndSaveEventLocal(t, ctx, signer, 1, "Bitcoin post",
tag.NewS(tag.NewFromAny("t", "bitcoin")), baseTs)
createAndSaveEvent(t, ctx, db, signer, 1, "Nostr post",
createAndSaveEventLocal(t, ctx, signer, 1, "Nostr post",
tag.NewS(tag.NewFromAny("t", "nostr")), baseTs+1)
createAndSaveEvent(t, ctx, db, signer, 1, "Bitcoin and Nostr post",
createAndSaveEventLocal(t, ctx, signer, 1, "Bitcoin and Nostr post",
tag.NewS(tag.NewFromAny("t", "bitcoin"), tag.NewFromAny("t", "nostr")), baseTs+2)
// Query for bitcoin tagged events
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Tags: tag.NewS(tag.NewFromAny("t", "bitcoin")),
})
if err != nil {
@@ -247,21 +238,24 @@ func TestQueryEventsByTag(t *testing.T) {
}
func TestQueryEventsByKindAndAuthor(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
alice := createTestSigner(t)
bob := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
alice := createTestSignerLocal(t)
bob := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events
createAndSaveEvent(t, ctx, db, alice, 1, "Alice note", nil, baseTs)
createAndSaveEvent(t, ctx, db, alice, 7, "Alice reaction", nil, baseTs+1)
createAndSaveEvent(t, ctx, db, bob, 1, "Bob note", nil, baseTs+2)
createAndSaveEventLocal(t, ctx, alice, 1, "Alice note", nil, baseTs)
createAndSaveEventLocal(t, ctx, alice, 7, "Alice reaction", nil, baseTs+1)
createAndSaveEventLocal(t, ctx, bob, 1, "Bob note", nil, baseTs+2)
// Query for Alice's kind 1 events
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
Authors: tag.NewFromBytesSlice(alice.Pub()),
})
@@ -277,21 +271,24 @@ func TestQueryEventsByKindAndAuthor(t *testing.T) {
}
func TestQueryEventsWithLimit(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
signer := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create many events
for i := 0; i < 20; i++ {
createAndSaveEvent(t, ctx, db, signer, 1, "Event", nil, baseTs+int64(i))
createAndSaveEventLocal(t, ctx, signer, 1, "Event", nil, baseTs+int64(i))
}
// Query with limit
limit := uint(5)
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
Limit: &limit,
})
@@ -307,20 +304,23 @@ func TestQueryEventsWithLimit(t *testing.T) {
}
func TestQueryEventsOrderByCreatedAt(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
signer := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events at different times
createAndSaveEvent(t, ctx, db, signer, 1, "First", nil, baseTs)
createAndSaveEvent(t, ctx, db, signer, 1, "Second", nil, baseTs+100)
createAndSaveEvent(t, ctx, db, signer, 1, "Third", nil, baseTs+200)
createAndSaveEventLocal(t, ctx, signer, 1, "First", nil, baseTs)
createAndSaveEventLocal(t, ctx, signer, 1, "Second", nil, baseTs+100)
createAndSaveEventLocal(t, ctx, signer, 1, "Third", nil, baseTs+200)
// Query and verify order (should be descending by created_at)
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
})
if err != nil {
@@ -343,12 +343,16 @@ func TestQueryEventsOrderByCreatedAt(t *testing.T) {
}
func TestQueryEventsEmpty(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
// Query for non-existent kind
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(99999)),
})
if err != nil {
@@ -363,20 +367,23 @@ func TestQueryEventsEmpty(t *testing.T) {
}
func TestQueryEventsMultipleKinds(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
signer := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events of different kinds
createAndSaveEvent(t, ctx, db, signer, 1, "Note", nil, baseTs)
createAndSaveEvent(t, ctx, db, signer, 7, "Reaction", nil, baseTs+1)
createAndSaveEvent(t, ctx, db, signer, 30023, "Article", nil, baseTs+2)
createAndSaveEventLocal(t, ctx, signer, 1, "Note", nil, baseTs)
createAndSaveEventLocal(t, ctx, signer, 7, "Reaction", nil, baseTs+1)
createAndSaveEventLocal(t, ctx, signer, 30023, "Article", nil, baseTs+2)
// Query for multiple kinds
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1), kind.New(7)),
})
if err != nil {
@@ -391,24 +398,27 @@ func TestQueryEventsMultipleKinds(t *testing.T) {
}
func TestQueryEventsMultipleAuthors(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
alice := createTestSigner(t)
bob := createTestSigner(t)
charlie := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
alice := createTestSignerLocal(t)
bob := createTestSignerLocal(t)
charlie := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events from different authors
createAndSaveEvent(t, ctx, db, alice, 1, "Alice", nil, baseTs)
createAndSaveEvent(t, ctx, db, bob, 1, "Bob", nil, baseTs+1)
createAndSaveEvent(t, ctx, db, charlie, 1, "Charlie", nil, baseTs+2)
createAndSaveEventLocal(t, ctx, alice, 1, "Alice", nil, baseTs)
createAndSaveEventLocal(t, ctx, bob, 1, "Bob", nil, baseTs+1)
createAndSaveEventLocal(t, ctx, charlie, 1, "Charlie", nil, baseTs+2)
// Query for Alice and Bob's events
authors := tag.NewFromBytesSlice(alice.Pub(), bob.Pub())
evs, err := db.QueryEvents(ctx, &filter.F{
evs, err := testDB.QueryEvents(ctx, &filter.F{
Authors: authors,
})
if err != nil {
@@ -423,20 +433,23 @@ func TestQueryEventsMultipleAuthors(t *testing.T) {
}
func TestCountEvents(t *testing.T) {
db, ctx, cancel := setupTestDatabase(t)
defer db.Close()
defer cancel()
if testDB == nil {
t.Skip("Neo4j not available")
}
signer := createTestSigner(t)
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events
for i := 0; i < 5; i++ {
createAndSaveEvent(t, ctx, db, signer, 1, "Event", nil, baseTs+int64(i))
createAndSaveEventLocal(t, ctx, signer, 1, "Event", nil, baseTs+int64(i))
}
// Count events
count, _, err := db.CountEvents(ctx, &filter.F{
count, _, err := testDB.CountEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
})
if err != nil {

View File

@@ -1,3 +1,9 @@
//go:build integration
// +build integration
// NOTE: This file requires updates to match the current nostr library types.
// The filter/tag/kind types have changed since this test was written.
package neo4j
import (
@@ -81,10 +87,10 @@ func TestQueryEventsWithNilFilter(t *testing.T) {
}
})
// Test 5: Filter with empty Ids slice
// Test 5: Filter with empty Ids (using tag with empty slice)
t.Run("EmptyIds", func(t *testing.T) {
f := &filter.F{
Ids: &tag.S{T: [][]byte{}},
Ids: &tag.T{T: [][]byte{}},
}
_, err := testDB.QueryEvents(ctx, f)
if err != nil {
@@ -92,10 +98,10 @@ func TestQueryEventsWithNilFilter(t *testing.T) {
}
})
// Test 6: Filter with empty Authors slice
// Test 6: Filter with empty Authors (using tag with empty slice)
t.Run("EmptyAuthors", func(t *testing.T) {
f := &filter.F{
Authors: &tag.S{T: [][]byte{}},
Authors: &tag.T{T: [][]byte{}},
}
_, err := testDB.QueryEvents(ctx, f)
if err != nil {
@@ -106,7 +112,7 @@ func TestQueryEventsWithNilFilter(t *testing.T) {
// Test 7: Filter with empty Kinds slice
t.Run("EmptyKinds", func(t *testing.T) {
f := &filter.F{
Kinds: &kind.S{K: []*kind.T{}},
Kinds: kind.NewS(),
}
_, err := testDB.QueryEvents(ctx, f)
if err != nil {
@@ -190,7 +196,7 @@ func TestQueryEventsWithValidFilters(t *testing.T) {
// Test 5: Filter with limit
t.Run("FilterWithLimit", func(t *testing.T) {
limit := 1
limit := uint(1)
f := &filter.F{
Kinds: kind.NewS(kind.New(1)),
Limit: &limit,
@@ -234,9 +240,9 @@ func TestBuildCypherQueryWithNilFields(t *testing.T) {
// Test with empty slices
t.Run("EmptySlices", func(t *testing.T) {
f := &filter.F{
Ids: &tag.S{T: [][]byte{}},
Authors: &tag.S{T: [][]byte{}},
Kinds: &kind.S{K: []*kind.T{}},
Ids: &tag.T{T: [][]byte{}},
Authors: &tag.T{T: [][]byte{}},
Kinds: kind.NewS(),
}
cypher, params := testDB.buildCypherQuery(f, false)
if cypher == "" {
@@ -252,8 +258,8 @@ func TestBuildCypherQueryWithNilFields(t *testing.T) {
since := timestamp.Now()
until := timestamp.Now()
f := &filter.F{
Since: &since,
Until: &until,
Since: since,
Until: until,
}
cypher, params := testDB.buildCypherQuery(f, false)
if _, ok := params["since"]; !ok {

View File

@@ -16,12 +16,19 @@ func parseInt64(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
}
// tagBatchSize is the maximum number of tags to process in a single transaction
// This prevents Neo4j stack overflow errors with events that have thousands of tags
const tagBatchSize = 500
// SaveEvent stores a Nostr event in the Neo4j database.
// It creates event nodes and relationships for authors, tags, and references.
// This method leverages Neo4j's graph capabilities to model Nostr's social graph naturally.
//
// For social graph events (kinds 0, 3, 1984, 10000), it additionally processes them
// to maintain NostrUser nodes and FOLLOWS/MUTES/REPORTS relationships with event traceability.
//
// To prevent Neo4j stack overflow errors with events containing thousands of tags,
// tags are processed in batches using UNWIND instead of generating inline Cypher.
func (n *N) SaveEvent(c context.Context, ev *event.E) (exists bool, err error) {
eventID := hex.Enc(ev.ID[:])
@@ -42,7 +49,7 @@ func (n *N) SaveEvent(c context.Context, ev *event.E) (exists bool, err error) {
if ev.Kind == 0 || ev.Kind == 3 || ev.Kind == 1984 || ev.Kind == 10000 {
processor := NewSocialEventProcessor(n)
if err := processor.ProcessSocialEvent(c, ev); err != nil {
n.Logger.Warningf("failed to reprocess social event %s: %v", eventID[:16], err)
n.Logger.Warningf("failed to reprocess social event %s: %v", safePrefix(eventID, 16), err)
// Don't fail the whole save, social processing is supplementary
}
}
@@ -55,14 +62,20 @@ func (n *N) SaveEvent(c context.Context, ev *event.E) (exists bool, err error) {
return false, fmt.Errorf("failed to get serial number: %w", err)
}
// Build and execute Cypher query to create event with all relationships
// This creates Event and Author nodes for NIP-01 query support
cypher, params := n.buildEventCreationCypher(ev, serial)
// Step 1: Create base event with author (small, fixed-size query)
cypher, params := n.buildBaseEventCypher(ev, serial)
if _, err = n.ExecuteWrite(c, cypher, params); err != nil {
return false, fmt.Errorf("failed to save event: %w", err)
}
// Step 2: Process tags in batches to avoid stack overflow
if ev.Tags != nil {
if err := n.addTagsInBatches(c, eventID, ev); err != nil {
// Log but don't fail - base event is saved, tags are supplementary for queries
n.Logger.Errorf("failed to add tags for event %s: %v", safePrefix(eventID, 16), err)
}
}
// Process social graph events (kinds 0, 3, 1984, 10000)
// This creates NostrUser nodes and social relationships (FOLLOWS, MUTES, REPORTS)
// with event traceability for diff-based updates
@@ -72,7 +85,7 @@ func (n *N) SaveEvent(c context.Context, ev *event.E) (exists bool, err error) {
// Log error but don't fail the whole save
// NIP-01 queries will still work even if social processing fails
n.Logger.Errorf("failed to process social event kind %d, event %s: %v",
ev.Kind, eventID[:16], err)
ev.Kind, safePrefix(eventID, 16), err)
// Consider: should we fail here or continue?
// For now, continue - social graph is supplementary to base relay
}
@@ -81,13 +94,20 @@ func (n *N) SaveEvent(c context.Context, ev *event.E) (exists bool, err error) {
return false, nil
}
// buildEventCreationCypher constructs a Cypher query to create an event node with all relationships
// This is a single atomic operation that creates:
// safePrefix returns up to n characters from a string, handling short strings gracefully
func safePrefix(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
// buildBaseEventCypher constructs a Cypher query to create just the base event node and author.
// Tags are added separately in batches to prevent stack overflow with large tag sets.
// This creates:
// - Event node with all properties
// - NostrUser node and AUTHORED_BY relationship (unified author + WoT node)
// - Tag nodes and TAGGED_WITH relationships
// - Reference relationships (REFERENCES for 'e' tags, MENTIONS for 'p' tags)
func (n *N) buildEventCreationCypher(ev *event.E, serial uint64) (string, map[string]any) {
func (n *N) buildBaseEventCypher(ev *event.E, serial uint64) (string, map[string]any) {
params := make(map[string]any)
// Event properties
@@ -123,7 +143,7 @@ func (n *N) buildEventCreationCypher(ev *event.E, serial uint64) (string, map[st
}
params["tags"] = string(tagsJSON)
// Start building the Cypher query
// Build Cypher query - just event + author, no tags (tags added in batches)
// Use MERGE to ensure idempotency for NostrUser nodes
// NostrUser serves both NIP-01 author tracking and WoT social graph
cypher := `
@@ -146,143 +166,180 @@ CREATE (e:Event {
// Link event to author
CREATE (e)-[:AUTHORED_BY]->(a)
`
// Process tags to create relationships
// Different tag types create different relationship patterns
tagNodeIndex := 0
eTagIndex := 0
pTagIndex := 0
// Track if we need to add WITH clause before OPTIONAL MATCH
// This is required because Cypher doesn't allow MATCH after CREATE without WITH
needsWithClause := true
// Collect all e-tags, p-tags, and other tags first so we can generate proper Cypher
// Neo4j requires WITH clauses between certain clause types (FOREACH -> MATCH/MERGE)
type tagInfo struct {
tagType string
value string
}
var eTags, pTags, otherTags []tagInfo
// Only process tags if they exist
if ev.Tags != nil {
for _, tagItem := range *ev.Tags {
if len(tagItem.T) < 2 {
continue
}
tagType := string(tagItem.T[0])
switch tagType {
case "e": // Event reference
tagValue := ExtractETagValue(tagItem)
if tagValue != "" {
eTags = append(eTags, tagInfo{"e", tagValue})
}
case "p": // Pubkey mention
tagValue := ExtractPTagValue(tagItem)
if tagValue != "" {
pTags = append(pTags, tagInfo{"p", tagValue})
}
default: // Other tags
tagValue := string(tagItem.T[1])
otherTags = append(otherTags, tagInfo{tagType, tagValue})
}
}
}
// Generate Cypher for e-tags (OPTIONAL MATCH + FOREACH pattern)
// These need WITH clause before first one, and WITH after all FOREACHes
for i, tag := range eTags {
paramName := fmt.Sprintf("eTag_%d", eTagIndex)
params[paramName] = tag.value
// Add WITH clause before first OPTIONAL MATCH only
if needsWithClause {
cypher += `
// Carry forward event and author nodes for tag processing
WITH e, a
`
needsWithClause = false
}
cypher += fmt.Sprintf(`
// Reference to event (e-tag)
OPTIONAL MATCH (ref%d:Event {id: $%s})
FOREACH (ignoreMe IN CASE WHEN ref%d IS NOT NULL THEN [1] ELSE [] END |
CREATE (e)-[:REFERENCES]->(ref%d)
)
`, eTagIndex, paramName, eTagIndex, eTagIndex)
eTagIndex++
// After the last e-tag FOREACH, add WITH clause if there are p-tags or other tags
if i == len(eTags)-1 && (len(pTags) > 0 || len(otherTags) > 0) {
cypher += `
// Required WITH after FOREACH before MERGE/MATCH
WITH e, a
`
}
}
// Generate Cypher for p-tags (MERGE pattern)
for _, tag := range pTags {
paramName := fmt.Sprintf("pTag_%d", pTagIndex)
params[paramName] = tag.value
// If no e-tags were processed, we still need the initial WITH
if needsWithClause {
cypher += `
// Carry forward event and author nodes for tag processing
WITH e, a
`
needsWithClause = false
}
cypher += fmt.Sprintf(`
// Mention of NostrUser (p-tag)
MERGE (mentioned%d:NostrUser {pubkey: $%s})
ON CREATE SET mentioned%d.created_at = timestamp()
CREATE (e)-[:MENTIONS]->(mentioned%d)
`, pTagIndex, paramName, pTagIndex, pTagIndex)
pTagIndex++
}
// Generate Cypher for other tags (MERGE pattern)
for _, tag := range otherTags {
typeParam := fmt.Sprintf("tagType_%d", tagNodeIndex)
valueParam := fmt.Sprintf("tagValue_%d", tagNodeIndex)
params[typeParam] = tag.tagType
params[valueParam] = tag.value
// If no e-tags or p-tags were processed, we still need the initial WITH
if needsWithClause {
cypher += `
// Carry forward event and author nodes for tag processing
WITH e, a
`
needsWithClause = false
}
cypher += fmt.Sprintf(`
// Generic tag relationship
MERGE (tag%d:Tag {type: $%s, value: $%s})
CREATE (e)-[:TAGGED_WITH]->(tag%d)
`, tagNodeIndex, typeParam, valueParam, tagNodeIndex)
tagNodeIndex++
}
// Return the created event
cypher += `
RETURN e.id AS id`
return cypher, params
}
// tagTypeValue represents a generic tag with type and value for batch processing
type tagTypeValue struct {
Type string
Value string
}
// addTagsInBatches processes event tags in batches using UNWIND to prevent Neo4j stack overflow.
// This handles e-tags (event references), p-tags (pubkey mentions), and other tags separately.
func (n *N) addTagsInBatches(c context.Context, eventID string, ev *event.E) error {
if ev.Tags == nil {
return nil
}
// Collect tags by type
var eTags, pTags []string
var otherTags []tagTypeValue
for _, tagItem := range *ev.Tags {
if len(tagItem.T) < 2 {
continue
}
tagType := string(tagItem.T[0])
switch tagType {
case "e": // Event reference
tagValue := ExtractETagValue(tagItem)
if tagValue != "" {
eTags = append(eTags, tagValue)
}
case "p": // Pubkey mention
tagValue := ExtractPTagValue(tagItem)
if tagValue != "" {
pTags = append(pTags, tagValue)
}
default: // Other tags
tagValue := string(tagItem.T[1])
otherTags = append(otherTags, tagTypeValue{Type: tagType, Value: tagValue})
}
}
// Add p-tags in batches (creates MENTIONS relationships)
if len(pTags) > 0 {
if err := n.addPTagsInBatches(c, eventID, pTags); err != nil {
return fmt.Errorf("failed to add p-tags: %w", err)
}
}
// Add e-tags in batches (creates REFERENCES relationships)
if len(eTags) > 0 {
if err := n.addETagsInBatches(c, eventID, eTags); err != nil {
return fmt.Errorf("failed to add e-tags: %w", err)
}
}
// Add other tags in batches (creates TAGGED_WITH relationships)
if len(otherTags) > 0 {
if err := n.addOtherTagsInBatches(c, eventID, otherTags); err != nil {
return fmt.Errorf("failed to add other tags: %w", err)
}
}
return nil
}
// addPTagsInBatches adds p-tag (pubkey mention) relationships using UNWIND for efficiency.
// Creates NostrUser nodes for mentioned pubkeys and MENTIONS relationships.
func (n *N) addPTagsInBatches(c context.Context, eventID string, pTags []string) error {
// Process in batches to avoid memory issues
for i := 0; i < len(pTags); i += tagBatchSize {
end := i + tagBatchSize
if end > len(pTags) {
end = len(pTags)
}
batch := pTags[i:end]
// Use UNWIND to process multiple p-tags in a single query
cypher := `
MATCH (e:Event {id: $eventId})
UNWIND $pubkeys AS pubkey
MERGE (u:NostrUser {pubkey: pubkey})
ON CREATE SET u.created_at = timestamp()
CREATE (e)-[:MENTIONS]->(u)`
params := map[string]any{
"eventId": eventID,
"pubkeys": batch,
}
if _, err := n.ExecuteWrite(c, cypher, params); err != nil {
return fmt.Errorf("batch %d-%d: %w", i, end, err)
}
}
return nil
}
// addETagsInBatches adds e-tag (event reference) relationships using UNWIND for efficiency.
// Only creates REFERENCES relationships if the referenced event exists.
func (n *N) addETagsInBatches(c context.Context, eventID string, eTags []string) error {
// Process in batches to avoid memory issues
for i := 0; i < len(eTags); i += tagBatchSize {
end := i + tagBatchSize
if end > len(eTags) {
end = len(eTags)
}
batch := eTags[i:end]
// Use UNWIND to process multiple e-tags in a single query
// OPTIONAL MATCH ensures we only create relationships if referenced event exists
cypher := `
MATCH (e:Event {id: $eventId})
UNWIND $eventIds AS refId
OPTIONAL MATCH (ref:Event {id: refId})
WITH e, ref
WHERE ref IS NOT NULL
CREATE (e)-[:REFERENCES]->(ref)`
params := map[string]any{
"eventId": eventID,
"eventIds": batch,
}
if _, err := n.ExecuteWrite(c, cypher, params); err != nil {
return fmt.Errorf("batch %d-%d: %w", i, end, err)
}
}
return nil
}
// addOtherTagsInBatches adds generic tag relationships using UNWIND for efficiency.
// Creates Tag nodes with type and value, and TAGGED_WITH relationships.
func (n *N) addOtherTagsInBatches(c context.Context, eventID string, tags []tagTypeValue) error {
// Process in batches to avoid memory issues
for i := 0; i < len(tags); i += tagBatchSize {
end := i + tagBatchSize
if end > len(tags) {
end = len(tags)
}
batch := tags[i:end]
// Convert to map slice for Neo4j parameter passing
tagMaps := make([]map[string]string, len(batch))
for j, t := range batch {
tagMaps[j] = map[string]string{"type": t.Type, "value": t.Value}
}
// Use UNWIND to process multiple tags in a single query
cypher := `
MATCH (e:Event {id: $eventId})
UNWIND $tags AS tag
MERGE (t:Tag {type: tag.type, value: tag.value})
CREATE (e)-[:TAGGED_WITH]->(t)`
params := map[string]any{
"eventId": eventID,
"tags": tagMaps,
}
if _, err := n.ExecuteWrite(c, cypher, params); err != nil {
return fmt.Errorf("batch %d-%d: %w", i, end, err)
}
}
return nil
}
// GetSerialsFromFilter returns event serials matching a filter
func (n *N) GetSerialsFromFilter(f *filter.F) (serials types.Uint40s, err error) {
// Use QueryForSerials with background context

View File

@@ -3,7 +3,6 @@ package neo4j
import (
"context"
"fmt"
"os"
"strings"
"testing"
@@ -14,167 +13,9 @@ import (
"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) {
// 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()
@@ -185,216 +26,45 @@ func TestCypherQueryGeneration_MultipleETags(t *testing.T) {
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",
description: "Event without tags",
},
{
name: "OnlyPTags",
name: "WithPTags",
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",
description: "Event with p-tags (stored in tags JSON, relationships added separately)",
},
{
name: "OnlyETags",
name: "WithETags",
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",
description: "Event with e-tags (stored in tags JSON, relationships added separately)",
},
{
name: "MixedTags",
tags: tag.NewS(
tag.NewFromAny("t", "nostr"),
tag.NewFromAny("e", "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"),
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000005"),
tag.NewFromAny("r", "https://example.com"),
tag.NewFromAny("e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
tag.NewFromAny("p", "0000000000000000000000000000000000000000000000000000000000000003"),
),
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",
description: "Event with mixed tags",
},
}
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
@@ -406,24 +76,75 @@ func TestBuildEventCreationCypher_WithClause(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
// Build Cypher query
cypher, params := db.buildEventCreationCypher(ev, 1)
cypher, params := n.buildBaseEventCypher(ev, 12345)
// 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)
// 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 !tt.wantWithClause && hasWithClause {
t.Errorf("%s: unexpected WITH clause found.\nCypher:\n%s", tt.description, cypher)
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)
}
// 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)
// 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)
}
})
}
@@ -431,27 +152,16 @@ func TestBuildEventCreationCypher_WithClause(t *testing.T) {
// 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) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx := context.Background()
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)
}
// Clean up before test
cleanTestDatabase()
// Generate keypairs
alice, err := p8k.New()
@@ -482,7 +192,7 @@ func TestSaveEvent_ETagReference(t *testing.T) {
}
// Save root event
exists, err := db.SaveEvent(ctx, rootEvent)
exists, err := testDB.SaveEvent(ctx, rootEvent)
if err != nil {
t.Fatalf("Failed to save root event: %v", err)
}
@@ -507,8 +217,8 @@ func TestSaveEvent_ETagReference(t *testing.T) {
t.Fatalf("Failed to sign reply event: %v", err)
}
// Save reply event - this exercises the WITH clause fix
exists, err = db.SaveEvent(ctx, replyEvent)
// 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)
}
@@ -526,7 +236,7 @@ func TestSaveEvent_ETagReference(t *testing.T) {
"rootId": rootEventID,
}
result, err := db.ExecuteRead(ctx, cypher, params)
result, err := testDB.ExecuteRead(ctx, cypher, params)
if err != nil {
t.Fatalf("Failed to query REFERENCES relationship: %v", err)
}
@@ -550,7 +260,7 @@ func TestSaveEvent_ETagReference(t *testing.T) {
"authorPubkey": hex.Enc(alice.Pub()),
}
mentionsResult, err := db.ExecuteRead(ctx, mentionsCypher, mentionsParams)
mentionsResult, err := testDB.ExecuteRead(ctx, mentionsCypher, mentionsParams)
if err != nil {
t.Fatalf("Failed to query MENTIONS relationship: %v", err)
}
@@ -563,28 +273,17 @@ func TestSaveEvent_ETagReference(t *testing.T) {
}
// TestSaveEvent_ETagMissingReference tests that e-tags to non-existent events
// don't create broken relationships (OPTIONAL MATCH handles this gracefully).
// 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) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx := context.Background()
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)
}
// Clean up before test
cleanTestDatabase()
signer, err := p8k.New()
if err != nil {
@@ -610,8 +309,8 @@ func TestSaveEvent_ETagMissingReference(t *testing.T) {
t.Fatalf("Failed to sign event: %v", err)
}
// Save should succeed (OPTIONAL MATCH handles missing reference)
exists, err := db.SaveEvent(ctx, ev)
// 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)
}
@@ -623,7 +322,7 @@ func TestSaveEvent_ETagMissingReference(t *testing.T) {
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)
result, err := testDB.ExecuteRead(ctx, checkCypher, checkParams)
if err != nil {
t.Fatalf("Failed to check event: %v", err)
}
@@ -639,7 +338,7 @@ func TestSaveEvent_ETagMissingReference(t *testing.T) {
`
refParams := map[string]any{"eventId": hex.Enc(ev.ID[:])}
refResult, err := db.ExecuteRead(ctx, refCypher, refParams)
refResult, err := testDB.ExecuteRead(ctx, refCypher, refParams)
if err != nil {
t.Fatalf("Failed to check references: %v", err)
}
@@ -655,27 +354,16 @@ func TestSaveEvent_ETagMissingReference(t *testing.T) {
}
// 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) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx := context.Background()
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)
}
// Clean up before test
cleanTestDatabase()
signer, err := p8k.New()
if err != nil {
@@ -698,7 +386,7 @@ func TestSaveEvent_MultipleETags(t *testing.T) {
t.Fatalf("Failed to sign event %d: %v", i, err)
}
if _, err := db.SaveEvent(ctx, ev); err != nil {
if _, err := testDB.SaveEvent(ctx, ev); err != nil {
t.Fatalf("Failed to save event %d: %v", i, err)
}
@@ -721,8 +409,8 @@ func TestSaveEvent_MultipleETags(t *testing.T) {
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)
// 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)
}
@@ -737,7 +425,7 @@ func TestSaveEvent_MultipleETags(t *testing.T) {
`
params := map[string]any{"replyId": hex.Enc(replyEvent.ID[:])}
result, err := db.ExecuteRead(ctx, cypher, params)
result, err := testDB.ExecuteRead(ctx, cypher, params)
if err != nil {
t.Fatalf("Failed to query REFERENCES relationships: %v", err)
}
@@ -761,25 +449,18 @@ func TestSaveEvent_MultipleETags(t *testing.T) {
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")
// 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, cancel := context.WithCancel(context.Background())
defer cancel()
ctx := context.Background()
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()
// Clean up before test
cleanTestDatabase()
signer, err := p8k.New()
if err != nil {
@@ -789,36 +470,52 @@ func TestBuildEventCreationCypher_CypherSyntaxValidation(t *testing.T) {
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)))
// 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 = 1
ev.Content = []byte("Event with many e-tags")
ev.Tags = manyETags
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)
}
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)
// 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")
}
// 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)
// 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)
}
t.Logf("✓ WITH clause correctly added once, followed by %d OPTIONAL MATCH statements", optionalMatchCount)
}
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)
}
}
}

View File

@@ -95,7 +95,7 @@ func (p *SocialEventProcessor) processProfileMetadata(ctx context.Context, ev *e
return fmt.Errorf("failed to update profile: %w", err)
}
p.db.Logger.Infof("updated profile for user %s", pubkey[:16])
p.db.Logger.Infof("updated profile for user %s", safePrefix(pubkey, 16))
return nil
}
@@ -113,7 +113,7 @@ func (p *SocialEventProcessor) processContactList(ctx context.Context, ev *event
// 2. Reject if this event is older than existing
if existingEvent != nil && existingEvent.CreatedAt >= ev.CreatedAt {
p.db.Logger.Infof("rejecting older contact list event %s (existing: %s)",
eventID[:16], existingEvent.EventID[:16])
safePrefix(eventID, 16), safePrefix(existingEvent.EventID, 16))
return nil // Not an error, just skip
}
@@ -150,7 +150,7 @@ func (p *SocialEventProcessor) processContactList(ctx context.Context, ev *event
}
p.db.Logger.Infof("processed contact list: author=%s, event=%s, added=%d, removed=%d, total=%d",
authorPubkey[:16], eventID[:16], len(added), len(removed), len(newFollows))
safePrefix(authorPubkey, 16), safePrefix(eventID, 16), len(added), len(removed), len(newFollows))
return nil
}
@@ -168,7 +168,7 @@ func (p *SocialEventProcessor) processMuteList(ctx context.Context, ev *event.E)
// Reject if older
if existingEvent != nil && existingEvent.CreatedAt >= ev.CreatedAt {
p.db.Logger.Infof("rejecting older mute list event %s", eventID[:16])
p.db.Logger.Infof("rejecting older mute list event %s", safePrefix(eventID, 16))
return nil
}
@@ -205,7 +205,7 @@ func (p *SocialEventProcessor) processMuteList(ctx context.Context, ev *event.E)
}
p.db.Logger.Infof("processed mute list: author=%s, event=%s, added=%d, removed=%d",
authorPubkey[:16], eventID[:16], len(added), len(removed))
safePrefix(authorPubkey, 16), safePrefix(eventID, 16), len(added), len(removed))
return nil
}
@@ -232,7 +232,7 @@ func (p *SocialEventProcessor) processReport(ctx context.Context, ev *event.E) e
}
if reportedPubkey == "" {
p.db.Logger.Warningf("report event %s has no p-tag, skipping", eventID[:16])
p.db.Logger.Warningf("report event %s has no p-tag, skipping", safePrefix(eventID, 16))
return nil
}
@@ -280,7 +280,7 @@ func (p *SocialEventProcessor) processReport(ctx context.Context, ev *event.E) e
}
p.db.Logger.Infof("processed report: reporter=%s, reported=%s, type=%s",
reporterPubkey[:16], reportedPubkey[:16], reportType)
safePrefix(reporterPubkey, 16), safePrefix(reportedPubkey, 16), reportType)
return nil
}
@@ -298,15 +298,17 @@ type UpdateContactListParams struct {
// updateContactListGraph performs atomic graph update for contact list changes
func (p *SocialEventProcessor) updateContactListGraph(ctx context.Context, params UpdateContactListParams) error {
// Note: WITH is required between CREATE and MERGE in Cypher
cypher := `
// Mark old event as superseded (if exists)
OPTIONAL MATCH (old:ProcessedSocialEvent {event_id: $old_event_id})
SET old.superseded_by = $new_event_id
// We need to break this into separate operations because Neo4j's UNWIND
// produces zero rows for empty arrays, which stops query execution.
// Also, complex query chains with OPTIONAL MATCH can have issues.
// Create new event tracking node
// WITH required after OPTIONAL MATCH + SET before CREATE
WITH old
// Step 1: Create the ProcessedSocialEvent and NostrUser nodes
createCypher := `
// Get or create author node first
MERGE (author:NostrUser {pubkey: $author_pubkey})
ON CREATE SET author.created_at = timestamp()
// Create new ProcessedSocialEvent tracking node
CREATE (new:ProcessedSocialEvent {
event_id: $new_event_id,
event_kind: 3,
@@ -317,54 +319,107 @@ func (p *SocialEventProcessor) updateContactListGraph(ctx context.Context, param
superseded_by: null
})
// WITH required to transition from CREATE to MERGE
WITH new
// Get or create author node
MERGE (author:NostrUser {pubkey: $author_pubkey})
// Update unchanged FOLLOWS relationships to point to new event
// (so they remain visible when filtering by non-superseded events)
WITH author
OPTIONAL MATCH (author)-[unchanged:FOLLOWS]->(followed:NostrUser)
WHERE unchanged.created_by_event = $old_event_id
AND NOT followed.pubkey IN $removed_follows
SET unchanged.created_by_event = $new_event_id,
unchanged.created_at = $created_at
// Remove old FOLLOWS relationships for removed follows
WITH author
OPTIONAL MATCH (author)-[old_follows:FOLLOWS]->(followed:NostrUser)
WHERE old_follows.created_by_event = $old_event_id
AND followed.pubkey IN $removed_follows
DELETE old_follows
// Create new FOLLOWS relationships for added follows
WITH author
UNWIND $added_follows AS followed_pubkey
MERGE (followed:NostrUser {pubkey: followed_pubkey})
MERGE (author)-[new_follows:FOLLOWS]->(followed)
ON CREATE SET
new_follows.created_by_event = $new_event_id,
new_follows.created_at = $created_at,
new_follows.relay_received_at = timestamp()
ON MATCH SET
new_follows.created_by_event = $new_event_id,
new_follows.created_at = $created_at
RETURN author.pubkey AS author_pubkey
`
cypherParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"new_event_id": params.NewEventID,
"old_event_id": params.OldEventID,
"created_at": params.CreatedAt,
"total_follows": params.TotalFollows,
"added_follows": params.AddedFollows,
"removed_follows": params.RemovedFollows,
createParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"new_event_id": params.NewEventID,
"created_at": params.CreatedAt,
"total_follows": params.TotalFollows,
}
_, err := p.db.ExecuteWrite(ctx, cypher, cypherParams)
return err
_, err := p.db.ExecuteWrite(ctx, createCypher, createParams)
if err != nil {
return fmt.Errorf("failed to create ProcessedSocialEvent: %w", err)
}
// Step 2: Mark old event as superseded (if it exists)
if params.OldEventID != "" {
supersedeCypher := `
MATCH (old:ProcessedSocialEvent {event_id: $old_event_id})
SET old.superseded_by = $new_event_id
`
supersedeParams := map[string]any{
"old_event_id": params.OldEventID,
"new_event_id": params.NewEventID,
}
// Ignore errors - old event may not exist
p.db.ExecuteWrite(ctx, supersedeCypher, supersedeParams)
// Step 3: Update unchanged FOLLOWS to point to new event
// Always update relationships that aren't being removed
updateCypher := `
MATCH (author:NostrUser {pubkey: $author_pubkey})-[f:FOLLOWS]->(followed:NostrUser)
WHERE f.created_by_event = $old_event_id
AND NOT followed.pubkey IN $removed_follows
SET f.created_by_event = $new_event_id,
f.created_at = $created_at
`
updateParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"old_event_id": params.OldEventID,
"new_event_id": params.NewEventID,
"created_at": params.CreatedAt,
"removed_follows": params.RemovedFollows,
}
p.db.ExecuteWrite(ctx, updateCypher, updateParams)
// Step 4: Remove FOLLOWS for removed follows
if len(params.RemovedFollows) > 0 {
removeCypher := `
MATCH (author:NostrUser {pubkey: $author_pubkey})-[f:FOLLOWS]->(followed:NostrUser)
WHERE f.created_by_event = $old_event_id
AND followed.pubkey IN $removed_follows
DELETE f
`
removeParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"old_event_id": params.OldEventID,
"removed_follows": params.RemovedFollows,
}
p.db.ExecuteWrite(ctx, removeCypher, removeParams)
}
}
// Step 5: Create new FOLLOWS relationships for added follows
// Process in batches to avoid memory issues
const batchSize = 500
for i := 0; i < len(params.AddedFollows); i += batchSize {
end := i + batchSize
if end > len(params.AddedFollows) {
end = len(params.AddedFollows)
}
batch := params.AddedFollows[i:end]
followsCypher := `
MATCH (author:NostrUser {pubkey: $author_pubkey})
UNWIND $added_follows AS followed_pubkey
MERGE (followed:NostrUser {pubkey: followed_pubkey})
ON CREATE SET followed.created_at = timestamp()
MERGE (author)-[f:FOLLOWS]->(followed)
ON CREATE SET
f.created_by_event = $new_event_id,
f.created_at = $created_at,
f.relay_received_at = timestamp()
ON MATCH SET
f.created_by_event = $new_event_id,
f.created_at = $created_at
`
followsParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"new_event_id": params.NewEventID,
"created_at": params.CreatedAt,
"added_follows": batch,
}
if _, err := p.db.ExecuteWrite(ctx, followsCypher, followsParams); err != nil {
return fmt.Errorf("failed to create FOLLOWS batch %d-%d: %w", i, end, err)
}
}
return nil
}
// UpdateMuteListParams holds parameters for mute list graph update
@@ -380,15 +435,16 @@ type UpdateMuteListParams struct {
// updateMuteListGraph performs atomic graph update for mute list changes
func (p *SocialEventProcessor) updateMuteListGraph(ctx context.Context, params UpdateMuteListParams) error {
// Note: WITH is required between CREATE and MERGE in Cypher
cypher := `
// Mark old event as superseded (if exists)
OPTIONAL MATCH (old:ProcessedSocialEvent {event_id: $old_event_id})
SET old.superseded_by = $new_event_id
// We need to break this into separate operations because Neo4j's UNWIND
// produces zero rows for empty arrays, which stops query execution.
// Create new event tracking node
// WITH required after OPTIONAL MATCH + SET before CREATE
WITH old
// Step 1: Create the ProcessedSocialEvent and NostrUser nodes
createCypher := `
// Get or create author node first
MERGE (author:NostrUser {pubkey: $author_pubkey})
ON CREATE SET author.created_at = timestamp()
// Create new ProcessedSocialEvent tracking node
CREATE (new:ProcessedSocialEvent {
event_id: $new_event_id,
event_kind: 10000,
@@ -399,53 +455,106 @@ func (p *SocialEventProcessor) updateMuteListGraph(ctx context.Context, params U
superseded_by: null
})
// WITH required to transition from CREATE to MERGE
WITH new
// Get or create author node
MERGE (author:NostrUser {pubkey: $author_pubkey})
// Update unchanged MUTES relationships to point to new event
WITH author
OPTIONAL MATCH (author)-[unchanged:MUTES]->(muted:NostrUser)
WHERE unchanged.created_by_event = $old_event_id
AND NOT muted.pubkey IN $removed_mutes
SET unchanged.created_by_event = $new_event_id,
unchanged.created_at = $created_at
// Remove old MUTES relationships
WITH author
OPTIONAL MATCH (author)-[old_mutes:MUTES]->(muted:NostrUser)
WHERE old_mutes.created_by_event = $old_event_id
AND muted.pubkey IN $removed_mutes
DELETE old_mutes
// Create new MUTES relationships
WITH author
UNWIND $added_mutes AS muted_pubkey
MERGE (muted:NostrUser {pubkey: muted_pubkey})
MERGE (author)-[new_mutes:MUTES]->(muted)
ON CREATE SET
new_mutes.created_by_event = $new_event_id,
new_mutes.created_at = $created_at,
new_mutes.relay_received_at = timestamp()
ON MATCH SET
new_mutes.created_by_event = $new_event_id,
new_mutes.created_at = $created_at
RETURN author.pubkey AS author_pubkey
`
cypherParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"new_event_id": params.NewEventID,
"old_event_id": params.OldEventID,
"created_at": params.CreatedAt,
"total_mutes": params.TotalMutes,
"added_mutes": params.AddedMutes,
"removed_mutes": params.RemovedMutes,
createParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"new_event_id": params.NewEventID,
"created_at": params.CreatedAt,
"total_mutes": params.TotalMutes,
}
_, err := p.db.ExecuteWrite(ctx, cypher, cypherParams)
return err
_, err := p.db.ExecuteWrite(ctx, createCypher, createParams)
if err != nil {
return fmt.Errorf("failed to create ProcessedSocialEvent: %w", err)
}
// Step 2: Mark old event as superseded (if it exists)
if params.OldEventID != "" {
supersedeCypher := `
MATCH (old:ProcessedSocialEvent {event_id: $old_event_id})
SET old.superseded_by = $new_event_id
`
supersedeParams := map[string]any{
"old_event_id": params.OldEventID,
"new_event_id": params.NewEventID,
}
p.db.ExecuteWrite(ctx, supersedeCypher, supersedeParams)
// Step 3: Update unchanged MUTES to point to new event
// Always update relationships that aren't being removed
updateCypher := `
MATCH (author:NostrUser {pubkey: $author_pubkey})-[m:MUTES]->(muted:NostrUser)
WHERE m.created_by_event = $old_event_id
AND NOT muted.pubkey IN $removed_mutes
SET m.created_by_event = $new_event_id,
m.created_at = $created_at
`
updateParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"old_event_id": params.OldEventID,
"new_event_id": params.NewEventID,
"created_at": params.CreatedAt,
"removed_mutes": params.RemovedMutes,
}
p.db.ExecuteWrite(ctx, updateCypher, updateParams)
// Step 4: Remove MUTES for removed mutes
if len(params.RemovedMutes) > 0 {
removeCypher := `
MATCH (author:NostrUser {pubkey: $author_pubkey})-[m:MUTES]->(muted:NostrUser)
WHERE m.created_by_event = $old_event_id
AND muted.pubkey IN $removed_mutes
DELETE m
`
removeParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"old_event_id": params.OldEventID,
"removed_mutes": params.RemovedMutes,
}
p.db.ExecuteWrite(ctx, removeCypher, removeParams)
}
}
// Step 5: Create new MUTES relationships for added mutes
// Process in batches to avoid memory issues
const batchSize = 500
for i := 0; i < len(params.AddedMutes); i += batchSize {
end := i + batchSize
if end > len(params.AddedMutes) {
end = len(params.AddedMutes)
}
batch := params.AddedMutes[i:end]
mutesCypher := `
MATCH (author:NostrUser {pubkey: $author_pubkey})
UNWIND $added_mutes AS muted_pubkey
MERGE (muted:NostrUser {pubkey: muted_pubkey})
ON CREATE SET muted.created_at = timestamp()
MERGE (author)-[m:MUTES]->(muted)
ON CREATE SET
m.created_by_event = $new_event_id,
m.created_at = $created_at,
m.relay_received_at = timestamp()
ON MATCH SET
m.created_by_event = $new_event_id,
m.created_at = $created_at
`
mutesParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"new_event_id": params.NewEventID,
"created_at": params.CreatedAt,
"added_mutes": batch,
}
if _, err := p.db.ExecuteWrite(ctx, mutesCypher, mutesParams); err != nil {
return fmt.Errorf("failed to create MUTES batch %d-%d: %w", i, end, err)
}
}
return nil
}
// getLatestSocialEvent retrieves the most recent non-superseded event of a given kind for a pubkey

View File

@@ -1,9 +1,11 @@
//go:build integration
// +build integration
package neo4j
import (
"context"
"fmt"
"os"
"testing"
"git.mleku.dev/mleku/nostr/encoders/event"
@@ -14,31 +16,16 @@ import (
)
// TestSocialEventProcessor tests the social event processor with kinds 0, 3, 1984, 10000
// Uses the shared testDB instance from testmain_test.go to avoid auth rate limiting
func TestSocialEventProcessor(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")
if testDB == nil {
t.Skip("Neo4j not available")
}
// Create test database
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx := context.Background()
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 for tests
if err := db.Wipe(); err != nil {
t.Fatalf("Failed to wipe database: %v", err)
}
// Clean database for this test
cleanTestDatabase()
// Generate test keypairs
alice := generateTestKeypair(t, "alice")
@@ -52,36 +39,36 @@ func TestSocialEventProcessor(t *testing.T) {
baseTimestamp := timestamp.Now().V
t.Run("Kind0_ProfileMetadata", func(t *testing.T) {
testProfileMetadata(t, ctx, db, alice, baseTimestamp)
testProfileMetadata(t, ctx, testDB, alice, baseTimestamp)
})
t.Run("Kind3_ContactList_Initial", func(t *testing.T) {
testContactListInitial(t, ctx, db, alice, bob, charlie, baseTimestamp+1)
testContactListInitial(t, ctx, testDB, alice, bob, charlie, baseTimestamp+1)
})
t.Run("Kind3_ContactList_Update_AddFollow", func(t *testing.T) {
testContactListUpdate(t, ctx, db, alice, bob, charlie, dave, baseTimestamp+2)
testContactListUpdate(t, ctx, testDB, alice, bob, charlie, dave, baseTimestamp+2)
})
t.Run("Kind3_ContactList_Update_RemoveFollow", func(t *testing.T) {
testContactListRemove(t, ctx, db, alice, bob, charlie, dave, baseTimestamp+3)
testContactListRemove(t, ctx, testDB, alice, bob, charlie, dave, baseTimestamp+3)
})
t.Run("Kind3_ContactList_OlderEventRejected", func(t *testing.T) {
// Use timestamp BEFORE the initial contact list to test rejection
testContactListOlderRejected(t, ctx, db, alice, bob, baseTimestamp)
testContactListOlderRejected(t, ctx, testDB, alice, bob, baseTimestamp)
})
t.Run("Kind10000_MuteList", func(t *testing.T) {
testMuteList(t, ctx, db, alice, eve)
testMuteList(t, ctx, testDB, alice, eve)
})
t.Run("Kind1984_Reports", func(t *testing.T) {
testReports(t, ctx, db, alice, bob, eve)
testReports(t, ctx, testDB, alice, bob, eve)
})
t.Run("VerifyGraphState", func(t *testing.T) {
verifyFinalGraphState(t, ctx, db, alice, bob, charlie, dave, eve)
verifyFinalGraphState(t, ctx, testDB, alice, bob, charlie, dave, eve)
})
}

View File

@@ -1,8 +1,9 @@
//go:build integration
// +build integration
package neo4j
import (
"context"
"os"
"testing"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
@@ -12,37 +13,25 @@ import (
// RemoveSubscription, ClearSubscriptions) is handled at the app layer, not the
// database layer. Tests for those methods have been removed.
// All tests in this file use the shared testDB instance from testmain_test.go
// to avoid Neo4j authentication rate limiting from too many connections.
func TestMarkers_SetGetDelete(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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)
}
cleanTestDatabase()
// Set a marker
key := "test-marker"
value := []byte("test-value-123")
if err := db.SetMarker(key, value); err != nil {
if err := testDB.SetMarker(key, value); err != nil {
t.Fatalf("Failed to set marker: %v", err)
}
// Get the marker
retrieved, err := db.GetMarker(key)
retrieved, err := testDB.GetMarker(key)
if err != nil {
t.Fatalf("Failed to get marker: %v", err)
}
@@ -52,11 +41,11 @@ func TestMarkers_SetGetDelete(t *testing.T) {
// Update the marker
newValue := []byte("updated-value")
if err := db.SetMarker(key, newValue); err != nil {
if err := testDB.SetMarker(key, newValue); err != nil {
t.Fatalf("Failed to update marker: %v", err)
}
retrieved, err = db.GetMarker(key)
retrieved, err = testDB.GetMarker(key)
if err != nil {
t.Fatalf("Failed to get updated marker: %v", err)
}
@@ -65,12 +54,12 @@ func TestMarkers_SetGetDelete(t *testing.T) {
}
// Delete the marker
if err := db.DeleteMarker(key); err != nil {
if err := testDB.DeleteMarker(key); err != nil {
t.Fatalf("Failed to delete marker: %v", err)
}
// Verify marker is deleted
_, err = db.GetMarker(key)
_, err = testDB.GetMarker(key)
if err == nil {
t.Fatal("Expected error when getting deleted marker")
}
@@ -79,25 +68,12 @@ func TestMarkers_SetGetDelete(t *testing.T) {
}
func TestMarkers_GetNonExistent(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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()
// Try to get non-existent marker
_, err = db.GetMarker("non-existent-marker")
// Try to get non-existent marker (don't wipe - just test non-existent key)
_, err := testDB.GetMarker("non-existent-marker-unique-12345")
if err == nil {
t.Fatal("Expected error when getting non-existent marker")
}
@@ -106,35 +82,18 @@ func TestMarkers_GetNonExistent(t *testing.T) {
}
func TestSerial_GetNextSerial(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)
if testDB == nil {
t.Skip("Neo4j not available")
}
// Get first serial
serial1, err := db.getNextSerial()
serial1, err := testDB.getNextSerial()
if err != nil {
t.Fatalf("Failed to get first serial: %v", err)
}
// Get second serial
serial2, err := db.getNextSerial()
serial2, err := testDB.getNextSerial()
if err != nil {
t.Fatalf("Failed to get second serial: %v", err)
}
@@ -147,7 +106,7 @@ func TestSerial_GetNextSerial(t *testing.T) {
// Get multiple more serials and verify they're all unique and increasing
var serials []uint64
for i := 0; i < 10; i++ {
s, err := db.getNextSerial()
s, err := testDB.getNextSerial()
if err != nil {
t.Fatalf("Failed to get serial %d: %v", i, err)
}
@@ -164,53 +123,28 @@ func TestSerial_GetNextSerial(t *testing.T) {
}
func TestDatabaseReady(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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)
// Database should already be ready (testDB is initialized in TestMain)
select {
case <-testDB.Ready():
t.Logf("✓ Database ready signal works correctly")
default:
t.Fatal("Expected database to be ready")
}
defer db.Close()
// Wait for ready
<-db.Ready()
// Database should be ready now
t.Logf("✓ Database ready signal works correctly")
}
func TestIdentity(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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()
// Wipe to ensure clean state
if err := db.Wipe(); err != nil {
t.Fatalf("Failed to wipe database: %v", err)
}
cleanTestDatabase()
// Get identity (creates if not exists)
secret1, err := db.GetOrCreateRelayIdentitySecret()
secret1, err := testDB.GetOrCreateRelayIdentitySecret()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
@@ -219,7 +153,7 @@ func TestIdentity(t *testing.T) {
}
// Get identity again (should return same one)
secret2, err := db.GetOrCreateRelayIdentitySecret()
secret2, err := testDB.GetOrCreateRelayIdentitySecret()
if err != nil {
t.Fatalf("Failed to get identity second time: %v", err)
}
@@ -241,38 +175,25 @@ func TestIdentity(t *testing.T) {
}
func TestWipe(t *testing.T) {
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
if neo4jURI == "" {
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set")
if testDB == nil {
t.Skip("Neo4j not available")
}
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, _ := p8k.New()
signer.Generate()
// Add some data
if err := db.AddNIP43Member(signer.Pub(), "test"); err != nil {
if err := testDB.AddNIP43Member(signer.Pub(), "test"); err != nil {
t.Fatalf("Failed to add member: %v", err)
}
// Wipe the database
if err := db.Wipe(); err != nil {
if err := testDB.Wipe(); err != nil {
t.Fatalf("Failed to wipe database: %v", err)
}
// Verify data is gone
isMember, _ := db.IsNIP43Member(signer.Pub())
isMember, _ := testDB.IsNIP43Member(signer.Pub())
if isMember {
t.Fatal("Expected data to be wiped")
}

View File

@@ -69,13 +69,15 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
// cleanTestDatabase removes all nodes and relationships
// cleanTestDatabase removes all nodes and relationships, then re-initializes
func cleanTestDatabase() {
ctx := context.Background()
// Delete all nodes and relationships
_, _ = testDB.ExecuteWrite(ctx, "MATCH (n) DETACH DELETE n", nil)
// Clear migration markers so migrations can run fresh
_, _ = testDB.ExecuteWrite(ctx, "MATCH (m:Migration) DELETE m", nil)
// Re-apply schema (constraints and indexes)
_ = testDB.applySchema(ctx)
// Re-initialize serial counter
_ = testDB.initSerialCounter()
}
// setupTestEvent creates a test event directly in Neo4j for testing queries

View File

@@ -1 +1 @@
v0.34.5
v0.34.6

240
scripts/test-neo4j-integration.sh Executable file
View File

@@ -0,0 +1,240 @@
#!/bin/bash
# Neo4j Integration Test Runner
#
# This script runs the Neo4j integration tests by:
# 1. Checking if Docker/Docker Compose are available
# 2. Starting a Neo4j container
# 3. Running the integration tests
# 4. Stopping the container
#
# Usage:
# ./scripts/test-neo4j-integration.sh
#
# Environment variables:
# SKIP_DOCKER_INSTALL=1 - Skip Docker installation check
# KEEP_CONTAINER=1 - Don't stop container after tests
# NEO4J_TEST_REQUIRED=1 - Fail if Docker/Neo4j not available (for local testing)
#
# Exit codes:
# 0 - Tests passed OR Docker/Neo4j not available (soft fail for CI)
# 1 - Tests failed (only when Neo4j is available)
# 2 - Tests required but Docker/Neo4j not available (when NEO4J_TEST_REQUIRED=1)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$PROJECT_ROOT/pkg/neo4j/docker-compose.yaml"
CONTAINER_NAME="neo4j-test"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_skip() {
echo -e "${BLUE}[SKIP]${NC} $1"
}
# Soft fail - exit 0 for CI compatibility unless NEO4J_TEST_REQUIRED is set
soft_fail() {
local message="$1"
if [ "$NEO4J_TEST_REQUIRED" = "1" ]; then
log_error "$message"
log_error "NEO4J_TEST_REQUIRED=1 is set, failing"
exit 2
else
log_skip "$message"
log_skip "Neo4j integration tests skipped (set NEO4J_TEST_REQUIRED=1 to require)"
exit 0
fi
}
# Check if Docker is installed and running
check_docker() {
if ! command -v docker &> /dev/null; then
soft_fail "Docker is not installed"
return 1
fi
if ! docker info &> /dev/null 2>&1; then
soft_fail "Docker daemon is not running or permission denied"
return 1
fi
log_info "Docker is available"
return 0
}
# Check if Docker Compose is installed
check_docker_compose() {
# Try docker compose (v2) first, then docker-compose (v1)
if docker compose version &> /dev/null 2>&1; then
COMPOSE_CMD="docker compose"
log_info "Using Docker Compose v2"
return 0
elif command -v docker-compose &> /dev/null; then
COMPOSE_CMD="docker-compose"
log_info "Using Docker Compose v1"
return 0
else
soft_fail "Docker Compose is not installed"
return 1
fi
}
# Start Neo4j container
start_neo4j() {
log_info "Starting Neo4j container..."
cd "$PROJECT_ROOT"
# Try to start container, soft fail if it doesn't work
if ! $COMPOSE_CMD -f "$COMPOSE_FILE" up -d 2>&1; then
soft_fail "Failed to start Neo4j container"
return 1
fi
log_info "Waiting for Neo4j to become healthy..."
# Wait for container to be healthy (up to 2 minutes)
local timeout=120
local elapsed=0
while [ $elapsed -lt $timeout ]; do
local health=$(docker inspect --format='{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || echo "not_found")
if [ "$health" = "healthy" ]; then
log_info "Neo4j is healthy and ready"
return 0
elif [ "$health" = "not_found" ]; then
log_warn "Container $CONTAINER_NAME not found, retrying..."
fi
echo -n "."
sleep 2
elapsed=$((elapsed + 2))
done
echo ""
log_warn "Neo4j failed to become healthy within $timeout seconds"
log_info "Container logs:"
docker logs "$CONTAINER_NAME" --tail 20 2>/dev/null || true
# Clean up failed container
$COMPOSE_CMD -f "$COMPOSE_FILE" down -v 2>/dev/null || true
soft_fail "Neo4j container failed to start properly"
return 1
}
# Stop Neo4j container
stop_neo4j() {
if [ "$KEEP_CONTAINER" = "1" ]; then
log_info "KEEP_CONTAINER=1, leaving Neo4j running"
return 0
fi
log_info "Stopping Neo4j container..."
cd "$PROJECT_ROOT"
$COMPOSE_CMD -f "$COMPOSE_FILE" down -v 2>/dev/null || true
}
# Run integration tests
run_tests() {
log_info "Running Neo4j integration tests..."
cd "$PROJECT_ROOT"
# Set environment variables for tests
# Note: Tests use ORLY_NEO4J_* prefix (consistent with app config)
export ORLY_NEO4J_URI="bolt://localhost:7687"
export ORLY_NEO4J_USER="neo4j"
export ORLY_NEO4J_PASSWORD="testpassword"
# Also set NEO4J_TEST_URI for testmain_test.go compatibility
export NEO4J_TEST_URI="bolt://localhost:7687"
# Run tests with integration tag
if go test -tags=integration ./pkg/neo4j/... -v -timeout 5m; then
log_info "All integration tests passed!"
return 0
else
log_error "Some integration tests failed"
return 1
fi
}
# Main execution
main() {
log_info "Neo4j Integration Test Runner"
log_info "=============================="
if [ "$NEO4J_TEST_REQUIRED" = "1" ]; then
log_info "NEO4J_TEST_REQUIRED=1 - tests will fail if Neo4j unavailable"
else
log_info "NEO4J_TEST_REQUIRED not set - tests will skip if Neo4j unavailable"
fi
# Check prerequisites (these will soft_fail if not available)
check_docker || exit $?
check_docker_compose || exit $?
# Check if compose file exists
if [ ! -f "$COMPOSE_FILE" ]; then
soft_fail "Docker Compose file not found: $COMPOSE_FILE"
fi
# Track if we need to stop the container
local need_cleanup=0
# Check if container is already running
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_NAME}$"; then
log_info "Neo4j container is already running"
else
start_neo4j || exit $?
need_cleanup=1
fi
# Run tests
local test_result=0
run_tests || test_result=1
# Cleanup
if [ $need_cleanup -eq 1 ]; then
stop_neo4j
fi
if [ $test_result -eq 0 ]; then
log_info "Integration tests completed successfully"
else
log_error "Integration tests failed"
fi
exit $test_result
}
# Handle cleanup on script exit
cleanup() {
if [ "$KEEP_CONTAINER" != "1" ] && docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_NAME}$"; then
log_warn "Cleaning up after interrupt..."
stop_neo4j
fi
}
trap cleanup EXIT INT TERM
main "$@"