diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 79d54cf3..7060b7ce 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -2,9 +2,12 @@ package bugseti import ( + "bytes" "crypto/rand" "encoding/hex" + "encoding/json" "fmt" + "io" "net/http" "sync" "time" @@ -122,6 +125,83 @@ func generateClientID() string { return hex.EncodeToString(b) } +// doRequest builds and executes an HTTP request against the hub API. +// It returns the raw *http.Response and any transport-level error. +func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) { + hubURL := h.config.GetHubURL() + if hubURL == "" { + return nil, fmt.Errorf("hub URL not configured") + } + + fullURL := hubURL + "/api/bugseti" + path + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, fullURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + token := h.config.GetHubToken() + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := h.client.Do(req) + if err != nil { + h.mu.Lock() + h.connected = false + h.mu.Unlock() + return nil, err + } + + h.mu.Lock() + h.connected = true + h.mu.Unlock() + + return resp, nil +} + +// doJSON executes an HTTP request and decodes the JSON response into dest. +// It handles common error status codes with typed errors. +func (h *HubService) doJSON(method, path string, body, dest interface{}) error { + resp, err := h.doRequest(method, path, body) + if err != nil { + return err + } + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusUnauthorized: + return fmt.Errorf("unauthorised") + case resp.StatusCode == http.StatusConflict: + return &ConflictError{StatusCode: resp.StatusCode} + case resp.StatusCode == http.StatusNotFound: + return &NotFoundError{StatusCode: resp.StatusCode} + case resp.StatusCode >= 400: + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(respBody)) + } + + if dest != nil { + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + + return nil +} + // loadPendingOps is a no-op placeholder (disk persistence comes in Task 7). func (h *HubService) loadPendingOps() {} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go index b04cb15b..70b402ac 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -1,6 +1,9 @@ package bugseti import ( + "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -46,3 +49,62 @@ func TestNewHubService_Good_ReusesClientID(t *testing.T) { h := NewHubService(cfg) assert.Equal(t, "existing-client-id", h.GetClientID()) } + +// ---- doRequest ---- + +func TestDoRequest_Good(t *testing.T) { + var gotAuth string + var gotContentType string + var gotAccept string + var gotBody map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotContentType = r.Header.Get("Content-Type") + gotAccept = r.Header.Get("Accept") + + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "test-token-123" + h := NewHubService(cfg) + + body := map[string]string{"key": "value"} + resp, err := h.doRequest("POST", "/test", body) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "Bearer test-token-123", gotAuth) + assert.Equal(t, "application/json", gotContentType) + assert.Equal(t, "application/json", gotAccept) + assert.Equal(t, "value", gotBody["key"]) + assert.True(t, h.IsConnected()) +} + +func TestDoRequest_Bad_NoHubURL(t *testing.T) { + h := testHubService(t, "") + + resp, err := h.doRequest("GET", "/test", nil) + assert.Nil(t, resp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "hub URL not configured") +} + +func TestDoRequest_Bad_NetworkError(t *testing.T) { + // Point to a port where nothing is listening. + h := testHubService(t, "http://127.0.0.1:1") + + resp, err := h.doRequest("GET", "/test", nil) + assert.Nil(t, resp) + assert.Error(t, err) + assert.False(t, h.IsConnected()) +}