Skip to content

Commit 8c7c090

Browse files
committed
fix(search): check HTTP status code and add test coverage
Switch queryAPI from httpClient.Get to httputil.Do so 429 responses trigger the existing retry/rate-limit logic. Add an explicit status check after the request so non-200 responses (429, 500, …) return a meaningful error instead of falling through to a confusing JSON-parse failure. Add search_test.go with 13 table-driven tests covering 200 OK, empty results, 429, 500, invalid JSON, network errors, flag mapping, and SearchOnline partial-success; statement coverage is 94.2%.
1 parent 6fbe139 commit 8c7c090

2 files changed

Lines changed: 260 additions & 1 deletion

File tree

internal/search/search.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/openbootdotdev/openboot/internal/config"
13+
"github.com/openbootdotdev/openboot/internal/httputil"
1314
"github.com/openbootdotdev/openboot/internal/system"
1415
)
1516

@@ -41,12 +42,22 @@ type searchResponse struct {
4142

4243
func queryAPI(endpoint, query string) ([]config.Package, error) {
4344
u := fmt.Sprintf("%s/%s/search?q=%s", getAPIBase(), endpoint, url.QueryEscape(query))
44-
resp, err := httpClient.Get(u)
45+
46+
req, err := http.NewRequest(http.MethodGet, u, nil)
47+
if err != nil {
48+
return nil, fmt.Errorf("%s search: %w", endpoint, err)
49+
}
50+
51+
resp, err := httputil.Do(httpClient, req)
4552
if err != nil {
4653
return nil, fmt.Errorf("%s search: %w", endpoint, err)
4754
}
4855
defer resp.Body.Close()
4956

57+
if resp.StatusCode != http.StatusOK {
58+
return nil, fmt.Errorf("%s search: server returned %d", endpoint, resp.StatusCode)
59+
}
60+
5061
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
5162
if err != nil {
5263
return nil, fmt.Errorf("read %s body: %w", endpoint, err)

internal/search/search_test.go

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
package search
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/openbootdotdev/openboot/internal/config"
14+
)
15+
16+
// newTestServer creates an httptest.Server that serves a fixed response for
17+
// any request path. It returns both the server and a cleanup function.
18+
func newTestServer(t *testing.T, statusCode int, body string) *httptest.Server {
19+
t.Helper()
20+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
w.WriteHeader(statusCode)
22+
_, _ = w.Write([]byte(body))
23+
}))
24+
t.Cleanup(srv.Close)
25+
return srv
26+
}
27+
28+
// setAPIURL points the package at the given test server and restores the
29+
// original environment value when the test ends.
30+
func setAPIURL(t *testing.T, serverURL string) {
31+
t.Helper()
32+
t.Setenv("OPENBOOT_API_URL", serverURL)
33+
}
34+
35+
// marshalResponse is a helper to build a JSON searchResponse body.
36+
func marshalResponse(t *testing.T, results []searchResult) string {
37+
t.Helper()
38+
b, err := json.Marshal(searchResponse{Results: results})
39+
require.NoError(t, err)
40+
return string(b)
41+
}
42+
43+
// --- queryAPI tests ---
44+
45+
func TestQueryAPI_200_ValidJSON(t *testing.T) {
46+
results := []searchResult{
47+
{Name: "git", Desc: "Distributed version control", Type: "formula"},
48+
{Name: "iterm2", Desc: "Terminal emulator", Type: "cask"},
49+
{Name: "typescript", Desc: "TypeScript compiler", Type: "npm"},
50+
}
51+
body := marshalResponse(t, results)
52+
srv := newTestServer(t, http.StatusOK, body)
53+
setAPIURL(t, srv.URL)
54+
55+
pkgs, err := queryAPI("homebrew", "git")
56+
57+
require.NoError(t, err)
58+
require.Len(t, pkgs, 3)
59+
60+
assert.Equal(t, "git", pkgs[0].Name)
61+
assert.Equal(t, "Distributed version control", pkgs[0].Description)
62+
assert.False(t, pkgs[0].IsCask, "formula should not be a cask")
63+
assert.False(t, pkgs[0].IsNpm, "formula should not be npm")
64+
65+
assert.Equal(t, "iterm2", pkgs[1].Name)
66+
assert.True(t, pkgs[1].IsCask, "type=cask should set IsCask")
67+
assert.False(t, pkgs[1].IsNpm)
68+
69+
assert.Equal(t, "typescript", pkgs[2].Name)
70+
assert.True(t, pkgs[2].IsNpm, "type=npm should set IsNpm")
71+
assert.False(t, pkgs[2].IsCask)
72+
}
73+
74+
func TestQueryAPI_200_EmptyResults(t *testing.T) {
75+
body := marshalResponse(t, []searchResult{})
76+
srv := newTestServer(t, http.StatusOK, body)
77+
setAPIURL(t, srv.URL)
78+
79+
pkgs, err := queryAPI("homebrew", "nonexistent-package-xyz")
80+
81+
require.NoError(t, err)
82+
assert.Nil(t, pkgs, "empty results should return nil slice")
83+
}
84+
85+
func TestQueryAPI_429_RateLimited(t *testing.T) {
86+
// httputil.Do retries once on 429; serve 429 for every request so the
87+
// retry also gets 429 and the function returns a RateLimitError.
88+
// The wrapped error message is "Rate limited. Please wait N seconds and try again."
89+
srv := newTestServer(t, http.StatusTooManyRequests, "rate limited")
90+
setAPIURL(t, srv.URL)
91+
92+
pkgs, err := queryAPI("homebrew", "git")
93+
94+
require.Error(t, err, "429 should produce a non-nil error")
95+
assert.Nil(t, pkgs)
96+
// httputil.Do converts a persistent 429 into a RateLimitError whose message
97+
// contains "Rate limited" — the outer wrapper adds the endpoint prefix.
98+
assert.Contains(t, err.Error(), "Rate limited", "error message should indicate rate limiting")
99+
}
100+
101+
func TestQueryAPI_500_ServerError(t *testing.T) {
102+
srv := newTestServer(t, http.StatusInternalServerError, "internal server error")
103+
setAPIURL(t, srv.URL)
104+
105+
pkgs, err := queryAPI("homebrew", "git")
106+
107+
require.Error(t, err, "500 should produce a non-nil error")
108+
assert.Nil(t, pkgs)
109+
assert.Contains(t, err.Error(), "500", "error message should mention 500")
110+
}
111+
112+
func TestQueryAPI_InvalidJSON(t *testing.T) {
113+
srv := newTestServer(t, http.StatusOK, `{not valid json}`)
114+
setAPIURL(t, srv.URL)
115+
116+
pkgs, err := queryAPI("homebrew", "git")
117+
118+
require.Error(t, err, "invalid JSON should produce a non-nil error")
119+
assert.Nil(t, pkgs)
120+
}
121+
122+
func TestQueryAPI_NetworkError(t *testing.T) {
123+
// Start a server, capture its URL, then close it before the request is made.
124+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
125+
url := srv.URL
126+
srv.Close() // close immediately — connection will be refused
127+
t.Setenv("OPENBOOT_API_URL", url)
128+
129+
pkgs, err := queryAPI("homebrew", "git")
130+
131+
require.Error(t, err, "closed server should produce a non-nil error")
132+
assert.Nil(t, pkgs)
133+
}
134+
135+
// --- SearchOnline tests ---
136+
137+
func TestSearchOnline_EmptyQuery(t *testing.T) {
138+
// No server needed; empty query returns early.
139+
pkgs, err := SearchOnline("")
140+
141+
require.NoError(t, err)
142+
assert.Nil(t, pkgs)
143+
}
144+
145+
func TestSearchOnline_CombinesBrewAndNpm(t *testing.T) {
146+
// Both "homebrew" and "npm" endpoints share the same mock server.
147+
// The server returns one result regardless of path.
148+
result := searchResult{Name: "ripgrep", Desc: "Fast grep", Type: "formula"}
149+
body := marshalResponse(t, []searchResult{result})
150+
srv := newTestServer(t, http.StatusOK, body)
151+
setAPIURL(t, srv.URL)
152+
153+
pkgs, err := SearchOnline("ripgrep")
154+
155+
require.NoError(t, err)
156+
// Two requests (homebrew + npm), each returns one result → 2 total.
157+
assert.Len(t, pkgs, 2)
158+
}
159+
160+
func TestSearchOnline_BothEndpointsError_ReturnsError(t *testing.T) {
161+
srv := newTestServer(t, http.StatusInternalServerError, "error")
162+
setAPIURL(t, srv.URL)
163+
164+
pkgs, err := SearchOnline("git")
165+
166+
// Both endpoints fail; no results → firstErr propagated.
167+
require.Error(t, err)
168+
assert.Empty(t, pkgs)
169+
assert.Contains(t, err.Error(), "500")
170+
}
171+
172+
func TestSearchOnline_PartialSuccess_ReturnsResults(t *testing.T) {
173+
// Serve valid JSON for any path that contains "homebrew", error for npm.
174+
// Because both share the same URL, we distinguish by path.
175+
brewResult := searchResult{Name: "wget", Desc: "HTTP client", Type: "formula"}
176+
brewBody := marshalResponse(t, []searchResult{brewResult})
177+
178+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
179+
if strings.Contains(r.URL.Path, "homebrew") {
180+
w.WriteHeader(http.StatusOK)
181+
_, _ = w.Write([]byte(brewBody))
182+
} else {
183+
w.WriteHeader(http.StatusInternalServerError)
184+
}
185+
}))
186+
t.Cleanup(srv.Close)
187+
setAPIURL(t, srv.URL)
188+
189+
pkgs, err := SearchOnline("wget")
190+
191+
// One endpoint succeeds → results returned, error suppressed.
192+
require.NoError(t, err, "partial success should not propagate error when results exist")
193+
require.Len(t, pkgs, 1)
194+
assert.Equal(t, "wget", pkgs[0].Name)
195+
}
196+
197+
// --- IsCask / IsNpm flag tests (via queryAPI) ---
198+
199+
func TestQueryAPI_CaskAndNpmFlags(t *testing.T) {
200+
tests := []struct {
201+
name string
202+
resultType string
203+
wantCask bool
204+
wantNpm bool
205+
}{
206+
{"formula", "formula", false, false},
207+
{"cask", "cask", true, false},
208+
{"npm", "npm", false, true},
209+
{"unknown type", "unknown", false, false},
210+
}
211+
212+
for _, tc := range tests {
213+
t.Run(tc.name, func(t *testing.T) {
214+
result := searchResult{Name: "pkg", Desc: "desc", Type: tc.resultType}
215+
body := marshalResponse(t, []searchResult{result})
216+
srv := newTestServer(t, http.StatusOK, body)
217+
setAPIURL(t, srv.URL)
218+
219+
pkgs, err := queryAPI("homebrew", "pkg")
220+
require.NoError(t, err)
221+
require.Len(t, pkgs, 1)
222+
223+
got := pkgs[0]
224+
assert.Equal(t, tc.wantCask, got.IsCask, "IsCask mismatch for type=%s", tc.resultType)
225+
assert.Equal(t, tc.wantNpm, got.IsNpm, "IsNpm mismatch for type=%s", tc.resultType)
226+
})
227+
}
228+
}
229+
230+
// --- Package field mapping ---
231+
232+
func TestQueryAPI_PackageFieldMapping(t *testing.T) {
233+
result := searchResult{Name: "fzf", Desc: "Fuzzy finder", Type: "formula"}
234+
body := marshalResponse(t, []searchResult{result})
235+
srv := newTestServer(t, http.StatusOK, body)
236+
setAPIURL(t, srv.URL)
237+
238+
pkgs, err := queryAPI("homebrew", "fzf")
239+
require.NoError(t, err)
240+
require.Len(t, pkgs, 1)
241+
242+
pkg := pkgs[0]
243+
assert.Equal(t, "fzf", pkg.Name)
244+
assert.Equal(t, "Fuzzy finder", pkg.Description)
245+
246+
// Verify config.Package type is returned correctly.
247+
var _ config.Package = pkg
248+
}

0 commit comments

Comments
 (0)