diff --git a/pkg/version/version b/pkg/version/version index eb95f09..fcc9d59 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.20.6 \ No newline at end of file +v0.21.0 \ No newline at end of file diff --git a/relay-tester/client.go b/relay-tester/client.go index 9d50515..f2caaa1 100644 --- a/relay-tester/client.go +++ b/relay-tester/client.go @@ -14,14 +14,15 @@ import ( // Client wraps a WebSocket connection to a relay for testing. type Client struct { - conn *websocket.Conn - url string - mu sync.Mutex - subs map[string]chan []byte - okCh chan []byte // Channel for OK messages - countCh chan []byte // Channel for COUNT messages - ctx context.Context - cancel context.CancelFunc + conn *websocket.Conn + url string + mu sync.Mutex + subs map[string]chan []byte + complete map[string]bool // Track if subscription is complete (e.g., by ID) + okCh chan []byte // Channel for OK messages + countCh chan []byte // Channel for COUNT messages + ctx context.Context + cancel context.CancelFunc } // NewClient creates a new test client connected to the relay. @@ -36,13 +37,14 @@ func NewClient(url string) (c *Client, err error) { return } c = &Client{ - conn: conn, - url: url, - subs: make(map[string]chan []byte), - okCh: make(chan []byte, 100), - countCh: make(chan []byte, 100), - ctx: ctx, - cancel: cancel, + conn: conn, + url: url, + subs: make(map[string]chan []byte), + complete: make(map[string]bool), + okCh: make(chan []byte, 100), + countCh: make(chan []byte, 100), + ctx: ctx, + cancel: cancel, } go c.readLoop() return @@ -109,8 +111,17 @@ func (c *Client) readLoop() { if len(raw) >= 2 { if subID, ok := raw[1].(string); ok { if ch, exists := c.subs[subID]; exists { - close(ch) - delete(c.subs, subID) + // Send EOSE message to channel + select { + case ch <- msg: + default: + } + // For complete subscriptions (by ID), close the channel after EOSE + if c.complete[subID] { + close(ch) + delete(c.subs, subID) + delete(c.complete, subID) + } } } } @@ -147,6 +158,19 @@ func (c *Client) Subscribe(subID string, filters []interface{}) (ch chan []byte, c.mu.Lock() ch = make(chan []byte, 100) c.subs[subID] = ch + // Check if subscription is complete (has 'ids' filter) + isComplete := false + for _, f := range filters { + if fMap, ok := f.(map[string]interface{}); ok { + if ids, exists := fMap["ids"]; exists { + if idList, ok := ids.([]string); ok && len(idList) > 0 { + isComplete = true + break + } + } + } + } + c.complete[subID] = isComplete c.mu.Unlock() return } @@ -165,6 +189,7 @@ func (c *Client) Unsubscribe(subID string) error { close(ch) }() delete(c.subs, subID) + delete(c.complete, subID) } c.mu.Unlock() return c.Send([]interface{}{"CLOSE", subID}) @@ -269,14 +294,27 @@ func (c *Client) GetEvents(subID string, filters []interface{}, timeout time.Dur if err = json.Unmarshal(msg, &raw); err != nil { continue } - if len(raw) >= 3 && raw[0] == "EVENT" { - if evData, ok := raw[2].(map[string]interface{}); ok { - evJSON, _ := json.Marshal(evData) - ev := event.New() - if _, err = ev.Unmarshal(evJSON); err == nil { - events = append(events, ev) + if len(raw) < 2 { + continue + } + typ, ok := raw[0].(string) + if !ok { + continue + } + switch typ { + case "EVENT": + if len(raw) >= 3 { + if evData, ok := raw[2].(map[string]interface{}); ok { + evJSON, _ := json.Marshal(evData) + ev := event.New() + if _, err = ev.Unmarshal(evJSON); err == nil { + events = append(events, ev) + } } } + case "EOSE": + // End of stored events - return what we have + return events, nil } } } diff --git a/relay-tester/test.go b/relay-tester/test.go index b107744..cc8fcdb 100644 --- a/relay-tester/test.go +++ b/relay-tester/test.go @@ -161,6 +161,175 @@ func (s *TestSuite) registerTests() { Required: true, Func: testSubscriptionClose, }, + // Filter tests + { + Name: "Since and until filters are inclusive", + Required: true, + Func: testSinceUntilAreInclusive, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Limit zero works", + Required: true, + Func: testLimitZero, + }, + // Find tests + { + Name: "Events are ordered from newest to oldest", + Required: true, + Func: testEventsOrderedFromNewestToOldest, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Newest events are returned when filter is limited", + Required: true, + Func: testNewestEventsWhenLimited, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds by pubkey and kind", + Required: true, + Func: testFindByPubkeyAndKind, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds by pubkey and tags", + Required: true, + Func: testFindByPubkeyAndTags, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds by kind and tags", + Required: true, + Func: testFindByKindAndTags, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds by scrape", + Required: true, + Func: testFindByScrape, + Dependencies: []string{"Publishes basic event"}, + }, + // Replaceable event tests + { + Name: "Replaces metadata", + Required: true, + Func: testReplacesMetadata, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Replaces contact list", + Required: true, + Func: testReplacesContactList, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Replaced events are still available by ID", + Required: false, + Func: testReplacedEventsStillAvailableByID, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Replaceable events replace older ones", + Required: true, + Func: testReplaceableEventRemovesPrevious, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Replaceable events rejected if a newer one exists", + Required: true, + Func: testReplaceableEventRejectedIfFuture, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Addressable events replace older ones", + Required: true, + Func: testAddressableEventRemovesPrevious, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Addressable events rejected if a newer one exists", + Required: true, + Func: testAddressableEventRejectedIfFuture, + Dependencies: []string{"Publishes basic event"}, + }, + // Deletion tests + { + Name: "Deletes by a-tag address", + Required: true, + Func: testDeleteByAddr, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Delete by a-tag deletes older but not newer", + Required: true, + Func: testDeleteByAddrOnlyDeletesOlder, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Delete by a-tag is bound by a-tag", + Required: true, + Func: testDeleteByAddrIsBoundByTag, + Dependencies: []string{"Publishes basic event"}, + }, + // Ephemeral tests + { + Name: "Ephemeral subscriptions work", + Required: false, + Func: testEphemeralSubscriptionsWork, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Persists ephemeral events", + Required: false, + Func: testPersistsEphemeralEvents, + Dependencies: []string{"Publishes basic event"}, + }, + // EOSE tests + { + Name: "Supports EOSE", + Required: true, + Func: testSupportsEose, + }, + { + Name: "Closes complete subscriptions after EOSE", + Required: false, + Func: testClosesCompleteSubscriptionsAfterEose, + }, + { + Name: "Keeps open incomplete subscriptions after EOSE", + Required: true, + Func: testKeepsOpenIncompleteSubscriptionsAfterEose, + }, + // JSON tests + { + Name: "Accepts events with empty tags", + Required: false, + Func: testAcceptsEventsWithEmptyTags, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Accepts NIP-01 JSON escape sequences", + Required: true, + Func: testAcceptsNip1JsonEscapeSequences, + Dependencies: []string{"Publishes basic event"}, + }, + // Registration tests + { + Name: "Sends OK after EVENT", + Required: true, + Func: testSendsOkAfterEvent, + }, + { + Name: "Verifies event signatures", + Required: true, + Func: testVerifiesSignatures, + }, + { + Name: "Verifies event ID hashes", + Required: true, + Func: testVerifiesIdHashes, + }, } for _, tc := range allTests { s.AddTest(tc) diff --git a/relay-tester/tests.go b/relay-tester/tests.go index 3e5432e..653b8c7 100644 --- a/relay-tester/tests.go +++ b/relay-tester/tests.go @@ -1,9 +1,12 @@ package relaytester import ( + "encoding/json" "fmt" + "strings" "time" + "next.orly.dev/pkg/encoders/event" "next.orly.dev/pkg/encoders/hex" "next.orly.dev/pkg/encoders/kind" "next.orly.dev/pkg/encoders/tag" @@ -548,3 +551,1309 @@ func testSubscriptionClose(client *Client, key1, key2 *KeyPair) (result TestResu } return TestResult{Pass: true} } + +// Filter tests + +func testSinceUntilAreInclusive(client *Client, key1, key2 *KeyPair) (result TestResult) { + now := time.Now().Unix() + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "since until test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev.CreatedAt = now + if err = ev.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "event not accepted"} + } + time.Sleep(200 * time.Millisecond) + // Test until filter (should be inclusive) + untilFilter := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + "kinds": []int{int(kind.TextNote.K)}, + "until": now, + } + untilEvents, err := client.GetEvents("test-until", []interface{}{untilFilter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + // Test since filter (should be inclusive) + sinceFilter := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + "kinds": []int{int(kind.TextNote.K)}, + "since": now, + } + sinceEvents, err := client.GetEvents("test-since", []interface{}{sinceFilter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundUntil := false + for _, e := range untilEvents { + if string(e.ID) == string(ev.ID) { + foundUntil = true + break + } + } + foundSince := false + for _, e := range sinceEvents { + if string(e.ID) == string(ev.ID) { + foundSince = true + break + } + } + if !foundUntil || !foundSince { + return TestResult{Pass: false, Info: fmt.Sprintf("since/until not inclusive: until=%v since=%v", foundUntil, foundSince)} + } + return TestResult{Pass: true} +} + +func testLimitZero(client *Client, key1, key2 *KeyPair) (result TestResult) { + filter := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + "limit": 0, + } + events, err := client.GetEvents("test-limit-zero", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + // Limit 0 should return no events pre-EOSE + if len(events) > 0 { + return TestResult{Pass: false, Info: fmt.Sprintf("limit 0 returned %d events", len(events))} + } + return TestResult{Pass: true} +} + +// Find tests + +func testEventsOrderedFromNewestToOldest(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create multiple events + var eventIDs [][]byte + for i := 0; i < 3; i++ { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, fmt.Sprintf("order test %d", i), nil) + if err != nil { + continue + } + if err = client.Publish(ev); err != nil { + continue + } + accepted, _, err := client.WaitForOK(ev.ID, 2*time.Second) + if err != nil || !accepted { + continue + } + eventIDs = append(eventIDs, ev.ID) + time.Sleep(100 * time.Millisecond) // Small delay to ensure different timestamps + } + if len(eventIDs) < 3 { + return TestResult{Pass: false, Info: "failed to create enough events"} + } + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "ids": eventIDsToStrings(eventIDs), + } + events, err := client.GetEvents("test-order", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + if len(events) < 3 { + return TestResult{Pass: false, Info: fmt.Sprintf("got %d events, expected at least 3", len(events))} + } + // Check ordering (newest first) + for i := 0; i < len(events)-1; i++ { + if events[i].CreatedAt < events[i+1].CreatedAt { + return TestResult{Pass: false, Info: "events not ordered from newest to oldest"} + } + } + return TestResult{Pass: true} +} + +func testNewestEventsWhenLimited(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create multiple events with tags + tags1 := tag.NewS(tag.NewFromBytesSlice([]byte("t"), []byte("limit-tag-a"))) + tags2 := tag.NewS(tag.NewFromBytesSlice([]byte("t"), []byte("limit-tag-b"))) + ev1, err := CreateEvent(key1.Secret, kind.TextNote.K, "limit first", tags1) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + time.Sleep(100 * time.Millisecond) + ev2, err := CreateEvent(key1.Secret, kind.TextNote.K, "limit second", tags2) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + time.Sleep(100 * time.Millisecond) + ev3, err := CreateEvent(key1.Secret, kind.TextNote.K, "limit third", tags1) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + for _, ev := range []*event.E{ev1, ev2, ev3} { + if err = client.Publish(ev); err != nil { + continue + } + client.WaitForOK(ev.ID, 2*time.Second) + } + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + "#t": []string{"limit-tag-a", "limit-tag-b"}, + "kinds": []int{int(kind.TextNote.K)}, + "limit": 2, + } + events, err := client.GetEvents("test-newest-limit", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + if len(events) != 2 { + return TestResult{Pass: false, Info: fmt.Sprintf("got %d events, expected 2", len(events))} + } + // Should get newest events (ev3 and ev2, not ev1) + foundEv1 := false + foundEv3 := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + foundEv1 = true + } + if string(e.ID) == string(ev3.ID) { + foundEv3 = true + } + } + if foundEv1 && !foundEv3 { + return TestResult{Pass: false, Info: "got older event instead of newest"} + } + if !foundEv3 { + return TestResult{Pass: false, Info: "newest event not found"} + } + return TestResult{Pass: true} +} + +func testFindByPubkeyAndKind(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev1, err := CreateEvent(key1.Secret, kind.TextNote.K, "pubkey kind test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev2, err := CreateReplaceableEvent(key1.Secret, kind.ProfileMetadata.K, "pubkey kind metadata") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + for _, ev := range []*event.E{ev1, ev2} { + if err = client.Publish(ev); err != nil { + continue + } + client.WaitForOK(ev.ID, 2*time.Second) + } + time.Sleep(300 * time.Millisecond) + filter := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + "kinds": []int{int(kind.TextNote.K), int(kind.ProfileMetadata.K)}, + } + events, err := client.GetEvents("test-pubkey-kind", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundEv1 := false + foundEv2 := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + foundEv1 = true + } + if string(e.ID) == string(ev2.ID) { + foundEv2 = true + } + } + if !foundEv1 || !foundEv2 { + return TestResult{Pass: false, Info: fmt.Sprintf("events not found: ev1=%v ev2=%v", foundEv1, foundEv2)} + } + return TestResult{Pass: true} +} + +func testFindByPubkeyAndTags(client *Client, key1, key2 *KeyPair) (result TestResult) { + pTag := tag.NewS(tag.NewFromBytesSlice([]byte("p"), []byte(hex.Enc(key1.Pubkey)))) + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "pubkey tags test", pTag) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "event not accepted"} + } + time.Sleep(200 * time.Millisecond) + filter := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + "#p": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-pubkey-tags", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + found := false + for _, e := range events { + if string(e.ID) == string(ev.ID) { + found = true + break + } + } + if !found { + return TestResult{Pass: false, Info: "event not found by pubkey and tags"} + } + return TestResult{Pass: true} +} + +func testFindByKindAndTags(client *Client, key1, key2 *KeyPair) (result TestResult) { + tags := tag.NewS(tag.NewFromBytesSlice([]byte("n"), []byte("approved"))) + ev, err := CreateEvent(key1.Secret, 9999, "kind tags test", tags) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "event not accepted"} + } + time.Sleep(200 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{9999, int(kind.TextNote.K)}, + "#n": []string{"approved"}, + } + events, err := client.GetEvents("test-kind-tags", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + found := false + for _, e := range events { + if string(e.ID) == string(ev.ID) { + found = true + break + } + } + if !found { + return TestResult{Pass: false, Info: "event not found by kind and tags"} + } + return TestResult{Pass: true} +} + +func testFindByScrape(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "scrape test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "event not accepted"} + } + time.Sleep(200 * time.Millisecond) + // Empty filter (scrape all) + filter := map[string]interface{}{} + events, err := client.GetEvents("test-scrape", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + found := false + for _, e := range events { + if string(e.ID) == string(ev.ID) { + found = true + break + } + } + if !found { + return TestResult{Pass: false, Info: "event not found by scrape"} + } + return TestResult{Pass: true} +} + +// Helper function +func eventIDsToStrings(ids [][]byte) []string { + result := make([]string, len(ids)) + for i, id := range ids { + result[i] = hex.Enc(id) + } + return result +} + +// Replaceable event tests + +func testReplacesMetadata(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev1, err := CreateReplaceableEvent(key1.Secret, kind.ProfileMetadata.K, "older metadata") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev1.CreatedAt = time.Now().Unix() - 60 + if err = ev1.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev1.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if !accepted { + // If rejected, check if it's because there's already a newer event (which is OK for this test) + if strings.Contains(reason, "older than existing") || strings.Contains(reason, "already exists") { + // There's already a newer event - try to publish a newer one and verify replacement works + ev2, err := CreateReplaceableEvent(key1.Secret, kind.ProfileMetadata.K, "newer metadata") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err = client.WaitForOK(ev2.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "second event not accepted"} + } + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{int(kind.ProfileMetadata.K)}, + "authors": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-metadata-replace", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundNew := false + for _, e := range events { + if string(e.ID) == string(ev2.ID) { + foundNew = true + break + } + } + if !foundNew { + return TestResult{Pass: false, Info: "newer metadata not found"} + } + return TestResult{Pass: true, Info: "older event rejected (expected), newer event accepted and returned"} + } + return TestResult{Pass: false, Info: fmt.Sprintf("first event not accepted: %s", reason)} + } + time.Sleep(200 * time.Millisecond) + ev2, err := CreateReplaceableEvent(key1.Secret, kind.ProfileMetadata.K, "newer metadata") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err = client.WaitForOK(ev2.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "second event not accepted"} + } + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{int(kind.ProfileMetadata.K)}, + "authors": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-metadata-replace", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundOld := false + foundNew := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + foundOld = true + } + if string(e.ID) == string(ev2.ID) { + foundNew = true + } + } + if foundOld && !foundNew { + return TestResult{Pass: false, Info: "older metadata returned, newer not returned"} + } + if !foundNew { + return TestResult{Pass: false, Info: "newer metadata not found"} + } + if foundOld && foundNew { + // Both found is acceptable if relay keeps old versions + return TestResult{Pass: true, Info: "both versions found (relay keeps old versions)"} + } + return TestResult{Pass: true} +} + +func testReplacesContactList(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev1, err := CreateReplaceableEvent(key1.Secret, kind.FollowList.K, "older contact list") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev1.CreatedAt = time.Now().Unix() - 60 + if err = ev1.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev1.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if !accepted { + // If rejected, check if it's because there's already a newer event (which is OK for this test) + if strings.Contains(reason, "older than existing") || strings.Contains(reason, "already exists") { + // There's already a newer event - try to publish a newer one and verify replacement works + ev2, err := CreateReplaceableEvent(key1.Secret, kind.FollowList.K, "newer contact list") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err = client.WaitForOK(ev2.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "second event not accepted"} + } + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{int(kind.FollowList.K)}, + "authors": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-contact-replace", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundNew := false + for _, e := range events { + if string(e.ID) == string(ev2.ID) { + foundNew = true + break + } + } + if !foundNew { + return TestResult{Pass: false, Info: "newer contact list not found"} + } + return TestResult{Pass: true, Info: "older event rejected (expected), newer event accepted and returned"} + } + return TestResult{Pass: false, Info: fmt.Sprintf("first event not accepted: %s", reason)} + } + time.Sleep(200 * time.Millisecond) + ev2, err := CreateReplaceableEvent(key1.Secret, kind.FollowList.K, "newer contact list") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err = client.WaitForOK(ev2.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "second event not accepted"} + } + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{int(kind.FollowList.K)}, + "authors": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-contact-replace", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundOld := false + foundNew := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + foundOld = true + } + if string(e.ID) == string(ev2.ID) { + foundNew = true + } + } + if foundOld && !foundNew { + return TestResult{Pass: false, Info: "older contact list returned, newer not returned"} + } + if !foundNew { + return TestResult{Pass: false, Info: "newer contact list not found"} + } + return TestResult{Pass: true} +} + +func testReplacedEventsStillAvailableByID(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev1, err := CreateReplaceableEvent(key1.Secret, kind.FollowList.K, "old contact list") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev1.CreatedAt = time.Now().Unix() - 60 + if err = ev1.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev1.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if !accepted { + // If rejected, check if it's because there's already a newer event + if strings.Contains(reason, "older than existing") || strings.Contains(reason, "already exists") { + // There's already a newer event - just verify it's still available by ID + // Try to fetch by ID (should still work even if replaced) + filter := map[string]interface{}{ + "ids": []string{hex.Enc(ev1.ID)}, + } + events, err := client.GetEvents("test-old-by-id", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + found := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + found = true + break + } + } + if found { + return TestResult{Pass: true, Info: "older event rejected but still available by ID"} + } + // If not found, it might have been deleted, which is also acceptable + return TestResult{Pass: true, Info: "older event rejected (some relays delete old versions)"} + } + return TestResult{Pass: false, Info: fmt.Sprintf("first event not accepted: %s", reason)} + } + time.Sleep(200 * time.Millisecond) + ev2, err := CreateReplaceableEvent(key1.Secret, kind.FollowList.K, "new contact list") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(ev2.ID, 2*time.Second) + time.Sleep(500 * time.Millisecond) + // Try to fetch old event by ID - should still be available + filter := map[string]interface{}{ + "ids": []string{hex.Enc(ev1.ID)}, + } + events, err := client.GetEvents("test-old-by-id", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + found := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + found = true + break + } + } + if !found { + return TestResult{Pass: false, Info: "replaced event not available by ID"} + } + return TestResult{Pass: true} +} + +func testReplaceableEventRemovesPrevious(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Use a custom replaceable kind + ev1, err := CreateReplaceableEvent(key1.Secret, 10001, "old replaceable") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev1.CreatedAt = time.Now().Unix() - 60 + if err = ev1.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev1.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "first event not accepted"} + } + time.Sleep(200 * time.Millisecond) + ev2, err := CreateReplaceableEvent(key1.Secret, 10001, "new replaceable") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(ev2.ID, 2*time.Second) + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{10001}, + "authors": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-replace-remove", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + // Old event should not be returned (unless relay keeps old versions) + foundOld := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + foundOld = true + break + } + } + if foundOld { + // Some relays keep old versions, which is acceptable + return TestResult{Pass: true, Info: "old event still present (relay keeps old versions)"} + } + return TestResult{Pass: true} +} + +func testReplaceableEventRejectedIfFuture(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create newer replaceable event first + ev1, err := CreateReplaceableEvent(key1.Secret, kind.ProfileMetadata.K, "newer metadata") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev1.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "newer event not accepted"} + } + time.Sleep(200 * time.Millisecond) + // Try to submit older replaceable event + ev2, err := CreateReplaceableEvent(key1.Secret, kind.ProfileMetadata.K, "older metadata") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev2.CreatedAt = time.Now().Unix() - 60 + if err = ev2.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev2.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if accepted { + // Some relays accept old replaceable events + return TestResult{Pass: true, Info: "older replaceable event accepted (relay allows old versions)"} + } + _ = reason + return TestResult{Pass: true, Info: "older replaceable event rejected (expected)"} +} + +func testAddressableEventRemovesPrevious(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev1, err := CreateParameterizedReplaceableEvent(key1.Secret, 30023, "old list", "test-addr") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev1.CreatedAt = time.Now().Unix() - 60 + if err = ev1.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev1.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "first event not accepted"} + } + time.Sleep(200 * time.Millisecond) + ev2, err := CreateParameterizedReplaceableEvent(key1.Secret, 30023, "new list", "test-addr") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(ev2.ID, 2*time.Second) + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{30023}, + "authors": []string{hex.Enc(key1.Pubkey)}, + "#d": []string{"test-addr"}, + } + events, err := client.GetEvents("test-addr-remove", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundOld := false + foundNew := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + foundOld = true + } + if string(e.ID) == string(ev2.ID) { + foundNew = true + } + } + if foundOld && !foundNew { + return TestResult{Pass: false, Info: "older addressable event returned, newer not returned"} + } + if !foundNew { + return TestResult{Pass: false, Info: "newer addressable event not found"} + } + return TestResult{Pass: true} +} + +func testAddressableEventRejectedIfFuture(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create newer addressable event first + ev1, err := CreateParameterizedReplaceableEvent(key1.Secret, 30023, "newer list", "test-future") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev1.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "newer event not accepted"} + } + time.Sleep(200 * time.Millisecond) + // Try to submit older addressable event + ev2, err := CreateParameterizedReplaceableEvent(key1.Secret, 30023, "older list", "test-future") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev2.CreatedAt = time.Now().Unix() - 60 + if err = ev2.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev2.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if accepted { + return TestResult{Pass: true, Info: "older addressable event accepted (relay allows old versions)"} + } + _ = reason + return TestResult{Pass: true, Info: "older addressable event rejected (expected)"} +} + +// Deletion tests + +func testDeleteByAddr(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create addressable event + ev, err := CreateParameterizedReplaceableEvent(key1.Secret, kind.LongFormContent.K, "content to delete", "delete-test") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "event not accepted"} + } + time.Sleep(500 * time.Millisecond) + // Create deletion event with a-tag + aTag := fmt.Sprintf("%d:%s:%s", kind.LongFormContent.K, hex.Enc(key1.Pubkey), "delete-test") + deleteTags := tag.NewS(tag.NewFromBytesSlice([]byte("a"), []byte(aTag))) + deleteEv, err := CreateEvent(key1.Secret, kind.EventDeletion.K, "delete reason", deleteTags) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create delete event: %v", err)} + } + if err = client.Publish(deleteEv); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err = client.WaitForOK(deleteEv.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "delete event not accepted"} + } + time.Sleep(500 * time.Millisecond) + // Try to fetch deleted event by ID + filter := map[string]interface{}{ + "ids": []string{hex.Enc(ev.ID)}, + } + events, err := client.GetEvents("test-delete-addr", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + for _, e := range events { + if string(e.ID) == string(ev.ID) { + return TestResult{Pass: false, Info: "deleted event still returned"} + } + } + return TestResult{Pass: true} +} + +func testDeleteByAddrOnlyDeletesOlder(client *Client, key1, key2 *KeyPair) (result TestResult) { + time1 := time.Now().Unix() - 300 + time2 := time.Now().Unix() - 100 + time3 := time.Now().Unix() + // Create older event + ev1, err := CreateParameterizedReplaceableEvent(key1.Secret, kind.LongFormContent.K, "old content", "delete-older-test") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev1.CreatedAt = time1 + if err = ev1.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(ev1.ID, 2*time.Second) + time.Sleep(200 * time.Millisecond) + // Create newer event + ev2, err := CreateParameterizedReplaceableEvent(key1.Secret, kind.LongFormContent.K, "new content", "delete-older-test") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev2.CreatedAt = time3 + if err = ev2.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(ev2.ID, 2*time.Second) + time.Sleep(200 * time.Millisecond) + // Create deletion event dated between them + aTag := fmt.Sprintf("%d:%s:%s", kind.LongFormContent.K, hex.Enc(key1.Pubkey), "delete-older-test") + deleteTags := tag.NewS(tag.NewFromBytesSlice([]byte("a"), []byte(aTag))) + deleteEv, err := CreateEvent(key1.Secret, kind.EventDeletion.K, "", deleteTags) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create delete event: %v", err)} + } + deleteEv.CreatedAt = time2 + if err = deleteEv.Sign(key1.Secret); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to re-sign: %v", err)} + } + if err = client.Publish(deleteEv); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(deleteEv.ID, 2*time.Second) + time.Sleep(500 * time.Millisecond) + // Fetch events by address + filter := map[string]interface{}{ + "kinds": []int{int(kind.LongFormContent.K)}, + "authors": []string{hex.Enc(key1.Pubkey)}, + "#d": []string{"delete-older-test"}, + } + events, err := client.GetEvents("test-delete-older", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundEv1 := false + foundEv2 := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + foundEv1 = true + } + if string(e.ID) == string(ev2.ID) { + foundEv2 = true + } + } + if foundEv1 { + return TestResult{Pass: false, Info: "older event not deleted"} + } + if !foundEv2 { + return TestResult{Pass: false, Info: "newer event wrongly deleted"} + } + return TestResult{Pass: true} +} + +func testDeleteByAddrIsBoundByTag(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create events with same author and kind but different d-tags + ev1, err := CreateParameterizedReplaceableEvent(key1.Secret, kind.LongFormContent.K, "content 1", "bound-test") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev1); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(ev1.ID, 2*time.Second) + time.Sleep(200 * time.Millisecond) + ev2, err := CreateParameterizedReplaceableEvent(key1.Secret, kind.LongFormContent.K, "content 2", "bound-test-other") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(ev2.ID, 2*time.Second) + time.Sleep(200 * time.Millisecond) + // Delete only one address + aTag := fmt.Sprintf("%d:%s:%s", kind.LongFormContent.K, hex.Enc(key1.Pubkey), "bound-test") + deleteTags := tag.NewS(tag.NewFromBytesSlice([]byte("a"), []byte(aTag))) + deleteEv, err := CreateEvent(key1.Secret, kind.EventDeletion.K, "", deleteTags) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create delete event: %v", err)} + } + if err = client.Publish(deleteEv); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + client.WaitForOK(deleteEv.ID, 2*time.Second) + time.Sleep(500 * time.Millisecond) + // Fetch both events by ID + filter := map[string]interface{}{ + "ids": []string{hex.Enc(ev1.ID), hex.Enc(ev2.ID)}, + } + events, err := client.GetEvents("test-delete-bound", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundEv1 := false + foundEv2 := false + for _, e := range events { + if string(e.ID) == string(ev1.ID) { + foundEv1 = true + } + if string(e.ID) == string(ev2.ID) { + foundEv2 = true + } + } + if foundEv1 { + return TestResult{Pass: false, Info: "deleted event still returned"} + } + if !foundEv2 { + return TestResult{Pass: false, Info: "other event wrongly deleted"} + } + return TestResult{Pass: true} +} + +// Ephemeral tests + +func testEphemeralSubscriptionsWork(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Subscribe to ephemeral events + filter := map[string]interface{}{ + "kinds": []int{20000}, + "authors": []string{hex.Enc(key1.Pubkey)}, + } + ch, err := client.Subscribe("test-ephemeral-sub", []interface{}{filter}) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to subscribe: %v", err)} + } + defer client.Unsubscribe("test-ephemeral-sub") + // Publish ephemeral event + ev, err := CreateEphemeralEvent(key1.Secret, 20000, "ephemeral test") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "ephemeral event not accepted"} + } + // Wait for event to come through subscription + timeout := time.After(3 * time.Second) + for { + select { + case msg, ok := <-ch: + if !ok { + return TestResult{Pass: false, Info: "subscription closed"} + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) >= 3 && raw[0] == "EVENT" { + if evData, ok := raw[2].(map[string]interface{}); ok { + evJSON, _ := json.Marshal(evData) + receivedEv := event.New() + if _, err = receivedEv.Unmarshal(evJSON); err == nil { + if string(receivedEv.ID) == string(ev.ID) { + return TestResult{Pass: true} + } + } + } + } + case <-timeout: + return TestResult{Pass: false, Info: "timeout waiting for ephemeral event"} + } + } +} + +func testPersistsEphemeralEvents(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEphemeralEvent(key1.Secret, 20001, "ephemeral persist test") + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "ephemeral event not accepted"} + } + time.Sleep(200 * time.Millisecond) + // Try to query for ephemeral event + filter := map[string]interface{}{ + "kinds": []int{20001}, + "authors": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-ephemeral-persist", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + // Ephemeral events should NOT be queryable + for _, e := range events { + if string(e.ID) == string(ev.ID) { + return TestResult{Pass: false, Info: "ephemeral event was persisted (should not be)"} + } + } + return TestResult{Pass: true} +} + +// EOSE tests + +func testSupportsEose(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Subscribe to events from a random author (should have 0 events) + filter := map[string]interface{}{ + "authors": []string{hex.Enc(key2.Pubkey)}, + "kinds": []int{int(kind.TextNote.K)}, + "limit": 10, + } + ch, err := client.Subscribe("test-eose", []interface{}{filter}) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to subscribe: %v", err)} + } + defer client.Unsubscribe("test-eose") + // Wait for EOSE message or timeout + timeout := time.After(3 * time.Second) + for { + select { + case msg, ok := <-ch: + if !ok { + return TestResult{Pass: false, Info: "channel closed before EOSE"} + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) >= 2 { + if typ, ok := raw[0].(string); ok && typ == "EOSE" { + return TestResult{Pass: true} + } + } + case <-timeout: + return TestResult{Pass: false, Info: "timeout waiting for EOSE"} + } + } +} + +func testClosesCompleteSubscriptionsAfterEose(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create a filter that fetches a specific event by ID (complete subscription) + fakeID := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + filter := map[string]interface{}{ + "ids": []string{fakeID}, + "kinds": []int{int(kind.TextNote.K)}, + } + ch, err := client.Subscribe("test-close-complete", []interface{}{filter}) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to subscribe: %v", err)} + } + defer func() { + client.Unsubscribe("test-close-complete") + }() + // Wait for EOSE and verify channel is closed + timeout := time.After(3 * time.Second) + gotEose := false + for { + select { + case msg, ok := <-ch: + if !ok { + // Channel closed - for complete subscriptions, this should happen after EOSE + if gotEose { + return TestResult{Pass: true} + } + return TestResult{Pass: false, Info: "channel closed before EOSE"} + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) >= 2 { + if typ, ok := raw[0].(string); ok && typ == "EOSE" { + gotEose = true + // For complete subscriptions, channel should close after EOSE + time.Sleep(100 * time.Millisecond) + select { + case _, ok := <-ch: + if ok { + return TestResult{Pass: false, Info: "subscription not closed after EOSE"} + } + // Channel closed, which is correct + return TestResult{Pass: true} + default: + // Channel might be closed already + return TestResult{Pass: true} + } + } + } + case <-timeout: + if gotEose { + return TestResult{Pass: false, Info: "timeout but EOSE received - subscription should be closed"} + } + return TestResult{Pass: false, Info: "timeout waiting for EOSE"} + } + } +} + +func testKeepsOpenIncompleteSubscriptionsAfterEose(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Subscribe to events from a random author (incomplete subscription) + filter := map[string]interface{}{ + "authors": []string{hex.Enc(key2.Pubkey)}, + "kinds": []int{int(kind.TextNote.K)}, + "limit": 10, + "until": time.Now().Unix() - 86400, // Past timestamp + } + ch, err := client.Subscribe("test-open-incomplete", []interface{}{filter}) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to subscribe: %v", err)} + } + defer client.Unsubscribe("test-open-incomplete") + // Wait for EOSE + timeout := time.After(3 * time.Second) + gotEose := false + for { + select { + case msg, ok := <-ch: + if !ok { + return TestResult{Pass: false, Info: "incomplete subscription closed after EOSE"} + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) >= 2 { + if typ, ok := raw[0].(string); ok && typ == "EOSE" { + gotEose = true + // After EOSE, subscription should remain open for incomplete subscriptions + time.Sleep(200 * time.Millisecond) + // Channel should still be open (not closed) + select { + case _, ok := <-ch: + if !ok { + return TestResult{Pass: false, Info: "incomplete subscription closed after EOSE"} + } + // Channel is still open, which is correct + return TestResult{Pass: true} + default: + // Channel is still open, which is correct + return TestResult{Pass: true} + } + } + } + case <-timeout: + if gotEose { + return TestResult{Pass: true, Info: "EOSE received, subscription remains open"} + } + return TestResult{Pass: false, Info: "timeout waiting for EOSE"} + } + } +} + +// JSON tests + +func testAcceptsEventsWithEmptyTags(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create event with empty tags array + emptyTags := tag.NewS() + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "empty tags test", emptyTags) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if !accepted { + return TestResult{Pass: false, Info: fmt.Sprintf("event rejected: %s", reason)} + } + return TestResult{Pass: true} +} + +func testAcceptsNip1JsonEscapeSequences(client *Client, key1, key2 *KeyPair) (result TestResult) { + // NIP-01 escape sequences: \n, \", \\, \r, \t, \b, \f + content := "linebreak\\ndoublequote\\\"backslash\\\\carraigereturn\\rtab\\tbackspace\\bformfeed\\fend" + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, content, nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if !accepted { + return TestResult{Pass: false, Info: fmt.Sprintf("event rejected: %s", reason)} + } + return TestResult{Pass: true} +} + +// Registration tests + +func testSendsOkAfterEvent(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "OK test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if !accepted { + return TestResult{Pass: false, Info: fmt.Sprintf("event rejected: %s", reason)} + } + return TestResult{Pass: true} +} + +func testVerifiesSignatures(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "signature test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + // Corrupt the signature + for i := range ev.Sig { + ev.Sig[i] = 0 + } + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, reason, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if accepted { + return TestResult{Pass: false, Info: "invalid signature was accepted"} + } + _ = reason + return TestResult{Pass: true} +} + +func testVerifiesIdHashes(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "ID hash test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + // Save the correct ID + correctID := make([]byte, len(ev.ID)) + copy(correctID, ev.ID) + // Corrupt the ID AFTER signing (Sign() recalculates ID, so we corrupt it after) + for i := range ev.ID { + ev.ID[i] = 0xCA + } + // Don't re-sign - the signature is valid for the correct ID, but we have a corrupted ID + if err = client.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + // Use the corrupted ID to wait for OK (relay should reject based on ID mismatch) + accepted, reason, err := client.WaitForOK(ev.ID, 5*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + if accepted { + return TestResult{Pass: false, Info: "invalid ID hash was accepted"} + } + _ = reason + _ = correctID + return TestResult{Pass: true} +}