A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at codeberg-source 1075 lines 32 kB view raw
1package atproto 2 3import ( 4 "context" 5 "encoding/json" 6 "net/http" 7 "net/http/httptest" 8 "strings" 9 "testing" 10 "time" 11) 12 13// TestNewClient verifies client initialization with Basic Auth 14func TestNewClient(t *testing.T) { 15 client := NewClient("https://pds.example.com", "did:plc:test123", "token123") 16 17 if client.pdsEndpoint != "https://pds.example.com" { 18 t.Errorf("pdsEndpoint = %v, want https://pds.example.com", client.pdsEndpoint) 19 } 20 if client.did != "did:plc:test123" { 21 t.Errorf("did = %v, want did:plc:test123", client.did) 22 } 23 if client.accessToken != "token123" { 24 t.Errorf("accessToken = %v, want token123", client.accessToken) 25 } 26 if client.sessionProvider != nil { 27 t.Error("sessionProvider should be nil for Basic Auth client") 28 } 29} 30 31// TestPutRecord tests storing a record in ATProto 32func TestPutRecord(t *testing.T) { 33 tests := []struct { 34 name string 35 collection string 36 rkey string 37 record any 38 serverResponse string 39 serverStatus int 40 wantErr bool 41 checkFunc func(*testing.T, *Record) 42 }{ 43 { 44 name: "successful put", 45 collection: ManifestCollection, 46 rkey: "abc123", 47 record: map[string]string{ 48 "$type": ManifestCollection, 49 "test": "value", 50 }, 51 serverResponse: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest"}`, 52 serverStatus: http.StatusOK, 53 wantErr: false, 54 checkFunc: func(t *testing.T, r *Record) { 55 if r.URI != "at://did:plc:test123/io.atcr.manifest/abc123" { 56 t.Errorf("URI = %v, want at://did:plc:test123/io.atcr.manifest/abc123", r.URI) 57 } 58 if r.CID != "bafytest" { 59 t.Errorf("CID = %v, want bafytest", r.CID) 60 } 61 }, 62 }, 63 { 64 name: "server error", 65 collection: ManifestCollection, 66 rkey: "abc123", 67 record: map[string]string{"test": "value"}, 68 serverResponse: `{"error":"InvalidRequest"}`, 69 serverStatus: http.StatusBadRequest, 70 wantErr: true, 71 }, 72 } 73 74 for _, tt := range tests { 75 t.Run(tt.name, func(t *testing.T) { 76 // Create test server 77 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 // Verify request method 79 if r.Method != "POST" { 80 t.Errorf("Method = %v, want POST", r.Method) 81 } 82 83 // Verify path 84 expectedPath := RepoPutRecord 85 if r.URL.Path != expectedPath { 86 t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) 87 } 88 89 // Verify Authorization header 90 auth := r.Header.Get("Authorization") 91 if !strings.HasPrefix(auth, "Bearer ") { 92 t.Errorf("Authorization header missing or malformed: %v", auth) 93 } 94 95 // Verify request body 96 var body map[string]any 97 if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 98 t.Errorf("Failed to decode request body: %v", err) 99 } 100 101 if body["repo"] != "did:plc:test123" { 102 t.Errorf("repo = %v, want did:plc:test123", body["repo"]) 103 } 104 if body["collection"] != tt.collection { 105 t.Errorf("collection = %v, want %v", body["collection"], tt.collection) 106 } 107 if body["rkey"] != tt.rkey { 108 t.Errorf("rkey = %v, want %v", body["rkey"], tt.rkey) 109 } 110 111 // Send response 112 w.WriteHeader(tt.serverStatus) 113 w.Write([]byte(tt.serverResponse)) 114 })) 115 defer server.Close() 116 117 // Create client pointing to test server 118 client := NewClient(server.URL, "did:plc:test123", "test-token") 119 120 // Call PutRecord 121 result, err := client.PutRecord(context.Background(), tt.collection, tt.rkey, tt.record) 122 123 // Check error 124 if (err != nil) != tt.wantErr { 125 t.Errorf("PutRecord() error = %v, wantErr %v", err, tt.wantErr) 126 return 127 } 128 129 // Run check function if provided 130 if !tt.wantErr && tt.checkFunc != nil { 131 tt.checkFunc(t, result) 132 } 133 }) 134 } 135} 136 137// TestGetRecord tests retrieving a record from ATProto 138func TestGetRecord(t *testing.T) { 139 tests := []struct { 140 name string 141 collection string 142 rkey string 143 serverResponse string 144 serverStatus int 145 wantErr bool 146 wantNotFound bool 147 checkFunc func(*testing.T, *Record) 148 }{ 149 { 150 name: "successful get", 151 collection: ManifestCollection, 152 rkey: "abc123", 153 serverResponse: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{"$type":"io.atcr.manifest","repository":"myapp"}}`, 154 serverStatus: http.StatusOK, 155 wantErr: false, 156 checkFunc: func(t *testing.T, r *Record) { 157 if r.URI != "at://did:plc:test123/io.atcr.manifest/abc123" { 158 t.Errorf("URI = %v, want at://did:plc:test123/io.atcr.manifest/abc123", r.URI) 159 } 160 161 var value map[string]any 162 if err := json.Unmarshal(r.Value, &value); err != nil { 163 t.Errorf("Failed to unmarshal value: %v", err) 164 } 165 166 if value["$type"] != ManifestCollection { 167 t.Errorf("value.$type = %v, want %v", value["$type"], ManifestCollection) 168 } 169 }, 170 }, 171 { 172 name: "record not found - 404", 173 collection: ManifestCollection, 174 rkey: "notfound", 175 serverResponse: ``, 176 serverStatus: http.StatusNotFound, 177 wantErr: true, 178 wantNotFound: true, 179 }, 180 { 181 name: "record not found - error message", 182 collection: ManifestCollection, 183 rkey: "notfound", 184 serverResponse: `{"error":"RecordNotFound","message":"Record not found"}`, 185 serverStatus: http.StatusBadRequest, 186 wantErr: true, 187 wantNotFound: true, 188 }, 189 } 190 191 for _, tt := range tests { 192 t.Run(tt.name, func(t *testing.T) { 193 // Create test server 194 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 195 // Verify request method 196 if r.Method != "GET" { 197 t.Errorf("Method = %v, want GET", r.Method) 198 } 199 200 // Verify path 201 expectedPath := RepoGetRecord 202 if r.URL.Path != expectedPath { 203 t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) 204 } 205 206 // Verify query parameters 207 query := r.URL.Query() 208 if query.Get("repo") != "did:plc:test123" { 209 t.Errorf("repo = %v, want did:plc:test123", query.Get("repo")) 210 } 211 if query.Get("collection") != tt.collection { 212 t.Errorf("collection = %v, want %v", query.Get("collection"), tt.collection) 213 } 214 if query.Get("rkey") != tt.rkey { 215 t.Errorf("rkey = %v, want %v", query.Get("rkey"), tt.rkey) 216 } 217 218 // Send response 219 w.WriteHeader(tt.serverStatus) 220 w.Write([]byte(tt.serverResponse)) 221 })) 222 defer server.Close() 223 224 // Create client pointing to test server 225 client := NewClient(server.URL, "did:plc:test123", "test-token") 226 227 // Call GetRecord 228 result, err := client.GetRecord(context.Background(), tt.collection, tt.rkey) 229 230 // Check error 231 if (err != nil) != tt.wantErr { 232 t.Errorf("GetRecord() error = %v, wantErr %v", err, tt.wantErr) 233 return 234 } 235 236 // Check for ErrRecordNotFound 237 if tt.wantNotFound && err != ErrRecordNotFound { 238 t.Errorf("Expected ErrRecordNotFound, got %v", err) 239 } 240 241 // Run check function if provided 242 if !tt.wantErr && tt.checkFunc != nil { 243 tt.checkFunc(t, result) 244 } 245 }) 246 } 247} 248 249// TestDeleteRecord tests deleting a record from ATProto 250func TestDeleteRecord(t *testing.T) { 251 tests := []struct { 252 name string 253 collection string 254 rkey string 255 serverResponse string 256 serverStatus int 257 wantErr bool 258 }{ 259 { 260 name: "successful delete", 261 collection: ManifestCollection, 262 rkey: "abc123", 263 serverResponse: `{}`, 264 serverStatus: http.StatusOK, 265 wantErr: false, 266 }, 267 { 268 name: "server error", 269 collection: ManifestCollection, 270 rkey: "abc123", 271 serverResponse: `{"error":"InvalidRequest"}`, 272 serverStatus: http.StatusBadRequest, 273 wantErr: true, 274 }, 275 } 276 277 for _, tt := range tests { 278 t.Run(tt.name, func(t *testing.T) { 279 // Create test server 280 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 281 // Verify request method 282 if r.Method != "POST" { 283 t.Errorf("Method = %v, want POST", r.Method) 284 } 285 286 // Verify path 287 expectedPath := RepoDeleteRecord 288 if r.URL.Path != expectedPath { 289 t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) 290 } 291 292 // Verify request body 293 var body map[string]any 294 if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 295 t.Errorf("Failed to decode request body: %v", err) 296 } 297 298 if body["repo"] != "did:plc:test123" { 299 t.Errorf("repo = %v, want did:plc:test123", body["repo"]) 300 } 301 if body["collection"] != tt.collection { 302 t.Errorf("collection = %v, want %v", body["collection"], tt.collection) 303 } 304 if body["rkey"] != tt.rkey { 305 t.Errorf("rkey = %v, want %v", body["rkey"], tt.rkey) 306 } 307 308 // Send response 309 w.WriteHeader(tt.serverStatus) 310 w.Write([]byte(tt.serverResponse)) 311 })) 312 defer server.Close() 313 314 // Create client pointing to test server 315 client := NewClient(server.URL, "did:plc:test123", "test-token") 316 317 // Call DeleteRecord 318 err := client.DeleteRecord(context.Background(), tt.collection, tt.rkey) 319 320 // Check error 321 if (err != nil) != tt.wantErr { 322 t.Errorf("DeleteRecord() error = %v, wantErr %v", err, tt.wantErr) 323 } 324 }) 325 } 326} 327 328// TestListRecords tests listing records in a collection 329func TestListRecords(t *testing.T) { 330 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 331 // Verify query parameters 332 query := r.URL.Query() 333 if query.Get("repo") != "did:plc:test123" { 334 t.Errorf("repo = %v, want did:plc:test123", query.Get("repo")) 335 } 336 if query.Get("collection") != ManifestCollection { 337 t.Errorf("collection = %v, want %v", query.Get("collection"), ManifestCollection) 338 } 339 if query.Get("limit") != "10" { 340 t.Errorf("limit = %v, want 10", query.Get("limit")) 341 } 342 343 // Send response 344 response := `{ 345 "records": [ 346 {"uri":"at://did:plc:test123/io.atcr.manifest/abc1","cid":"bafytest1","value":{"$type":"io.atcr.manifest"}}, 347 {"uri":"at://did:plc:test123/io.atcr.manifest/abc2","cid":"bafytest2","value":{"$type":"io.atcr.manifest"}} 348 ] 349 }` 350 w.WriteHeader(http.StatusOK) 351 w.Write([]byte(response)) 352 })) 353 defer server.Close() 354 355 client := NewClient(server.URL, "did:plc:test123", "test-token") 356 records, err := client.ListRecords(context.Background(), ManifestCollection, 10) 357 if err != nil { 358 t.Fatalf("ListRecords() error = %v", err) 359 } 360 361 if len(records) != 2 { 362 t.Errorf("len(records) = %v, want 2", len(records)) 363 } 364 365 if records[0].URI != "at://did:plc:test123/io.atcr.manifest/abc1" { 366 t.Errorf("records[0].URI = %v", records[0].URI) 367 } 368} 369 370// TestUploadBlob tests uploading a blob to PDS 371func TestUploadBlob(t *testing.T) { 372 blobData := []byte("test blob content") 373 mimeType := "application/octet-stream" 374 375 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 376 // Verify request 377 if r.Method != "POST" { 378 t.Errorf("Method = %v, want POST", r.Method) 379 } 380 381 if r.URL.Path != RepoUploadBlob { 382 t.Errorf("Path = %v, want %s", r.URL.Path, RepoUploadBlob) 383 } 384 385 if r.Header.Get("Content-Type") != mimeType { 386 t.Errorf("Content-Type = %v, want %v", r.Header.Get("Content-Type"), mimeType) 387 } 388 389 // Send response 390 response := `{ 391 "blob": { 392 "$type": "blob", 393 "ref": {"$link": "bafytest123"}, 394 "mimeType": "application/octet-stream", 395 "size": 17 396 } 397 }` 398 w.WriteHeader(http.StatusOK) 399 w.Write([]byte(response)) 400 })) 401 defer server.Close() 402 403 client := NewClient(server.URL, "did:plc:test123", "test-token") 404 blobRef, err := client.UploadBlob(context.Background(), blobData, mimeType) 405 if err != nil { 406 t.Fatalf("UploadBlob() error = %v", err) 407 } 408 409 if blobRef.Type != "blob" { 410 t.Errorf("Type = %v, want blob", blobRef.Type) 411 } 412 413 if blobRef.Ref.Link != "bafytest123" { 414 t.Errorf("Ref.Link = %v, want bafytest123", blobRef.Ref.Link) 415 } 416 417 if blobRef.Size != 17 { 418 t.Errorf("Size = %v, want 17", blobRef.Size) 419 } 420} 421 422// TestGetBlob tests downloading a blob from PDS 423func TestGetBlob(t *testing.T) { 424 tests := []struct { 425 name string 426 cid string 427 serverResponse string 428 contentType string 429 wantData []byte 430 wantErr bool 431 }{ 432 { 433 name: "raw blob response", 434 cid: "bafytest123", 435 serverResponse: "test blob content", 436 contentType: "application/octet-stream", 437 wantData: []byte("test blob content"), 438 wantErr: false, 439 }, 440 { 441 name: "JSON-wrapped blob (Bluesky PDS format)", 442 cid: "bafytest123", 443 serverResponse: `"dGVzdCBibG9iIGNvbnRlbnQ="`, // base64 of "test blob content" 444 contentType: "application/json", 445 wantData: []byte("test blob content"), 446 wantErr: false, 447 }, 448 { 449 name: "blob not found", 450 cid: "notfound", 451 serverResponse: "", 452 contentType: "text/plain", 453 wantData: nil, 454 wantErr: true, 455 }, 456 } 457 458 for _, tt := range tests { 459 t.Run(tt.name, func(t *testing.T) { 460 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 461 // Verify query parameters 462 query := r.URL.Query() 463 if query.Get("did") != "did:plc:test123" { 464 t.Errorf("did = %v, want did:plc:test123", query.Get("did")) 465 } 466 if query.Get("cid") != tt.cid { 467 t.Errorf("cid = %v, want %v", query.Get("cid"), tt.cid) 468 } 469 470 // Send response 471 if tt.wantErr { 472 w.WriteHeader(http.StatusNotFound) 473 } else { 474 w.Header().Set("Content-Type", tt.contentType) 475 w.WriteHeader(http.StatusOK) 476 w.Write([]byte(tt.serverResponse)) 477 } 478 })) 479 defer server.Close() 480 481 client := NewClient(server.URL, "did:plc:test123", "test-token") 482 data, err := client.GetBlob(context.Background(), tt.cid) 483 484 if (err != nil) != tt.wantErr { 485 t.Errorf("GetBlob() error = %v, wantErr %v", err, tt.wantErr) 486 return 487 } 488 489 if !tt.wantErr && string(data) != string(tt.wantData) { 490 t.Errorf("GetBlob() data = %v, want %v", string(data), string(tt.wantData)) 491 } 492 }) 493 } 494} 495 496// TestBlobCDNURL tests CDN URL construction 497func TestBlobCDNURL(t *testing.T) { 498 tests := []struct { 499 name string 500 didOrHandle string 501 cid string 502 want string 503 }{ 504 { 505 name: "with DID", 506 didOrHandle: "did:plc:alice123", 507 cid: "bafytest123", 508 want: "https://imgs.blue/did:plc:alice123/bafytest123", 509 }, 510 { 511 name: "with handle", 512 didOrHandle: "alice.bsky.social", 513 cid: "bafytest456", 514 want: "https://imgs.blue/alice.bsky.social/bafytest456", 515 }, 516 } 517 518 for _, tt := range tests { 519 t.Run(tt.name, func(t *testing.T) { 520 got := BlobCDNURL(tt.didOrHandle, tt.cid) 521 if got != tt.want { 522 t.Errorf("BlobCDNURL() = %v, want %v", got, tt.want) 523 } 524 }) 525 } 526} 527 528// TestFetchDIDDocument tests fetching and parsing DID documents 529func TestFetchDIDDocument(t *testing.T) { 530 tests := []struct { 531 name string 532 serverResponse string 533 serverStatus int 534 wantErr bool 535 checkFunc func(*testing.T, *DIDDocument) 536 }{ 537 { 538 name: "valid DID document", 539 serverResponse: `{ 540 "@context": ["https://www.w3.org/ns/did/v1"], 541 "id": "did:web:example.com", 542 "service": [ 543 { 544 "id": "#atproto_pds", 545 "type": "AtprotoPersonalDataServer", 546 "serviceEndpoint": "https://pds.example.com" 547 } 548 ] 549 }`, 550 serverStatus: http.StatusOK, 551 wantErr: false, 552 checkFunc: func(t *testing.T, doc *DIDDocument) { 553 if doc.ID != "did:web:example.com" { 554 t.Errorf("ID = %v, want did:web:example.com", doc.ID) 555 } 556 if len(doc.Service) != 1 { 557 t.Fatalf("len(Service) = %v, want 1", len(doc.Service)) 558 } 559 if doc.Service[0].Type != "AtprotoPersonalDataServer" { 560 t.Errorf("Service[0].Type = %v", doc.Service[0].Type) 561 } 562 if doc.Service[0].ServiceEndpoint != "https://pds.example.com" { 563 t.Errorf("Service[0].ServiceEndpoint = %v", doc.Service[0].ServiceEndpoint) 564 } 565 }, 566 }, 567 { 568 name: "404 not found", 569 serverResponse: "", 570 serverStatus: http.StatusNotFound, 571 wantErr: true, 572 }, 573 { 574 name: "invalid JSON", 575 serverResponse: "not json", 576 serverStatus: http.StatusOK, 577 wantErr: true, 578 }, 579 } 580 581 for _, tt := range tests { 582 t.Run(tt.name, func(t *testing.T) { 583 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 584 w.WriteHeader(tt.serverStatus) 585 w.Write([]byte(tt.serverResponse)) 586 })) 587 defer server.Close() 588 589 client := NewClient("https://pds.example.com", "did:plc:test123", "") 590 doc, err := client.FetchDIDDocument(context.Background(), server.URL) 591 592 if (err != nil) != tt.wantErr { 593 t.Errorf("FetchDIDDocument() error = %v, wantErr %v", err, tt.wantErr) 594 return 595 } 596 597 if !tt.wantErr && tt.checkFunc != nil { 598 tt.checkFunc(t, doc) 599 } 600 }) 601 } 602} 603 604// TestClientWithEmptyToken tests that client doesn't set auth header with empty token 605func TestClientWithEmptyToken(t *testing.T) { 606 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 607 auth := r.Header.Get("Authorization") 608 if auth != "" { 609 t.Errorf("Authorization header should not be set with empty token, got: %v", auth) 610 } 611 612 response := `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{}}` 613 w.WriteHeader(http.StatusOK) 614 w.Write([]byte(response)) 615 })) 616 defer server.Close() 617 618 // Create client with empty token 619 client := NewClient(server.URL, "did:plc:test123", "") 620 621 // Make request - should not include Authorization header 622 _, err := client.GetRecord(context.Background(), ManifestCollection, "abc123") 623 if err != nil { 624 t.Fatalf("GetRecord() error = %v", err) 625 } 626} 627 628// TestListRecordsForRepo tests listing records for a specific repository 629func TestListRecordsForRepo(t *testing.T) { 630 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 631 query := r.URL.Query() 632 if query.Get("repo") != "did:plc:alice123" { 633 t.Errorf("repo = %v, want did:plc:alice123", query.Get("repo")) 634 } 635 if query.Get("collection") != ManifestCollection { 636 t.Errorf("collection = %v, want %v", query.Get("collection"), ManifestCollection) 637 } 638 if query.Get("limit") != "50" { 639 t.Errorf("limit = %v, want 50", query.Get("limit")) 640 } 641 if query.Get("cursor") != "cursor123" { 642 t.Errorf("cursor = %v, want cursor123", query.Get("cursor")) 643 } 644 645 response := `{ 646 "records": [ 647 {"uri":"at://did:plc:alice123/io.atcr.manifest/abc1","cid":"bafytest1","value":{}} 648 ], 649 "cursor": "nextcursor456" 650 }` 651 w.WriteHeader(http.StatusOK) 652 w.Write([]byte(response)) 653 })) 654 defer server.Close() 655 656 client := NewClient(server.URL, "did:plc:test123", "test-token") 657 records, cursor, err := client.ListRecordsForRepo(context.Background(), "did:plc:alice123", ManifestCollection, 50, "cursor123") 658 659 if err != nil { 660 t.Fatalf("ListRecordsForRepo() error = %v", err) 661 } 662 663 if len(records) != 1 { 664 t.Errorf("len(records) = %v, want 1", len(records)) 665 } 666 667 if cursor != "nextcursor456" { 668 t.Errorf("cursor = %v, want nextcursor456", cursor) 669 } 670} 671 672// TestContextCancellation tests that client respects context cancellation 673func TestContextCancellation(t *testing.T) { 674 // Create a server that sleeps for a while 675 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 676 time.Sleep(100 * time.Millisecond) 677 w.WriteHeader(http.StatusOK) 678 w.Write([]byte(`{}`)) 679 })) 680 defer server.Close() 681 682 client := NewClient(server.URL, "did:plc:test123", "test-token") 683 684 // Create a context that gets canceled immediately 685 ctx, cancel := context.WithCancel(context.Background()) 686 cancel() // Cancel immediately 687 688 // Request should fail with context canceled error 689 _, err := client.GetRecord(ctx, ManifestCollection, "abc123") 690 if err == nil { 691 t.Error("Expected error due to context cancellation, got nil") 692 } 693} 694 695// TestListReposByCollection tests listing repositories by collection 696func TestListReposByCollection(t *testing.T) { 697 tests := []struct { 698 name string 699 collection string 700 limit int 701 cursor string 702 serverResponse string 703 serverStatus int 704 wantErr bool 705 checkFunc func(*testing.T, *ListReposByCollectionResult) 706 }{ 707 { 708 name: "successful list with results", 709 collection: ManifestCollection, 710 limit: 100, 711 cursor: "", 712 serverResponse: `{ 713 "repos": [ 714 {"did": "did:plc:alice123"}, 715 {"did": "did:plc:bob456"} 716 ], 717 "cursor": "nextcursor789" 718 }`, 719 serverStatus: http.StatusOK, 720 wantErr: false, 721 checkFunc: func(t *testing.T, result *ListReposByCollectionResult) { 722 if len(result.Repos) != 2 { 723 t.Errorf("len(Repos) = %v, want 2", len(result.Repos)) 724 } 725 if result.Repos[0].DID != "did:plc:alice123" { 726 t.Errorf("Repos[0].DID = %v, want did:plc:alice123", result.Repos[0].DID) 727 } 728 if result.Cursor != "nextcursor789" { 729 t.Errorf("Cursor = %v, want nextcursor789", result.Cursor) 730 } 731 }, 732 }, 733 { 734 name: "empty results", 735 collection: ManifestCollection, 736 limit: 50, 737 cursor: "cursor123", 738 serverResponse: `{"repos": []}`, 739 serverStatus: http.StatusOK, 740 wantErr: false, 741 checkFunc: func(t *testing.T, result *ListReposByCollectionResult) { 742 if len(result.Repos) != 0 { 743 t.Errorf("len(Repos) = %v, want 0", len(result.Repos)) 744 } 745 }, 746 }, 747 { 748 name: "server error", 749 collection: ManifestCollection, 750 limit: 100, 751 cursor: "", 752 serverResponse: `{"error":"InternalError"}`, 753 serverStatus: http.StatusInternalServerError, 754 wantErr: true, 755 }, 756 } 757 758 for _, tt := range tests { 759 t.Run(tt.name, func(t *testing.T) { 760 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 761 // Verify query parameters 762 query := r.URL.Query() 763 if query.Get("collection") != tt.collection { 764 t.Errorf("collection = %v, want %v", query.Get("collection"), tt.collection) 765 } 766 if tt.limit > 0 && query.Get("limit") != strings.TrimSpace(string(rune(tt.limit))) { 767 // Check if limit param exists when specified 768 if !strings.Contains(r.URL.RawQuery, "limit=") { 769 t.Error("limit parameter missing") 770 } 771 } 772 if tt.cursor != "" && query.Get("cursor") != tt.cursor { 773 t.Errorf("cursor = %v, want %v", query.Get("cursor"), tt.cursor) 774 } 775 776 // Send response 777 w.WriteHeader(tt.serverStatus) 778 w.Write([]byte(tt.serverResponse)) 779 })) 780 defer server.Close() 781 782 client := NewClient(server.URL, "did:plc:test123", "test-token") 783 result, err := client.ListReposByCollection(context.Background(), tt.collection, tt.limit, tt.cursor) 784 785 if (err != nil) != tt.wantErr { 786 t.Errorf("ListReposByCollection() error = %v, wantErr %v", err, tt.wantErr) 787 return 788 } 789 790 if !tt.wantErr && tt.checkFunc != nil { 791 tt.checkFunc(t, result) 792 } 793 }) 794 } 795} 796 797// TestGetActorProfile tests fetching actor profiles 798func TestGetActorProfile(t *testing.T) { 799 tests := []struct { 800 name string 801 actor string 802 serverResponse string 803 serverStatus int 804 wantErr bool 805 checkFunc func(*testing.T, *ActorProfile) 806 }{ 807 { 808 name: "successful profile fetch by handle", 809 actor: "alice.bsky.social", 810 serverResponse: `{ 811 "did": "did:plc:alice123", 812 "handle": "alice.bsky.social", 813 "displayName": "Alice Smith", 814 "description": "Test user", 815 "avatar": "https://cdn.example.com/avatar.jpg" 816 }`, 817 serverStatus: http.StatusOK, 818 wantErr: false, 819 checkFunc: func(t *testing.T, profile *ActorProfile) { 820 if profile.DID != "did:plc:alice123" { 821 t.Errorf("DID = %v, want did:plc:alice123", profile.DID) 822 } 823 if profile.Handle != "alice.bsky.social" { 824 t.Errorf("Handle = %v, want alice.bsky.social", profile.Handle) 825 } 826 if profile.DisplayName != "Alice Smith" { 827 t.Errorf("DisplayName = %v, want Alice Smith", profile.DisplayName) 828 } 829 }, 830 }, 831 { 832 name: "successful profile fetch by DID", 833 actor: "did:plc:bob456", 834 serverResponse: `{ 835 "did": "did:plc:bob456", 836 "handle": "bob.example.com" 837 }`, 838 serverStatus: http.StatusOK, 839 wantErr: false, 840 checkFunc: func(t *testing.T, profile *ActorProfile) { 841 if profile.DID != "did:plc:bob456" { 842 t.Errorf("DID = %v, want did:plc:bob456", profile.DID) 843 } 844 }, 845 }, 846 { 847 name: "profile not found", 848 actor: "nonexistent.example.com", 849 serverResponse: "", 850 serverStatus: http.StatusNotFound, 851 wantErr: true, 852 }, 853 { 854 name: "server error", 855 actor: "error.example.com", 856 serverResponse: `{"error":"InternalError"}`, 857 serverStatus: http.StatusInternalServerError, 858 wantErr: true, 859 }, 860 } 861 862 for _, tt := range tests { 863 t.Run(tt.name, func(t *testing.T) { 864 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 865 // Verify query parameter 866 query := r.URL.Query() 867 if query.Get("actor") != tt.actor { 868 t.Errorf("actor = %v, want %v", query.Get("actor"), tt.actor) 869 } 870 871 // Verify path 872 if !strings.Contains(r.URL.Path, "app.bsky.actor.getProfile") { 873 t.Errorf("Path = %v, should contain app.bsky.actor.getProfile", r.URL.Path) 874 } 875 876 // Send response 877 w.WriteHeader(tt.serverStatus) 878 w.Write([]byte(tt.serverResponse)) 879 })) 880 defer server.Close() 881 882 client := NewClient(server.URL, "did:plc:test123", "test-token") 883 profile, err := client.GetActorProfile(context.Background(), tt.actor) 884 885 if (err != nil) != tt.wantErr { 886 t.Errorf("GetActorProfile() error = %v, wantErr %v", err, tt.wantErr) 887 return 888 } 889 890 if !tt.wantErr && tt.checkFunc != nil { 891 tt.checkFunc(t, profile) 892 } 893 }) 894 } 895} 896 897// TestGetProfileRecord tests fetching profile records from PDS 898func TestGetProfileRecord(t *testing.T) { 899 tests := []struct { 900 name string 901 did string 902 serverResponse string 903 serverStatus int 904 wantErr bool 905 checkFunc func(*testing.T, *ProfileRecord) 906 }{ 907 { 908 name: "successful profile record fetch", 909 did: "did:plc:alice123", 910 serverResponse: `{ 911 "uri": "at://did:plc:alice123/app.bsky.actor.profile/self", 912 "cid": "bafytest", 913 "value": { 914 "displayName": "Alice Smith", 915 "description": "Test description", 916 "avatar": { 917 "$type": "blob", 918 "ref": {"$link": "bafyavatar"}, 919 "mimeType": "image/jpeg", 920 "size": 12345 921 } 922 } 923 }`, 924 serverStatus: http.StatusOK, 925 wantErr: false, 926 checkFunc: func(t *testing.T, profile *ProfileRecord) { 927 if profile.DisplayName != "Alice Smith" { 928 t.Errorf("DisplayName = %v, want Alice Smith", profile.DisplayName) 929 } 930 if profile.Description != "Test description" { 931 t.Errorf("Description = %v, want Test description", profile.Description) 932 } 933 if profile.Avatar == nil { 934 t.Fatal("Avatar should not be nil") 935 } 936 if profile.Avatar.Ref.Link != "bafyavatar" { 937 t.Errorf("Avatar.Ref.Link = %v, want bafyavatar", profile.Avatar.Ref.Link) 938 } 939 }, 940 }, 941 { 942 name: "profile record not found", 943 did: "did:plc:nonexistent", 944 serverResponse: "", 945 serverStatus: http.StatusNotFound, 946 wantErr: true, 947 }, 948 } 949 950 for _, tt := range tests { 951 t.Run(tt.name, func(t *testing.T) { 952 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 953 // Verify query parameters 954 query := r.URL.Query() 955 if query.Get("repo") != tt.did { 956 t.Errorf("repo = %v, want %v", query.Get("repo"), tt.did) 957 } 958 if query.Get("collection") != "app.bsky.actor.profile" { 959 t.Errorf("collection = %v, want app.bsky.actor.profile", query.Get("collection")) 960 } 961 if query.Get("rkey") != "self" { 962 t.Errorf("rkey = %v, want self", query.Get("rkey")) 963 } 964 965 // Send response 966 w.WriteHeader(tt.serverStatus) 967 w.Write([]byte(tt.serverResponse)) 968 })) 969 defer server.Close() 970 971 client := NewClient(server.URL, "did:plc:test123", "test-token") 972 profile, err := client.GetProfileRecord(context.Background(), tt.did) 973 974 if (err != nil) != tt.wantErr { 975 t.Errorf("GetProfileRecord() error = %v, wantErr %v", err, tt.wantErr) 976 return 977 } 978 979 if !tt.wantErr && tt.checkFunc != nil { 980 tt.checkFunc(t, profile) 981 } 982 }) 983 } 984} 985 986// TestClientDID tests the DID() getter method 987func TestClientDID(t *testing.T) { 988 expectedDID := "did:plc:test123" 989 client := NewClient("https://pds.example.com", expectedDID, "token") 990 991 if client.DID() != expectedDID { 992 t.Errorf("DID() = %v, want %v", client.DID(), expectedDID) 993 } 994} 995 996// TestClientPDSEndpoint tests the PDSEndpoint() getter method 997func TestClientPDSEndpoint(t *testing.T) { 998 expectedEndpoint := "https://pds.example.com" 999 client := NewClient(expectedEndpoint, "did:plc:test123", "token") 1000 1001 if client.PDSEndpoint() != expectedEndpoint { 1002 t.Errorf("PDSEndpoint() = %v, want %v", client.PDSEndpoint(), expectedEndpoint) 1003 } 1004} 1005 1006// TestListRecordsError tests error handling in ListRecords 1007func TestListRecordsError(t *testing.T) { 1008 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1009 w.WriteHeader(http.StatusInternalServerError) 1010 w.Write([]byte(`{"error":"InternalError"}`)) 1011 })) 1012 defer server.Close() 1013 1014 client := NewClient(server.URL, "did:plc:test123", "test-token") 1015 _, err := client.ListRecords(context.Background(), ManifestCollection, 10) 1016 1017 if err == nil { 1018 t.Error("Expected error from ListRecords, got nil") 1019 } 1020} 1021 1022// TestUploadBlobError tests error handling in UploadBlob 1023func TestUploadBlobError(t *testing.T) { 1024 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1025 w.WriteHeader(http.StatusBadRequest) 1026 w.Write([]byte(`{"error":"InvalidBlob"}`)) 1027 })) 1028 defer server.Close() 1029 1030 client := NewClient(server.URL, "did:plc:test123", "test-token") 1031 _, err := client.UploadBlob(context.Background(), []byte("test"), "application/octet-stream") 1032 1033 if err == nil { 1034 t.Error("Expected error from UploadBlob, got nil") 1035 } 1036} 1037 1038// TestGetBlobServerError tests error handling in GetBlob for non-404 errors 1039func TestGetBlobServerError(t *testing.T) { 1040 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1041 w.WriteHeader(http.StatusInternalServerError) 1042 w.Write([]byte(`{"error":"InternalError"}`)) 1043 })) 1044 defer server.Close() 1045 1046 client := NewClient(server.URL, "did:plc:test123", "test-token") 1047 _, err := client.GetBlob(context.Background(), "bafytest") 1048 1049 if err == nil { 1050 t.Error("Expected error from GetBlob, got nil") 1051 } 1052 if !strings.Contains(err.Error(), "failed with status 500") { 1053 t.Errorf("Error should mention status 500, got: %v", err) 1054 } 1055} 1056 1057// TestGetBlobInvalidBase64 tests error handling for invalid base64 in JSON-wrapped blob 1058func TestGetBlobInvalidBase64(t *testing.T) { 1059 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1060 // Return JSON string with invalid base64 1061 w.WriteHeader(http.StatusOK) 1062 w.Write([]byte(`"not-valid-base64!!!"`)) 1063 })) 1064 defer server.Close() 1065 1066 client := NewClient(server.URL, "did:plc:test123", "test-token") 1067 _, err := client.GetBlob(context.Background(), "bafytest") 1068 1069 if err == nil { 1070 t.Error("Expected error from GetBlob with invalid base64, got nil") 1071 } 1072 if !strings.Contains(err.Error(), "base64") { 1073 t.Errorf("Error should mention base64, got: %v", err) 1074 } 1075}