A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}