[DEPRECATED] Go implementation of plcbundle
at 4c90c2aa4383441204bab523347d2e6e2bd7dfc2 1068 lines 28 kB view raw
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}