aboutsummaryrefslogtreecommitdiff
path: root/internal/bunny/api_test.go
diff options
context:
space:
mode:
authortsne <tsne.dev@outlook.com>2026-07-06 16:52:20 +0200
committertsne <tsne.dev@outlook.com>2026-07-06 21:37:42 +0200
commit84da43998f7cc42cc72652333ac6e3d293abb23e (patch)
tree6610f82df854ae86f7f34152bb05fc712ef6f314 /internal/bunny/api_test.go
downloadhopper-84da43998f7cc42cc72652333ac6e3d293abb23e.tar.gz
initialHEADmain
Diffstat (limited to 'internal/bunny/api_test.go')
-rw-r--r--internal/bunny/api_test.go349
1 files changed, 349 insertions, 0 deletions
diff --git a/internal/bunny/api_test.go b/internal/bunny/api_test.go
new file mode 100644
index 0000000..e74a3b9
--- /dev/null
+++ b/internal/bunny/api_test.go
@@ -0,0 +1,349 @@
+package bunny
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+func newTestAPI(srvURL string) *API {
+ api := NewAPI(srvURL, "zone", "key")
+ api.http = &http.Client{Transport: rewriteTransport{}}
+ return api
+}
+
+// rewriteTransport downgrades https://host/... to http://host/... for tests.
+type rewriteTransport struct{}
+
+func (rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req.URL.Scheme = "http"
+ return http.DefaultTransport.RoundTrip(req)
+}
+
+func TestUploadSuccess(t *testing.T) {
+ var gotKey, gotChecksum, gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotKey = r.Header.Get("AccessKey")
+ gotChecksum = r.Header.Get("Checksum")
+ gotPath = r.URL.Path
+ if r.Header.Get("Content-Type") != "" {
+ t.Errorf("unexpected Content-Type %q", r.Header.Get("Content-Type"))
+ }
+ w.WriteHeader(201)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "a/b.js", []byte("data")); err != nil {
+ t.Fatalf("Upload: %v", err)
+ }
+ sum := sha256.Sum256([]byte("data"))
+ wantChecksum := strings.ToUpper(hex.EncodeToString(sum[:]))
+ if gotKey != "key" || gotChecksum != wantChecksum {
+ t.Fatalf("headers: key=%q checksum=%q (want %q)", gotKey, gotChecksum, wantChecksum)
+ }
+ if gotPath != "/zone/a/b.js" {
+ t.Fatalf("path = %q", gotPath)
+ }
+}
+
+func TestUploadRetriesOn429WithRetryAfter(t *testing.T) {
+ var n int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if atomic.AddInt32(&n, 1) <= 2 {
+ w.Header().Set("Retry-After", "0")
+ w.WriteHeader(429)
+ return
+ }
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "x", []byte("d")); err != nil {
+ t.Fatalf("Upload: %v", err)
+ }
+ if n != 3 {
+ t.Fatalf("attempts = %d, want 3", n)
+ }
+}
+
+func TestUploadFailsOn429NoRetryAfter(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(429)
+ }))
+ defer srv.Close()
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestUploadFailsOn5xx(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ }))
+ defer srv.Close()
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestUploadRetriesExhausted(t *testing.T) {
+ var n int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&n, 1)
+ w.Header().Set("Retry-After", "0")
+ w.WriteHeader(429)
+ }))
+ defer srv.Close()
+ api := newTestAPI(srv.URL)
+ if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
+ t.Fatal("expected error")
+ }
+ if n != 4 { // initial + 3 retries
+ t.Fatalf("attempts = %d, want 4", n)
+ }
+}
+
+func TestUploadClassifiesStatus(t *testing.T) {
+ cases := []struct {
+ status int
+ kind errorKind
+ }{
+ {http.StatusUnauthorized, errorKindAuth},
+ {http.StatusForbidden, errorKindAuth},
+ {http.StatusBadRequest, errorKindInternal},
+ {http.StatusNotFound, errorKindInternal},
+ {http.StatusInternalServerError, errorKindServer},
+ {http.StatusBadGateway, errorKindServer},
+ }
+ for _, tc := range cases {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.status)
+ }))
+ api := newTestAPI(srv.URL)
+ err := api.Upload(context.Background(), "x", []byte("d"))
+ srv.Close()
+
+ var apiErr *apiError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("status %d: want *APIError, got %T (%v)", tc.status, err, err)
+ }
+ if apiErr.kind != tc.kind {
+ t.Fatalf("status %d: kind = %d, want %d", tc.status, apiErr.kind, tc.kind)
+ }
+ if apiErr.status != tc.status {
+ t.Fatalf("status %d: APIError.Status = %d", tc.status, apiErr.status)
+ }
+ }
+}
+
+func TestUploadSurfacesBunnyErrorMessage(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"HttpCode":400,"Message":"checksum mismatch"}`))
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ err := api.Upload(context.Background(), "x", []byte("d"))
+
+ var apiErr *apiError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("want *apiError, got %T (%v)", err, err)
+ }
+ if got := apiErr.ErrorVerbose(); !strings.Contains(got, "checksum mismatch") {
+ t.Fatalf("verbose detail = %q, want it to contain Bunny's Message", got)
+ }
+}
+
+func TestListParsesFullPaths(t *testing.T) {
+ var gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.Write([]byte(`[
+ {"Path":"/zone/assets/","ObjectName":"app.abc.js","Length":14,"IsDirectory":false,"LastChanged":"2023-06-29T12:00:00.000"},
+ {"Path":"/zone/assets/","ObjectName":"sub","Length":0,"IsDirectory":true,"LastChanged":"2023-06-29T12:00:00"}
+ ]`))
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ objs, err := api.listDir(context.Background(), "assets")
+ if err != nil {
+ t.Fatalf("listDir: %v", err)
+ }
+ if gotPath != "/zone/assets/" {
+ t.Fatalf("list path = %q, want trailing slash", gotPath)
+ }
+ if len(objs) != 2 {
+ t.Fatalf("objs = %v", objs)
+ }
+ if objs[0].Path != "assets/app.abc.js" || objs[0].Size != 14 || objs[0].IsDirectory {
+ t.Fatalf("file object = %+v", objs[0])
+ }
+ if objs[1].Path != "assets/sub" || !objs[1].IsDirectory {
+ t.Fatalf("dir object = %+v", objs[1])
+ }
+}
+
+func TestListRootTrailingSlash(t *testing.T) {
+ var gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.Write([]byte(`[]`))
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if _, err := api.listDir(context.Background(), ""); err != nil {
+ t.Fatalf("listDir: %v", err)
+ }
+ if gotPath != "/zone/" {
+ t.Fatalf("root list path = %q, want /zone/", gotPath)
+ }
+}
+
+func TestWalkBuildsTreeAndDescends(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/zone/":
+ w.Write([]byte(`[
+ {"Path":"/zone/","ObjectName":"index.html","Length":6,"IsDirectory":false},
+ {"Path":"/zone/","ObjectName":"assets","IsDirectory":true}
+ ]`))
+ case "/zone/assets/":
+ w.Write([]byte(`[{"Path":"/zone/assets/","ObjectName":"app.js","Length":3,"IsDirectory":false}]`))
+ default:
+ t.Errorf("unexpected list path %q", r.URL.Path)
+ }
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ it := api.List(context.Background(), "")
+ var got []Object
+ for it.Next() {
+ got = append(got, it.Object())
+ }
+ if err := it.Err(); err != nil {
+ t.Fatalf("Walk: %v", err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("got %d entries: %+v", len(got), got)
+ }
+ var sawDir, sawNested bool
+ for _, o := range got {
+ if o.Path == "assets" && o.IsDirectory {
+ sawDir = true
+ }
+ if o.Path == "assets/app.js" && !o.IsDirectory && o.Size == 3 {
+ sawNested = true
+ }
+ }
+ if !sawDir || !sawNested {
+ t.Fatalf("expected descent into assets; got %+v", got)
+ }
+}
+
+func TestWalk404IsEmpty(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(404)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ it := api.List(context.Background(), "")
+ for it.Next() {
+ t.Fatalf("unexpected entry %+v", it.Object())
+ }
+ if err := it.Err(); err != nil {
+ t.Fatalf("Walk: %v", err)
+ }
+}
+
+func TestWalkFatalOnNon404(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ it := api.List(context.Background(), "")
+ for it.Next() {
+ }
+ if it.Err() == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestUploadRateLimitedIsTyped(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(429)
+ }))
+ defer srv.Close()
+ api := newTestAPI(srv.URL)
+ err := api.Upload(context.Background(), "x", []byte("d"))
+ var apiErr *apiError
+ if !errors.As(err, &apiErr) || apiErr.kind != errorKindRateLimited {
+ t.Fatalf("want KindRateLimited APIError, got %v", err)
+ }
+}
+
+func TestDeleteSuccess(t *testing.T) {
+ var gotMethod, gotKey, gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ gotKey = r.Header.Get("AccessKey")
+ gotPath = r.URL.Path
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if err := api.Delete(context.Background(), "a/b.js"); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ if gotMethod != http.MethodDelete {
+ t.Fatalf("method = %q", gotMethod)
+ }
+ if gotKey != "key" || gotPath != "/zone/a/b.js" {
+ t.Fatalf("key=%q path=%q", gotKey, gotPath)
+ }
+}
+
+func TestDeleteFailsOn5xx(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ }))
+ defer srv.Close()
+
+ api := newTestAPI(srv.URL)
+ if err := api.Delete(context.Background(), "a/b.js"); err == nil {
+ t.Fatal("expected error on 5xx")
+ }
+}
+
+// TestAPIErrorUnwrapsCause ensures a network apiError preserves its cause in the
+// error chain, so errors.Is can detect e.g. context.Canceled from an aborted
+// in-flight upload. Without this, the worker pool cannot filter cancellation
+// collateral out of a fail-fast failure report.
+func TestAPIErrorUnwrapsCause(t *testing.T) {
+ req, err := http.NewRequest(http.MethodPut, "https://example.test/zone/a.js", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Simulate a transport error whose cause is context.Canceled.
+ apiErr := newAPIError(errorKindNetwork, context.Canceled, req, 0)
+ if !errors.Is(apiErr, context.Canceled) {
+ t.Fatalf("apiError must unwrap to its cause; got chain that is not context.Canceled")
+ }
+}