Add relay testing framework and utilities

- 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.
This commit is contained in:
2025-10-30 18:14:22 +00:00
parent 5e6c0b80aa
commit 8954846864
5 changed files with 1425 additions and 0 deletions

280
relay-tester/client.go Normal file
View File

@@ -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)
}
}
}
}
}
}

130
relay-tester/keys.go Normal file
View File

@@ -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)
}

261
relay-tester/test.go Normal file
View File

@@ -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
}

547
relay-tester/tests.go Normal file
View File

@@ -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}
}

207
relay_test.go Normal file
View File

@@ -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)
}