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