From 35ab1d1040b143abb9031609d1e705d82ddcafe7 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 2 Mar 2026 21:39:22 +0800 Subject: [PATCH] Refactor GitHub workflows for Registry Client SDK tests - Add environment variables for registry initialization in both `pr-test.yml` and `unit-test.yml`. - Implement a waiting mechanism for the registry service to ensure readiness before executing tests. - Remove redundant steps for user creation and service restart, streamlining the test setup process. - Enhance the client code to support auto-discovery of the API prefix, improving flexibility in server URL configuration. - Introduce comprehensive error handling in client tests to cover various edge cases and improve test coverage. --- .github/workflows/pr-test.yml | 31 ++-- .github/workflows/unit-test.yml | 31 ++-- registry/README.md | 56 +++++++ registry/client.go | 100 ++++++++---- registry/client_test.go | 281 ++++++++++++++++++++++++++++++++ 5 files changed, 430 insertions(+), 69 deletions(-) create mode 100644 registry/README.md diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 02684d51..e9e87832 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1378,6 +1378,9 @@ jobs: image: yaoapp/registry:latest ports: - "8080:8080" + env: + REGISTRY_INIT_USER: yaoagents + REGISTRY_INIT_PASS: yaoagents strategy: matrix: go: ["1.25"] @@ -1428,6 +1431,17 @@ jobs: body: '🤖 Registry Client SDK Tests running...' }); + - name: Wait for Registry + run: | + for i in $(seq 1 15); do + if curl -sf http://localhost:8080/.well-known/yao-registry > /dev/null 2>&1; then + echo "Registry is ready" + break + fi + echo "Waiting for registry... ($i)" + sleep 1 + done + - name: Checkout Kun uses: actions/checkout@v4 with: @@ -1493,23 +1507,6 @@ jobs: with: go-version: ${{ matrix.go }} - - name: Create Registry Test User - run: | - docker exec ${{ job.services.yao-registry.id }} \ - registry user add --password yaoagents yaoagents - - - name: Restart Registry Service - run: | - docker restart ${{ job.services.yao-registry.id }} - for i in $(seq 1 15); do - if curl -sf http://localhost:8080/.well-known/yao-registry > /dev/null 2>&1; then - echo "Registry is ready" - break - fi - echo "Waiting for registry... ($i)" - sleep 1 - done - - name: Run Registry Client Tests env: YAO_REGISTRY_URL: http://localhost:8080 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 42861eea..93d8cb46 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -1042,10 +1042,24 @@ jobs: image: yaoapp/registry:latest ports: - "8080:8080" + env: + REGISTRY_INIT_USER: yaoagents + REGISTRY_INIT_PASS: yaoagents strategy: matrix: go: ["1.25"] steps: + - name: Wait for Registry + run: | + for i in $(seq 1 15); do + if curl -sf http://localhost:8080/.well-known/yao-registry > /dev/null 2>&1; then + echo "Registry is ready" + break + fi + echo "Waiting for registry... ($i)" + sleep 1 + done + - name: Checkout Kun uses: actions/checkout@v4 with: @@ -1109,23 +1123,6 @@ jobs: with: go-version: ${{ matrix.go }} - - name: Create Registry Test User - run: | - docker exec ${{ job.services.yao-registry.id }} \ - registry user add --password yaoagents yaoagents - - - name: Restart Registry Service - run: | - docker restart ${{ job.services.yao-registry.id }} - for i in $(seq 1 15); do - if curl -sf http://localhost:8080/.well-known/yao-registry > /dev/null 2>&1; then - echo "Registry is ready" - break - fi - echo "Waiting for registry... ($i)" - sleep 1 - done - - name: Run Registry Client Tests env: YAO_REGISTRY_URL: http://localhost:8080 diff --git a/registry/README.md b/registry/README.md new file mode 100644 index 00000000..821778d4 --- /dev/null +++ b/registry/README.md @@ -0,0 +1,56 @@ +# registry + +Go client SDK for [Yao Registry](https://github.com/YaoApp/registry). + +## Usage + +```go +import "github.com/yaoapp/yao/registry" + +// Only the server URL is required. API prefix is auto-discovered +// via /.well-known/yao-registry on the first call. +c := registry.New("https://registry.yaoagents.com", + registry.WithAuth("user", "pass"), // optional, for push/delete +) + +// Push a .yao.zip package +result, err := c.Push("assistants", "@yao", "hello", "1.0.0", zipBytes) + +// Pull (by version or dist-tag) +data, digest, err := c.Pull("assistants", "@yao", "hello", "latest") + +// Query +pack, err := c.GetPackument("assistants", "@yao", "hello") +ver, err := c.GetVersion("assistants", "@yao", "hello", "1.0.0") +list, err := c.Search("hello", "assistants", 1, 20) + +// Dependencies +deps, err := c.GetDependencies("assistants", "@yao", "hello", "1.0.0", true) + +// Dist-tags +c.SetTag("assistants", "@yao", "hello", "stable", "1.0.0") +c.DeleteTag("assistants", "@yao", "hello", "stable") + +// Delete +c.DeleteVersion("assistants", "@yao", "hello", "1.0.0") +``` + +## Options + +| Option | Description | +|--------|-------------| +| `WithAuth(user, pass)` | Basic Auth for push/delete | +| `WithHTTPClient(hc)` | Custom `*http.Client` | +| `WithTimeout(d)` | HTTP timeout | + +## Environment + +Tests require a running registry server. Set `YAO_REGISTRY_URL` (default `http://localhost:8080`) and create user `yaoagents`/`yaoagents`. + +```bash +# Start registry with test user +REGISTRY_INIT_USER=yaoagents REGISTRY_INIT_PASS=yaoagents registry start + +# Run tests +go test ./registry/... -v +``` diff --git a/registry/client.go b/registry/client.go index 069846bf..2f23e6f0 100644 --- a/registry/client.go +++ b/registry/client.go @@ -11,15 +11,20 @@ import ( "net/http" "net/url" "strings" + "sync" "time" ) // Client talks to a Yao Registry server over HTTP. +// On first API call it discovers the API prefix via /.well-known/yao-registry +// so callers only need to provide the server root URL (e.g. "https://registry.yaoagents.com"). type Client struct { - baseURL string - username string - password string - httpClient *http.Client + baseURL string + apiPrefix string // resolved from well-known, e.g. "/v1" + discoverOnce sync.Once + username string + password string + httpClient *http.Client } // Option configures a Client. @@ -43,10 +48,13 @@ func WithTimeout(d time.Duration) Option { return func(c *Client) { c.httpClient.Timeout = d } } -// New creates a registry client. serverURL is the base URL without trailing slash. +// New creates a registry client. serverURL is the root URL users configure, +// e.g. "http://localhost:8080" or "https://registry.yaoagents.com". +// The actual API prefix is auto-discovered via /.well-known/yao-registry. func New(serverURL string, opts ...Option) *Client { c := &Client{ baseURL: strings.TrimRight(serverURL, "/"), + apiPrefix: "/v1", // sensible default, overridden by discovery httpClient: &http.Client{Timeout: 60 * time.Second}, } for _, o := range opts { @@ -55,6 +63,16 @@ func New(serverURL string, opts ...Option) *Client { return c } +// ensureDiscovered runs well-known discovery exactly once (thread-safe). +func (c *Client) ensureDiscovered() { + c.discoverOnce.Do(func() { + var info RegistryInfo + if err := c.doGet("/.well-known/yao-registry", nil, &info); err == nil && info.Registry.API != "" { + c.apiPrefix = strings.TrimRight(info.Registry.API, "/") + } + }) +} + // --- Response types --- // RegistryInfo is returned by the discovery endpoint. @@ -184,16 +202,17 @@ func (e *APIError) Error() string { // Discover calls GET /.well-known/yao-registry. func (c *Client) Discover() (*RegistryInfo, error) { var info RegistryInfo - if err := c.get("/.well-known/yao-registry", nil, &info); err != nil { + if err := c.doGet("/.well-known/yao-registry", nil, &info); err != nil { return nil, err } return &info, nil } -// Info calls GET /v1/. +// Info calls GET {apiPrefix}/. func (c *Client) Info() (*ServerInfo, error) { + c.ensureDiscovered() var info ServerInfo - if err := c.get("/v1/", nil, &info); err != nil { + if err := c.doGet(c.apiPrefix+"/", nil, &info); err != nil { return nil, err } return &info, nil @@ -201,8 +220,9 @@ func (c *Client) Info() (*ServerInfo, error) { // --- List & Search --- -// List calls GET /v1/:type with optional filters. +// List calls GET {apiPrefix}/:type with optional filters. func (c *Client) List(pkgType string, scope string, query string, page, pageSize int) (*ListResult, error) { + c.ensureDiscovered() params := url.Values{} if scope != "" { params.Set("scope", scope) @@ -217,14 +237,15 @@ func (c *Client) List(pkgType string, scope string, query string, page, pageSize params.Set("pagesize", fmt.Sprintf("%d", pageSize)) } var result ListResult - if err := c.get("/v1/"+pkgType, params, &result); err != nil { + if err := c.doGet(c.apiPrefix+"/"+pkgType, params, &result); err != nil { return nil, err } return &result, nil } -// Search calls GET /v1/search. +// Search calls GET {apiPrefix}/search. func (c *Client) Search(q string, pkgType string, page, pageSize int) (*ListResult, error) { + c.ensureDiscovered() params := url.Values{"q": {q}} if pkgType != "" { params.Set("type", pkgType) @@ -236,7 +257,7 @@ func (c *Client) Search(q string, pkgType string, page, pageSize int) (*ListResu params.Set("pagesize", fmt.Sprintf("%d", pageSize)) } var result ListResult - if err := c.get("/v1/search", params, &result); err != nil { + if err := c.doGet(c.apiPrefix+"/search", params, &result); err != nil { return nil, err } return &result, nil @@ -244,21 +265,23 @@ func (c *Client) Search(q string, pkgType string, page, pageSize int) (*ListResu // --- Package metadata --- -// GetPackument calls GET /v1/:type/:scope/:name. +// GetPackument calls GET {apiPrefix}/:type/:scope/:name. func (c *Client) GetPackument(pkgType, scope, name string) (*Packument, error) { + c.ensureDiscovered() var p Packument - path := fmt.Sprintf("/v1/%s/%s/%s", pkgType, scope, name) - if err := c.get(path, nil, &p); err != nil { + path := fmt.Sprintf("%s/%s/%s/%s", c.apiPrefix, pkgType, scope, name) + if err := c.doGet(path, nil, &p); err != nil { return nil, err } return &p, nil } -// GetVersion calls GET /v1/:type/:scope/:name/:version. +// GetVersion calls GET {apiPrefix}/:type/:scope/:name/:version. func (c *Client) GetVersion(pkgType, scope, name, version string) (*VersionDetail, error) { + c.ensureDiscovered() var v VersionDetail - path := fmt.Sprintf("/v1/%s/%s/%s/%s", pkgType, scope, name, version) - if err := c.get(path, nil, &v); err != nil { + path := fmt.Sprintf("%s/%s/%s/%s/%s", c.apiPrefix, pkgType, scope, name, version) + if err := c.doGet(path, nil, &v); err != nil { return nil, err } return &v, nil @@ -266,25 +289,27 @@ func (c *Client) GetVersion(pkgType, scope, name, version string) (*VersionDetai // --- Dependencies --- -// GetDependencies calls GET /v1/:type/:scope/:name/:version/dependencies. +// GetDependencies calls GET {apiPrefix}/:type/:scope/:name/:version/dependencies. func (c *Client) GetDependencies(pkgType, scope, name, version string, recursive bool) (*DependencyList, error) { - path := fmt.Sprintf("/v1/%s/%s/%s/%s/dependencies", pkgType, scope, name, version) + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/%s/dependencies", c.apiPrefix, pkgType, scope, name, version) params := url.Values{} if recursive { params.Set("recursive", "true") } var dl DependencyList - if err := c.get(path, params, &dl); err != nil { + if err := c.doGet(path, params, &dl); err != nil { return nil, err } return &dl, nil } -// GetDependents calls GET /v1/:type/:scope/:name/dependents. +// GetDependents calls GET {apiPrefix}/:type/:scope/:name/dependents. func (c *Client) GetDependents(pkgType, scope, name string) (*DependentList, error) { - path := fmt.Sprintf("/v1/%s/%s/%s/dependents", pkgType, scope, name) + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/dependents", c.apiPrefix, pkgType, scope, name) var dl DependentList - if err := c.get(path, nil, &dl); err != nil { + if err := c.doGet(path, nil, &dl); err != nil { return nil, err } return &dl, nil @@ -292,9 +317,10 @@ func (c *Client) GetDependents(pkgType, scope, name string) (*DependentList, err // --- Push & Pull --- -// Push uploads a .yao.zip package via PUT /v1/:type/:scope/:name/:version. +// Push uploads a .yao.zip package via PUT {apiPrefix}/:type/:scope/:name/:version. func (c *Client) Push(pkgType, scope, name, version string, zipData []byte) (*PushResult, error) { - path := fmt.Sprintf("/v1/%s/%s/%s/%s", pkgType, scope, name, version) + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/%s", c.apiPrefix, pkgType, scope, name, version) req, err := http.NewRequest(http.MethodPut, c.baseURL+path, bytes.NewReader(zipData)) if err != nil { return nil, err @@ -319,10 +345,11 @@ func (c *Client) Push(pkgType, scope, name, version string, zipData []byte) (*Pu return &result, nil } -// Pull downloads a .yao.zip via GET /v1/:type/:scope/:name/:version/pull. +// Pull downloads a .yao.zip via GET {apiPrefix}/:type/:scope/:name/:version/pull. // The version parameter can be a semver or a dist-tag name. func (c *Client) Pull(pkgType, scope, name, version string) ([]byte, string, error) { - path := fmt.Sprintf("/v1/%s/%s/%s/%s/pull", pkgType, scope, name, version) + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/%s/pull", c.apiPrefix, pkgType, scope, name, version) resp, err := c.httpClient.Get(c.baseURL + path) if err != nil { return nil, "", err @@ -343,9 +370,10 @@ func (c *Client) Pull(pkgType, scope, name, version string) ([]byte, string, err // --- Tags --- -// SetTag calls PUT /v1/:type/:scope/:name/tags/:tag. +// SetTag calls PUT {apiPrefix}/:type/:scope/:name/tags/:tag. func (c *Client) SetTag(pkgType, scope, name, tag, version string) (*TagResult, error) { - path := fmt.Sprintf("/v1/%s/%s/%s/tags/%s", pkgType, scope, name, tag) + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/tags/%s", c.apiPrefix, pkgType, scope, name, tag) body, _ := json.Marshal(map[string]string{"version": version}) req, err := http.NewRequest(http.MethodPut, c.baseURL+path, bytes.NewReader(body)) @@ -372,9 +400,10 @@ func (c *Client) SetTag(pkgType, scope, name, tag, version string) (*TagResult, return &result, nil } -// DeleteTag calls DELETE /v1/:type/:scope/:name/tags/:tag. +// DeleteTag calls DELETE {apiPrefix}/:type/:scope/:name/tags/:tag. func (c *Client) DeleteTag(pkgType, scope, name, tag string) (*TagDeleteResult, error) { - path := fmt.Sprintf("/v1/%s/%s/%s/tags/%s", pkgType, scope, name, tag) + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/tags/%s", c.apiPrefix, pkgType, scope, name, tag) req, err := http.NewRequest(http.MethodDelete, c.baseURL+path, nil) if err != nil { return nil, err @@ -400,9 +429,10 @@ func (c *Client) DeleteTag(pkgType, scope, name, tag string) (*TagDeleteResult, // --- Delete --- -// DeleteVersion calls DELETE /v1/:type/:scope/:name/:version. +// DeleteVersion calls DELETE {apiPrefix}/:type/:scope/:name/:version. func (c *Client) DeleteVersion(pkgType, scope, name, version string) (*DeleteResult, error) { - path := fmt.Sprintf("/v1/%s/%s/%s/%s", pkgType, scope, name, version) + c.ensureDiscovered() + path := fmt.Sprintf("%s/%s/%s/%s/%s", c.apiPrefix, pkgType, scope, name, version) req, err := http.NewRequest(http.MethodDelete, c.baseURL+path, nil) if err != nil { return nil, err @@ -434,7 +464,7 @@ func (c *Client) setAuth(req *http.Request) { } } -func (c *Client) get(path string, params url.Values, out interface{}) error { +func (c *Client) doGet(path string, params url.Values, out interface{}) error { u := c.baseURL + path if len(params) > 0 { u += "?" + params.Encode() diff --git a/registry/client_test.go b/registry/client_test.go index 7277b544..4940a8eb 100644 --- a/registry/client_test.go +++ b/registry/client_test.go @@ -2,6 +2,7 @@ package registry_test import ( "net/http" + "net/http/httptest" "os" "testing" "time" @@ -705,6 +706,88 @@ func TestDeleteNonExistentTag(t *testing.T) { c.DeleteVersion("assistants", testScope, name, "1.0.0") } +// --- Push / Delete error responses --- + +func TestPushDuplicateVersion(t *testing.T) { + c := newClient() + zipData, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "assistant", Scope: testScope, Name: "dup-push", Version: "1.0.0", + }, nil) + + defer cleanup(c, "assistants", testScope, "dup-push", "1.0.0") + + _, err := c.Push("assistants", testScope, "dup-push", "1.0.0", zipData) + if err != nil { + t.Fatalf("first push failed: %v", err) + } + + _, err = c.Push("assistants", testScope, "dup-push", "1.0.0", zipData) + if err == nil { + t.Fatal("expected error on duplicate push") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 409 { + t.Errorf("expected 409, got %d", apiErr.StatusCode) + } + + c.DeleteVersion("assistants", testScope, "dup-push", "1.0.0") +} + +func TestDeleteNonExistentVersion(t *testing.T) { + c := newClient() + _, err := c.DeleteVersion("assistants", testScope, "never-existed", "9.9.9") + if err == nil { + t.Fatal("expected error deleting non-existent version") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 404 { + t.Errorf("expected 404, got %d", apiErr.StatusCode) + } +} + +func TestSetTagNonExistentPackage(t *testing.T) { + c := newClient() + _, err := c.SetTag("assistants", testScope, "no-such-pkg", "beta", "1.0.0") + if err == nil { + t.Fatal("expected error setting tag on non-existent package") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 404 { + t.Errorf("expected 404, got %d", apiErr.StatusCode) + } +} + +func TestListWithQueryFilter(t *testing.T) { + c := newClient() + zipData, _ := testdata.BuildZip(&testdata.Manifest{ + Type: "mcp", Scope: testScope, Name: "list-query-mcp", Version: "1.0.0", + Description: "filterable mcp tool", + }, nil) + + defer cleanup(c, "mcps", testScope, "list-query-mcp", "1.0.0") + + c.Push("mcps", testScope, "list-query-mcp", "1.0.0", zipData) + + list, err := c.List("mcps", testScope, "filterable", 1, 10) + if err != nil { + t.Fatalf("List with scope+query failed: %v", err) + } + if list.Total < 1 { + t.Errorf("expected at least 1 result, got %d", list.Total) + } + + c.DeleteVersion("mcps", testScope, "list-query-mcp", "1.0.0") +} + // --- Release type CRUD --- func TestReleaseCRUD(t *testing.T) { @@ -759,3 +842,201 @@ func TestReleaseCRUD(t *testing.T) { t.Fatalf("Delete failed: %v", err) } } + +// --- httptest-based edge-case coverage --- + +func TestParseErrorNonJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("plain text error")) + })) + defer srv.Close() + + c := registry.New(srv.URL, registry.WithAuth("u", "p")) + _, err := c.Push("assistants", "@t", "x", "1.0.0", []byte("data")) + if err == nil { + t.Fatal("expected error") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.Message != "plain text error" { + t.Errorf("expected plain text body in message, got %q", apiErr.Message) + } +} + +func TestInvalidBaseURL(t *testing.T) { + c := registry.New("http://invalid\x7f:8080", registry.WithAuth("u", "p")) + + _, err := c.Push("assistants", "@t", "x", "1.0.0", []byte("zip")) + if err == nil { + t.Error("expected error from Push with invalid URL") + } + + _, err = c.SetTag("assistants", "@t", "x", "beta", "1.0.0") + if err == nil { + t.Error("expected error from SetTag with invalid URL") + } + + _, err = c.DeleteTag("assistants", "@t", "x", "beta") + if err == nil { + t.Error("expected error from DeleteTag with invalid URL") + } + + _, err = c.DeleteVersion("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Error("expected error from DeleteVersion with invalid URL") + } +} + +func TestPullNonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"forbidden"}`)) + })) + defer srv.Close() + + c := registry.New(srv.URL) + _, _, err := c.Pull("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Fatal("expected error for forbidden pull") + } + apiErr, ok := err.(*registry.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 403 { + t.Errorf("expected 403, got %d", apiErr.StatusCode) + } +} + +func TestSetTagDeleteTagError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"bad request"}`)) + })) + defer srv.Close() + + c := registry.New(srv.URL, registry.WithAuth("u", "p")) + + _, err := c.SetTag("assistants", "@t", "x", "beta", "1.0.0") + if err == nil { + t.Error("expected error from SetTag") + } + + _, err = c.DeleteTag("assistants", "@t", "x", "beta") + if err == nil { + t.Error("expected error from DeleteTag") + } +} + +func TestDeleteVersionError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + w.Write([]byte(`{"error":"conflict"}`)) + })) + defer srv.Close() + + c := registry.New(srv.URL, registry.WithAuth("u", "p")) + _, err := c.DeleteVersion("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Fatal("expected error") + } +} + +func TestMalformedResponseBody(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == http.MethodPut && r.URL.Path != "/tags/" && callCount <= 2: + w.WriteHeader(http.StatusCreated) + w.Write([]byte("not-json")) + case r.Method == http.MethodPut: + w.WriteHeader(http.StatusOK) + w.Write([]byte("not-json")) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusOK) + w.Write([]byte("not-json")) + default: + w.WriteHeader(http.StatusOK) + w.Write([]byte("not-json")) + } + })) + defer srv.Close() + + c := registry.New(srv.URL, registry.WithAuth("u", "p")) + + _, err := c.Push("assistants", "@t", "x", "1.0.0", []byte("zip")) + if err == nil { + t.Error("expected decode error from Push") + } + + _, err = c.SetTag("assistants", "@t", "x", "beta", "1.0.0") + if err == nil { + t.Error("expected decode error from SetTag") + } + + _, err = c.DeleteTag("assistants", "@t", "x", "beta") + if err == nil { + t.Error("expected decode error from DeleteTag") + } + + _, err = c.DeleteVersion("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Error("expected decode error from DeleteVersion") + } +} + +func TestPullReadBodyError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "99999") + w.WriteHeader(http.StatusOK) + w.Write([]byte("short")) + })) + defer srv.Close() + + c := registry.New(srv.URL) + data, _, err := c.Pull("assistants", "@t", "x", "1.0.0") + if err != nil { + t.Logf("got expected error: %v", err) + return + } + if len(data) == 99999 { + t.Error("expected incomplete read") + } +} + +func TestDoTransportError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + closedURL := srv.URL + srv.Close() + + c := registry.New(closedURL, registry.WithAuth("u", "p")) + + _, err := c.Push("assistants", "@t", "x", "1.0.0", []byte("zip")) + if err == nil { + t.Error("expected transport error from Push") + } + + _, _, err = c.Pull("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Error("expected transport error from Pull") + } + + _, err = c.SetTag("assistants", "@t", "x", "beta", "1.0.0") + if err == nil { + t.Error("expected transport error from SetTag") + } + + _, err = c.DeleteTag("assistants", "@t", "x", "beta") + if err == nil { + t.Error("expected transport error from DeleteTag") + } + + _, err = c.DeleteVersion("assistants", "@t", "x", "1.0.0") + if err == nil { + t.Error("expected transport error from DeleteVersion") + } +}