Files
at-container-registry/pkg/atproto/client_test.go
T
2025-10-21 09:21:51 -05:00

694 lines
20 KiB
Go

package atproto
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestNewClient verifies client initialization with Basic Auth
func TestNewClient(t *testing.T) {
client := NewClient("https://pds.example.com", "did:plc:test123", "token123")
if client.pdsEndpoint != "https://pds.example.com" {
t.Errorf("pdsEndpoint = %v, want https://pds.example.com", client.pdsEndpoint)
}
if client.did != "did:plc:test123" {
t.Errorf("did = %v, want did:plc:test123", client.did)
}
if client.accessToken != "token123" {
t.Errorf("accessToken = %v, want token123", client.accessToken)
}
if client.useIndigoClient {
t.Error("useIndigoClient should be false for Basic Auth client")
}
}
// TestPutRecord tests storing a record in ATProto
func TestPutRecord(t *testing.T) {
tests := []struct {
name string
collection string
rkey string
record interface{}
serverResponse string
serverStatus int
wantErr bool
checkFunc func(*testing.T, *Record)
}{
{
name: "successful put",
collection: ManifestCollection,
rkey: "abc123",
record: map[string]string{
"$type": ManifestCollection,
"test": "value",
},
serverResponse: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest"}`,
serverStatus: http.StatusOK,
wantErr: false,
checkFunc: func(t *testing.T, r *Record) {
if r.URI != "at://did:plc:test123/io.atcr.manifest/abc123" {
t.Errorf("URI = %v, want at://did:plc:test123/io.atcr.manifest/abc123", r.URI)
}
if r.CID != "bafytest" {
t.Errorf("CID = %v, want bafytest", r.CID)
}
},
},
{
name: "server error",
collection: ManifestCollection,
rkey: "abc123",
record: map[string]string{"test": "value"},
serverResponse: `{"error":"InvalidRequest"}`,
serverStatus: http.StatusBadRequest,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request method
if r.Method != "POST" {
t.Errorf("Method = %v, want POST", r.Method)
}
// Verify path
expectedPath := RepoPutRecord
if r.URL.Path != expectedPath {
t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath)
}
// Verify Authorization header
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
t.Errorf("Authorization header missing or malformed: %v", auth)
}
// Verify request body
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("Failed to decode request body: %v", err)
}
if body["repo"] != "did:plc:test123" {
t.Errorf("repo = %v, want did:plc:test123", body["repo"])
}
if body["collection"] != tt.collection {
t.Errorf("collection = %v, want %v", body["collection"], tt.collection)
}
if body["rkey"] != tt.rkey {
t.Errorf("rkey = %v, want %v", body["rkey"], tt.rkey)
}
// Send response
w.WriteHeader(tt.serverStatus)
w.Write([]byte(tt.serverResponse))
}))
defer server.Close()
// Create client pointing to test server
client := NewClient(server.URL, "did:plc:test123", "test-token")
// Call PutRecord
result, err := client.PutRecord(context.Background(), tt.collection, tt.rkey, tt.record)
// Check error
if (err != nil) != tt.wantErr {
t.Errorf("PutRecord() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Run check function if provided
if !tt.wantErr && tt.checkFunc != nil {
tt.checkFunc(t, result)
}
})
}
}
// TestGetRecord tests retrieving a record from ATProto
func TestGetRecord(t *testing.T) {
tests := []struct {
name string
collection string
rkey string
serverResponse string
serverStatus int
wantErr bool
wantNotFound bool
checkFunc func(*testing.T, *Record)
}{
{
name: "successful get",
collection: ManifestCollection,
rkey: "abc123",
serverResponse: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{"$type":"io.atcr.manifest","repository":"myapp"}}`,
serverStatus: http.StatusOK,
wantErr: false,
checkFunc: func(t *testing.T, r *Record) {
if r.URI != "at://did:plc:test123/io.atcr.manifest/abc123" {
t.Errorf("URI = %v, want at://did:plc:test123/io.atcr.manifest/abc123", r.URI)
}
var value map[string]interface{}
if err := json.Unmarshal(r.Value, &value); err != nil {
t.Errorf("Failed to unmarshal value: %v", err)
}
if value["$type"] != ManifestCollection {
t.Errorf("value.$type = %v, want %v", value["$type"], ManifestCollection)
}
},
},
{
name: "record not found - 404",
collection: ManifestCollection,
rkey: "notfound",
serverResponse: ``,
serverStatus: http.StatusNotFound,
wantErr: true,
wantNotFound: true,
},
{
name: "record not found - error message",
collection: ManifestCollection,
rkey: "notfound",
serverResponse: `{"error":"RecordNotFound","message":"Record not found"}`,
serverStatus: http.StatusBadRequest,
wantErr: true,
wantNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request method
if r.Method != "GET" {
t.Errorf("Method = %v, want GET", r.Method)
}
// Verify path
expectedPath := RepoGetRecord
if r.URL.Path != expectedPath {
t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath)
}
// Verify query parameters
query := r.URL.Query()
if query.Get("repo") != "did:plc:test123" {
t.Errorf("repo = %v, want did:plc:test123", query.Get("repo"))
}
if query.Get("collection") != tt.collection {
t.Errorf("collection = %v, want %v", query.Get("collection"), tt.collection)
}
if query.Get("rkey") != tt.rkey {
t.Errorf("rkey = %v, want %v", query.Get("rkey"), tt.rkey)
}
// Send response
w.WriteHeader(tt.serverStatus)
w.Write([]byte(tt.serverResponse))
}))
defer server.Close()
// Create client pointing to test server
client := NewClient(server.URL, "did:plc:test123", "test-token")
// Call GetRecord
result, err := client.GetRecord(context.Background(), tt.collection, tt.rkey)
// Check error
if (err != nil) != tt.wantErr {
t.Errorf("GetRecord() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Check for ErrRecordNotFound
if tt.wantNotFound && err != ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
// Run check function if provided
if !tt.wantErr && tt.checkFunc != nil {
tt.checkFunc(t, result)
}
})
}
}
// TestDeleteRecord tests deleting a record from ATProto
func TestDeleteRecord(t *testing.T) {
tests := []struct {
name string
collection string
rkey string
serverResponse string
serverStatus int
wantErr bool
}{
{
name: "successful delete",
collection: ManifestCollection,
rkey: "abc123",
serverResponse: `{}`,
serverStatus: http.StatusOK,
wantErr: false,
},
{
name: "server error",
collection: ManifestCollection,
rkey: "abc123",
serverResponse: `{"error":"InvalidRequest"}`,
serverStatus: http.StatusBadRequest,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request method
if r.Method != "POST" {
t.Errorf("Method = %v, want POST", r.Method)
}
// Verify path
expectedPath := RepoDeleteRecord
if r.URL.Path != expectedPath {
t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath)
}
// Verify request body
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("Failed to decode request body: %v", err)
}
if body["repo"] != "did:plc:test123" {
t.Errorf("repo = %v, want did:plc:test123", body["repo"])
}
if body["collection"] != tt.collection {
t.Errorf("collection = %v, want %v", body["collection"], tt.collection)
}
if body["rkey"] != tt.rkey {
t.Errorf("rkey = %v, want %v", body["rkey"], tt.rkey)
}
// Send response
w.WriteHeader(tt.serverStatus)
w.Write([]byte(tt.serverResponse))
}))
defer server.Close()
// Create client pointing to test server
client := NewClient(server.URL, "did:plc:test123", "test-token")
// Call DeleteRecord
err := client.DeleteRecord(context.Background(), tt.collection, tt.rkey)
// Check error
if (err != nil) != tt.wantErr {
t.Errorf("DeleteRecord() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// TestListRecords tests listing records in a collection
func TestListRecords(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify query parameters
query := r.URL.Query()
if query.Get("repo") != "did:plc:test123" {
t.Errorf("repo = %v, want did:plc:test123", query.Get("repo"))
}
if query.Get("collection") != ManifestCollection {
t.Errorf("collection = %v, want %v", query.Get("collection"), ManifestCollection)
}
if query.Get("limit") != "10" {
t.Errorf("limit = %v, want 10", query.Get("limit"))
}
// Send response
response := `{
"records": [
{"uri":"at://did:plc:test123/io.atcr.manifest/abc1","cid":"bafytest1","value":{"$type":"io.atcr.manifest"}},
{"uri":"at://did:plc:test123/io.atcr.manifest/abc2","cid":"bafytest2","value":{"$type":"io.atcr.manifest"}}
]
}`
w.WriteHeader(http.StatusOK)
w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL, "did:plc:test123", "test-token")
records, err := client.ListRecords(context.Background(), ManifestCollection, 10)
if err != nil {
t.Fatalf("ListRecords() error = %v", err)
}
if len(records) != 2 {
t.Errorf("len(records) = %v, want 2", len(records))
}
if records[0].URI != "at://did:plc:test123/io.atcr.manifest/abc1" {
t.Errorf("records[0].URI = %v", records[0].URI)
}
}
// TestUploadBlob tests uploading a blob to PDS
func TestUploadBlob(t *testing.T) {
blobData := []byte("test blob content")
mimeType := "application/octet-stream"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
if r.Method != "POST" {
t.Errorf("Method = %v, want POST", r.Method)
}
if r.URL.Path != RepoUploadBlob {
t.Errorf("Path = %v, want %s", r.URL.Path, RepoUploadBlob)
}
if r.Header.Get("Content-Type") != mimeType {
t.Errorf("Content-Type = %v, want %v", r.Header.Get("Content-Type"), mimeType)
}
// Send response
response := `{
"blob": {
"$type": "blob",
"ref": {"$link": "bafytest123"},
"mimeType": "application/octet-stream",
"size": 17
}
}`
w.WriteHeader(http.StatusOK)
w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL, "did:plc:test123", "test-token")
blobRef, err := client.UploadBlob(context.Background(), blobData, mimeType)
if err != nil {
t.Fatalf("UploadBlob() error = %v", err)
}
if blobRef.Type != "blob" {
t.Errorf("Type = %v, want blob", blobRef.Type)
}
if blobRef.Ref.Link != "bafytest123" {
t.Errorf("Ref.Link = %v, want bafytest123", blobRef.Ref.Link)
}
if blobRef.Size != 17 {
t.Errorf("Size = %v, want 17", blobRef.Size)
}
}
// TestGetBlob tests downloading a blob from PDS
func TestGetBlob(t *testing.T) {
tests := []struct {
name string
cid string
serverResponse string
contentType string
wantData []byte
wantErr bool
}{
{
name: "raw blob response",
cid: "bafytest123",
serverResponse: "test blob content",
contentType: "application/octet-stream",
wantData: []byte("test blob content"),
wantErr: false,
},
{
name: "JSON-wrapped blob (Bluesky PDS format)",
cid: "bafytest123",
serverResponse: `"dGVzdCBibG9iIGNvbnRlbnQ="`, // base64 of "test blob content"
contentType: "application/json",
wantData: []byte("test blob content"),
wantErr: false,
},
{
name: "blob not found",
cid: "notfound",
serverResponse: "",
contentType: "text/plain",
wantData: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify query parameters
query := r.URL.Query()
if query.Get("did") != "did:plc:test123" {
t.Errorf("did = %v, want did:plc:test123", query.Get("did"))
}
if query.Get("cid") != tt.cid {
t.Errorf("cid = %v, want %v", query.Get("cid"), tt.cid)
}
// Send response
if tt.wantErr {
w.WriteHeader(http.StatusNotFound)
} else {
w.Header().Set("Content-Type", tt.contentType)
w.WriteHeader(http.StatusOK)
w.Write([]byte(tt.serverResponse))
}
}))
defer server.Close()
client := NewClient(server.URL, "did:plc:test123", "test-token")
data, err := client.GetBlob(context.Background(), tt.cid)
if (err != nil) != tt.wantErr {
t.Errorf("GetBlob() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && string(data) != string(tt.wantData) {
t.Errorf("GetBlob() data = %v, want %v", string(data), string(tt.wantData))
}
})
}
}
// TestBlobCDNURL tests CDN URL construction
func TestBlobCDNURL(t *testing.T) {
tests := []struct {
name string
didOrHandle string
cid string
want string
}{
{
name: "with DID",
didOrHandle: "did:plc:alice123",
cid: "bafytest123",
want: "https://imgs.blue/did:plc:alice123/bafytest123",
},
{
name: "with handle",
didOrHandle: "alice.bsky.social",
cid: "bafytest456",
want: "https://imgs.blue/alice.bsky.social/bafytest456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BlobCDNURL(tt.didOrHandle, tt.cid)
if got != tt.want {
t.Errorf("BlobCDNURL() = %v, want %v", got, tt.want)
}
})
}
}
// TestFetchDIDDocument tests fetching and parsing DID documents
func TestFetchDIDDocument(t *testing.T) {
tests := []struct {
name string
serverResponse string
serverStatus int
wantErr bool
checkFunc func(*testing.T, *DIDDocument)
}{
{
name: "valid DID document",
serverResponse: `{
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "did:web:example.com",
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}
]
}`,
serverStatus: http.StatusOK,
wantErr: false,
checkFunc: func(t *testing.T, doc *DIDDocument) {
if doc.ID != "did:web:example.com" {
t.Errorf("ID = %v, want did:web:example.com", doc.ID)
}
if len(doc.Service) != 1 {
t.Fatalf("len(Service) = %v, want 1", len(doc.Service))
}
if doc.Service[0].Type != "AtprotoPersonalDataServer" {
t.Errorf("Service[0].Type = %v", doc.Service[0].Type)
}
if doc.Service[0].ServiceEndpoint != "https://pds.example.com" {
t.Errorf("Service[0].ServiceEndpoint = %v", doc.Service[0].ServiceEndpoint)
}
},
},
{
name: "404 not found",
serverResponse: "",
serverStatus: http.StatusNotFound,
wantErr: true,
},
{
name: "invalid JSON",
serverResponse: "not json",
serverStatus: http.StatusOK,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.serverStatus)
w.Write([]byte(tt.serverResponse))
}))
defer server.Close()
client := NewClient("https://pds.example.com", "did:plc:test123", "")
doc, err := client.FetchDIDDocument(context.Background(), server.URL)
if (err != nil) != tt.wantErr {
t.Errorf("FetchDIDDocument() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkFunc != nil {
tt.checkFunc(t, doc)
}
})
}
}
// TestClientWithEmptyToken tests that client doesn't set auth header with empty token
func TestClientWithEmptyToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth != "" {
t.Errorf("Authorization header should not be set with empty token, got: %v", auth)
}
response := `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{}}`
w.WriteHeader(http.StatusOK)
w.Write([]byte(response))
}))
defer server.Close()
// Create client with empty token
client := NewClient(server.URL, "did:plc:test123", "")
// Make request - should not include Authorization header
_, err := client.GetRecord(context.Background(), ManifestCollection, "abc123")
if err != nil {
t.Fatalf("GetRecord() error = %v", err)
}
}
// TestListRecordsForRepo tests listing records for a specific repository
func TestListRecordsForRepo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
if query.Get("repo") != "did:plc:alice123" {
t.Errorf("repo = %v, want did:plc:alice123", query.Get("repo"))
}
if query.Get("collection") != ManifestCollection {
t.Errorf("collection = %v, want %v", query.Get("collection"), ManifestCollection)
}
if query.Get("limit") != "50" {
t.Errorf("limit = %v, want 50", query.Get("limit"))
}
if query.Get("cursor") != "cursor123" {
t.Errorf("cursor = %v, want cursor123", query.Get("cursor"))
}
response := `{
"records": [
{"uri":"at://did:plc:alice123/io.atcr.manifest/abc1","cid":"bafytest1","value":{}}
],
"cursor": "nextcursor456"
}`
w.WriteHeader(http.StatusOK)
w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL, "did:plc:test123", "test-token")
records, cursor, err := client.ListRecordsForRepo(context.Background(), "did:plc:alice123", ManifestCollection, 50, "cursor123")
if err != nil {
t.Fatalf("ListRecordsForRepo() error = %v", err)
}
if len(records) != 1 {
t.Errorf("len(records) = %v, want 1", len(records))
}
if cursor != "nextcursor456" {
t.Errorf("cursor = %v, want nextcursor456", cursor)
}
}
// TestContextCancellation tests that client respects context cancellation
func TestContextCancellation(t *testing.T) {
// Create a server that sleeps for a while
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}))
defer server.Close()
client := NewClient(server.URL, "did:plc:test123", "test-token")
// Create a context that gets canceled immediately
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
// Request should fail with context canceled error
_, err := client.GetRecord(ctx, ManifestCollection, "abc123")
if err == nil {
t.Error("Expected error due to context cancellation, got nil")
}
}