[DEPRECATED] Go implementation of plcbundle
at main 1243 lines 33 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-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}