Browse Source
- Introduced a new `relaytester` package to facilitate testing of relay functionalities. - Implemented a `TestSuite` structure to manage and execute various test cases against the relay. - Added multiple test cases for event publishing, retrieval, and validation, ensuring comprehensive coverage of relay behavior. - Created utility functions for generating key pairs and events, enhancing test reliability and maintainability. - Established a WebSocket client for interacting with the relay during tests, including subscription and message handling. - Included JSON formatting for test results to improve output readability. - This commit lays the groundwork for robust integration testing of relay features.main
5 changed files with 1425 additions and 0 deletions
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
Loading…
Reference in new issue