ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

split up locations.go (#396)

* split up locations.go

* gofumpt yourself

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
331216b4 232a048f

+851 -818
+2 -2
AGENTS.md
··· 16 16 - `export GOCACHE=/tmp/go-build` 17 17 - `export GOMODCACHE=/tmp/go-modcache` 18 18 - Alternative persistent path inside repo: `export GOCACHE=$PWD/.cache/go-build && export GOMODCACHE=$PWD/.cache/go-modcache` 19 - - `gofumpt -l -w .` then `go vet ./...`: Baseline formatting and static checks. 19 + - `task fmt` (preferred) or `gofumpt -l -w .`, then `go vet ./...`: Baseline formatting and static checks. 20 20 - From the repo root, run `golangci-lint run ./...`: Expanded Go linters. 21 21 - `export ENABLE_MOCKS=1`: to test without kroger, openai credentials 22 22 - `go test ./...`: Run unit tests across all packages; add `-cover` when changing core logic. ··· 26 26 - `tailwind\generate.sh`: run when ever you change css or html 27 27 28 28 ## Coding Style & Naming Conventions 29 - - Go 1.24; keep code `gofumpt`-clean before review. Favor small, focused functions and table-driven tests. 29 + - Go 1.24; always format Go changes with `task fmt` or `gofumpt`, and keep code `gofumpt`-clean before review. Favor small, focused functions and table-driven tests. 30 30 - Exported identifiers in `CamelCase`; package-private helpers in `lowerCamel`. Template names mirror file names in `internal/templates`. 31 31 - Prefer standard library first; add dependencies sparingly and record rationale in PR description if new. 32 32 - Prefer simple html to javascript frameworks
+2 -1
internal/actowiz/server.go
··· 1 1 package actowiz 2 2 3 3 import ( 4 - "careme/internal/routing" 5 4 "context" 6 5 "encoding/json" 7 6 "log/slog" 8 7 "net/http" 8 + 9 + "careme/internal/routing" 9 10 10 11 "github.com/samber/lo" 11 12 )
+7 -177
internal/locations/locations.go internal/locations/storage.go
··· 5 5 "encoding/json" 6 6 "errors" 7 7 "fmt" 8 - "html/template" 9 8 "log/slog" 10 - "net/http" 11 - "net/url" 12 9 "sort" 13 - "strconv" 14 - "strings" 15 10 "sync" 16 11 "time" 17 12 18 13 "careme/internal/albertsons" 19 14 "careme/internal/aldi" 20 - "careme/internal/auth" 21 15 "careme/internal/cache" 22 16 "careme/internal/config" 23 17 "careme/internal/heb" ··· 25 19 "careme/internal/locations/geo" 26 20 "careme/internal/logsetup" 27 21 "careme/internal/publix" 28 - "careme/internal/routing" 29 - "careme/internal/seasons" 30 - "careme/internal/templates" 31 22 "careme/internal/walmart" 32 23 "careme/internal/wholefoods" 33 24 34 25 locationtypes "careme/internal/locations/types" 35 26 36 - utypes "careme/internal/users/types" 37 - 38 27 "github.com/samber/lo" 39 28 ) 40 29 41 - type userLookup interface { 42 - FromRequest(ctx context.Context, r *http.Request, authClient auth.AuthClient) (*utypes.User, error) 43 - } 44 - 45 30 type locationStorage struct { 46 31 clients []locationBackend 47 32 zipCentroids centroidByZip 48 33 cache cache.ListCache 49 34 } 50 35 51 - // bad for rural areas if zip code is huge? 52 - const ( 53 - maxLocationDistanceMiles = 20.0 54 - locationCachePrefix = "location/" 55 - storeRequestPrefix = "location-store-requests/" 56 - ) 57 - 58 - type locationServer struct { 59 - storage locationStore 60 - zipFetcher zipFetcher 61 - userStorage userLookup 62 - } 63 - 64 36 type locationGetter interface { 65 37 GetLocationByID(ctx context.Context, locationID string) (*Location, error) 66 38 GetLocationsByZip(ctx context.Context, zipcode string) ([]Location, error) 67 39 HasInventory(locationID string) bool 68 - } 69 - 70 - type zipFetcher interface { 71 - NearestZIPToCoordinates(lat, lon float64) (string, bool) 72 40 } 73 41 74 42 type locationBackend interface { ··· 89 57 type centroidByZip interface { 90 58 ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) 91 59 } 60 + 61 + // bad for rural areas if zip code is huge? 62 + const ( 63 + maxLocationDistanceMiles = 20.0 64 + locationCachePrefix = "location/" 65 + storeRequestPrefix = "location-store-requests/" 66 + ) 92 67 93 68 func New(cfg *config.Config, c cache.ListCache, centroids centroidByZip) (locationStore, error) { 94 69 if c == nil { ··· 128 103 zipCentroids: centroids, 129 104 cache: c, 130 105 }, nil 131 - } 132 - 133 - func NewServer(storage locationStore, zipFetcher zipFetcher, userStorage userLookup) *locationServer { 134 - return &locationServer{ 135 - storage: storage, 136 - zipFetcher: zipFetcher, 137 - userStorage: userStorage, 138 - } 139 - } 140 - 141 - func isHTMXRequest(r *http.Request) bool { 142 - return strings.EqualFold(r.Header.Get("HX-Request"), "true") 143 106 } 144 107 145 108 func (l *locationStorage) HasInventory(locationID string) bool { ··· 349 312 centroid, _ := zipCentroids.ZipCentroidByZIP(loc.ZipCode) 350 313 return centroid.Lat, centroid.Lon 351 314 } 352 - 353 - func (l *locationServer) Ready(ctx context.Context) error { 354 - _, err := l.storage.GetLocationsByZip(ctx, "98005") // magic number is my zip code :) 355 - return err 356 - } 357 - 358 - func (l *locationServer) Register(mux routing.Registrar, authClient auth.AuthClient) { 359 - mux.HandleFunc("GET /locations/zip-from-coordinates", func(w http.ResponseWriter, r *http.Request) { 360 - lat, err := strconv.ParseFloat(r.URL.Query().Get("lat"), 64) 361 - if err != nil { 362 - http.Error(w, "invalid latitude", http.StatusBadRequest) 363 - return 364 - } 365 - lon, err := strconv.ParseFloat(r.URL.Query().Get("lon"), 64) 366 - if err != nil { 367 - http.Error(w, "invalid longitude", http.StatusBadRequest) 368 - return 369 - } 370 - 371 - zip, ok := l.zipFetcher.NearestZIPToCoordinates(lat, lon) 372 - if !ok { 373 - http.Error(w, "zip not found for coordinates", http.StatusNotFound) 374 - return 375 - } 376 - 377 - http.Redirect(w, r, "/locations?zip="+url.QueryEscape(zip), http.StatusFound) 378 - }) 379 - 380 - mux.HandleFunc("GET /locations", func(w http.ResponseWriter, r *http.Request) { 381 - ctx := r.Context() 382 - currentUser, err := l.userStorage.FromRequest(ctx, r, authClient) 383 - if err != nil { 384 - if !errors.Is(err, auth.ErrNoSession) { 385 - http.Error(w, "unable to load account", http.StatusInternalServerError) 386 - slog.ErrorContext(ctx, "failed to get user from request", "error", err) 387 - return 388 - } 389 - } 390 - 391 - zip := r.URL.Query().Get("zip") 392 - if zip == "" { 393 - slog.InfoContext(ctx, "no zip code provided to /locations") 394 - http.Error(w, "provide a zip code with ?zip=12345", http.StatusBadRequest) 395 - return 396 - } 397 - var favoriteStore string 398 - if currentUser != nil { 399 - favoriteStore = currentUser.FavoriteStore 400 - } 401 - if err := l.renderLocationsPage(w, ctx, zip, favoriteStore, currentUser != nil); err != nil { 402 - slog.ErrorContext(ctx, "failed to render locations page", "zip", zip, "error", err) 403 - http.Error(w, "Failed to render locations page. ", http.StatusInternalServerError) 404 - } 405 - }) 406 - 407 - mux.HandleFunc("POST /locations/request-store", func(w http.ResponseWriter, r *http.Request) { 408 - if !isHTMXRequest(r) { 409 - http.Error(w, "store requests must be made via HTMX", http.StatusBadRequest) 410 - return 411 - } 412 - 413 - if err := r.ParseForm(); err != nil { 414 - http.Error(w, "invalid request", http.StatusBadRequest) 415 - return 416 - } 417 - 418 - storeID := r.FormValue("store_id") 419 - if storeID == "" { 420 - http.Error(w, "store_id is required", http.StatusBadRequest) 421 - return 422 - } 423 - 424 - ctx := r.Context() 425 - if _, err := l.storage.GetLocationByID(ctx, storeID); err != nil { 426 - http.Error(w, "invalid store_id", http.StatusBadRequest) 427 - return 428 - } 429 - if l.storage.HasInventory(storeID) { 430 - http.Error(w, "store already supported", http.StatusBadRequest) 431 - return 432 - } 433 - 434 - if err := l.storage.RequestStore(ctx, storeID); err != nil { 435 - http.Error(w, "failed to submit request", http.StatusInternalServerError) 436 - return 437 - } 438 - 439 - if err := templates.Location.ExecuteTemplate(w, "location_request_store_success", nil); err != nil { 440 - slog.ErrorContext(ctx, "failed to render request-store success fragment", "store_id", storeID, "error", err) 441 - http.Error(w, "failed to submit request", http.StatusInternalServerError) 442 - return 443 - } 444 - }) 445 - } 446 - 447 - func (l *locationServer) renderLocationsPage(w http.ResponseWriter, ctx context.Context, zip string, favoriteStore string, serverSignedIn bool) error { 448 - locs, err := l.storage.GetLocationsByZip(ctx, zip) 449 - if err != nil { 450 - return fmt.Errorf("failed to get locations for zip %s: %w", zip, err) 451 - } 452 - 453 - type locationRow struct { 454 - Location 455 - SupportsStaples bool 456 - } 457 - 458 - rows := make([]locationRow, 0, len(locs)) 459 - for _, loc := range locs { 460 - rows = append(rows, locationRow{ 461 - Location: loc, 462 - SupportsStaples: l.storage.HasInventory(loc.ID), 463 - }) 464 - } 465 - 466 - data := struct { 467 - Locations []locationRow 468 - Zip string 469 - FavoriteStore string 470 - ClarityScript template.HTML 471 - GoogleTagScript template.HTML 472 - Style seasons.Style 473 - ServerSignedIn bool 474 - }{ 475 - Locations: rows, 476 - Zip: zip, 477 - FavoriteStore: favoriteStore, 478 - ClarityScript: templates.ClarityScript(ctx), 479 - GoogleTagScript: templates.GoogleTagScript(), 480 - Style: seasons.GetCurrentStyle(), 481 - ServerSignedIn: serverSignedIn, 482 - } 483 - return templates.Location.Execute(w, data) 484 - }
-638
internal/locations/locations_test.go
··· 1 - package locations 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "fmt" 8 - "io" 9 - "net/http" 10 - "net/http/httptest" 11 - "strings" 12 - "testing" 13 - "time" 14 - 15 - "careme/internal/auth" 16 - cachepkg "careme/internal/cache" 17 - "careme/internal/config" 18 - "careme/internal/templates" 19 - utypes "careme/internal/users/types" 20 - ) 21 - 22 - func TestGetLocationByIDUsesCache(t *testing.T) { 23 - client := newFakeLocationClient() 24 - fc := cachepkg.NewInMemoryCache() 25 - client.setDetailResponse("12345", Location{ 26 - ID: "12345", 27 - Name: "Friendly Market", 28 - Address: "123 Main St", 29 - ZipCode: "10001", 30 - }) 31 - 32 - server := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 33 - 34 - ctx := context.Background() 35 - got, err := server.GetLocationByID(ctx, "12345") 36 - if err != nil { 37 - t.Fatalf("GetLocationByID returned error: %v", err) 38 - } 39 - if got.Name != "Friendly Market" || got.Address != "123 Main St" { 40 - t.Fatalf("unexpected location returned: %+v", got) 41 - } 42 - if got.ZipCode != "10001" { 43 - t.Fatalf("unexpected zip code: %q", got.ZipCode) 44 - } 45 - requireEventuallyCached(t, fc, locationCachePrefix+"12345") 46 - // Remove backend value to prove the second read comes from persistent cache. 47 - delete(client.details, "12345") 48 - _, err = server.GetLocationByID(ctx, "12345") 49 - if err != nil { 50 - t.Fatalf("GetLocationByID second call returned error: %v", err) 51 - } 52 - requireEventuallyCached(t, fc, locationCachePrefix+"12345") 53 - } 54 - 55 - func TestGetLocationsByZipCachesLocations(t *testing.T) { 56 - client := newFakeLocationClient() 57 - fc := cachepkg.NewInMemoryCache() 58 - lat1 := 18.18060 59 - lon1 := -66.74990 60 - lat2 := 18.22000 61 - lon2 := -66.78000 62 - client.setListResponse("00601", []Location{ 63 - { 64 - ID: "111", 65 - Name: "Store 111", 66 - Address: "1 North Ave", 67 - State: "GA", 68 - ZipCode: "00601", 69 - Lat: &lat1, 70 - Lon: &lon1, 71 - }, 72 - { 73 - ID: "222", 74 - Name: "Store 222", 75 - Address: "2 South St", 76 - State: "GA", 77 - ZipCode: "00602", 78 - Lat: &lat2, 79 - Lon: &lon2, 80 - }, 81 - }) 82 - 83 - server := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 84 - 85 - ctx := context.Background() 86 - locs, err := server.GetLocationsByZip(ctx, "00601") 87 - if err != nil { 88 - t.Fatalf("GetLocationsByZip returned error: %v", err) 89 - } 90 - if len(locs) != 2 { 91 - t.Fatalf("expected 2 locations, got %d", len(locs)) 92 - } 93 - if locs[0].ID != "111" || locs[0].State != "GA" { 94 - t.Fatalf("unexpected first location: %+v", locs[0]) 95 - } 96 - if locs[0].ZipCode != "00601" { 97 - t.Fatalf("unexpected first location zip code: %+v", locs[0]) 98 - } 99 - if locs[1].ID != "222" || locs[1].Address != "2 South St" { 100 - t.Fatalf("unexpected second location: %+v", locs[1]) 101 - } 102 - if locs[1].ZipCode != "00602" { 103 - t.Fatalf("unexpected second location zip code: %+v", locs[1]) 104 - } 105 - 106 - requireEventuallyCached(t, fc, locationCachePrefix+"111") 107 - requireEventuallyCached(t, fc, locationCachePrefix+"222") 108 - } 109 - 110 - func TestGetLocationsByZipSortsByCentroidDistance(t *testing.T) { 111 - client := newFakeLocationClient() 112 - nearLat := 18.18060 113 - nearLon := -66.74990 114 - midLat := 18.30000 115 - midLon := -66.90000 116 - farLat := 47.60970 117 - farLon := -122.33310 118 - client.setListResponse("00601", []Location{ 119 - {ID: "far", Name: "Far", ZipCode: "98004", Lat: &farLat, Lon: &farLon}, 120 - {ID: "mid", Name: "Mid", ZipCode: "00602", Lat: &midLat, Lon: &midLon}, 121 - {ID: "near", Name: "Near", ZipCode: "00601", Lat: &nearLat, Lon: &nearLon}, 122 - }) 123 - 124 - server := newTestLocationServer(client) 125 - locs, err := server.GetLocationsByZip(context.Background(), "00601") 126 - if err != nil { 127 - t.Fatalf("GetLocationsByZip returned error: %v", err) 128 - } 129 - if len(locs) != 2 { 130 - t.Fatalf("expected 2 locations after distance filter, got %d", len(locs)) 131 - } 132 - if got, want := []string{locs[0].ID, locs[1].ID}, []string{"near", "mid"}; got[0] != want[0] || got[1] != want[1] { 133 - t.Fatalf("unexpected sorted order: got %v want %v", got, want) 134 - } 135 - } 136 - 137 - func TestGetLocationsByZipSortsUsingLocationZipCentroidFallback(t *testing.T) { 138 - client := newFakeLocationClient() 139 - farLat := 47.60970 140 - farLon := -122.33310 141 - client.setListResponse("00601", []Location{ 142 - {ID: "far", Name: "Far", ZipCode: "98004", Lat: &farLat, Lon: &farLon}, 143 - {ID: "near-by-zip", Name: "Near By Zip", ZipCode: "00601"}, 144 - {ID: "unknown", Name: "Unknown", ZipCode: "zip-unknown"}, 145 - }) 146 - 147 - server := newTestLocationServer(client) 148 - locs, err := server.GetLocationsByZip(context.Background(), "00601") 149 - if err != nil { 150 - t.Fatalf("GetLocationsByZip returned error: %v", err) 151 - } 152 - if len(locs) != 1 { 153 - t.Fatalf("expected 1 location after filtering, got %d", len(locs)) 154 - } 155 - if got, want := []string{locs[0].ID}, []string{"near-by-zip"}; got[0] != want[0] { 156 - t.Fatalf("unexpected sorted order: got %v want %v", got, want) 157 - } 158 - } 159 - 160 - func TestGetLocationsByZipLeavesOrderWhenQueryZipCentroidUnknown(t *testing.T) { 161 - client := newFakeLocationClient() 162 - client.setListResponse("not-a-zip", []Location{ 163 - {ID: "first", Name: "First", ZipCode: "00602"}, 164 - {ID: "second", Name: "Second", ZipCode: "00601"}, 165 - }) 166 - 167 - server := newTestLocationServer(client) 168 - locs, err := server.GetLocationsByZip(context.Background(), "not-a-zip") 169 - if err != nil { 170 - t.Fatalf("GetLocationsByZip returned error: %v", err) 171 - } 172 - if got, want := []string{locs[0].ID, locs[1].ID}, []string{"first", "second"}; got[0] != want[0] || got[1] != want[1] { 173 - t.Fatalf("unexpected order: got %v want %v", got, want) 174 - } 175 - } 176 - 177 - func TestGetLocationsByZipResolvesMissingRequestedZipCentroid(t *testing.T) { 178 - client := newFakeLocationClient() 179 - nearLat := 37.331714 180 - nearLon := -122.341466 181 - midLat := 37.388239 182 - midLon := -122.075351 183 - farLat := 47.60970 184 - farLon := -122.33310 185 - client.setListResponse("94012", []Location{ 186 - {ID: "far", Name: "Far", ZipCode: "98004", Lat: &farLat, Lon: &farLon}, 187 - {ID: "mid", Name: "Mid", ZipCode: "94041", Lat: &midLat, Lon: &midLon}, 188 - {ID: "near", Name: "Near", ZipCode: "94074", Lat: &nearLat, Lon: &nearLon}, 189 - }) 190 - 191 - server := newTestLocationServer(client) 192 - locs, err := server.GetLocationsByZip(context.Background(), "94012") 193 - if err != nil { 194 - t.Fatalf("GetLocationsByZip returned error: %v", err) 195 - } 196 - if len(locs) != 2 { 197 - t.Fatalf("expected 2 nearby locations after centroid backfill, got %d", len(locs)) 198 - } 199 - if got, want := []string{locs[0].ID, locs[1].ID}, []string{"near", "mid"}; got[0] != want[0] || got[1] != want[1] { 200 - t.Fatalf("unexpected order: got %v want %v", got, want) 201 - } 202 - } 203 - 204 - func TestGetLocationByIDReturnsErrorWhenNoData(t *testing.T) { 205 - client := newFakeLocationClient() 206 - 207 - server := newTestLocationServer(client) 208 - 209 - _, err := server.GetLocationByID(context.Background(), "999") 210 - if err == nil { 211 - t.Fatalf("expected error when no location data returned") 212 - } 213 - } 214 - 215 - func TestGetLocationByIDLoadsFromPersistentCache(t *testing.T) { 216 - client := newFakeLocationClient() 217 - fc := cachepkg.NewInMemoryCache() 218 - cachedAt := mustParseTime(t, "2026-01-01T00:00:00Z") 219 - preloaded := Location{ 220 - ID: "12345", 221 - Name: "Cached Store", 222 - Address: "1 Cache Way", 223 - ZipCode: "00601", 224 - CachedAt: cachedAt, 225 - } 226 - mustPutJSONInCache(t, fc, locationCachePrefix+"12345", preloaded) 227 - 228 - server := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 229 - got, err := server.GetLocationByID(context.Background(), "12345") 230 - if err != nil { 231 - t.Fatalf("GetLocationByID returned error: %v", err) 232 - } 233 - if got.Name != "Cached Store" { 234 - t.Fatalf("expected cached location name, got %q", got.Name) 235 - } 236 - } 237 - 238 - func TestGetLocationsByZipStoresToPersistentCacheIfMissing(t *testing.T) { 239 - client := newFakeLocationClient() 240 - lat := 18.18060 241 - lon := -66.74990 242 - client.setListResponse("00601", []Location{ 243 - {ID: "111", Name: "Store 111", ZipCode: "00601", Lat: &lat, Lon: &lon}, 244 - }) 245 - 246 - fc := cachepkg.NewInMemoryCache() 247 - server := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 248 - locs, err := server.GetLocationsByZip(context.Background(), "00601") 249 - if err != nil { 250 - t.Fatalf("GetLocationsByZip returned error: %v", err) 251 - } 252 - if len(locs) != 1 { 253 - t.Fatalf("expected 1 location, got %d", len(locs)) 254 - } 255 - 256 - storedRaw := requireEventuallyCached(t, fc, locationCachePrefix+"111") 257 - var stored Location 258 - if err := json.Unmarshal([]byte(storedRaw), &stored); err != nil { 259 - t.Fatalf("failed to decode stored location: %v", err) 260 - } 261 - if stored.CachedAt.IsZero() { 262 - t.Fatalf("expected cached_at to be set when persisted") 263 - } 264 - } 265 - 266 - func TestGetLocationsByZipReturnsErrorWhenAllBackendsFail(t *testing.T) { 267 - failA := newFakeLocationClient() 268 - failA.err = fmt.Errorf("backend A down") 269 - failB := newFakeLocationClient() 270 - failB.err = fmt.Errorf("backend B down") 271 - 272 - server := newTestLocationServerWithBackends([]locationBackend{failA, failB}) 273 - _, err := server.GetLocationsByZip(context.Background(), "00601") 274 - if err == nil { 275 - t.Fatalf("expected error when all backends fail") 276 - } 277 - } 278 - 279 - func TestLocationStorageNearestZIPToCoordinates(t *testing.T) { 280 - centroids := LoadCentroids() 281 - 282 - zip, ok := centroids.NearestZIPToCoordinates(47.6097, -122.3331) 283 - if !ok { 284 - t.Fatal("expected nearest zip for valid coordinates") 285 - } 286 - if zip != "98101" { 287 - t.Fatalf("unexpected nearest zip: got %q want %q", zip, "98101") 288 - } 289 - } 290 - 291 - func TestGetLocationsByZipSucceedsWhenAtLeastOneBackendSucceeds(t *testing.T) { 292 - fail := newFakeLocationClient() 293 - fail.err = fmt.Errorf("backend down") 294 - 295 - success := newFakeLocationClient() 296 - lat := 18.18060 297 - lon := -66.74990 298 - success.setListResponse("00601", []Location{ 299 - {ID: "ok", Name: "OK", ZipCode: "00601", Lat: &lat, Lon: &lon}, 300 - }) 301 - 302 - server := newTestLocationServerWithBackends([]locationBackend{fail, success}) 303 - locs, err := server.GetLocationsByZip(context.Background(), "00601") 304 - if err != nil { 305 - t.Fatalf("did not expect error when one backend succeeds: %v", err) 306 - } 307 - if len(locs) != 1 || locs[0].ID != "ok" { 308 - t.Fatalf("unexpected locations: %+v", locs) 309 - } 310 - } 311 - 312 - func TestHasInventory(t *testing.T) { 313 - server := newTestLocationServerWithBackends([]locationBackend{ 314 - inventoryBackend{ 315 - supported: map[string]bool{ 316 - "70500874": true, 317 - "wholefoods_123": true, 318 - }, 319 - }, 320 - inventoryBackend{ 321 - supported: map[string]bool{ 322 - "walmart_123": true, 323 - }, 324 - }, 325 - }) 326 - 327 - tests := []struct { 328 - name string 329 - storeID string 330 - hasInventory bool 331 - }{ 332 - {name: "kroger", storeID: "70500874", hasInventory: true}, 333 - {name: "wholefoods", storeID: "wholefoods_123", hasInventory: true}, 334 - {name: "walmart", storeID: "walmart_123", hasInventory: true}, 335 - {name: "unsupported", storeID: "publix_123", hasInventory: false}, 336 - } 337 - 338 - for _, tt := range tests { 339 - t.Run(tt.name, func(t *testing.T) { 340 - if got := server.HasInventory(tt.storeID); got != tt.hasInventory { 341 - t.Fatalf("HasInventory(%q) = %v, want %v", tt.storeID, got, tt.hasInventory) 342 - } 343 - }) 344 - } 345 - } 346 - 347 - func TestRequestStoreWritesRequestBlob(t *testing.T) { 348 - if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 349 - t.Fatalf("failed to init templates: %v", err) 350 - } 351 - 352 - fc := cachepkg.NewInMemoryCache() 353 - client := newFakeLocationClient() 354 - client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 355 - client.setHasInventory("publix_123", false) 356 - storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 357 - server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 358 - 359 - mux := http.NewServeMux() 360 - server.Register(mux, auth.DefaultMock()) 361 - 362 - req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 363 - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 364 - req.Header.Set("HX-Request", "true") 365 - rr := httptest.NewRecorder() 366 - mux.ServeHTTP(rr, req) 367 - 368 - if rr.Code != http.StatusOK { 369 - t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 370 - } 371 - if rr.Header().Get("Location") != "" { 372 - t.Fatalf("expected no redirect location, got %q", rr.Header().Get("Location")) 373 - } 374 - if body := rr.Body.String(); !strings.Contains(body, "Request sent") { 375 - t.Fatalf("expected success fragment, got %q", body) 376 - } 377 - 378 - raw := requireEventuallyCached(t, fc, storeRequestPrefix+"publix_123") 379 - var payload locationRequest 380 - if err := json.Unmarshal([]byte(raw), &payload); err != nil { 381 - t.Fatalf("failed to decode request blob: %v", err) 382 - } 383 - if payload.StoreID != "publix_123" { 384 - t.Fatalf("unexpected store id %q", payload.StoreID) 385 - } 386 - if payload.RequestedAt.IsZero() { 387 - t.Fatal("expected requested_at to be set") 388 - } 389 - } 390 - 391 - func TestRequestStoreIsIdempotent(t *testing.T) { 392 - if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 393 - t.Fatalf("failed to init templates: %v", err) 394 - } 395 - 396 - fc := cachepkg.NewInMemoryCache() 397 - client := newFakeLocationClient() 398 - client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 399 - client.setHasInventory("publix_123", false) 400 - storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 401 - server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 402 - 403 - mux := http.NewServeMux() 404 - server.Register(mux, auth.DefaultMock()) 405 - 406 - for i := 0; i < 2; i++ { 407 - req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 408 - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 409 - req.Header.Set("HX-Request", "true") 410 - rr := httptest.NewRecorder() 411 - mux.ServeHTTP(rr, req) 412 - 413 - if rr.Code != http.StatusOK { 414 - t.Fatalf("request %d status = %d, want %d; body=%q", i+1, rr.Code, http.StatusOK, rr.Body.String()) 415 - } 416 - } 417 - } 418 - 419 - func TestRequestStoreRejectsSupportedStore(t *testing.T) { 420 - if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 421 - t.Fatalf("failed to init templates: %v", err) 422 - } 423 - 424 - fc := cachepkg.NewInMemoryCache() 425 - client := newFakeLocationClient() 426 - client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 427 - client.setHasInventory("publix_123", true) 428 - storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 429 - server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 430 - 431 - mux := http.NewServeMux() 432 - server.Register(mux, auth.DefaultMock()) 433 - 434 - req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 435 - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 436 - req.Header.Set("HX-Request", "true") 437 - rr := httptest.NewRecorder() 438 - mux.ServeHTTP(rr, req) 439 - 440 - if rr.Code != http.StatusBadRequest { 441 - t.Fatalf("status = %d, want %d; body=%q", rr.Code, http.StatusBadRequest, rr.Body.String()) 442 - } 443 - } 444 - 445 - func TestRequestStoreReturnsWriteErrors(t *testing.T) { 446 - storage := &locationStorage{ 447 - cache: failingListCache{putErr: errors.New("boom")}, 448 - } 449 - 450 - err := storage.RequestStore(context.Background(), "publix_123") 451 - if err == nil { 452 - t.Fatal("RequestStore error = nil, want error") 453 - } 454 - } 455 - 456 - func TestRequestedStoreIDsListsStoredRequests(t *testing.T) { 457 - fc := cachepkg.NewInMemoryCache() 458 - storage := newTestLocationServerWithBackendsAndCache([]locationBackend{newFakeLocationClient()}, fc) 459 - 460 - mustPutJSONInCache(t, fc, storeRequestPrefix+"publix_123", locationRequest{StoreID: "publix_123"}) 461 - mustPutJSONInCache(t, fc, storeRequestPrefix+"walmart_456", locationRequest{StoreID: "walmart_456"}) 462 - 463 - got, err := storage.RequestedStoreIDs(context.Background()) 464 - if err != nil { 465 - t.Fatalf("RequestedStoreIDs returned error: %v", err) 466 - } 467 - 468 - if got, want := strings.Join(got, ","), "publix_123,walmart_456"; got != want { 469 - t.Fatalf("RequestedStoreIDs = %q, want %q", got, want) 470 - } 471 - } 472 - 473 - type fakeLocationClient struct { 474 - details map[string]Location 475 - lists map[string][]Location 476 - inv map[string]bool 477 - err error 478 - } 479 - 480 - func newFakeLocationClient() *fakeLocationClient { 481 - return &fakeLocationClient{ 482 - details: make(map[string]Location), 483 - lists: make(map[string][]Location), 484 - inv: make(map[string]bool), 485 - } 486 - } 487 - 488 - func (f *fakeLocationClient) setDetailResponse(locationID string, location Location) { 489 - f.details[locationID] = location 490 - } 491 - 492 - func (f *fakeLocationClient) setListResponse(zip string, locations []Location) { 493 - f.lists[zip] = locations 494 - } 495 - 496 - func (f *fakeLocationClient) setHasInventory(locationID string, hasInventory bool) { 497 - f.inv[locationID] = hasInventory 498 - } 499 - 500 - func (f *fakeLocationClient) GetLocationsByZip(_ context.Context, zipcode string) ([]Location, error) { 501 - if f.err != nil { 502 - return nil, f.err 503 - } 504 - if locations, ok := f.lists[zipcode]; ok { 505 - return locations, nil 506 - } 507 - return nil, nil 508 - } 509 - 510 - func (f *fakeLocationClient) GetLocationByID(_ context.Context, locationID string) (*Location, error) { 511 - if f.err != nil { 512 - return nil, f.err 513 - } 514 - if location, ok := f.details[locationID]; ok { 515 - locationCopy := location 516 - return &locationCopy, nil 517 - } 518 - return nil, fmt.Errorf("no data found for location ID %s", locationID) 519 - } 520 - 521 - func (f *fakeLocationClient) IsID(locationID string) bool { 522 - return locationID != "" 523 - } 524 - 525 - func (f *fakeLocationClient) HasInventory(locationID string) bool { 526 - if hasInventory, ok := f.inv[locationID]; ok { 527 - return hasInventory 528 - } 529 - return true 530 - } 531 - 532 - type inventoryBackend struct { 533 - supported map[string]bool 534 - } 535 - 536 - func (b inventoryBackend) GetLocationByID(context.Context, string) (*Location, error) { 537 - return nil, fmt.Errorf("not implemented") 538 - } 539 - 540 - func (b inventoryBackend) GetLocationsByZip(context.Context, string) ([]Location, error) { 541 - return nil, nil 542 - } 543 - 544 - func (b inventoryBackend) IsID(locationID string) bool { 545 - _, ok := b.supported[locationID] 546 - return ok 547 - } 548 - 549 - func (b inventoryBackend) HasInventory(locationID string) bool { 550 - return b.supported[locationID] 551 - } 552 - 553 - type failingListCache struct { 554 - putErr error 555 - } 556 - 557 - func (f failingListCache) Get(context.Context, string) (io.ReadCloser, error) { 558 - return nil, cachepkg.ErrNotFound 559 - } 560 - 561 - func (f failingListCache) Exists(context.Context, string) (bool, error) { 562 - return false, nil 563 - } 564 - 565 - func (f failingListCache) Put(context.Context, string, string, cachepkg.PutOptions) error { 566 - return f.putErr 567 - } 568 - 569 - func (f failingListCache) List(context.Context, string, string) ([]string, error) { 570 - return nil, nil 571 - } 572 - 573 - func newTestLocationServer(client locationBackend) *locationStorage { 574 - return newTestLocationServerWithBackends([]locationBackend{client}) 575 - } 576 - 577 - func newTestLocationServerWithBackends(backends []locationBackend) *locationStorage { 578 - return newTestLocationServerWithBackendsAndCache(backends, cachepkg.NewInMemoryCache()) 579 - } 580 - 581 - func newTestLocationServerWithBackendsAndCache(backends []locationBackend, c cachepkg.ListCache) *locationStorage { 582 - zipCentroids := LoadCentroids() 583 - return &locationStorage{ 584 - clients: backends, 585 - zipCentroids: zipCentroids, 586 - cache: c, 587 - } 588 - } 589 - 590 - func mustPutJSONInCache(t *testing.T, c cachepkg.Cache, key string, value any) { 591 - t.Helper() 592 - raw, err := json.Marshal(value) 593 - if err != nil { 594 - t.Fatalf("failed to marshal test cache value: %v", err) 595 - } 596 - if err := c.Put(context.Background(), key, string(raw), cachepkg.Unconditional()); err != nil { 597 - t.Fatalf("failed to preload cache key %q: %v", key, err) 598 - } 599 - } 600 - 601 - func requireEventuallyCached(t *testing.T, c cachepkg.Cache, key string) string { 602 - t.Helper() 603 - deadline := time.Now().Add(2 * time.Second) 604 - for time.Now().Before(deadline) { 605 - raw, err := c.Get(context.Background(), key) 606 - if err == nil { 607 - defer func() { 608 - _ = raw.Close() 609 - }() 610 - body, readErr := io.ReadAll(raw) 611 - if readErr != nil { 612 - t.Fatalf("failed to read cached value for key %q: %v", key, readErr) 613 - } 614 - return string(body) 615 - } 616 - if err != cachepkg.ErrNotFound { 617 - t.Fatalf("failed checking cache for key %q: %v", key, err) 618 - } 619 - time.Sleep(10 * time.Millisecond) 620 - } 621 - t.Fatalf("expected cache entry %q to be persisted within timeout", key) 622 - return "" 623 - } 624 - 625 - func mustParseTime(t *testing.T, value string) time.Time { 626 - t.Helper() 627 - ts, err := time.Parse(time.RFC3339, value) 628 - if err != nil { 629 - t.Fatalf("failed to parse time %q: %v", value, err) 630 - } 631 - return ts 632 - } 633 - 634 - type fakeUserLookup struct{} 635 - 636 - func (fakeUserLookup) FromRequest(context.Context, *http.Request, auth.AuthClient) (*utypes.User, error) { 637 - return nil, auth.ErrNoSession 638 - }
+179
internal/locations/server.go
··· 1 + package locations 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "html/template" 8 + "log/slog" 9 + "net/http" 10 + "net/url" 11 + "strconv" 12 + "strings" 13 + 14 + "careme/internal/auth" 15 + "careme/internal/routing" 16 + "careme/internal/seasons" 17 + "careme/internal/templates" 18 + 19 + utypes "careme/internal/users/types" 20 + ) 21 + 22 + type userLookup interface { 23 + FromRequest(ctx context.Context, r *http.Request, authClient auth.AuthClient) (*utypes.User, error) 24 + } 25 + 26 + type locationServer struct { 27 + storage locationStore 28 + zipFetcher zipFetcher 29 + userStorage userLookup 30 + } 31 + 32 + type zipFetcher interface { 33 + NearestZIPToCoordinates(lat, lon float64) (string, bool) 34 + } 35 + 36 + func NewServer(storage locationStore, zipFetcher zipFetcher, userStorage userLookup) *locationServer { 37 + return &locationServer{ 38 + storage: storage, 39 + zipFetcher: zipFetcher, 40 + userStorage: userStorage, 41 + } 42 + } 43 + 44 + func isHTMXRequest(r *http.Request) bool { 45 + return strings.EqualFold(r.Header.Get("HX-Request"), "true") 46 + } 47 + 48 + func (l *locationServer) Ready(ctx context.Context) error { 49 + _, err := l.storage.GetLocationsByZip(ctx, "98005") // magic number is my zip code :) 50 + return err 51 + } 52 + 53 + func (l *locationServer) Register(mux routing.Registrar, authClient auth.AuthClient) { 54 + mux.HandleFunc("GET /locations/zip-from-coordinates", func(w http.ResponseWriter, r *http.Request) { 55 + lat, err := strconv.ParseFloat(r.URL.Query().Get("lat"), 64) 56 + if err != nil { 57 + http.Error(w, "invalid latitude", http.StatusBadRequest) 58 + return 59 + } 60 + lon, err := strconv.ParseFloat(r.URL.Query().Get("lon"), 64) 61 + if err != nil { 62 + http.Error(w, "invalid longitude", http.StatusBadRequest) 63 + return 64 + } 65 + 66 + zip, ok := l.zipFetcher.NearestZIPToCoordinates(lat, lon) 67 + if !ok { 68 + http.Error(w, "zip not found for coordinates", http.StatusNotFound) 69 + return 70 + } 71 + 72 + http.Redirect(w, r, "/locations?zip="+url.QueryEscape(zip), http.StatusFound) 73 + }) 74 + 75 + mux.HandleFunc("GET /locations", func(w http.ResponseWriter, r *http.Request) { 76 + ctx := r.Context() 77 + currentUser, err := l.userStorage.FromRequest(ctx, r, authClient) 78 + if err != nil { 79 + if !errors.Is(err, auth.ErrNoSession) { 80 + http.Error(w, "unable to load account", http.StatusInternalServerError) 81 + slog.ErrorContext(ctx, "failed to get user from request", "error", err) 82 + return 83 + } 84 + } 85 + 86 + zip := r.URL.Query().Get("zip") 87 + if zip == "" { 88 + slog.InfoContext(ctx, "no zip code provided to /locations") 89 + http.Error(w, "provide a zip code with ?zip=12345", http.StatusBadRequest) 90 + return 91 + } 92 + var favoriteStore string 93 + if currentUser != nil { 94 + favoriteStore = currentUser.FavoriteStore 95 + } 96 + if err := l.renderLocationsPage(w, ctx, zip, favoriteStore, currentUser != nil); err != nil { 97 + slog.ErrorContext(ctx, "failed to render locations page", "zip", zip, "error", err) 98 + http.Error(w, "Failed to render locations page. ", http.StatusInternalServerError) 99 + } 100 + }) 101 + 102 + mux.HandleFunc("POST /locations/request-store", func(w http.ResponseWriter, r *http.Request) { 103 + if !isHTMXRequest(r) { 104 + http.Error(w, "store requests must be made via HTMX", http.StatusBadRequest) 105 + return 106 + } 107 + 108 + if err := r.ParseForm(); err != nil { 109 + http.Error(w, "invalid request", http.StatusBadRequest) 110 + return 111 + } 112 + 113 + storeID := r.FormValue("store_id") 114 + if storeID == "" { 115 + http.Error(w, "store_id is required", http.StatusBadRequest) 116 + return 117 + } 118 + 119 + ctx := r.Context() 120 + if _, err := l.storage.GetLocationByID(ctx, storeID); err != nil { 121 + http.Error(w, "invalid store_id", http.StatusBadRequest) 122 + return 123 + } 124 + if l.storage.HasInventory(storeID) { 125 + http.Error(w, "store already supported", http.StatusBadRequest) 126 + return 127 + } 128 + 129 + if err := l.storage.RequestStore(ctx, storeID); err != nil { 130 + http.Error(w, "failed to submit request", http.StatusInternalServerError) 131 + return 132 + } 133 + 134 + if err := templates.Location.ExecuteTemplate(w, "location_request_store_success", nil); err != nil { 135 + slog.ErrorContext(ctx, "failed to render request-store success fragment", "store_id", storeID, "error", err) 136 + http.Error(w, "failed to submit request", http.StatusInternalServerError) 137 + return 138 + } 139 + }) 140 + } 141 + 142 + func (l *locationServer) renderLocationsPage(w http.ResponseWriter, ctx context.Context, zip string, favoriteStore string, serverSignedIn bool) error { 143 + locs, err := l.storage.GetLocationsByZip(ctx, zip) 144 + if err != nil { 145 + return fmt.Errorf("failed to get locations for zip %s: %w", zip, err) 146 + } 147 + 148 + type locationRow struct { 149 + Location 150 + SupportsStaples bool 151 + } 152 + 153 + rows := make([]locationRow, 0, len(locs)) 154 + for _, loc := range locs { 155 + rows = append(rows, locationRow{ 156 + Location: loc, 157 + SupportsStaples: l.storage.HasInventory(loc.ID), 158 + }) 159 + } 160 + 161 + data := struct { 162 + Locations []locationRow 163 + Zip string 164 + FavoriteStore string 165 + ClarityScript template.HTML 166 + GoogleTagScript template.HTML 167 + Style seasons.Style 168 + ServerSignedIn bool 169 + }{ 170 + Locations: rows, 171 + Zip: zip, 172 + FavoriteStore: favoriteStore, 173 + ClarityScript: templates.ClarityScript(ctx), 174 + GoogleTagScript: templates.GoogleTagScript(), 175 + Style: seasons.GetCurrentStyle(), 176 + ServerSignedIn: serverSignedIn, 177 + } 178 + return templates.Location.Execute(w, data) 179 + }
+113
internal/locations/server_test.go
··· 1 + package locations 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + 10 + "careme/internal/auth" 11 + cachepkg "careme/internal/cache" 12 + "careme/internal/config" 13 + "careme/internal/templates" 14 + ) 15 + 16 + func TestRequestStoreWritesRequestBlob(t *testing.T) { 17 + mustInitLocationTemplates(t) 18 + 19 + fc := cachepkg.NewInMemoryCache() 20 + client := newFakeLocationClient() 21 + client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 22 + client.setHasInventory("publix_123", false) 23 + storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 24 + server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 25 + 26 + mux := http.NewServeMux() 27 + server.Register(mux, auth.DefaultMock()) 28 + 29 + req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 30 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 31 + req.Header.Set("HX-Request", "true") 32 + rr := httptest.NewRecorder() 33 + mux.ServeHTTP(rr, req) 34 + 35 + if rr.Code != http.StatusOK { 36 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 37 + } 38 + if rr.Header().Get("Location") != "" { 39 + t.Fatalf("expected no redirect location, got %q", rr.Header().Get("Location")) 40 + } 41 + if body := rr.Body.String(); !strings.Contains(body, "Request sent") { 42 + t.Fatalf("expected success fragment, got %q", body) 43 + } 44 + 45 + raw := requireEventuallyCached(t, fc, storeRequestPrefix+"publix_123") 46 + var payload locationRequest 47 + if err := json.Unmarshal([]byte(raw), &payload); err != nil { 48 + t.Fatalf("failed to decode request blob: %v", err) 49 + } 50 + if payload.StoreID != "publix_123" { 51 + t.Fatalf("unexpected store id %q", payload.StoreID) 52 + } 53 + if payload.RequestedAt.IsZero() { 54 + t.Fatal("expected requested_at to be set") 55 + } 56 + } 57 + 58 + func TestRequestStoreIsIdempotent(t *testing.T) { 59 + mustInitLocationTemplates(t) 60 + 61 + fc := cachepkg.NewInMemoryCache() 62 + client := newFakeLocationClient() 63 + client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 64 + client.setHasInventory("publix_123", false) 65 + storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 66 + server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 67 + 68 + mux := http.NewServeMux() 69 + server.Register(mux, auth.DefaultMock()) 70 + 71 + for i := 0; i < 2; i++ { 72 + req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 73 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 74 + req.Header.Set("HX-Request", "true") 75 + rr := httptest.NewRecorder() 76 + mux.ServeHTTP(rr, req) 77 + 78 + if rr.Code != http.StatusOK { 79 + t.Fatalf("request %d status = %d, want %d; body=%q", i+1, rr.Code, http.StatusOK, rr.Body.String()) 80 + } 81 + } 82 + } 83 + 84 + func TestRequestStoreRejectsSupportedStore(t *testing.T) { 85 + mustInitLocationTemplates(t) 86 + 87 + fc := cachepkg.NewInMemoryCache() 88 + client := newFakeLocationClient() 89 + client.setDetailResponse("publix_123", Location{ID: "publix_123", Name: "Publix 123"}) 90 + client.setHasInventory("publix_123", true) 91 + storage := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 92 + server := NewServer(storage, LoadCentroids(), fakeUserLookup{}) 93 + 94 + mux := http.NewServeMux() 95 + server.Register(mux, auth.DefaultMock()) 96 + 97 + req := httptest.NewRequest(http.MethodPost, "/locations/request-store", strings.NewReader("store_id=publix_123")) 98 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 99 + req.Header.Set("HX-Request", "true") 100 + rr := httptest.NewRecorder() 101 + mux.ServeHTTP(rr, req) 102 + 103 + if rr.Code != http.StatusBadRequest { 104 + t.Fatalf("status = %d, want %d; body=%q", rr.Code, http.StatusBadRequest, rr.Body.String()) 105 + } 106 + } 107 + 108 + func mustInitLocationTemplates(t *testing.T) { 109 + t.Helper() 110 + if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 111 + t.Fatalf("failed to init templates: %v", err) 112 + } 113 + }
+365
internal/locations/storage_test.go
··· 1 + package locations 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "testing" 10 + 11 + cachepkg "careme/internal/cache" 12 + ) 13 + 14 + func TestGetLocationByIDUsesCache(t *testing.T) { 15 + client := newFakeLocationClient() 16 + fc := cachepkg.NewInMemoryCache() 17 + client.setDetailResponse("12345", Location{ 18 + ID: "12345", 19 + Name: "Friendly Market", 20 + Address: "123 Main St", 21 + ZipCode: "10001", 22 + }) 23 + 24 + server := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 25 + 26 + ctx := context.Background() 27 + got, err := server.GetLocationByID(ctx, "12345") 28 + if err != nil { 29 + t.Fatalf("GetLocationByID returned error: %v", err) 30 + } 31 + if got.Name != "Friendly Market" || got.Address != "123 Main St" { 32 + t.Fatalf("unexpected location returned: %+v", got) 33 + } 34 + if got.ZipCode != "10001" { 35 + t.Fatalf("unexpected zip code: %q", got.ZipCode) 36 + } 37 + requireEventuallyCached(t, fc, locationCachePrefix+"12345") 38 + // Remove backend value to prove the second read comes from persistent cache. 39 + delete(client.details, "12345") 40 + _, err = server.GetLocationByID(ctx, "12345") 41 + if err != nil { 42 + t.Fatalf("GetLocationByID second call returned error: %v", err) 43 + } 44 + requireEventuallyCached(t, fc, locationCachePrefix+"12345") 45 + } 46 + 47 + func TestGetLocationsByZipCachesLocations(t *testing.T) { 48 + client := newFakeLocationClient() 49 + fc := cachepkg.NewInMemoryCache() 50 + lat1 := 18.18060 51 + lon1 := -66.74990 52 + lat2 := 18.22000 53 + lon2 := -66.78000 54 + client.setListResponse("00601", []Location{ 55 + { 56 + ID: "111", 57 + Name: "Store 111", 58 + Address: "1 North Ave", 59 + State: "GA", 60 + ZipCode: "00601", 61 + Lat: &lat1, 62 + Lon: &lon1, 63 + }, 64 + { 65 + ID: "222", 66 + Name: "Store 222", 67 + Address: "2 South St", 68 + State: "GA", 69 + ZipCode: "00602", 70 + Lat: &lat2, 71 + Lon: &lon2, 72 + }, 73 + }) 74 + 75 + server := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 76 + 77 + ctx := context.Background() 78 + locs, err := server.GetLocationsByZip(ctx, "00601") 79 + if err != nil { 80 + t.Fatalf("GetLocationsByZip returned error: %v", err) 81 + } 82 + if len(locs) != 2 { 83 + t.Fatalf("expected 2 locations, got %d", len(locs)) 84 + } 85 + if locs[0].ID != "111" || locs[0].State != "GA" { 86 + t.Fatalf("unexpected first location: %+v", locs[0]) 87 + } 88 + if locs[0].ZipCode != "00601" { 89 + t.Fatalf("unexpected first location zip code: %+v", locs[0]) 90 + } 91 + if locs[1].ID != "222" || locs[1].Address != "2 South St" { 92 + t.Fatalf("unexpected second location: %+v", locs[1]) 93 + } 94 + if locs[1].ZipCode != "00602" { 95 + t.Fatalf("unexpected second location zip code: %+v", locs[1]) 96 + } 97 + 98 + requireEventuallyCached(t, fc, locationCachePrefix+"111") 99 + requireEventuallyCached(t, fc, locationCachePrefix+"222") 100 + } 101 + 102 + func TestGetLocationsByZipSortsByCentroidDistance(t *testing.T) { 103 + client := newFakeLocationClient() 104 + nearLat := 18.18060 105 + nearLon := -66.74990 106 + midLat := 18.30000 107 + midLon := -66.90000 108 + farLat := 47.60970 109 + farLon := -122.33310 110 + client.setListResponse("00601", []Location{ 111 + {ID: "far", Name: "Far", ZipCode: "98004", Lat: &farLat, Lon: &farLon}, 112 + {ID: "mid", Name: "Mid", ZipCode: "00602", Lat: &midLat, Lon: &midLon}, 113 + {ID: "near", Name: "Near", ZipCode: "00601", Lat: &nearLat, Lon: &nearLon}, 114 + }) 115 + 116 + server := newTestLocationServer(client) 117 + locs, err := server.GetLocationsByZip(context.Background(), "00601") 118 + if err != nil { 119 + t.Fatalf("GetLocationsByZip returned error: %v", err) 120 + } 121 + if len(locs) != 2 { 122 + t.Fatalf("expected 2 locations after distance filter, got %d", len(locs)) 123 + } 124 + if got, want := []string{locs[0].ID, locs[1].ID}, []string{"near", "mid"}; got[0] != want[0] || got[1] != want[1] { 125 + t.Fatalf("unexpected sorted order: got %v want %v", got, want) 126 + } 127 + } 128 + 129 + func TestGetLocationsByZipSortsUsingLocationZipCentroidFallback(t *testing.T) { 130 + client := newFakeLocationClient() 131 + farLat := 47.60970 132 + farLon := -122.33310 133 + client.setListResponse("00601", []Location{ 134 + {ID: "far", Name: "Far", ZipCode: "98004", Lat: &farLat, Lon: &farLon}, 135 + {ID: "near-by-zip", Name: "Near By Zip", ZipCode: "00601"}, 136 + {ID: "unknown", Name: "Unknown", ZipCode: "zip-unknown"}, 137 + }) 138 + 139 + server := newTestLocationServer(client) 140 + locs, err := server.GetLocationsByZip(context.Background(), "00601") 141 + if err != nil { 142 + t.Fatalf("GetLocationsByZip returned error: %v", err) 143 + } 144 + if len(locs) != 1 { 145 + t.Fatalf("expected 1 location after filtering, got %d", len(locs)) 146 + } 147 + if got, want := []string{locs[0].ID}, []string{"near-by-zip"}; got[0] != want[0] { 148 + t.Fatalf("unexpected sorted order: got %v want %v", got, want) 149 + } 150 + } 151 + 152 + func TestGetLocationsByZipLeavesOrderWhenQueryZipCentroidUnknown(t *testing.T) { 153 + client := newFakeLocationClient() 154 + client.setListResponse("not-a-zip", []Location{ 155 + {ID: "first", Name: "First", ZipCode: "00602"}, 156 + {ID: "second", Name: "Second", ZipCode: "00601"}, 157 + }) 158 + 159 + server := newTestLocationServer(client) 160 + locs, err := server.GetLocationsByZip(context.Background(), "not-a-zip") 161 + if err != nil { 162 + t.Fatalf("GetLocationsByZip returned error: %v", err) 163 + } 164 + if got, want := []string{locs[0].ID, locs[1].ID}, []string{"first", "second"}; got[0] != want[0] || got[1] != want[1] { 165 + t.Fatalf("unexpected order: got %v want %v", got, want) 166 + } 167 + } 168 + 169 + func TestGetLocationsByZipResolvesMissingRequestedZipCentroid(t *testing.T) { 170 + client := newFakeLocationClient() 171 + nearLat := 37.331714 172 + nearLon := -122.341466 173 + midLat := 37.388239 174 + midLon := -122.075351 175 + farLat := 47.60970 176 + farLon := -122.33310 177 + client.setListResponse("94012", []Location{ 178 + {ID: "far", Name: "Far", ZipCode: "98004", Lat: &farLat, Lon: &farLon}, 179 + {ID: "mid", Name: "Mid", ZipCode: "94041", Lat: &midLat, Lon: &midLon}, 180 + {ID: "near", Name: "Near", ZipCode: "94074", Lat: &nearLat, Lon: &nearLon}, 181 + }) 182 + 183 + server := newTestLocationServer(client) 184 + locs, err := server.GetLocationsByZip(context.Background(), "94012") 185 + if err != nil { 186 + t.Fatalf("GetLocationsByZip returned error: %v", err) 187 + } 188 + if len(locs) != 2 { 189 + t.Fatalf("expected 2 nearby locations after centroid backfill, got %d", len(locs)) 190 + } 191 + if got, want := []string{locs[0].ID, locs[1].ID}, []string{"near", "mid"}; got[0] != want[0] || got[1] != want[1] { 192 + t.Fatalf("unexpected order: got %v want %v", got, want) 193 + } 194 + } 195 + 196 + func TestGetLocationByIDReturnsErrorWhenNoData(t *testing.T) { 197 + client := newFakeLocationClient() 198 + 199 + server := newTestLocationServer(client) 200 + 201 + _, err := server.GetLocationByID(context.Background(), "999") 202 + if err == nil { 203 + t.Fatalf("expected error when no location data returned") 204 + } 205 + } 206 + 207 + func TestGetLocationByIDLoadsFromPersistentCache(t *testing.T) { 208 + client := newFakeLocationClient() 209 + fc := cachepkg.NewInMemoryCache() 210 + cachedAt := mustParseTime(t, "2026-01-01T00:00:00Z") 211 + preloaded := Location{ 212 + ID: "12345", 213 + Name: "Cached Store", 214 + Address: "1 Cache Way", 215 + ZipCode: "00601", 216 + CachedAt: cachedAt, 217 + } 218 + mustPutJSONInCache(t, fc, locationCachePrefix+"12345", preloaded) 219 + 220 + server := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 221 + got, err := server.GetLocationByID(context.Background(), "12345") 222 + if err != nil { 223 + t.Fatalf("GetLocationByID returned error: %v", err) 224 + } 225 + if got.Name != "Cached Store" { 226 + t.Fatalf("expected cached location name, got %q", got.Name) 227 + } 228 + } 229 + 230 + func TestGetLocationsByZipStoresToPersistentCacheIfMissing(t *testing.T) { 231 + client := newFakeLocationClient() 232 + lat := 18.18060 233 + lon := -66.74990 234 + client.setListResponse("00601", []Location{ 235 + {ID: "111", Name: "Store 111", ZipCode: "00601", Lat: &lat, Lon: &lon}, 236 + }) 237 + 238 + fc := cachepkg.NewInMemoryCache() 239 + server := newTestLocationServerWithBackendsAndCache([]locationBackend{client}, fc) 240 + locs, err := server.GetLocationsByZip(context.Background(), "00601") 241 + if err != nil { 242 + t.Fatalf("GetLocationsByZip returned error: %v", err) 243 + } 244 + if len(locs) != 1 { 245 + t.Fatalf("expected 1 location, got %d", len(locs)) 246 + } 247 + 248 + storedRaw := requireEventuallyCached(t, fc, locationCachePrefix+"111") 249 + var stored Location 250 + if err := json.Unmarshal([]byte(storedRaw), &stored); err != nil { 251 + t.Fatalf("failed to decode stored location: %v", err) 252 + } 253 + if stored.CachedAt.IsZero() { 254 + t.Fatalf("expected cached_at to be set when persisted") 255 + } 256 + } 257 + 258 + func TestGetLocationsByZipReturnsErrorWhenAllBackendsFail(t *testing.T) { 259 + failA := newFakeLocationClient() 260 + failA.err = fmt.Errorf("backend A down") 261 + failB := newFakeLocationClient() 262 + failB.err = fmt.Errorf("backend B down") 263 + 264 + server := newTestLocationServerWithBackends([]locationBackend{failA, failB}) 265 + _, err := server.GetLocationsByZip(context.Background(), "00601") 266 + if err == nil { 267 + t.Fatalf("expected error when all backends fail") 268 + } 269 + } 270 + 271 + func TestLocationStorageNearestZIPToCoordinates(t *testing.T) { 272 + centroids := LoadCentroids() 273 + 274 + zip, ok := centroids.NearestZIPToCoordinates(47.6097, -122.3331) 275 + if !ok { 276 + t.Fatal("expected nearest zip for valid coordinates") 277 + } 278 + if zip != "98101" { 279 + t.Fatalf("unexpected nearest zip: got %q want %q", zip, "98101") 280 + } 281 + } 282 + 283 + func TestGetLocationsByZipSucceedsWhenAtLeastOneBackendSucceeds(t *testing.T) { 284 + fail := newFakeLocationClient() 285 + fail.err = fmt.Errorf("backend down") 286 + 287 + success := newFakeLocationClient() 288 + lat := 18.18060 289 + lon := -66.74990 290 + success.setListResponse("00601", []Location{ 291 + {ID: "ok", Name: "OK", ZipCode: "00601", Lat: &lat, Lon: &lon}, 292 + }) 293 + 294 + server := newTestLocationServerWithBackends([]locationBackend{fail, success}) 295 + locs, err := server.GetLocationsByZip(context.Background(), "00601") 296 + if err != nil { 297 + t.Fatalf("did not expect error when one backend succeeds: %v", err) 298 + } 299 + if len(locs) != 1 || locs[0].ID != "ok" { 300 + t.Fatalf("unexpected locations: %+v", locs) 301 + } 302 + } 303 + 304 + func TestHasInventory(t *testing.T) { 305 + server := newTestLocationServerWithBackends([]locationBackend{ 306 + inventoryBackend{ 307 + supported: map[string]bool{ 308 + "70500874": true, 309 + "wholefoods_123": true, 310 + }, 311 + }, 312 + inventoryBackend{ 313 + supported: map[string]bool{ 314 + "walmart_123": true, 315 + }, 316 + }, 317 + }) 318 + 319 + tests := []struct { 320 + name string 321 + storeID string 322 + hasInventory bool 323 + }{ 324 + {name: "kroger", storeID: "70500874", hasInventory: true}, 325 + {name: "wholefoods", storeID: "wholefoods_123", hasInventory: true}, 326 + {name: "walmart", storeID: "walmart_123", hasInventory: true}, 327 + {name: "unsupported", storeID: "publix_123", hasInventory: false}, 328 + } 329 + 330 + for _, tt := range tests { 331 + t.Run(tt.name, func(t *testing.T) { 332 + if got := server.HasInventory(tt.storeID); got != tt.hasInventory { 333 + t.Fatalf("HasInventory(%q) = %v, want %v", tt.storeID, got, tt.hasInventory) 334 + } 335 + }) 336 + } 337 + } 338 + 339 + func TestRequestStoreReturnsWriteErrors(t *testing.T) { 340 + storage := &locationStorage{ 341 + cache: failingListCache{putErr: errors.New("boom")}, 342 + } 343 + 344 + err := storage.RequestStore(context.Background(), "publix_123") 345 + if err == nil { 346 + t.Fatal("RequestStore error = nil, want error") 347 + } 348 + } 349 + 350 + func TestRequestedStoreIDsListsStoredRequests(t *testing.T) { 351 + fc := cachepkg.NewInMemoryCache() 352 + storage := newTestLocationServerWithBackendsAndCache([]locationBackend{newFakeLocationClient()}, fc) 353 + 354 + mustPutJSONInCache(t, fc, storeRequestPrefix+"publix_123", locationRequest{StoreID: "publix_123"}) 355 + mustPutJSONInCache(t, fc, storeRequestPrefix+"walmart_456", locationRequest{StoreID: "walmart_456"}) 356 + 357 + got, err := storage.RequestedStoreIDs(context.Background()) 358 + if err != nil { 359 + t.Fatalf("RequestedStoreIDs returned error: %v", err) 360 + } 361 + 362 + if got, want := strings.Join(got, ","), "publix_123,walmart_456"; got != want { 363 + t.Fatalf("RequestedStoreIDs = %q, want %q", got, want) 364 + } 365 + }
+183
internal/locations/test_helpers_test.go
··· 1 + package locations 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "testing" 10 + "time" 11 + 12 + "careme/internal/auth" 13 + cachepkg "careme/internal/cache" 14 + 15 + utypes "careme/internal/users/types" 16 + ) 17 + 18 + type fakeLocationClient struct { 19 + details map[string]Location 20 + lists map[string][]Location 21 + inv map[string]bool 22 + err error 23 + } 24 + 25 + func newFakeLocationClient() *fakeLocationClient { 26 + return &fakeLocationClient{ 27 + details: make(map[string]Location), 28 + lists: make(map[string][]Location), 29 + inv: make(map[string]bool), 30 + } 31 + } 32 + 33 + func (f *fakeLocationClient) setDetailResponse(locationID string, location Location) { 34 + f.details[locationID] = location 35 + } 36 + 37 + func (f *fakeLocationClient) setListResponse(zip string, locations []Location) { 38 + f.lists[zip] = locations 39 + } 40 + 41 + func (f *fakeLocationClient) setHasInventory(locationID string, hasInventory bool) { 42 + f.inv[locationID] = hasInventory 43 + } 44 + 45 + func (f *fakeLocationClient) GetLocationsByZip(_ context.Context, zipcode string) ([]Location, error) { 46 + if f.err != nil { 47 + return nil, f.err 48 + } 49 + if locations, ok := f.lists[zipcode]; ok { 50 + return locations, nil 51 + } 52 + return nil, nil 53 + } 54 + 55 + func (f *fakeLocationClient) GetLocationByID(_ context.Context, locationID string) (*Location, error) { 56 + if f.err != nil { 57 + return nil, f.err 58 + } 59 + if location, ok := f.details[locationID]; ok { 60 + locationCopy := location 61 + return &locationCopy, nil 62 + } 63 + return nil, fmt.Errorf("no data found for location ID %s", locationID) 64 + } 65 + 66 + func (f *fakeLocationClient) IsID(locationID string) bool { 67 + return locationID != "" 68 + } 69 + 70 + func (f *fakeLocationClient) HasInventory(locationID string) bool { 71 + if hasInventory, ok := f.inv[locationID]; ok { 72 + return hasInventory 73 + } 74 + return true 75 + } 76 + 77 + type inventoryBackend struct { 78 + supported map[string]bool 79 + } 80 + 81 + func (b inventoryBackend) GetLocationByID(context.Context, string) (*Location, error) { 82 + return nil, fmt.Errorf("not implemented") 83 + } 84 + 85 + func (b inventoryBackend) GetLocationsByZip(context.Context, string) ([]Location, error) { 86 + return nil, nil 87 + } 88 + 89 + func (b inventoryBackend) IsID(locationID string) bool { 90 + _, ok := b.supported[locationID] 91 + return ok 92 + } 93 + 94 + func (b inventoryBackend) HasInventory(locationID string) bool { 95 + return b.supported[locationID] 96 + } 97 + 98 + type failingListCache struct { 99 + putErr error 100 + } 101 + 102 + func (f failingListCache) Get(context.Context, string) (io.ReadCloser, error) { 103 + return nil, cachepkg.ErrNotFound 104 + } 105 + 106 + func (f failingListCache) Exists(context.Context, string) (bool, error) { 107 + return false, nil 108 + } 109 + 110 + func (f failingListCache) Put(context.Context, string, string, cachepkg.PutOptions) error { 111 + return f.putErr 112 + } 113 + 114 + func (f failingListCache) List(context.Context, string, string) ([]string, error) { 115 + return nil, nil 116 + } 117 + 118 + func newTestLocationServer(client locationBackend) *locationStorage { 119 + return newTestLocationServerWithBackends([]locationBackend{client}) 120 + } 121 + 122 + func newTestLocationServerWithBackends(backends []locationBackend) *locationStorage { 123 + return newTestLocationServerWithBackendsAndCache(backends, cachepkg.NewInMemoryCache()) 124 + } 125 + 126 + func newTestLocationServerWithBackendsAndCache(backends []locationBackend, c cachepkg.ListCache) *locationStorage { 127 + zipCentroids := LoadCentroids() 128 + return &locationStorage{ 129 + clients: backends, 130 + zipCentroids: zipCentroids, 131 + cache: c, 132 + } 133 + } 134 + 135 + func mustPutJSONInCache(t *testing.T, c cachepkg.Cache, key string, value any) { 136 + t.Helper() 137 + raw, err := json.Marshal(value) 138 + if err != nil { 139 + t.Fatalf("failed to marshal test cache value: %v", err) 140 + } 141 + if err := c.Put(context.Background(), key, string(raw), cachepkg.Unconditional()); err != nil { 142 + t.Fatalf("failed to preload cache key %q: %v", key, err) 143 + } 144 + } 145 + 146 + func requireEventuallyCached(t *testing.T, c cachepkg.Cache, key string) string { 147 + t.Helper() 148 + deadline := time.Now().Add(2 * time.Second) 149 + for time.Now().Before(deadline) { 150 + raw, err := c.Get(context.Background(), key) 151 + if err == nil { 152 + defer func() { 153 + _ = raw.Close() 154 + }() 155 + body, readErr := io.ReadAll(raw) 156 + if readErr != nil { 157 + t.Fatalf("failed to read cached value for key %q: %v", key, readErr) 158 + } 159 + return string(body) 160 + } 161 + if err != cachepkg.ErrNotFound { 162 + t.Fatalf("failed checking cache for key %q: %v", key, err) 163 + } 164 + time.Sleep(10 * time.Millisecond) 165 + } 166 + t.Fatalf("expected cache entry %q to be persisted within timeout", key) 167 + return "" 168 + } 169 + 170 + func mustParseTime(t *testing.T, value string) time.Time { 171 + t.Helper() 172 + ts, err := time.Parse(time.RFC3339, value) 173 + if err != nil { 174 + t.Fatalf("failed to parse time %q: %v", value, err) 175 + } 176 + return ts 177 + } 178 + 179 + type fakeUserLookup struct{} 180 + 181 + func (fakeUserLookup) FromRequest(context.Context, *http.Request, auth.AuthClient) (*utypes.User, error) { 182 + return nil, auth.ErrNoSession 183 + }