package cloud import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" ) func serve(t *testing.T, h http.HandlerFunc) *Client { t.Helper() srv := httptest.NewServer(h) t.Cleanup(srv.Close) c := New(srv.URL) c.SetSession(Session{Token: "test-token"}) return c } func fail(w http.ResponseWriter, status int, code, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": code, "message": msg}) } // A customer with no photo is the DEFAULT configuration of this product, not a // fault. If it surfaced as an error the record sheet would show a red failure // box for every customer in every shop that has not turned images on. func TestNoPhotoIsNotAnError(t *testing.T) { for _, tc := range []struct{ code, want string }{ {"no_image", "No photo"}, {"images_disabled", "not storing"}, } { t.Run(tc.code, func(t *testing.T) { c := serve(t, func(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusNotFound, tc.code, "server prose") }) p, err := c.VisitorImage(context.Background(), "abc") if err != nil { t.Fatalf("returned an error for a normal state: %v", err) } if p.Available { t.Error("Available should be false when there is no photo") } if p.Reason == "" { t.Error("a missing photo must come with an explanation") } }) } } func TestPhotoReturnsTheSignedLink(t *testing.T) { c := serve(t, func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer test-token" { t.Errorf("Authorization = %q", got) } json.NewEncoder(w).Encode(map[string]any{ "url": "https://example.test/signed", "expires_in": 900}) }) p, err := c.VisitorImage(context.Background(), "abc") if err != nil { t.Fatal(err) } if !p.Available || p.URL != "https://example.test/signed" || p.ExpiresIn != 900 { t.Fatalf("got %+v", p) } } // A real failure must still be a failure: silently rendering initials would // hide a broken server behind a design that looks intentional. func TestPhotoServerErrorIsAnError(t *testing.T) { c := serve(t, func(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusInternalServerError, "server_error", "boom") }) if _, err := c.VisitorImage(context.Background(), "abc"); err == nil { t.Fatal("a 500 must not be reported as 'no photo'") } } // The server deletes stored images before it touches the database and refuses // the whole request if one fails, so an error here means NOTHING was erased. // Swallowing it would tell a shop a legal request had been honoured when it // had not. func TestForgetVisitorSurfacesFailure(t *testing.T) { c := serve(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("method = %s, want DELETE", r.Method) } fail(w, http.StatusBadGateway, "storage_error", "The photo could not be deleted, so nothing was erased.") }) err := c.ForgetVisitor(context.Background(), "abc") if err == nil { t.Fatal("a refused erasure must not look like success") } if err.Error() != "The photo could not be deleted, so nothing was erased." { t.Errorf("lost the server's own words: %q", err) } } func TestForgetVisitorSucceedsOn204(t *testing.T) { c := serve(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) if err := c.ForgetVisitor(context.Background(), "abc"); err != nil { t.Fatal(err) } } // APIError carries the code without changing what anything that prints the // error sees — every existing screen relies on that text. func TestAPIErrorKeepsServerMessage(t *testing.T) { c := serve(t, func(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusForbidden, "forbidden", "Your account cannot delete customer records.") }) err := c.ForgetVisitor(context.Background(), "abc") if err.Error() != "Your account cannot delete customer records." { t.Errorf("message = %q", err) } var ae *APIError if !errors.As(err, &ae) || ae.Code != "forbidden" || ae.Status != 403 { t.Errorf("code not preserved: %+v", ae) } } // An id with a slash or a space must not silently address a different route. func TestVisitorIDIsPathEscaped(t *testing.T) { var got string c := serve(t, func(w http.ResponseWriter, r *http.Request) { got = r.URL.EscapedPath() w.WriteHeader(http.StatusNoContent) }) c.ForgetVisitor(context.Background(), "a b/c") //nolint:errcheck if got != "/api/visitors/a%20b%2Fc" { t.Errorf("path = %q", got) } } // Redeeming an installation code is the one call a fresh PC makes before it // has any session. When the server refuses it - wrong code, wrong head office - // it answers 401 with a message written for the installer. That message must // reach them: "session expired" on a screen where nobody has signed in sent a // real installer looking for a login problem that did not exist. func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "" { t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization")) } fail(w, http.StatusUnauthorized, "bad_token", "That installation code is not valid. Ask for a new one.") })) t.Cleanup(srv.Close) c := New(srv.URL) // deliberately no session _, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D") if err == nil { t.Fatal("a refused code must be an error") } if errors.Is(err, ErrUnauthorized) { t.Fatalf("a refused code is not a session problem, got %v", err) } if !strings.Contains(err.Error(), "installation code is not valid") { t.Fatalf("the server's own words should reach the installer, got %v", err) } } // The other side of the same rule: a 401 on a call that DID carry a session is // a session problem, and must still read as one. func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) { c := serve(t, func(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.") }) err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil) if !errors.Is(err, ErrUnauthorized) { t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err) } }