test.go raw

   1  package relaytester
   2  
   3  import (
   4  	"encoding/json"
   5  	"time"
   6  
   7  	"next.orly.dev/pkg/lol/errorf"
   8  )
   9  
  10  // TestResult represents the result of a test.
  11  type TestResult struct {
  12  	Name     string `json:"test"`
  13  	Pass     bool   `json:"pass"`
  14  	Required bool   `json:"required"`
  15  	Info     string `json:"info,omitempty"`
  16  }
  17  
  18  // TestFunc is a function that runs a test case.
  19  type TestFunc func(client *Client, key1, key2 *KeyPair) (result TestResult)
  20  
  21  // TestCase represents a test case with dependencies.
  22  type TestCase struct {
  23  	Name         string
  24  	Required     bool
  25  	Func         TestFunc
  26  	Dependencies []string // Names of tests that must run before this one
  27  }
  28  
  29  // TestSuite runs all tests against a relay.
  30  type TestSuite struct {
  31  	relayURL string
  32  	key1     *KeyPair
  33  	key2     *KeyPair
  34  	tests    map[string]*TestCase
  35  	results  map[string]TestResult
  36  	order    []string
  37  }
  38  
  39  // NewTestSuite creates a new test suite.
  40  func NewTestSuite(relayURL string) (suite *TestSuite, err error) {
  41  	suite = &TestSuite{
  42  		relayURL: relayURL,
  43  		tests:    make(map[string]*TestCase),
  44  		results:  make(map[string]TestResult),
  45  	}
  46  	if suite.key1, err = GenerateKeyPair(); err != nil {
  47  		return
  48  	}
  49  	if suite.key2, err = GenerateKeyPair(); err != nil {
  50  		return
  51  	}
  52  	suite.registerTests()
  53  	return
  54  }
  55  
  56  // AddTest adds a test case to the suite.
  57  func (s *TestSuite) AddTest(tc *TestCase) {
  58  	s.tests[tc.Name] = tc
  59  }
  60  
  61  // registerTests registers all test cases.
  62  func (s *TestSuite) registerTests() {
  63  	allTests := []*TestCase{
  64  		{
  65  			Name:     "Publishes basic event",
  66  			Required: true,
  67  			Func:     testPublishBasicEvent,
  68  		},
  69  		{
  70  			Name:         "Finds event by ID",
  71  			Required:     true,
  72  			Func:         testFindByID,
  73  			Dependencies: []string{"Publishes basic event"},
  74  		},
  75  		{
  76  			Name:         "Finds event by author",
  77  			Required:     true,
  78  			Func:         testFindByAuthor,
  79  			Dependencies: []string{"Publishes basic event"},
  80  		},
  81  		{
  82  			Name:         "Finds event by kind",
  83  			Required:     true,
  84  			Func:         testFindByKind,
  85  			Dependencies: []string{"Publishes basic event"},
  86  		},
  87  		{
  88  			Name:         "Finds event by tags",
  89  			Required:     true,
  90  			Func:         testFindByTags,
  91  			Dependencies: []string{"Publishes basic event"},
  92  		},
  93  		{
  94  			Name:         "Finds by multiple tags",
  95  			Required:     true,
  96  			Func:         testFindByMultipleTags,
  97  			Dependencies: []string{"Publishes basic event"},
  98  		},
  99  		{
 100  			Name:         "Finds by time range",
 101  			Required:     true,
 102  			Func:         testFindByTimeRange,
 103  			Dependencies: []string{"Publishes basic event"},
 104  		},
 105  		{
 106  			Name:     "Rejects invalid signature",
 107  			Required: true,
 108  			Func:     testRejectInvalidSignature,
 109  		},
 110  		{
 111  			Name:     "Rejects future event",
 112  			Required: true,
 113  			Func:     testRejectFutureEvent,
 114  		},
 115  		{
 116  			Name:     "Rejects expired event",
 117  			Required: false,
 118  			Func:     testRejectExpiredEvent,
 119  		},
 120  		{
 121  			Name:     "Handles replaceable events",
 122  			Required: true,
 123  			Func:     testReplaceableEvents,
 124  		},
 125  		{
 126  			Name:     "Handles ephemeral events",
 127  			Required: false,
 128  			Func:     testEphemeralEvents,
 129  		},
 130  		{
 131  			Name:     "Handles parameterized replaceable events",
 132  			Required: true,
 133  			Func:     testParameterizedReplaceableEvents,
 134  		},
 135  		{
 136  			Name:         "Handles deletion events",
 137  			Required:     true,
 138  			Func:         testDeletionEvents,
 139  			Dependencies: []string{"Publishes basic event"},
 140  		},
 141  		{
 142  			Name:         "Handles COUNT request",
 143  			Required:     true,
 144  			Func:         testCountRequest,
 145  			Dependencies: []string{"Publishes basic event"},
 146  		},
 147  		{
 148  			Name:         "Handles limit parameter",
 149  			Required:     true,
 150  			Func:         testLimitParameter,
 151  			Dependencies: []string{"Publishes basic event"},
 152  		},
 153  		{
 154  			Name:         "Handles multiple filters",
 155  			Required:     true,
 156  			Func:         testMultipleFilters,
 157  			Dependencies: []string{"Publishes basic event"},
 158  		},
 159  		{
 160  			Name:     "Handles subscription close",
 161  			Required: true,
 162  			Func:     testSubscriptionClose,
 163  		},
 164  		// Filter tests
 165  		{
 166  			Name:         "Since and until filters are inclusive",
 167  			Required:     true,
 168  			Func:         testSinceUntilAreInclusive,
 169  			Dependencies: []string{"Publishes basic event"},
 170  		},
 171  		{
 172  			Name:     "Limit zero works",
 173  			Required: true,
 174  			Func:     testLimitZero,
 175  		},
 176  		// Find tests
 177  		{
 178  			Name:         "Events are ordered from newest to oldest",
 179  			Required:     true,
 180  			Func:         testEventsOrderedFromNewestToOldest,
 181  			Dependencies: []string{"Publishes basic event"},
 182  		},
 183  		{
 184  			Name:         "Newest events are returned when filter is limited",
 185  			Required:     true,
 186  			Func:         testNewestEventsWhenLimited,
 187  			Dependencies: []string{"Publishes basic event"},
 188  		},
 189  		{
 190  			Name:         "Finds by pubkey and kind",
 191  			Required:     true,
 192  			Func:         testFindByPubkeyAndKind,
 193  			Dependencies: []string{"Publishes basic event"},
 194  		},
 195  		{
 196  			Name:         "Finds by pubkey and tags",
 197  			Required:     true,
 198  			Func:         testFindByPubkeyAndTags,
 199  			Dependencies: []string{"Publishes basic event"},
 200  		},
 201  		{
 202  			Name:         "Finds by kind and tags",
 203  			Required:     true,
 204  			Func:         testFindByKindAndTags,
 205  			Dependencies: []string{"Publishes basic event"},
 206  		},
 207  		{
 208  			Name:         "Finds by scrape",
 209  			Required:     true,
 210  			Func:         testFindByScrape,
 211  			Dependencies: []string{"Publishes basic event"},
 212  		},
 213  		// Replaceable event tests
 214  		{
 215  			Name:         "Replaces metadata",
 216  			Required:     true,
 217  			Func:         testReplacesMetadata,
 218  			Dependencies: []string{"Publishes basic event"},
 219  		},
 220  		{
 221  			Name:         "Replaces contact list",
 222  			Required:     true,
 223  			Func:         testReplacesContactList,
 224  			Dependencies: []string{"Publishes basic event"},
 225  		},
 226  		{
 227  			Name:         "Replaced events are still available by ID",
 228  			Required:     false,
 229  			Func:         testReplacedEventsStillAvailableByID,
 230  			Dependencies: []string{"Publishes basic event"},
 231  		},
 232  		{
 233  			Name:         "Replaceable events replace older ones",
 234  			Required:     true,
 235  			Func:         testReplaceableEventRemovesPrevious,
 236  			Dependencies: []string{"Publishes basic event"},
 237  		},
 238  		{
 239  			Name:         "Replaceable events rejected if a newer one exists",
 240  			Required:     true,
 241  			Func:         testReplaceableEventRejectedIfFuture,
 242  			Dependencies: []string{"Publishes basic event"},
 243  		},
 244  		{
 245  			Name:         "Addressable events replace older ones",
 246  			Required:     true,
 247  			Func:         testAddressableEventRemovesPrevious,
 248  			Dependencies: []string{"Publishes basic event"},
 249  		},
 250  		{
 251  			Name:         "Addressable events rejected if a newer one exists",
 252  			Required:     true,
 253  			Func:         testAddressableEventRejectedIfFuture,
 254  			Dependencies: []string{"Publishes basic event"},
 255  		},
 256  		// Deletion tests
 257  		{
 258  			Name:         "Deletes by a-tag address",
 259  			Required:     true,
 260  			Func:         testDeleteByAddr,
 261  			Dependencies: []string{"Publishes basic event"},
 262  		},
 263  		{
 264  			Name:         "Delete by a-tag deletes older but not newer",
 265  			Required:     true,
 266  			Func:         testDeleteByAddrOnlyDeletesOlder,
 267  			Dependencies: []string{"Publishes basic event"},
 268  		},
 269  		{
 270  			Name:         "Delete by a-tag is bound by a-tag",
 271  			Required:     true,
 272  			Func:         testDeleteByAddrIsBoundByTag,
 273  			Dependencies: []string{"Publishes basic event"},
 274  		},
 275  		// Ephemeral tests
 276  		{
 277  			Name:         "Ephemeral subscriptions work",
 278  			Required:     false,
 279  			Func:         testEphemeralSubscriptionsWork,
 280  			Dependencies: []string{"Publishes basic event"},
 281  		},
 282  		{
 283  			Name:         "Persists ephemeral events",
 284  			Required:     false,
 285  			Func:         testPersistsEphemeralEvents,
 286  			Dependencies: []string{"Publishes basic event"},
 287  		},
 288  		// EOSE tests
 289  		{
 290  			Name:     "Supports EOSE",
 291  			Required: true,
 292  			Func:     testSupportsEose,
 293  		},
 294  		{
 295  			Name:     "Subscription receives event after ping period",
 296  			Required: true,
 297  			Func:     testSubscriptionReceivesEventAfterPingPeriod,
 298  		},
 299  		{
 300  			Name:     "Closes complete subscriptions after EOSE",
 301  			Required: false,
 302  			Func:     testClosesCompleteSubscriptionsAfterEose,
 303  		},
 304  		{
 305  			Name:     "Keeps open incomplete subscriptions after EOSE",
 306  			Required: true,
 307  			Func:     testKeepsOpenIncompleteSubscriptionsAfterEose,
 308  		},
 309  		// JSON tests
 310  		{
 311  			Name:         "Accepts events with empty tags",
 312  			Required:     false,
 313  			Func:         testAcceptsEventsWithEmptyTags,
 314  			Dependencies: []string{"Publishes basic event"},
 315  		},
 316  		{
 317  			Name:         "Accepts NIP-01 JSON escape sequences",
 318  			Required:     true,
 319  			Func:         testAcceptsNip1JsonEscapeSequences,
 320  			Dependencies: []string{"Publishes basic event"},
 321  		},
 322  		// Registration tests
 323  		{
 324  			Name:     "Sends OK after EVENT",
 325  			Required: true,
 326  			Func:     testSendsOkAfterEvent,
 327  		},
 328  		{
 329  			Name:     "Verifies event signatures",
 330  			Required: true,
 331  			Func:     testVerifiesSignatures,
 332  		},
 333  		{
 334  			Name:     "Verifies event ID hashes",
 335  			Required: true,
 336  			Func:     testVerifiesIdHashes,
 337  		},
 338  	}
 339  	for _, tc := range allTests {
 340  		s.AddTest(tc)
 341  	}
 342  	s.topologicalSort()
 343  }
 344  
 345  // topologicalSort orders tests based on dependencies.
 346  func (s *TestSuite) topologicalSort() {
 347  	visited := make(map[string]bool)
 348  	temp := make(map[string]bool)
 349  	var visit func(name string)
 350  	visit = func(name string) {
 351  		if temp[name] {
 352  			return
 353  		}
 354  		if visited[name] {
 355  			return
 356  		}
 357  		temp[name] = true
 358  		if tc, exists := s.tests[name]; exists {
 359  			for _, dep := range tc.Dependencies {
 360  				visit(dep)
 361  			}
 362  		}
 363  		temp[name] = false
 364  		visited[name] = true
 365  		s.order = append(s.order, name)
 366  	}
 367  	for name := range s.tests {
 368  		if !visited[name] {
 369  			visit(name)
 370  		}
 371  	}
 372  }
 373  
 374  // Run runs all tests in the suite.
 375  func (s *TestSuite) Run() (results []TestResult, err error) {
 376  	for _, name := range s.order {
 377  		tc := s.tests[name]
 378  		if tc == nil {
 379  			continue
 380  		}
 381  		// Create a new client for each test to avoid connection issues
 382  		client, clientErr := NewClient(s.relayURL)
 383  		if clientErr != nil {
 384  			return nil, errorf.E("failed to connect to relay: %w", clientErr)
 385  		}
 386  		result := tc.Func(client, s.key1, s.key2)
 387  		result.Name = name
 388  		result.Required = tc.Required
 389  		s.results[name] = result
 390  		results = append(results, result)
 391  		client.Close()
 392  		time.Sleep(100 * time.Millisecond) // Small delay between tests
 393  	}
 394  	return
 395  }
 396  
 397  // RunTest runs a specific test by name.
 398  func (s *TestSuite) RunTest(testName string) (result TestResult, err error) {
 399  	tc, exists := s.tests[testName]
 400  	if !exists {
 401  		return result, errorf.E("test %s not found", testName)
 402  	}
 403  	// Check dependencies
 404  	for _, dep := range tc.Dependencies {
 405  		if _, exists := s.results[dep]; !exists {
 406  			return result, errorf.E("test %s depends on %s which has not been run", testName, dep)
 407  		}
 408  		if !s.results[dep].Pass {
 409  			return result, errorf.E("test %s depends on %s which failed", testName, dep)
 410  		}
 411  	}
 412  	// Create a new client for the test
 413  	client, clientErr := NewClient(s.relayURL)
 414  	if clientErr != nil {
 415  		return result, errorf.E("failed to connect to relay: %w", clientErr)
 416  	}
 417  	defer client.Close()
 418  	result = tc.Func(client, s.key1, s.key2)
 419  	result.Name = testName
 420  	result.Required = tc.Required
 421  	s.results[testName] = result
 422  	return
 423  }
 424  
 425  // GetResults returns all test results.
 426  func (s *TestSuite) GetResults() map[string]TestResult {
 427  	return s.results
 428  }
 429  
 430  // ListTests returns a list of all test names in execution order.
 431  func (s *TestSuite) ListTests() []string {
 432  	return s.order
 433  }
 434  
 435  // GetTestNames returns all registered test names as a map (name -> required).
 436  func (s *TestSuite) GetTestNames() map[string]bool {
 437  	result := make(map[string]bool)
 438  	for name, tc := range s.tests {
 439  		result[name] = tc.Required
 440  	}
 441  	return result
 442  }
 443  
 444  // FormatJSON formats results as JSON.
 445  func FormatJSON(results []TestResult) (output string, err error) {
 446  	var data []byte
 447  	if data, err = json.Marshal(results); err != nil {
 448  		return
 449  	}
 450  	return string(data), nil
 451  }
 452