|
| 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