[DEPRECATED] Go implementation of plcbundle
1package server_test
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "path/filepath"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/gorilla/websocket"
18 "tangled.org/atscan.net/plcbundle/bundle"
19 "tangled.org/atscan.net/plcbundle/internal/bundleindex"
20 "tangled.org/atscan.net/plcbundle/internal/plcclient"
21 "tangled.org/atscan.net/plcbundle/internal/storage"
22 "tangled.org/atscan.net/plcbundle/server"
23)
24
25type testLogger struct {
26 t *testing.T
27}
28
29func (l *testLogger) Printf(format string, v ...interface{}) {
30 l.t.Logf(format, v...)
31}
32
33func (l *testLogger) Println(v ...interface{}) {
34 l.t.Log(v...)
35}
36
37// ====================================================================================
38// HTTP ENDPOINT TESTS
39// ====================================================================================
40
41func TestServerHTTPEndpoints(t *testing.T) {
42 handler, _, cleanup := setupTestServer(t, false)
43 defer cleanup()
44
45 ts := httptest.NewServer(handler)
46 defer ts.Close()
47
48 t.Run("RootEndpoint", func(t *testing.T) {
49 resp, err := http.Get(ts.URL + "/")
50 if err != nil {
51 t.Fatalf("GET / failed: %v", err)
52 }
53 defer resp.Body.Close()
54
55 if resp.StatusCode != 200 {
56 t.Errorf("expected 200, got %d", resp.StatusCode)
57 }
58
59 body, _ := io.ReadAll(resp.Body)
60 bodyStr := string(body)
61
62 // Should contain welcome message
63 if !strings.Contains(bodyStr, "plcbundle server") {
64 t.Error("root page missing title")
65 }
66
67 // Should show API endpoints
68 if !strings.Contains(bodyStr, "API Endpoints") {
69 t.Error("root page missing API documentation")
70 }
71 })
72
73 t.Run("IndexJSON", func(t *testing.T) {
74 resp, err := http.Get(ts.URL + "/index.json")
75 if err != nil {
76 t.Fatalf("GET /index.json failed: %v", err)
77 }
78 defer resp.Body.Close()
79
80 if resp.StatusCode != 200 {
81 t.Errorf("expected 200, got %d", resp.StatusCode)
82 }
83
84 // Should be JSON
85 contentType := resp.Header.Get("Content-Type")
86 if !strings.Contains(contentType, "application/json") {
87 t.Errorf("wrong content type: %s", contentType)
88 }
89
90 // Parse JSON
91 var idx bundleindex.Index
92 if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
93 t.Fatalf("failed to parse index JSON: %v", err)
94 }
95
96 if idx.Version != "1.0" {
97 t.Errorf("index version mismatch: got %s", idx.Version)
98 }
99 })
100
101 t.Run("BundleMetadata", func(t *testing.T) {
102 resp, err := http.Get(ts.URL + "/bundle/1")
103 if err != nil {
104 t.Fatalf("GET /bundle/1 failed: %v", err)
105 }
106 defer resp.Body.Close()
107
108 if resp.StatusCode != 200 {
109 t.Errorf("expected 200, got %d", resp.StatusCode)
110 }
111
112 var meta bundleindex.BundleMetadata
113 if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
114 t.Fatalf("failed to parse bundle metadata: %v", err)
115 }
116
117 if meta.BundleNumber != 1 {
118 t.Error("wrong bundle returned")
119 }
120
121 // Verify it has the fields we set
122 if meta.ContentHash == "" {
123 t.Error("metadata missing content hash")
124 }
125 })
126
127 t.Run("BundleMetadata_NotFound", func(t *testing.T) {
128 resp, err := http.Get(ts.URL + "/bundle/9999")
129 if err != nil {
130 t.Fatalf("GET /bundle/9999 failed: %v", err)
131 }
132 defer resp.Body.Close()
133
134 if resp.StatusCode != 404 {
135 t.Errorf("expected 404 for nonexistent bundle, got %d", resp.StatusCode)
136 }
137 })
138
139 t.Run("BundleMetadata_InvalidNumber", func(t *testing.T) {
140 resp, err := http.Get(ts.URL + "/bundle/invalid")
141 if err != nil {
142 t.Fatalf("GET /bundle/invalid failed: %v", err)
143 }
144 defer resp.Body.Close()
145
146 if resp.StatusCode != 400 {
147 t.Errorf("expected 400 for invalid bundle number, got %d", resp.StatusCode)
148 }
149 })
150
151 t.Run("BundleData_Raw", func(t *testing.T) {
152 resp, err := http.Get(ts.URL + "/data/1")
153 if err != nil {
154 t.Fatalf("GET /data/1 failed: %v", err)
155 }
156 defer resp.Body.Close()
157
158 if resp.StatusCode != 200 {
159 // If 500, read error body
160 if resp.StatusCode == 500 {
161 body, _ := io.ReadAll(resp.Body)
162 t.Fatalf("expected 200, got 500. Error: %s", string(body))
163 }
164 t.Errorf("expected 200, got %d", resp.StatusCode)
165 }
166
167 // Should be zstd compressed
168 contentType := resp.Header.Get("Content-Type")
169 if !strings.Contains(contentType, "application/zstd") {
170 t.Errorf("wrong content type for raw data: %s", contentType)
171 }
172
173 // Should have content-disposition header
174 disposition := resp.Header.Get("Content-Disposition")
175 if !strings.Contains(disposition, "000001.jsonl.zst") {
176 t.Errorf("wrong disposition header: %s", disposition)
177 }
178
179 // Should have data
180 data, _ := io.ReadAll(resp.Body)
181 if len(data) == 0 {
182 t.Error("bundle data is empty")
183 }
184
185 t.Logf("Bundle data size: %d bytes", len(data))
186 })
187
188 t.Run("BundleJSONL_Decompressed", func(t *testing.T) {
189 resp, err := http.Get(ts.URL + "/jsonl/1")
190 if err != nil {
191 t.Fatalf("GET /jsonl/1 failed: %v", err)
192 }
193 defer resp.Body.Close()
194
195 if resp.StatusCode != 200 {
196 t.Errorf("expected 200, got %d", resp.StatusCode)
197 }
198
199 // Should be JSONL
200 contentType := resp.Header.Get("Content-Type")
201 if !strings.Contains(contentType, "application/x-ndjson") {
202 t.Errorf("wrong content type for JSONL: %s", contentType)
203 }
204
205 // Count lines
206 data, _ := io.ReadAll(resp.Body)
207 lines := bytes.Count(data, []byte("\n"))
208
209 if lines == 0 {
210 t.Error("JSONL should have lines")
211 }
212 })
213
214 t.Run("StatusEndpoint", func(t *testing.T) {
215 resp, err := http.Get(ts.URL + "/status")
216 if err != nil {
217 t.Fatalf("GET /status failed: %v", err)
218 }
219 defer resp.Body.Close()
220
221 if resp.StatusCode != 200 {
222 t.Errorf("expected 200, got %d", resp.StatusCode)
223 }
224
225 var status server.StatusResponse
226 if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
227 t.Fatalf("failed to parse status JSON: %v", err)
228 }
229
230 // Verify structure
231 if status.Server.Version == "" {
232 t.Error("status missing server version")
233 }
234
235 if status.Bundles.Count < 0 {
236 t.Error("invalid bundle count")
237 }
238
239 if status.Server.UptimeSeconds < 0 {
240 t.Error("invalid uptime")
241 }
242 })
243}
244
245// ====================================================================================
246// DID RESOLUTION ENDPOINT TESTS
247// ====================================================================================
248
249func TestServerDIDResolution(t *testing.T) {
250 handler, _, cleanup := setupTestServerWithResolver(t)
251 defer cleanup()
252
253 ts := httptest.NewServer(handler)
254 defer ts.Close()
255
256 // Use valid did:plc format: "did:plc:" + 24 chars base32 (a-z, 2-7 only)
257 testDID := "did:plc:abc234def567ghi234jkl456" // Valid format
258
259 t.Run("DIDDocument", func(t *testing.T) {
260 resp, err := http.Get(ts.URL + "/" + testDID)
261 if err != nil {
262 t.Fatalf("GET /%s failed: %v", testDID, err)
263 }
264 defer resp.Body.Close()
265
266 // Should be 404 (not in test data) or 500 (no DID index)
267 // NOT 400 (that means invalid format)
268 if resp.StatusCode == 400 {
269 body, _ := io.ReadAll(resp.Body)
270 t.Fatalf("got 400 (invalid DID format): %s", string(body))
271 }
272
273 if resp.StatusCode == 500 {
274 t.Log("Expected 500 (no DID index)")
275 return
276 }
277
278 if resp.StatusCode == 404 {
279 t.Log("Expected 404 (DID not found)")
280 return
281 }
282
283 if resp.StatusCode == 200 {
284 var doc plcclient.DIDDocument
285 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
286 t.Fatalf("failed to parse DID document: %v", err)
287 }
288 }
289 })
290
291 t.Run("DIDData_RawState", func(t *testing.T) {
292 resp, err := http.Get(ts.URL + "/" + testDID + "/data")
293 if err != nil {
294 t.Fatalf("GET /%s/data failed: %v", testDID, err)
295 }
296 defer resp.Body.Close()
297
298 // /data endpoint validates format, so 400 is NOT acceptable for valid DID
299 if resp.StatusCode == 400 {
300 body, _ := io.ReadAll(resp.Body)
301 t.Fatalf("got 400 for valid DID format: %s", string(body))
302 }
303
304 // 404 or 500 acceptable (no data / no index)
305 if resp.StatusCode == 500 || resp.StatusCode == 404 {
306 t.Logf("Expected error (no DID index): status %d", resp.StatusCode)
307 return
308 }
309
310 if resp.StatusCode == 200 {
311 var state plcclient.DIDState
312 json.NewDecoder(resp.Body).Decode(&state)
313 }
314 })
315
316 t.Run("DIDAuditLog", func(t *testing.T) {
317 resp, err := http.Get(ts.URL + "/" + testDID + "/log/audit")
318 if err != nil {
319 t.Fatalf("request failed: %v", err)
320 }
321 defer resp.Body.Close()
322
323 // Should NOT be 400 for valid DID
324 if resp.StatusCode == 400 {
325 body, _ := io.ReadAll(resp.Body)
326 t.Fatalf("got 400 for valid DID format: %s", string(body))
327 }
328
329 // 404, 500 acceptable
330 if resp.StatusCode == 500 || resp.StatusCode == 404 {
331 t.Logf("Expected error (no DID index): status %d", resp.StatusCode)
332 return
333 }
334 })
335
336 // Test invalid formats on /data endpoint (which validates properly)
337 t.Run("InvalidDIDFormat_OnDataEndpoint", func(t *testing.T) {
338 // Test DIDs that START with "did:plc:" but are still invalid
339 // (routing checks prefix first, so "did:invalid:" returns 404 before validation)
340 invalidDIDs := []string{
341 "did:plc:short", // Too short (< 24 chars)
342 "did:plc:tooshort2345", // Still too short
343 "did:plc:contains0189invalidchars456", // Has 0,1,8,9 (invalid in base32)
344 "did:plc:UPPERCASENOTALLOWED1234", // Has uppercase
345 "did:plc:has-dashes-not-allowed12", // Has dashes
346 "did:plc:waytoolonggggggggggggggggg", // Too long (> 24 chars)
347 }
348
349 for _, invalidDID := range invalidDIDs {
350 resp, err := http.Get(ts.URL + "/" + invalidDID + "/data")
351 if err != nil {
352 t.Fatalf("request to %s failed: %v", invalidDID, err)
353 }
354
355 body, _ := io.ReadAll(resp.Body)
356 resp.Body.Close()
357
358 // /data endpoint validates format and should return 400
359 if resp.StatusCode != 400 {
360 t.Logf("DID %s: got %d (body: %s)", invalidDID, resp.StatusCode, string(body))
361 // Some might also return 500 if they pass initial checks
362 // but fail deeper validation - that's also acceptable
363 if resp.StatusCode != 500 {
364 t.Errorf("DID %s: expected 400 or 500, got %d", invalidDID, resp.StatusCode)
365 }
366 }
367 }
368 })
369
370 t.Run("InvalidDIDMethod_Returns404", func(t *testing.T) {
371 // DIDs with wrong method get 404 from routing (never reach validation)
372 wrongMethodDIDs := []string{
373 "did:invalid:format",
374 "did:web:example.com",
375 "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
376 "notadid",
377 }
378
379 for _, did := range wrongMethodDIDs {
380 resp, err := http.Get(ts.URL + "/" + did + "/data")
381 if err != nil {
382 t.Fatalf("request failed: %v", err)
383 }
384 resp.Body.Close()
385
386 // Should get 404 (not a did:plc: path)
387 if resp.StatusCode != 404 {
388 t.Errorf("DID %s: expected 404 from routing, got %d", did, resp.StatusCode)
389 }
390 }
391 })
392
393 t.Run("NotADIDPath", func(t *testing.T) {
394 resp, err := http.Get(ts.URL + "/notadid")
395 if err != nil {
396 t.Fatalf("request failed: %v", err)
397 }
398 defer resp.Body.Close()
399
400 if resp.StatusCode != 404 {
401 t.Errorf("expected 404 for non-DID path, got %d", resp.StatusCode)
402 }
403 })
404}
405
406// ====================================================================================
407// CORS MIDDLEWARE TESTS
408// ====================================================================================
409
410func TestServerCORS(t *testing.T) {
411 srv, _, cleanup := setupTestServer(t, false)
412 defer cleanup()
413
414 ts := httptest.NewServer(srv)
415 defer ts.Close()
416
417 t.Run("CORS_Headers_GET", func(t *testing.T) {
418 resp, err := http.Get(ts.URL + "/index.json")
419 if err != nil {
420 t.Fatalf("request failed: %v", err)
421 }
422 defer resp.Body.Close()
423
424 // Check CORS headers
425 if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
426 t.Error("missing or wrong Access-Control-Allow-Origin header")
427 }
428
429 methods := resp.Header.Get("Access-Control-Allow-Methods")
430 if !strings.Contains(methods, "GET") {
431 t.Errorf("Access-Control-Allow-Methods missing GET: %s", methods)
432 }
433 })
434
435 t.Run("CORS_Preflight_OPTIONS", func(t *testing.T) {
436 req, _ := http.NewRequest("OPTIONS", ts.URL+"/index.json", nil)
437 req.Header.Set("Access-Control-Request-Method", "GET")
438 req.Header.Set("Access-Control-Request-Headers", "Content-Type")
439
440 resp, err := http.DefaultClient.Do(req)
441 if err != nil {
442 t.Fatalf("OPTIONS request failed: %v", err)
443 }
444 defer resp.Body.Close()
445
446 if resp.StatusCode != 204 {
447 t.Errorf("expected 204 for OPTIONS, got %d", resp.StatusCode)
448 }
449
450 if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
451 t.Error("CORS headers missing on OPTIONS")
452 }
453
454 maxAge := resp.Header.Get("Access-Control-Max-Age")
455 if maxAge != "86400" {
456 t.Errorf("wrong max-age: %s", maxAge)
457 }
458 })
459}
460
461// ====================================================================================
462// WEBSOCKET TESTS
463// ====================================================================================
464
465func TestServerWebSocket(t *testing.T) {
466 srv, _, cleanup := setupTestServer(t, true) // Enable WebSocket
467 defer cleanup()
468
469 ts := httptest.NewServer(srv)
470 defer ts.Close()
471
472 wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws"
473
474 t.Run("WebSocket_Connect", func(t *testing.T) {
475 ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
476 if err != nil {
477 t.Fatalf("WebSocket dial failed: %v", err)
478 }
479 defer ws.Close()
480
481 // Should connect successfully
482 t.Log("WebSocket connected successfully")
483 })
484
485 t.Run("WebSocket_ReceiveOperations", func(t *testing.T) {
486 ws, _, err := websocket.DefaultDialer.Dial(wsURL+"?cursor=0", nil)
487 if err != nil {
488 t.Fatalf("WebSocket dial failed: %v", err)
489 }
490 defer ws.Close()
491
492 // Set read deadline
493 ws.SetReadDeadline(time.Now().Add(5 * time.Second))
494
495 // Read a message (should get operations or timeout)
496 _, message, err := ws.ReadMessage()
497 if err != nil {
498 // Timeout is OK (no operations available)
499 if !strings.Contains(err.Error(), "timeout") {
500 t.Logf("Read error (may be OK if no ops): %v", err)
501 }
502 return
503 }
504
505 // If we got a message, verify it's valid JSON
506 var op plcclient.PLCOperation
507 if err := json.Unmarshal(message, &op); err != nil {
508 t.Errorf("received invalid operation JSON: %v", err)
509 }
510
511 t.Logf("Received operation: %s", op.CID)
512 })
513
514 t.Run("WebSocket_InvalidCursor", func(t *testing.T) {
515 resp, err := http.Get(ts.URL + "/ws?cursor=invalid")
516 if err != nil {
517 t.Fatalf("request failed: %v", err)
518 }
519 defer resp.Body.Close()
520
521 if resp.StatusCode != 400 {
522 t.Errorf("expected 400 for invalid cursor, got %d", resp.StatusCode)
523 }
524 })
525
526 t.Run("WebSocket_CloseGracefully", func(t *testing.T) {
527 ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
528 if err != nil {
529 t.Fatalf("WebSocket dial failed: %v", err)
530 }
531
532 // Close immediately
533 err = ws.WriteMessage(websocket.CloseMessage,
534 websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
535 if err != nil {
536 t.Logf("close message error (may be OK): %v", err)
537 }
538
539 ws.Close()
540 t.Log("WebSocket closed gracefully")
541 })
542}
543
544// ====================================================================================
545// SYNC MODE TESTS
546// ====================================================================================
547
548func TestServerSyncMode(t *testing.T) {
549 srv, _, cleanup := setupTestServer(t, true)
550 defer cleanup()
551
552 ts := httptest.NewServer(srv)
553 defer ts.Close()
554
555 t.Run("MempoolEndpoint", func(t *testing.T) {
556 resp, err := http.Get(ts.URL + "/mempool")
557 if err != nil {
558 t.Fatalf("GET /mempool failed: %v", err)
559 }
560 defer resp.Body.Close()
561
562 if resp.StatusCode != 200 {
563 t.Errorf("expected 200, got %d", resp.StatusCode)
564 }
565
566 // Should be JSONL
567 contentType := resp.Header.Get("Content-Type")
568 if !strings.Contains(contentType, "application/x-ndjson") {
569 t.Errorf("wrong content type: %s", contentType)
570 }
571 })
572
573 t.Run("StatusWithMempool", func(t *testing.T) {
574 resp, err := http.Get(ts.URL + "/status")
575 if err != nil {
576 t.Fatalf("GET /status failed: %v", err)
577 }
578 defer resp.Body.Close()
579
580 var status server.StatusResponse
581 if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
582 t.Fatalf("failed to parse status: %v", err)
583 }
584
585 // Sync mode should include mempool stats
586 if status.Server.SyncMode {
587 if status.Mempool == nil {
588 t.Error("sync mode status missing mempool")
589 }
590 }
591 })
592}
593
594// ====================================================================================
595// CONCURRENT REQUEST TESTS
596// ====================================================================================
597
598func TestServerConcurrency(t *testing.T) {
599 srv, _, cleanup := setupTestServer(t, false)
600 defer cleanup()
601
602 ts := httptest.NewServer(srv)
603 defer ts.Close()
604
605 t.Run("ConcurrentIndexRequests", func(t *testing.T) {
606 var wg sync.WaitGroup
607 errors := make(chan error, 100)
608
609 for i := 0; i < 100; i++ {
610 wg.Add(1)
611 go func() {
612 defer wg.Done()
613
614 resp, err := http.Get(ts.URL + "/index.json")
615 if err != nil {
616 errors <- err
617 return
618 }
619 defer resp.Body.Close()
620
621 if resp.StatusCode != 200 {
622 errors <- fmt.Errorf("status %d", resp.StatusCode)
623 }
624 }()
625 }
626
627 wg.Wait()
628 close(errors)
629
630 for err := range errors {
631 t.Errorf("concurrent request error: %v", err)
632 }
633 })
634
635 t.Run("ConcurrentBundleRequests", func(t *testing.T) {
636 var wg sync.WaitGroup
637 errors := make(chan error, 50)
638
639 for i := 0; i < 50; i++ {
640 wg.Add(1)
641 go func(bundleNum int) {
642 defer wg.Done()
643
644 resp, err := http.Get(fmt.Sprintf("%s/bundle/%d", ts.URL, bundleNum%3+1))
645 if err != nil {
646 errors <- err
647 return
648 }
649 defer resp.Body.Close()
650
651 if resp.StatusCode != 200 && resp.StatusCode != 404 {
652 errors <- fmt.Errorf("unexpected status %d", resp.StatusCode)
653 }
654 }(i)
655 }
656
657 wg.Wait()
658 close(errors)
659
660 for err := range errors {
661 t.Errorf("concurrent request error: %v", err)
662 }
663 })
664
665 t.Run("MixedEndpointConcurrency", func(t *testing.T) {
666 var wg sync.WaitGroup
667
668 endpoints := []string{
669 "/",
670 "/index.json",
671 "/bundle/1",
672 "/data/1",
673 "/jsonl/1",
674 "/status",
675 }
676
677 for i := 0; i < 30; i++ {
678 wg.Add(1)
679 go func(id int) {
680 defer wg.Done()
681
682 endpoint := endpoints[id%len(endpoints)]
683 resp, err := http.Get(ts.URL + endpoint)
684 if err != nil {
685 t.Errorf("request to %s failed: %v", endpoint, err)
686 return
687 }
688 defer resp.Body.Close()
689
690 // Read body to completion
691 io.ReadAll(resp.Body)
692 }(i)
693 }
694
695 wg.Wait()
696 })
697}
698
699// ====================================================================================
700// ERROR HANDLING TESTS
701// ====================================================================================
702
703func TestServerErrorHandling(t *testing.T) {
704 srv, _, cleanup := setupTestServer(t, false)
705 defer cleanup()
706
707 ts := httptest.NewServer(srv)
708 defer ts.Close()
709
710 t.Run("404_NotFound", func(t *testing.T) {
711 resp, err := http.Get(ts.URL + "/nonexistent")
712 if err != nil {
713 t.Fatalf("request failed: %v", err)
714 }
715 defer resp.Body.Close()
716
717 if resp.StatusCode != 404 {
718 t.Errorf("expected 404, got %d", resp.StatusCode)
719 }
720 })
721
722 t.Run("405_MethodNotAllowed", func(t *testing.T) {
723 // POST to GET-only endpoint
724 resp, err := http.Post(ts.URL+"/index.json", "application/json", bytes.NewReader([]byte("{}")))
725 if err != nil {
726 t.Fatalf("request failed: %v", err)
727 }
728 defer resp.Body.Close()
729
730 if resp.StatusCode != 404 && resp.StatusCode != 405 {
731 t.Logf("Note: Got status %d (404/405 both acceptable)", resp.StatusCode)
732 }
733 })
734
735 t.Run("LargeRequestHandling", func(t *testing.T) {
736 // Request very large bundle number
737 resp, err := http.Get(ts.URL + "/bundle/999999")
738 if err != nil {
739 t.Fatalf("request failed: %v", err)
740 }
741 defer resp.Body.Close()
742
743 if resp.StatusCode != 404 {
744 t.Errorf("expected 404 for large bundle number, got %d", resp.StatusCode)
745 }
746 })
747}
748
749// ====================================================================================
750// MIDDLEWARE TESTS
751// ====================================================================================
752
753func TestServerMiddleware(t *testing.T) {
754 srv, _, cleanup := setupTestServer(t, false)
755 defer cleanup()
756
757 ts := httptest.NewServer(srv)
758 defer ts.Close()
759
760 t.Run("JSON_ContentType", func(t *testing.T) {
761 resp, err := http.Get(ts.URL + "/index.json")
762 if err != nil {
763 t.Fatalf("request failed: %v", err)
764 }
765 defer resp.Body.Close()
766
767 contentType := resp.Header.Get("Content-Type")
768 if !strings.Contains(contentType, "application/json") {
769 t.Errorf("wrong content type: %s", contentType)
770 }
771 })
772
773 t.Run("CORS_AllowsAllOrigins", func(t *testing.T) {
774 req, _ := http.NewRequest("GET", ts.URL+"/index.json", nil)
775 req.Header.Set("Origin", "https://example.com")
776
777 resp, err := http.DefaultClient.Do(req)
778 if err != nil {
779 t.Fatalf("request failed: %v", err)
780 }
781 defer resp.Body.Close()
782
783 allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
784 if allowOrigin != "*" {
785 t.Errorf("CORS not allowing all origins: %s", allowOrigin)
786 }
787 })
788}
789
790// ====================================================================================
791// HELPER FUNCTIONS & FORMATTERS
792// ====================================================================================
793
794func TestServerHelpers(t *testing.T) {
795 t.Run("FormatNumber", func(t *testing.T) {
796 // Note: formatNumber is not exported, so we test indirectly
797 // through endpoints that use it (like root page)
798
799 srv, _, cleanup := setupTestServer(t, false)
800 defer cleanup()
801
802 ts := httptest.NewServer(srv)
803 defer ts.Close()
804
805 resp, _ := http.Get(ts.URL + "/")
806 body, _ := io.ReadAll(resp.Body)
807 resp.Body.Close()
808
809 // Should have formatted numbers with commas
810 // (if there are any large numbers in output)
811 t.Logf("Root page length: %d bytes", len(body))
812 })
813}
814
815// ====================================================================================
816// MEMORY & PERFORMANCE TESTS
817// ====================================================================================
818
819func TestServerPerformance(t *testing.T) {
820 if testing.Short() {
821 t.Skip("skipping performance test in short mode")
822 }
823
824 srv, _, cleanup := setupTestServer(t, false)
825 defer cleanup()
826
827 ts := httptest.NewServer(srv)
828 defer ts.Close()
829
830 t.Run("MemoryDebugEndpoint", func(t *testing.T) {
831 resp, err := http.Get(ts.URL + "/debug/memory")
832 if err != nil {
833 t.Fatalf("GET /debug/memory failed: %v", err)
834 }
835 defer resp.Body.Close()
836
837 if resp.StatusCode != 200 {
838 t.Errorf("expected 200, got %d", resp.StatusCode)
839 }
840
841 body, _ := io.ReadAll(resp.Body)
842 bodyStr := string(body)
843
844 if !strings.Contains(bodyStr, "Memory Stats") {
845 t.Error("memory debug output missing stats")
846 }
847
848 if !strings.Contains(bodyStr, "Alloc:") {
849 t.Error("memory debug missing allocation info")
850 }
851 })
852
853 t.Run("ResponseTime", func(t *testing.T) {
854 // Measure response time for index
855 start := time.Now()
856 resp, err := http.Get(ts.URL + "/index.json")
857 elapsed := time.Since(start)
858
859 if err != nil {
860 t.Fatalf("request failed: %v", err)
861 }
862 resp.Body.Close()
863
864 // Should be fast (< 100ms for index)
865 if elapsed > 100*time.Millisecond {
866 t.Logf("Warning: slow response time: %v", elapsed)
867 }
868
869 t.Logf("Index response time: %v", elapsed)
870 })
871}
872
873// ====================================================================================
874// SERVER LIFECYCLE TESTS
875// ====================================================================================
876
877func TestServerLifecycle(t *testing.T) {
878 t.Run("StartAndStop", func(t *testing.T) {
879 mgr, mgrCleanup := setupTestManager(t)
880 defer mgrCleanup()
881
882 config := &server.Config{
883 Addr: "127.0.0.1:0", // Random port
884 SyncMode: false,
885 EnableWebSocket: false,
886 EnableResolver: false,
887 Version: "test",
888 }
889
890 srv := server.New(mgr, config)
891
892 // Start in goroutine
893 errChan := make(chan error, 1)
894 go func() {
895 // This will block
896 errChan <- srv.ListenAndServe()
897 }()
898
899 // Give it time to start
900 time.Sleep(100 * time.Millisecond)
901
902 // Shutdown
903 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
904 defer cancel()
905
906 if err := srv.Shutdown(ctx); err != nil {
907 t.Errorf("shutdown failed: %v", err)
908 }
909
910 // Should exit
911 select {
912 case err := <-errChan:
913 if err != nil && err != http.ErrServerClosed {
914 t.Errorf("unexpected error: %v", err)
915 }
916 case <-time.After(2 * time.Second):
917 t.Error("server did not stop after shutdown")
918 }
919 })
920
921 t.Run("GetStartTime", func(t *testing.T) {
922 mgr, cleanup := setupTestManager(t)
923 defer cleanup()
924
925 config := &server.Config{
926 Addr: ":0",
927 Version: "test",
928 }
929
930 before := time.Now()
931 srv := server.New(mgr, config)
932 after := time.Now()
933
934 startTime := srv.GetStartTime()
935
936 if startTime.Before(before) || startTime.After(after) {
937 t.Error("start time not in expected range")
938 }
939 })
940}
941
942// ====================================================================================
943// SETUP HELPERS
944// ====================================================================================
945
946func setupTestServer(t *testing.T, enableWebSocket bool) (http.Handler, *server.Server, func()) {
947 mgr, cleanup := setupTestManager(t)
948
949 config := &server.Config{
950 Addr: ":8080",
951 SyncMode: true,
952 SyncInterval: 1 * time.Minute,
953 EnableWebSocket: enableWebSocket,
954 EnableResolver: false,
955 Version: "test",
956 }
957
958 srv := server.New(mgr, config)
959
960 // Get handler from server
961 handler := srv.Handler() // Use new method
962
963 return handler, srv, cleanup
964}
965
966func setupTestServerWithResolver(t *testing.T) (http.Handler, *server.Server, func()) {
967 mgr, cleanup := setupTestManager(t)
968
969 config := &server.Config{
970 Addr: ":8080",
971 SyncMode: false,
972 EnableWebSocket: false,
973 EnableResolver: true,
974 Version: "test",
975 }
976
977 srv := server.New(mgr, config)
978 handler := srv.Handler()
979
980 return handler, srv, cleanup
981}
982
983func setupTestManager(t *testing.T) (*bundle.Manager, func()) {
984 tmpDir := t.TempDir()
985
986 config := bundle.DefaultConfig(tmpDir)
987 config.AutoInit = true
988 config.VerifyOnLoad = false // Disable verification in tests
989
990 // Create storage operations ONCE and reuse
991 logger := &testLogger{t: t}
992 storageOps, err := storage.NewOperations(logger, false)
993 if err != nil {
994 t.Fatalf("failed to create storage operations: %v", err)
995 }
996
997 mgr, err := bundle.NewManager(config, nil)
998 if err != nil {
999 storageOps.Close()
1000 t.Fatalf("failed to create manager: %v", err)
1001 }
1002
1003 // Add test bundles with actual files
1004 for i := 1; i <= 3; i++ {
1005 // Create actual bundle file FIRST
1006 path := filepath.Join(tmpDir, fmt.Sprintf("%06d.jsonl.zst", i))
1007 ops := makeMinimalTestOperations(10000, i*10000) // Unique ops per bundle
1008
1009 contentHash, compHash, uncompSize, compSize, err := storageOps.SaveBundle(path, ops, nil)
1010 if err != nil {
1011 t.Fatalf("failed to save test bundle %d: %v", i, err)
1012 }
1013
1014 // Create metadata that matches the actual file
1015 meta := &bundleindex.BundleMetadata{
1016 BundleNumber: i,
1017 StartTime: ops[0].CreatedAt,
1018 EndTime: ops[len(ops)-1].CreatedAt,
1019 OperationCount: len(ops),
1020 DIDCount: len(ops), // All unique in test data
1021 Hash: fmt.Sprintf("hash%d", i),
1022 ContentHash: contentHash, // Use actual hash
1023 CompressedHash: compHash, // Use actual hash
1024 CompressedSize: compSize, // Use actual size
1025 UncompressedSize: uncompSize, // Use actual size
1026 CreatedAt: time.Now(),
1027 }
1028
1029 mgr.GetIndex().AddBundle(meta)
1030 }
1031
1032 if err := mgr.SaveIndex(); err != nil {
1033 t.Fatalf("failed to save index: %v", err)
1034 }
1035
1036 cleanup := func() {
1037 storageOps.Close()
1038 mgr.Close()
1039 }
1040
1041 return mgr, cleanup
1042}
1043
1044func makeMinimalTestOperations(count int, offset int) []plcclient.PLCOperation {
1045 ops := make([]plcclient.PLCOperation, count)
1046 baseTime := time.Now().Add(-time.Hour)
1047
1048 for i := 0; i < count; i++ {
1049 idx := offset + i
1050
1051 // Create valid base32 DID identifier (24 chars, only a-z and 2-7)
1052 // Convert index to base32-like string
1053 identifier := fmt.Sprintf("%024d", idx)
1054 // Replace invalid chars (0,1,8,9) with valid ones
1055 identifier = strings.ReplaceAll(identifier, "0", "a")
1056 identifier = strings.ReplaceAll(identifier, "1", "b")
1057 identifier = strings.ReplaceAll(identifier, "8", "c")
1058 identifier = strings.ReplaceAll(identifier, "9", "d")
1059
1060 ops[i] = plcclient.PLCOperation{
1061 DID: "did:plc:" + identifier,
1062 CID: fmt.Sprintf("bafytest%012d", idx),
1063 CreatedAt: baseTime.Add(time.Duration(idx) * time.Second),
1064 }
1065 }
1066
1067 return ops
1068}