package atp import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" ) func TestNewClient(t *testing.T) { did, err := syntax.ParseDID("did:plc:testuser123") if err != nil { t.Fatal(err) } c := NewClient(nil, did) if c.DID() != did { t.Fatalf("got DID %v, want %v", c.DID(), did) } } func TestClient_APIClient(t *testing.T) { did, _ := syntax.ParseDID("did:plc:testuser123") c := NewClient(nil, did) if c.APIClient() != nil { t.Fatal("expected nil APIClient") } } // testDID is the hardcoded DID used in all testClient-created clients. const testDID = "did:plc:testuser" // testClient creates a Client backed by a test HTTP server that responds to // XRPC endpoints with the given handler. func testClient(t *testing.T, handler http.HandlerFunc) *Client { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) did, _ := syntax.ParseDID(testDID) api := atclient.NewAPIClient(srv.URL) api.Client = srv.Client() return NewClient(api, did) } func TestClient_CreateRecord(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("expected POST, got %s", r.Method) } if r.URL.Path != "/xrpc/com.atproto.repo.createRecord" { t.Fatalf("unexpected path: %s", r.URL.Path) } var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } if body["repo"] != testDID { t.Fatalf("unexpected repo: %v", body["repo"]) } if body["collection"] != "app.bsky.feed.post" { t.Fatalf("unexpected collection: %v", body["collection"]) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "uri": "at://" + testDID + "/app.bsky.feed.post/3jxy", "cid": "bafyabc123", }) }) uri, cid, err := c.CreateRecord(t.Context(), "app.bsky.feed.post", map[string]string{"text": "hello"}) if err != nil { t.Fatal(err) } if uri != "at://"+testDID+"/app.bsky.feed.post/3jxy" { t.Fatalf("unexpected URI: %s", uri) } if cid != "bafyabc123" { t.Fatalf("unexpected CID: %s", cid) } } func TestClient_CreateRecord_Error(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ "error": "InternalServerError", "message": "something went wrong", }) }) _, _, err := c.CreateRecord(t.Context(), "app.bsky.feed.post", map[string]string{}) if err == nil { t.Fatal("expected error") } } func TestClient_CreateRecordWithRKey(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("expected POST, got %s", r.Method) } if r.URL.Path != "/xrpc/com.atproto.repo.createRecord" { t.Fatalf("unexpected path: %s", r.URL.Path) } var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } if body["rkey"] != "my-custom-key" { t.Fatalf("unexpected rkey: %v", body["rkey"]) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "uri": "at://" + testDID + "/app.bsky.feed.post/my-custom-key", "cid": "bafyabc123", }) }) uri, cid, err := c.CreateRecordWithRKey(t.Context(), "app.bsky.feed.post", "my-custom-key", map[string]string{"text": "hello"}) if err != nil { t.Fatal(err) } if uri != "at://"+testDID+"/app.bsky.feed.post/my-custom-key" { t.Fatalf("unexpected URI: %s", uri) } if cid != "bafyabc123" { t.Fatalf("unexpected CID: %s", cid) } } func TestClient_GetRecord(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Fatalf("expected GET, got %s", r.Method) } if r.URL.Path != "/xrpc/com.atproto.repo.getRecord" { t.Fatalf("unexpected path: %s", r.URL.Path) } if r.URL.Query().Get("rkey") != "3jxy" { t.Fatalf("unexpected rkey: %s", r.URL.Query().Get("rkey")) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "uri": "at://" + testDID + "/app.bsky.feed.post/3jxy", "cid": "bafyabc123", "value": map[string]string{"text": "hello world"}, }) }) rec, err := c.GetRecord(t.Context(), "app.bsky.feed.post", "3jxy") if err != nil { t.Fatal(err) } if rec.URI != "at://"+testDID+"/app.bsky.feed.post/3jxy" { t.Fatalf("unexpected URI: %s", rec.URI) } if rec.CID != "bafyabc123" { t.Fatalf("unexpected CID: %s", rec.CID) } if rec.Value["text"] != "hello world" { t.Fatalf("unexpected value: %v", rec.Value) } } func TestClient_GetRecord_Error(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{ "error": "RecordNotFound", "message": "record not found", }) }) _, err := c.GetRecord(t.Context(), "app.bsky.feed.post", "nonexistent") if err == nil { t.Fatal("expected error") } } func TestClient_ListRecords(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Fatalf("expected GET, got %s", r.Method) } if r.URL.Path != "/xrpc/com.atproto.repo.listRecords" { t.Fatalf("unexpected path: %s", r.URL.Path) } if r.URL.Query().Get("collection") != "app.bsky.feed.post" { t.Fatalf("unexpected collection: %s", r.URL.Query().Get("collection")) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "records": []map[string]any{ { "uri": "at://" + testDID + "/app.bsky.feed.post/3jxy", "cid": "cid1", "value": map[string]string{"text": "first"}, }, { "uri": "at://" + testDID + "/app.bsky.feed.post/3kfk", "cid": "cid2", "value": map[string]string{"text": "second"}, }, }, }) }) result, err := c.ListRecords(t.Context(), "app.bsky.feed.post", 0, "") if err != nil { t.Fatal(err) } if len(result.Records) != 2 { t.Fatalf("expected 2 records, got %d", len(result.Records)) } if result.Records[0].URI != "at://"+testDID+"/app.bsky.feed.post/3jxy" { t.Fatalf("unexpected first record URI: %s", result.Records[0].URI) } if result.Cursor != "" { t.Fatalf("expected empty cursor, got %s", result.Cursor) } } func TestClient_ListRecords_WithLimitAndCursor(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { cursor := r.URL.Query().Get("cursor") limit := r.URL.Query().Get("limit") w.Header().Set("Content-Type", "application/json") if cursor == "" { json.NewEncoder(w).Encode(map[string]any{ "records": []map[string]any{ {"uri": "at://" + testDID + "/app.bsky.feed.post/3jxy", "cid": "cid1", "value": map[string]string{"text": "first"}}, }, "cursor": "next-page-token", }) } else { json.NewEncoder(w).Encode(map[string]any{ "records": []map[string]any{ {"uri": "at://" + testDID + "/app.bsky.feed.post/3kfk", "cid": "cid2", "value": map[string]string{"text": "second"}}, }, }) } if limit != "100" { t.Fatalf("expected limit=100, got %s", limit) } }) result, err := c.ListRecords(t.Context(), "app.bsky.feed.post", 100, "") if err != nil { t.Fatal(err) } if len(result.Records) != 1 { t.Fatalf("expected 1 record, got %d", len(result.Records)) } if result.Cursor != "next-page-token" { t.Fatalf("expected cursor next-page-token, got %s", result.Cursor) } result, err = c.ListRecords(t.Context(), "app.bsky.feed.post", 100, "next-page-token") if err != nil { t.Fatal(err) } if len(result.Records) != 1 { t.Fatalf("expected 1 record on second page, got %d", len(result.Records)) } if result.Cursor != "" { t.Fatalf("expected empty cursor on last page, got %s", result.Cursor) } } func TestClient_ListRecords_Error(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "InvalidRequest", "message": "bad request", }) }) _, err := c.ListRecords(t.Context(), "app.bsky.feed.post", 0, "") if err == nil { t.Fatal("expected error") } } func TestClient_ListAllRecords(t *testing.T) { pageNum := 0 c := testClient(t, func(w http.ResponseWriter, r *http.Request) { pageNum++ w.Header().Set("Content-Type", "application/json") if pageNum <= 2 { json.NewEncoder(w).Encode(map[string]any{ "records": []map[string]any{ {"uri": "at://" + testDID + "/app.bsky.feed.post/3jxy", "cid": "cid1", "value": map[string]string{"text": "hello"}}, }, "cursor": "token", }) } else { json.NewEncoder(w).Encode(map[string]any{ "records": []map[string]any{ {"uri": "at://" + testDID + "/app.bsky.feed.post/3kfk", "cid": "cid2", "value": map[string]string{"text": "world"}}, }, }) } }) records, err := c.ListAllRecords(t.Context(), "app.bsky.feed.post") if err != nil { t.Fatal(err) } if len(records) != 3 { t.Fatalf("expected 3 records total, got %d", len(records)) } } func TestClient_ListAllRecords_Cancel(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "records": []map[string]any{ {"uri": "at://" + testDID + "/app.bsky.feed.post/3jxy", "cid": "cid1", "value": map[string]string{"text": "hi"}}, }, "cursor": "keep-going", }) }) ctx, cancel := context.WithCancel(t.Context()) cancel() _, err := c.ListAllRecords(ctx, "app.bsky.feed.post") if err == nil { t.Fatal("expected context cancellation error") } } func TestClient_PutRecord(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("expected POST, got %s", r.Method) } if r.URL.Path != "/xrpc/com.atproto.repo.putRecord" { t.Fatalf("unexpected path: %s", r.URL.Path) } var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } if body["rkey"] != "3jxy" { t.Fatalf("unexpected rkey: %v", body["rkey"]) } if body["repo"] != testDID { t.Fatalf("unexpected repo: %v", body["repo"]) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "uri": "at://" + testDID + "/app.bsky.feed.post/3jxy", "cid": "bafyupdated", }) }) uri, cid, err := c.PutRecord(t.Context(), "app.bsky.feed.post", "3jxy", map[string]string{"text": "updated"}) if err != nil { t.Fatal(err) } if uri != "at://"+testDID+"/app.bsky.feed.post/3jxy" { t.Fatalf("unexpected URI: %s", uri) } if cid != "bafyupdated" { t.Fatalf("unexpected CID: %s", cid) } } func TestClient_DeleteRecord(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("expected POST, got %s", r.Method) } if r.URL.Path != "/xrpc/com.atproto.repo.deleteRecord" { t.Fatalf("unexpected path: %s", r.URL.Path) } var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } if body["rkey"] != "3jxy" { t.Fatalf("unexpected rkey: %v", body["rkey"]) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{}) }) if err := c.DeleteRecord(t.Context(), "app.bsky.feed.post", "3jxy"); err != nil { t.Fatal(err) } } func TestClient_DeleteRecord_Error(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ "error": "InternalServerError", "message": "could not delete", }) }) if err := c.DeleteRecord(t.Context(), "app.bsky.feed.post", "3jxy"); err == nil { t.Fatal("expected error") } } func TestClient_UploadBlob(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("expected POST, got %s", r.Method) } if r.URL.Path != "/xrpc/com.atproto.repo.uploadBlob" { t.Fatalf("unexpected path: %s", r.URL.Path) } if r.Header.Get("Content-Type") != "image/png" { t.Fatalf("unexpected Content-Type: %s", r.Header.Get("Content-Type")) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // LexBlob JSON: ref uses $link format json.NewEncoder(w).Encode(map[string]any{ "blob": map[string]any{ "$type": "blob", "mimeType": "image/png", "size": 12345, "ref": map[string]string{"$link": "bafkreiern4acpjlva5gookrtc534gr4nmuj7pbvfsg6yslnbuv336izv7e"}, }, }) }) blob, err := c.UploadBlob(t.Context(), []byte("fake-image-data"), "image/png") if err != nil { t.Fatal(err) } if blob.Type != "blob" { t.Fatalf("expected type blob, got %s", blob.Type) } if blob.MimeType != "image/png" { t.Fatalf("expected image/png, got %s", blob.MimeType) } if blob.Size != 12345 { t.Fatalf("expected size 12345, got %d", blob.Size) } if blob.Ref.Link != "bafkreiern4acpjlva5gookrtc534gr4nmuj7pbvfsg6yslnbuv336izv7e" { t.Fatalf("unexpected ref link: %s", blob.Ref.Link) } } func TestClient_UploadBlob_TooLarge(t *testing.T) { did, _ := syntax.ParseDID(testDID) api := atclient.NewAPIClient("http://localhost:9999") c := NewClient(api, did) _, err := c.UploadBlob(t.Context(), make([]byte, MaxBlobSize+1), "application/octet-stream") if err == nil { t.Fatal("expected error for blob exceeding MaxBlobSize") } } func TestClient_UploadBlob_Error(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { // Return a non-JSON body so json.Unmarshal into RepoUploadBlob_Output fails w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Internal Server Error")) }) _, err := c.UploadBlob(t.Context(), []byte("data"), "text/plain") if err == nil { t.Fatal("expected error") } } func TestClient_GetBlob(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Fatalf("expected GET, got %s", r.Method) } if r.URL.Path != "/xrpc/com.atproto.sync.getBlob" { t.Fatalf("unexpected path: %s", r.URL.Path) } if r.URL.Query().Get("cid") != "bafyblob123" { t.Fatalf("unexpected cid: %s", r.URL.Query().Get("cid")) } if r.URL.Query().Get("did") != testDID { t.Fatalf("unexpected did: %s", r.URL.Query().Get("did")) } w.Header().Set("Content-Type", "application/octet-stream") w.Write([]byte("fake-blob-data")) }) data, err := c.GetBlob(t.Context(), "bafyblob123") if err != nil { t.Fatal(err) } if string(data) != "fake-blob-data" { t.Fatalf("unexpected blob data: %s", string(data)) } } func TestClient_GetBlob_Error(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }) _, err := c.GetBlob(t.Context(), "nonexistent") if err == nil { t.Fatal("expected error") } } func TestClient_CreateRecordWithRKey_Error(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]string{ "error": "InvalidRecord", "message": "record already exists", }) }) _, _, err := c.CreateRecordWithRKey(t.Context(), "app.bsky.feed.post", "existing-key", map[string]string{}) if err == nil { t.Fatal("expected error for conflict") } } func TestClient_PutRecord_Error(t *testing.T) { c := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{ "error": "Forbidden", "message": "not authorized to write", }) }) _, _, err := c.PutRecord(t.Context(), "app.bsky.feed.post", "3jxy", map[string]string{}) if err == nil { t.Fatal("expected error") } }