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