package client import ( "context" "errors" "io" "net/http" "net/http/httptest" "testing" ) type handlerTransport struct { handler http.Handler } func (transport handlerTransport) RoundTrip(request *http.Request) (*http.Response, error) { recorder := httptest.NewRecorder() transport.handler.ServeHTTP(recorder, request) response := recorder.Result() response.Request = request return response, nil } func newTestClient(t *testing.T, handler http.Handler, options ...Option) *Client { t.Helper() httpClient := &http.Client{Transport: handlerTransport{handler: handler}} options = append([]Option{WithHTTPClient(httpClient)}, options...) apiClient, err := New("https://api.example", options...) if err != nil { t.Fatal(err) } return apiClient } func TestClientSupportsBearerAuthenticationAndBasePath(t *testing.T) { t.Parallel() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/healthcheck" { t.Errorf("path: got %q, want %q", r.URL.Path, "/api/v1/healthcheck") } if got := r.Header.Get("Authorization"); got != "Bearer secret" { t.Errorf("Authorization: got %q, want %q", got, "Bearer secret") } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`)) }) httpClient := &http.Client{Transport: handlerTransport{handler: handler}} apiClient, err := New("https://api.example/api", WithHTTPClient(httpClient), WithBearerToken("secret")) if err != nil { t.Fatal(err) } health, response, err := apiClient.Healthcheck(context.Background()) if err != nil { t.Fatal(err) } if response.StatusCode != http.StatusOK { t.Errorf("status: got %d, want %d", response.StatusCode, http.StatusOK) } if health.Status != "available" || health.SystemInfo.Environment != "test" { t.Errorf("unexpected health response: %+v", health) } if health.ServerTime.IsZero() { t.Errorf("health response does not include server time: %+v", health) } } func TestSessionClientPersistsCookies(t *testing.T) { t.Parallel() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/v1/session": http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "session-token", Path: "/", HttpOnly: true}) w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"user":{"id":42,"name":"Alice","email":"alice@example.com","activated":true}}`)) case r.Method == http.MethodGet && r.URL.Path == "/v1/session": cookie, err := r.Cookie("gardomatic_session") if err != nil || cookie.Value != "session-token" { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"you must be authenticated"}`)) return } _, _ = w.Write([]byte(`{"user":{"id":42,"name":"Alice","email":"alice@example.com","activated":true}}`)) default: http.NotFound(w, r) } }) apiClient := newTestClient(t, handler, WithSessions()) user, response, err := apiClient.CreateSession(context.Background(), Credentials{ Email: "alice@example.com", Password: "correct horse battery staple", }) if err != nil { t.Fatal(err) } if user.ID != 42 { t.Errorf("user ID: got %d, want 42", user.ID) } if len(response.Cookies()) != 1 { t.Fatalf("response cookies: got %d, want 1", len(response.Cookies())) } user, _, err = apiClient.Session(context.Background()) if err != nil { t.Fatal(err) } if user.Email != "alice@example.com" { t.Errorf("email: got %q, want %q", user.Email, "alice@example.com") } } func TestForRequestUsesIsolatedIncomingSession(t *testing.T) { t.Parallel() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, err := r.Cookie("frontend_csrf"); !errors.Is(err, http.ErrNoCookie) { t.Errorf("frontend-only cookie was forwarded to API") } cookie, err := r.Cookie("gardomatic_session") if err != nil { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"missing session"}`)) return } _, _ = w.Write([]byte(`{"user":{"id":1,"name":"` + cookie.Value + `"}}`)) }) baseClient := newTestClient(t, handler) incoming := httptest.NewRequest(http.MethodGet, "https://frontend.example/", nil) incoming.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-a"}) incoming.AddCookie(&http.Cookie{Name: "frontend_csrf", Value: "do-not-forward"}) requestClient, err := baseClient.ForRequest(incoming) if err != nil { t.Fatal(err) } user, _, err := requestClient.Session(context.Background()) if err != nil { t.Fatal(err) } if user.Name != "browser-a" { t.Errorf("user name: got %q, want %q", user.Name, "browser-a") } _, _, err = baseClient.Session(context.Background()) var apiError *APIError if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusUnauthorized { t.Fatalf("base client should have no session; got %v", err) } } func TestValidationError(t *testing.T) { t.Parallel() handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"error":{"email":"must be a valid email address"}}`)) }) apiClient := newTestClient(t, handler) _, response, err := apiClient.RegisterUser(context.Background(), RegisterUserInput{}) var apiError *APIError if !errors.As(err, &apiError) { t.Fatalf("error type: got %T, want *APIError", err) } if response.StatusCode != http.StatusUnprocessableEntity { t.Errorf("response status: got %d, want %d", response.StatusCode, http.StatusUnprocessableEntity) } if apiError.Validation["email"] != "must be a valid email address" { t.Errorf("validation errors: got %#v", apiError.Validation) } } func TestResponseBodyIsClosed(t *testing.T) { t.Parallel() closed := false apiClient, err := New("https://api.example", WithHTTPClient(&http.Client{ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: &trackingReadCloser{ Reader: io.NopCloser(http.NoBody), closed: &closed, }, Request: request, }, nil }), })) if err != nil { t.Fatal(err) } _, _, _ = apiClient.Healthcheck(context.Background()) if !closed { t.Error("response body was not closed") } } type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) } type trackingReadCloser struct { Reader io.ReadCloser closed *bool } func (reader *trackingReadCloser) Read(buffer []byte) (int, error) { return reader.Reader.Read(buffer) } func (reader *trackingReadCloser) Close() error { *reader.closed = true return reader.Reader.Close() } func TestForwardCookies(t *testing.T) { t.Parallel() apiResponse := &http.Response{Header: make(http.Header)} apiResponse.Header.Add("Set-Cookie", "gardomatic_session=token; Path=/; HttpOnly") frontendResponse := httptest.NewRecorder() ForwardCookies(frontendResponse, &Response{apiResponse}) if got := frontendResponse.Header().Values("Set-Cookie"); len(got) != 1 { t.Fatalf("Set-Cookie headers: got %d, want 1", len(got)) } }