Browse Source
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.main
21 changed files with 1891 additions and 1560 deletions
@ -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 |
||||||
|
} |
||||||
@ -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 "$@" |
||||||
Loading…
Reference in new issue