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:
280
relay-tester/client.go
Normal file
280
relay-tester/client.go
Normal 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
130
relay-tester/keys.go
Normal 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
261
relay-tester/test.go
Normal 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
547
relay-tester/tests.go
Normal 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
207
relay_test.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user