From 50829dc3ba702287bcd3b4d9955f127ba313b259 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:25:28 +0000 Subject: [PATCH] feat(bugseti): add HubService HTTP request helpers Add doRequest() and doJSON() methods for hub API communication. doRequest builds full URLs, sets bearer auth and JSON headers, tracks connected state. doJSON handles status codes: 401 unauthorised, 409 ConflictError, 404 NotFoundError, and generic errors for other 4xx/5xx responses. Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- internal/bugseti/hub.go | 80 ++++++++++++++++++++++++++++++++++++ internal/bugseti/hub_test.go | 62 ++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) 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()) +}