diff --git a/relay-tester/client.go b/relay-tester/client.go new file mode 100644 index 0000000..56fe8b5 --- /dev/null +++ b/relay-tester/client.go @@ -0,0 +1,280 @@ +package relaytester + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" + "lol.mleku.dev/errorf" + "next.orly.dev/pkg/encoders/event" + "next.orly.dev/pkg/encoders/hex" +) + +// 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 + ctx context.Context + cancel context.CancelFunc +} + +// NewClient creates a new test client connected to the relay. +func NewClient(url string) (c *Client, err error) { + ctx, cancel := context.WithCancel(context.Background()) + var conn *websocket.Conn + dialer := websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + } + if conn, _, err = dialer.Dial(url, nil); err != nil { + cancel() + return + } + c = &Client{ + conn: conn, + url: url, + subs: make(map[string]chan []byte), + ctx: ctx, + cancel: cancel, + } + go c.readLoop() + return +} + +// Close closes the client connection. +func (c *Client) Close() error { + c.cancel() + return c.conn.Close() +} + +// Send sends a JSON message to the relay. +func (c *Client) Send(msg interface{}) (err error) { + c.mu.Lock() + defer c.mu.Unlock() + var data []byte + if data, err = json.Marshal(msg); err != nil { + return errorf.E("failed to marshal message: %w", err) + } + if err = c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return errorf.E("failed to write message: %w", err) + } + return +} + +// readLoop reads messages from the relay and routes them to subscriptions. +func (c *Client) readLoop() { + defer c.conn.Close() + for { + select { + case <-c.ctx.Done(): + return + default: + } + _, msg, err := c.conn.ReadMessage() + if err != nil { + return + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) < 2 { + continue + } + typ, ok := raw[0].(string) + if !ok { + continue + } + c.mu.Lock() + switch typ { + case "EVENT": + if len(raw) >= 2 { + if subID, ok := raw[1].(string); ok { + if ch, exists := c.subs[subID]; exists { + select { + case ch <- msg: + default: + } + } + } + } + case "EOSE": + if len(raw) >= 2 { + if subID, ok := raw[1].(string); ok { + if ch, exists := c.subs[subID]; exists { + close(ch) + } + } + } + case "OK": + // OK messages are handled by WaitForOK + case "NOTICE": + // Notice messages are logged + case "CLOSED": + // Closed messages indicate subscription ended + case "AUTH": + // Auth challenge messages + } + c.mu.Unlock() + } +} + +// Subscribe creates a subscription and returns a channel for events. +func (c *Client) Subscribe(subID string, filters []interface{}) (ch chan []byte, err error) { + req := []interface{}{"REQ", subID} + req = append(req, filters...) + if err = c.Send(req); err != nil { + return + } + c.mu.Lock() + ch = make(chan []byte, 100) + c.subs[subID] = ch + c.mu.Unlock() + return +} + +// Unsubscribe closes a subscription. +func (c *Client) Unsubscribe(subID string) error { + c.mu.Lock() + if ch, exists := c.subs[subID]; exists { + close(ch) + delete(c.subs, subID) + } + c.mu.Unlock() + return c.Send([]interface{}{"CLOSE", subID}) +} + +// Publish sends an EVENT message to the relay. +func (c *Client) Publish(ev *event.E) (err error) { + evJSON, err := json.Marshal(ev.Serialize()) + if err != nil { + return errorf.E("failed to marshal event: %w", err) + } + var evMap map[string]interface{} + if err = json.Unmarshal(evJSON, &evMap); err != nil { + return errorf.E("failed to unmarshal event: %w", err) + } + return c.Send([]interface{}{"EVENT", evMap}) +} + +// WaitForOK waits for an OK response for the given event ID. +func (c *Client) WaitForOK(eventID []byte, timeout time.Duration) (accepted bool, reason string, err error) { + ctx, cancel := context.WithTimeout(c.ctx, timeout) + defer cancel() + idStr := hex.Enc(eventID) + for { + select { + case <-ctx.Done(): + return false, "", errorf.E("timeout waiting for OK response") + default: + } + var msg []byte + _, msg, err = c.conn.ReadMessage() + if err != nil { + return false, "", errorf.E("connection closed: %w", err) + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) < 3 { + continue + } + if typ, ok := raw[0].(string); ok && typ == "OK" { + if id, ok := raw[1].(string); ok && id == idStr { + accepted, _ = raw[2].(bool) + if len(raw) > 3 { + reason, _ = raw[3].(string) + } + return + } + } + } +} + +// Count sends a COUNT request and returns the count. +func (c *Client) Count(filters []interface{}) (count int64, err error) { + req := []interface{}{"COUNT", "count-sub"} + req = append(req, filters...) + if err = c.Send(req); err != nil { + return + } + ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second) + defer cancel() + for { + select { + case <-ctx.Done(): + return 0, errorf.E("timeout waiting for COUNT response") + default: + } + _, msg, err := c.conn.ReadMessage() + if err != nil { + return 0, errorf.E("connection closed: %w", err) + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) >= 3 { + if typ, ok := raw[0].(string); ok && typ == "COUNT" { + if subID, ok := raw[1].(string); ok && subID == "count-sub" { + if countObj, ok := raw[2].(map[string]interface{}); ok { + if c, ok := countObj["count"].(float64); ok { + return int64(c), nil + } + } + } + } + } + } +} + +// Auth sends an AUTH message with the signed event. +func (c *Client) Auth(ev *event.E) error { + evJSON, err := json.Marshal(ev.Serialize()) + if err != nil { + return errorf.E("failed to marshal event: %w", err) + } + var evMap map[string]interface{} + if err = json.Unmarshal(evJSON, &evMap); err != nil { + return errorf.E("failed to unmarshal event: %w", err) + } + return c.Send([]interface{}{"AUTH", evMap}) +} + +// GetEvents collects all events from a subscription until EOSE. +func (c *Client) GetEvents(subID string, filters []interface{}, timeout time.Duration) (events []*event.E, err error) { + ch, err := c.Subscribe(subID, filters) + if err != nil { + return + } + defer c.Unsubscribe(subID) + ctx, cancel := context.WithTimeout(c.ctx, timeout) + defer cancel() + for { + select { + case <-ctx.Done(): + return events, nil + case msg, ok := <-ch: + if !ok { + return events, nil + } + 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) + ev := event.New() + if _, err = ev.Unmarshal(evJSON); err == nil { + events = append(events, ev) + } + } + } + } + } +} diff --git a/relay-tester/keys.go b/relay-tester/keys.go new file mode 100644 index 0000000..327fd1b --- /dev/null +++ b/relay-tester/keys.go @@ -0,0 +1,130 @@ +package relaytester + +import ( + "crypto/rand" + "fmt" + "time" + + "lol.mleku.dev/chk" + "next.orly.dev/pkg/crypto/p256k" + "next.orly.dev/pkg/encoders/bech32encoding" + "next.orly.dev/pkg/encoders/event" + "next.orly.dev/pkg/encoders/hex" + "next.orly.dev/pkg/encoders/kind" + "next.orly.dev/pkg/encoders/tag" +) + +// KeyPair represents a test keypair. +type KeyPair struct { + Secret *p256k.Signer + Pubkey []byte + Nsec string + Npub string +} + +// GenerateKeyPair generates a new keypair for testing. +func GenerateKeyPair() (kp *KeyPair, err error) { + kp = &KeyPair{} + kp.Secret = &p256k.Signer{} + if err = kp.Secret.Generate(); chk.E(err) { + return + } + kp.Pubkey = kp.Secret.Pub() + nsecBytes, err := bech32encoding.BinToNsec(kp.Secret.Sec()) + if chk.E(err) { + return + } + kp.Nsec = string(nsecBytes) + npubBytes, err := bech32encoding.BinToNpub(kp.Pubkey) + if chk.E(err) { + return + } + kp.Npub = string(npubBytes) + return +} + +// CreateEvent creates a signed event with the given parameters. +func CreateEvent(signer *p256k.Signer, kindNum uint16, content string, tags *tag.S) (ev *event.E, err error) { + ev = event.New() + ev.CreatedAt = time.Now().Unix() + ev.Kind = kindNum + ev.Content = []byte(content) + if tags != nil { + ev.Tags = tags + } else { + ev.Tags = tag.NewS() + } + if err = ev.Sign(signer); chk.E(err) { + return + } + return +} + +// CreateEventWithTags creates an event with specific tags. +func CreateEventWithTags(signer *p256k.Signer, kindNum uint16, content string, tagPairs [][]string) (ev *event.E, err error) { + tags := tag.NewS() + for _, pair := range tagPairs { + if len(pair) >= 2 { + // Build tag fields as []byte variadic arguments + tagFields := make([][]byte, len(pair)) + tagFields[0] = []byte(pair[0]) + for i := 1; i < len(pair); i++ { + tagFields[i] = []byte(pair[i]) + } + tags.Append(tag.NewFromBytesSlice(tagFields...)) + } + } + return CreateEvent(signer, kindNum, content, tags) +} + +// CreateReplaceableEvent creates a replaceable event (kind 0-3, 10000-19999). +func CreateReplaceableEvent(signer *p256k.Signer, kindNum uint16, content string) (ev *event.E, err error) { + return CreateEvent(signer, kindNum, content, nil) +} + +// CreateEphemeralEvent creates an ephemeral event (kind 20000-29999). +func CreateEphemeralEvent(signer *p256k.Signer, kindNum uint16, content string) (ev *event.E, err error) { + return CreateEvent(signer, kindNum, content, nil) +} + +// CreateDeleteEvent creates a deletion event (kind 5). +func CreateDeleteEvent(signer *p256k.Signer, eventIDs [][]byte, reason string) (ev *event.E, err error) { + tags := tag.NewS() + for _, id := range eventIDs { + tags.Append(tag.NewFromBytesSlice([]byte("e"), id)) + } + if reason != "" { + tags.Append(tag.NewFromBytesSlice([]byte("content"), []byte(reason))) + } + return CreateEvent(signer, kind.EventDeletion.K, reason, tags) +} + +// CreateParameterizedReplaceableEvent creates a parameterized replaceable event (kind 30000-39999). +func CreateParameterizedReplaceableEvent(signer *p256k.Signer, kindNum uint16, content string, dTag string) (ev *event.E, err error) { + tags := tag.NewS() + tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(dTag))) + return CreateEvent(signer, kindNum, content, tags) +} + +// RandomID generates a random 32-byte ID. +func RandomID() (id []byte, err error) { + id = make([]byte, 32) + if _, err = rand.Read(id); err != nil { + return nil, fmt.Errorf("failed to generate random ID: %w", err) + } + return +} + +// MustHex decodes a hex string or panics. +func MustHex(s string) []byte { + b, err := hex.Dec(s) + if err != nil { + panic(fmt.Sprintf("invalid hex: %s", s)) + } + return b +} + +// HexID returns the hex-encoded event ID. +func HexID(ev *event.E) string { + return hex.Enc(ev.ID) +} diff --git a/relay-tester/test.go b/relay-tester/test.go new file mode 100644 index 0000000..b107744 --- /dev/null +++ b/relay-tester/test.go @@ -0,0 +1,261 @@ +package relaytester + +import ( + "encoding/json" + "time" + + "lol.mleku.dev/errorf" +) + +// TestResult represents the result of a test. +type TestResult struct { + Name string `json:"test"` + Pass bool `json:"pass"` + Required bool `json:"required"` + Info string `json:"info,omitempty"` +} + +// TestFunc is a function that runs a test case. +type TestFunc func(client *Client, key1, key2 *KeyPair) (result TestResult) + +// TestCase represents a test case with dependencies. +type TestCase struct { + Name string + Required bool + Func TestFunc + Dependencies []string // Names of tests that must run before this one +} + +// TestSuite runs all tests against a relay. +type TestSuite struct { + relayURL string + key1 *KeyPair + key2 *KeyPair + tests map[string]*TestCase + results map[string]TestResult + order []string +} + +// NewTestSuite creates a new test suite. +func NewTestSuite(relayURL string) (suite *TestSuite, err error) { + suite = &TestSuite{ + relayURL: relayURL, + tests: make(map[string]*TestCase), + results: make(map[string]TestResult), + } + if suite.key1, err = GenerateKeyPair(); err != nil { + return + } + if suite.key2, err = GenerateKeyPair(); err != nil { + return + } + suite.registerTests() + return +} + +// AddTest adds a test case to the suite. +func (s *TestSuite) AddTest(tc *TestCase) { + s.tests[tc.Name] = tc +} + +// registerTests registers all test cases. +func (s *TestSuite) registerTests() { + allTests := []*TestCase{ + { + Name: "Publishes basic event", + Required: true, + Func: testPublishBasicEvent, + }, + { + Name: "Finds event by ID", + Required: true, + Func: testFindByID, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds event by author", + Required: true, + Func: testFindByAuthor, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds event by kind", + Required: true, + Func: testFindByKind, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds event by tags", + Required: true, + Func: testFindByTags, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds by multiple tags", + Required: true, + Func: testFindByMultipleTags, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Finds by time range", + Required: true, + Func: testFindByTimeRange, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Rejects invalid signature", + Required: true, + Func: testRejectInvalidSignature, + }, + { + Name: "Rejects future event", + Required: true, + Func: testRejectFutureEvent, + }, + { + Name: "Rejects expired event", + Required: false, + Func: testRejectExpiredEvent, + }, + { + Name: "Handles replaceable events", + Required: true, + Func: testReplaceableEvents, + }, + { + Name: "Handles ephemeral events", + Required: false, + Func: testEphemeralEvents, + }, + { + Name: "Handles parameterized replaceable events", + Required: true, + Func: testParameterizedReplaceableEvents, + }, + { + Name: "Handles deletion events", + Required: true, + Func: testDeletionEvents, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Handles COUNT request", + Required: true, + Func: testCountRequest, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Handles limit parameter", + Required: true, + Func: testLimitParameter, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Handles multiple filters", + Required: true, + Func: testMultipleFilters, + Dependencies: []string{"Publishes basic event"}, + }, + { + Name: "Handles subscription close", + Required: true, + Func: testSubscriptionClose, + }, + } + for _, tc := range allTests { + s.AddTest(tc) + } + s.topologicalSort() +} + +// topologicalSort orders tests based on dependencies. +func (s *TestSuite) topologicalSort() { + visited := make(map[string]bool) + temp := make(map[string]bool) + var visit func(name string) + visit = func(name string) { + if temp[name] { + return + } + if visited[name] { + return + } + temp[name] = true + if tc, exists := s.tests[name]; exists { + for _, dep := range tc.Dependencies { + visit(dep) + } + } + temp[name] = false + visited[name] = true + s.order = append(s.order, name) + } + for name := range s.tests { + if !visited[name] { + visit(name) + } + } +} + +// Run runs all tests in the suite. +func (s *TestSuite) Run() (results []TestResult, err error) { + client, err := NewClient(s.relayURL) + if err != nil { + return nil, errorf.E("failed to connect to relay: %w", err) + } + defer client.Close() + for _, name := range s.order { + tc := s.tests[name] + if tc == nil { + continue + } + result := tc.Func(client, s.key1, s.key2) + result.Name = name + result.Required = tc.Required + s.results[name] = result + results = append(results, result) + time.Sleep(100 * time.Millisecond) // Small delay between tests + } + return +} + +// RunTest runs a specific test by name. +func (s *TestSuite) RunTest(testName string) (result TestResult, err error) { + tc, exists := s.tests[testName] + if !exists { + return result, errorf.E("test %s not found", testName) + } + // Check dependencies + for _, dep := range tc.Dependencies { + if _, exists := s.results[dep]; !exists { + return result, errorf.E("test %s depends on %s which has not been run", testName, dep) + } + if !s.results[dep].Pass { + return result, errorf.E("test %s depends on %s which failed", testName, dep) + } + } + client, err := NewClient(s.relayURL) + if err != nil { + return result, errorf.E("failed to connect to relay: %w", err) + } + defer client.Close() + result = tc.Func(client, s.key1, s.key2) + result.Name = testName + result.Required = tc.Required + s.results[testName] = result + return +} + +// GetResults returns all test results. +func (s *TestSuite) GetResults() map[string]TestResult { + return s.results +} + +// FormatJSON formats results as JSON. +func FormatJSON(results []TestResult) (output string, err error) { + var data []byte + if data, err = json.Marshal(results); err != nil { + return + } + return string(data), nil +} diff --git a/relay-tester/tests.go b/relay-tester/tests.go new file mode 100644 index 0000000..8b5ebab --- /dev/null +++ b/relay-tester/tests.go @@ -0,0 +1,547 @@ +package relaytester + +import ( + "fmt" + "time" + + "next.orly.dev/pkg/encoders/hex" + "next.orly.dev/pkg/encoders/kind" + "next.orly.dev/pkg/encoders/tag" +) + +// Test implementations - these are referenced by test.go + +func testPublishBasicEvent(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "test 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} +} + +func testFindByID(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "find by id 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) + filter := map[string]interface{}{ + "ids": []string{hex.Enc(ev.ID)}, + } + events, err := client.GetEvents("test-sub", []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 ID"} + } + return TestResult{Pass: true} +} + +func testFindByAuthor(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "find by author 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) + filter := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-author", []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 author"} + } + return TestResult{Pass: true} +} + +func testFindByKind(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "find by kind 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) + filter := map[string]interface{}{ + "kinds": []int{int(kind.TextNote.K)}, + } + events, err := client.GetEvents("test-kind", []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"} + } + return TestResult{Pass: true} +} + +func testFindByTags(client *Client, key1, key2 *KeyPair) (result TestResult) { + tags := tag.NewS(tag.NewFromBytesSlice([]byte("t"), []byte("test-tag"))) + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "find by 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{}{ + "#t": []string{"test-tag"}, + } + events, err := client.GetEvents("test-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 tags"} + } + return TestResult{Pass: true} +} + +func testFindByMultipleTags(client *Client, key1, key2 *KeyPair) (result TestResult) { + tags := tag.NewS( + tag.NewFromBytesSlice([]byte("t"), []byte("multi-tag-1")), + tag.NewFromBytesSlice([]byte("t"), []byte("multi-tag-2")), + ) + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "find by multiple 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{}{ + "#t": []string{"multi-tag-1", "multi-tag-2"}, + } + events, err := client.GetEvents("test-multi-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 multiple tags"} + } + return TestResult{Pass: true} +} + +func testFindByTimeRange(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "find by time range 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) + now := time.Now().Unix() + filter := map[string]interface{}{ + "since": now - 3600, + "until": now + 3600, + } + events, err := client.GetEvents("test-time", []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 time range"} + } + return TestResult{Pass: true} +} + +func testRejectInvalidSignature(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "invalid sig test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + // Corrupt the signature + ev.Sig[0] ^= 0xFF + 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 testRejectFutureEvent(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "future event test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev.CreatedAt = time.Now().Unix() + 3600 // 1 hour in the future + // Re-sign with new timestamp + 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, 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: "future event was accepted"} + } + _ = reason + return TestResult{Pass: true} +} + +func testRejectExpiredEvent(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "expired event test", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev.CreatedAt = time.Now().Unix() - 86400*365 // 1 year ago + // Re-sign with new timestamp + 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 { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get OK: %v", err)} + } + // Some relays may accept old events, so this is optional + if !accepted { + return TestResult{Pass: true, Info: "expired event rejected (expected)"} + } + return TestResult{Pass: true, Info: "expired event accepted (relay allows old events)"} +} + +func testReplaceableEvents(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev1, err := CreateReplaceableEvent(key1.Secret, kind.ProfileMetadata.K, "first version") + 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: "first event not accepted"} + } + time.Sleep(200 * time.Millisecond) + ev2, err := CreateReplaceableEvent(key1.Secret, kind.ProfileMetadata.K, "second version") + 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(200 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{int(kind.ProfileMetadata.K)}, + "authors": []string{hex.Enc(key1.Pubkey)}, + } + events, err := client.GetEvents("test-replaceable", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + foundSecond := false + for _, e := range events { + if string(e.ID) == string(ev2.ID) { + foundSecond = true + break + } + } + if !foundSecond { + return TestResult{Pass: false, Info: "second replaceable event not found"} + } + return TestResult{Pass: true} +} + +func testEphemeralEvents(client *Client, key1, key2 *KeyPair) (result TestResult) { + 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"} + } + // Ephemeral events should not be stored, so query should not find them + time.Sleep(200 * time.Millisecond) + filter := map[string]interface{}{ + "kinds": []int{20000}, + } + events, err := client.GetEvents("test-ephemeral", []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 stored (should not be)"} + } + } + return TestResult{Pass: true} +} + +func testParameterizedReplaceableEvents(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev1, err := CreateParameterizedReplaceableEvent(key1.Secret, 30023, "first list", "test-list") + 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: "first event not accepted"} + } + time.Sleep(200 * time.Millisecond) + ev2, err := CreateParameterizedReplaceableEvent(key1.Secret, 30023, "second list", "test-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"} + } + return TestResult{Pass: true} +} + +func testDeletionEvents(client *Client, key1, key2 *KeyPair) (result TestResult) { + // First create an event to delete + targetEv, err := CreateEvent(key1.Secret, kind.TextNote.K, "event to delete", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = client.Publish(targetEv); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(targetEv.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "target event not accepted"} + } + time.Sleep(200 * time.Millisecond) + // Now create deletion event + deleteEv, err := CreateDeleteEvent(key1.Secret, [][]byte{targetEv.ID}, "deletion reason") + 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"} + } + return TestResult{Pass: true} +} + +func testCountRequest(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "count 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) + filter := map[string]interface{}{ + "kinds": []int{int(kind.TextNote.K)}, + } + count, err := client.Count([]interface{}{filter}) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("COUNT failed: %v", err)} + } + if count < 1 { + return TestResult{Pass: false, Info: fmt.Sprintf("COUNT returned %d, expected at least 1", count)} + } + return TestResult{Pass: true} +} + +func testLimitParameter(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Publish multiple events + for i := 0; i < 5; i++ { + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, fmt.Sprintf("limit test %d", i), nil) + if err != nil { + continue + } + client.Publish(ev) + client.WaitForOK(ev.ID, 2*time.Second) + } + time.Sleep(500 * time.Millisecond) + filter := map[string]interface{}{ + "limit": 2, + } + events, err := client.GetEvents("test-limit", []interface{}{filter}, 2*time.Second) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to get events: %v", err)} + } + // Limit should be respected (though exact count may vary) + if len(events) > 10 { + return TestResult{Pass: false, Info: fmt.Sprintf("got %d events, limit may not be working", len(events))} + } + return TestResult{Pass: true} +} + +func testMultipleFilters(client *Client, key1, key2 *KeyPair) (result TestResult) { + ev1, err := CreateEvent(key1.Secret, kind.TextNote.K, "filter 1", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + ev2, err := CreateEvent(key2.Secret, kind.TextNote.K, "filter 2", nil) + 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)} + } + if err = client.Publish(ev2); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := client.WaitForOK(ev1.ID, 2*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "event 1 not accepted"} + } + accepted, _, err = client.WaitForOK(ev2.ID, 2*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "event 2 not accepted"} + } + time.Sleep(300 * time.Millisecond) + filter1 := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + } + filter2 := map[string]interface{}{ + "authors": []string{hex.Enc(key2.Pubkey)}, + } + events, err := client.GetEvents("test-multi-filter", []interface{}{filter1, filter2}, 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 at least 2", len(events))} + } + return TestResult{Pass: true} +} + +func testSubscriptionClose(client *Client, key1, key2 *KeyPair) (result TestResult) { + ch, err := client.Subscribe("close-test", []interface{}{map[string]interface{}{}}) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to subscribe: %v", err)} + } + if err = client.Unsubscribe("close-test"); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to unsubscribe: %v", err)} + } + // Channel should be closed + select { + case _, ok := <-ch: + if ok { + return TestResult{Pass: false, Info: "subscription channel not closed"} + } + default: + // Channel already closed, which is fine + } + return TestResult{Pass: true} +} diff --git a/relay_test.go b/relay_test.go new file mode 100644 index 0000000..39313d4 --- /dev/null +++ b/relay_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "testing" + "time" + + lol "lol.mleku.dev" + "next.orly.dev/app/config" + "next.orly.dev/pkg/run" + relaytester "next.orly.dev/relay-tester" +) + +var ( + testRelayURL string + testName string + testJSON bool + keepDataDir bool + relayPort int + relayDataDir string +) + +func TestRelay(t *testing.T) { + var err error + var relay *run.Relay + var relayURL string + + // Determine relay URL + if testRelayURL != "" { + relayURL = testRelayURL + } else { + // Start local relay for testing + if relay, err = startTestRelay(); err != nil { + t.Fatalf("Failed to start test relay: %v", err) + } + defer func() { + if stopErr := relay.Stop(); stopErr != nil { + t.Logf("Error stopping relay: %v", stopErr) + } + }() + port := relayPort + if port == 0 { + port = 3334 // Default port + } + relayURL = fmt.Sprintf("ws://127.0.0.1:%d", port) + // Wait for relay to be ready + time.Sleep(2 * time.Second) + } + + // Create test suite + suite, err := relaytester.NewTestSuite(relayURL) + if err != nil { + t.Fatalf("Failed to create test suite: %v", err) + } + + // Run tests + var results []relaytester.TestResult + if testName != "" { + // Run specific test + result, err := suite.RunTest(testName) + if err != nil { + t.Fatalf("Failed to run test %s: %v", testName, err) + } + results = []relaytester.TestResult{result} + } else { + // Run all tests + if results, err = suite.Run(); err != nil { + t.Fatalf("Failed to run tests: %v", err) + } + } + + // Output results + if testJSON { + jsonOutput, err := relaytester.FormatJSON(results) + if err != nil { + t.Fatalf("Failed to format JSON: %v", err) + } + fmt.Println(jsonOutput) + } else { + outputResults(results, t) + } + + // Check if any required tests failed + for _, result := range results { + if result.Required && !result.Pass { + t.Errorf("Required test '%s' failed: %s", result.Name, result.Info) + } + } +} + +func startTestRelay() (relay *run.Relay, err error) { + cfg := &config.C{ + AppName: "ORLY-TEST", + DataDir: relayDataDir, + Listen: "127.0.0.1", + Port: relayPort, + LogLevel: "warn", + DBLogLevel: "warn", + ACLMode: "none", + } + + // Set default port if not specified + if cfg.Port == 0 { + cfg.Port = 3334 + } + + // Set default data dir if not specified + if cfg.DataDir == "" { + tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("orly-test-%d", time.Now().UnixNano())) + cfg.DataDir = tmpDir + } + + // Set up logging + lol.SetLogLevel(cfg.LogLevel) + + // Create options + cleanup := !keepDataDir + opts := &run.Options{ + CleanupDataDir: &cleanup, + } + + // Start relay + if relay, err = run.Start(cfg, opts); err != nil { + return nil, fmt.Errorf("failed to start relay: %w", err) + } + + // Set up signal handling for graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + if relay != nil { + relay.Stop() + } + os.Exit(0) + }() + + return relay, nil +} + +func outputResults(results []relaytester.TestResult, t *testing.T) { + passed := 0 + failed := 0 + requiredFailed := 0 + + for _, result := range results { + if result.Pass { + passed++ + t.Logf("PASS: %s", result.Name) + } else { + failed++ + if result.Required { + requiredFailed++ + t.Errorf("FAIL (required): %s - %s", result.Name, result.Info) + } else { + t.Logf("FAIL (optional): %s - %s", result.Name, result.Info) + } + } + } + + t.Logf("\nTest Summary:") + t.Logf(" Total: %d", len(results)) + t.Logf(" Passed: %d", passed) + t.Logf(" Failed: %d", failed) + t.Logf(" Required Failed: %d", requiredFailed) +} + +// TestMain allows custom test setup/teardown +func TestMain(m *testing.M) { + // Manually parse our custom flags to avoid conflicts with Go's test flags + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + switch arg { + case "-relay-url": + if i+1 < len(os.Args) { + testRelayURL = os.Args[i+1] + i++ + } + case "-test-name": + if i+1 < len(os.Args) { + testName = os.Args[i+1] + i++ + } + case "-json": + testJSON = true + case "-keep-data": + keepDataDir = true + case "-port": + if i+1 < len(os.Args) { + fmt.Sscanf(os.Args[i+1], "%d", &relayPort) + i++ + } + case "-data-dir": + if i+1 < len(os.Args) { + relayDataDir = os.Args[i+1] + i++ + } + } + } + + code := m.Run() + os.Exit(code) +}