ai cooking
0
fork

Configure Feed

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

Wholefoodsscraper (#336)

* initial whole food scraper

* have store ids and start of a client

* working location getter kind of neat

* fix test

* good by stores.txt

* ligten up for tests

* fix cache locations and docs and tests

* let wholefoods use simple interface without adding new type

* dead code and name prefix

* simplify

* latency for backends

* close readers

* forgot geo package

* lint baby

* local test data

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
f926522d 6386adf0

+1311 -57
+2 -1
AGENTS.md
··· 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 19 - `go fmt ./...` then `go vet ./...`: Baseline formatting and static checks. 20 - - `golangci-lint run ./...`: For expanded go linters 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. 23 23 - `go run ./cmd/careme -serve -addr :8080`: Start the web server (requires env vars below). ··· 34 34 35 35 ## Testing Guidelines 36 36 - Always run tests after making code changes. Default to `go test ./...`; use a narrower `go test ./... -run TestName` only when appropriate for quick iteration. If you cannot run tests, explicitly say why. 37 + - From the repo root, run `golangci-lint run ./...` after Go changes unless the task clearly does not affect linted code. 37 38 - Place tests alongside code in `*_test.go`; prefer table-driven cases and explicit fixtures over implicit globals. 38 39 - Use `go test ./... -run TestName` for targeted debugging; keep deterministic by avoiding network calls and using fakes where possible. 39 40 - When touching recipe generation or Kroger client code, add assertions that cover API shape changes and template output (see existing tests in `internal/recipes` and `internal/html`).
+106
cmd/wholefoods/main.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/wholefoods" 6 + "context" 7 + "errors" 8 + "flag" 9 + "fmt" 10 + "log" 11 + "log/slog" 12 + "net/http" 13 + "time" 14 + ) 15 + 16 + func main() { 17 + var ( 18 + baseURL string 19 + sitemapURL string 20 + timeoutSec int 21 + ) 22 + 23 + flag.StringVar(&baseURL, "base-url", wholefoods.DefaultBaseURL, "Whole Foods base URL") 24 + flag.StringVar(&sitemapURL, "sitemap-url", wholefoods.DefaultStoreSitemapURL, "Whole Foods store sitemap URL") 25 + flag.IntVar(&timeoutSec, "timeout", 20, "HTTP timeout in seconds") 26 + flag.Parse() 27 + 28 + cacheStore, err := cache.EnsureCache(wholefoods.Container) 29 + if err != nil { 30 + log.Fatalf("failed to create cache: %v", err) 31 + } 32 + 33 + httpClient := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} 34 + client := wholefoods.NewClientWithBaseURL(baseURL, httpClient) 35 + 36 + ctx := context.Background() 37 + refs, err := resolveStoreReferences(ctx, cacheStore, httpClient, sitemapURL) 38 + if err != nil { 39 + log.Fatalf("failed to resolve store references: %v", err) 40 + } 41 + if len(refs) == 0 { 42 + log.Fatalf("no Whole Foods store references found") 43 + } 44 + 45 + slog.Info("syncing Whole Foods store summaries", "count", len(refs)) 46 + var synced int 47 + for _, ref := range refs { 48 + summary, err := client.StoreSummary(ctx, ref.ID) 49 + if err != nil { 50 + slog.Warn("failed to fetch Whole Foods store summary", "store_id", ref.ID, "url", ref.URL, "error", err) 51 + continue 52 + } 53 + if err := wholefoods.CacheStoreSummary(ctx, cacheStore, summary); err != nil { 54 + slog.Warn("failed to cache Whole Foods store summary", "store_id", ref.ID, "error", err) 55 + continue 56 + } 57 + time.Sleep(5 * time.Second) // be nice to the server no rush here 58 + synced++ 59 + } 60 + 61 + fmt.Printf("synced %d Whole Foods store summaries\n", synced) 62 + } 63 + 64 + func resolveStoreReferences(ctx context.Context, cacheStore cache.ListCache, httpClient *http.Client, sitemapURL string) ([]wholefoods.StoreReference, error) { 65 + urlMap, err := wholefoods.LoadStoreURLMap(ctx, cacheStore) 66 + if err != nil && !errors.Is(err, cache.ErrNotFound) { 67 + return nil, err 68 + } 69 + 70 + urls, err := wholefoods.FetchSitemap(ctx, httpClient, sitemapURL) 71 + if err != nil { 72 + return nil, err 73 + } 74 + 75 + if urlMap == nil { 76 + urlMap = make(map[string]string, len(urls)) 77 + } 78 + 79 + refs := make([]wholefoods.StoreReference, 0, len(urls)) 80 + var updated bool 81 + for _, url := range urls { 82 + if storeID := urlMap[url]; storeID != "" { 83 + refs = append(refs, wholefoods.StoreReference{ID: storeID, URL: url}) 84 + continue 85 + } 86 + 87 + storeID, err := wholefoods.FetchStoreIDFromPage(ctx, httpClient, url) 88 + if err != nil { 89 + slog.Warn("failed to discover Whole Foods store id", "url", url, "error", err) 90 + continue 91 + } 92 + time.Sleep(2 * time.Second) // be nice to the server no rush 93 + urlMap[url] = storeID 94 + updated = true 95 + refs = append(refs, wholefoods.StoreReference{ID: storeID, URL: url}) 96 + } 97 + 98 + //TOD remove stores from url map not in itemap? 99 + 100 + if updated { 101 + if err := wholefoods.SaveStoreURLMap(ctx, cacheStore, refs); err != nil { 102 + return nil, err 103 + } 104 + } 105 + return refs, nil 106 + }
+111
cmd/wholefoods/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/wholefoods" 6 + "context" 7 + "fmt" 8 + "net/http" 9 + "net/http/httptest" 10 + "sync/atomic" 11 + "testing" 12 + ) 13 + 14 + func TestResolveStoreReferencesFillsMissingCachedSitemapEntries(t *testing.T) { 15 + t.Parallel() 16 + 17 + cacheStore := cache.NewInMemoryCache() 18 + var server *httptest.Server 19 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 + switch r.URL.Path { 21 + case "/sitemap.xml": 22 + if _, err := fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>%s/stores/westlake</loc></url><url><loc>%s/stores/greenville</loc></url></urlset>`, server.URL, server.URL); err != nil { 23 + t.Fatalf("Fprintf returned error: %v", err) 24 + } 25 + case "/stores/greenville": 26 + if _, err := fmt.Fprint(w, `<div store-id="10224"></div>`); err != nil { 27 + t.Fatalf("Fprint returned error: %v", err) 28 + } 29 + default: 30 + http.NotFound(w, r) 31 + } 32 + })) 33 + defer server.Close() 34 + 35 + if err := wholefoods.SaveStoreURLMap(context.Background(), cacheStore, []wholefoods.StoreReference{ 36 + {ID: "10216", URL: server.URL + "/stores/westlake"}, 37 + }); err != nil { 38 + t.Fatalf("SaveStoreURLMap returned error: %v", err) 39 + } 40 + 41 + refs, err := resolveStoreReferences(context.Background(), cacheStore, server.Client(), server.URL+"/sitemap.xml") 42 + if err != nil { 43 + t.Fatalf("resolveStoreReferences returned error: %v", err) 44 + } 45 + 46 + if len(refs) != 2 { 47 + t.Fatalf("expected 2 refs, got %d", len(refs)) 48 + } 49 + if refs[0] != (wholefoods.StoreReference{ID: "10216", URL: server.URL + "/stores/westlake"}) { 50 + t.Fatalf("unexpected first ref: %+v", refs[0]) 51 + } 52 + if refs[1] != (wholefoods.StoreReference{ID: "10224", URL: server.URL + "/stores/greenville"}) { 53 + t.Fatalf("unexpected second ref: %+v", refs[1]) 54 + } 55 + 56 + urlMap, err := wholefoods.LoadStoreURLMap(context.Background(), cacheStore) 57 + if err != nil { 58 + t.Fatalf("LoadStoreURLMap returned error: %v", err) 59 + } 60 + if got := urlMap[server.URL+"/stores/greenville"]; got != "10224" { 61 + t.Fatalf("expected greenville to be added to cache, got %q", got) 62 + } 63 + } 64 + 65 + func TestResolveStoreReferencesChecksSitemapEvenWithCompleteCache(t *testing.T) { 66 + t.Parallel() 67 + 68 + cacheStore := cache.NewInMemoryCache() 69 + var sitemapRequests atomic.Int32 70 + var pageRequests atomic.Int32 71 + 72 + var server *httptest.Server 73 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 + switch r.URL.Path { 75 + case "/sitemap.xml": 76 + sitemapRequests.Add(1) 77 + if _, err := fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>%s/stores/westlake</loc></url></urlset>`, server.URL); err != nil { 78 + t.Fatalf("Fprintf returned error: %v", err) 79 + } 80 + case "/stores/westlake": 81 + pageRequests.Add(1) 82 + if _, err := fmt.Fprint(w, `<div store-id="10216"></div>`); err != nil { 83 + t.Fatalf("Fprint returned error: %v", err) 84 + } 85 + default: 86 + http.NotFound(w, r) 87 + } 88 + })) 89 + defer server.Close() 90 + 91 + if err := wholefoods.SaveStoreURLMap(context.Background(), cacheStore, []wholefoods.StoreReference{ 92 + {ID: "10216", URL: server.URL + "/stores/westlake"}, 93 + }); err != nil { 94 + t.Fatalf("SaveStoreURLMap returned error: %v", err) 95 + } 96 + 97 + refs, err := resolveStoreReferences(context.Background(), cacheStore, server.Client(), server.URL+"/sitemap.xml") 98 + if err != nil { 99 + t.Fatalf("resolveStoreReferences returned error: %v", err) 100 + } 101 + 102 + if len(refs) != 1 { 103 + t.Fatalf("expected 1 ref, got %d", len(refs)) 104 + } 105 + if sitemapRequests.Load() != 1 { 106 + t.Fatalf("expected sitemap to be requested once, got %d", sitemapRequests.Load()) 107 + } 108 + if pageRequests.Load() != 0 { 109 + t.Fatalf("expected no store page requests when cache is complete, got %d", pageRequests.Load()) 110 + } 111 + }
+11 -5
docs/cache-layout.md
··· 1 1 # Cache Layout 2 2 3 3 This project stores cache entries in: 4 - - Local filesystem under `cache/` (default) 5 - - Azure Blob container `recipes` (when `AZURE_STORAGE_ACCOUNT_NAME` is set) 4 + - Local filesystem under `cache/` (default app cache) 5 + - Local filesystem under `wholefoods/` (Whole Foods cache) 6 + - Azure Blob container `recipes` (default app cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 7 + - Azure Blob container `wholefoods` (Whole Foods cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 6 8 7 - The same cache keys are used in both backends. Keys with `/` become subdirectories (filesystem) or blob prefixes (Azure). 9 + Within a given cache backend, keys with `/` become subdirectories (filesystem) or blob prefixes (Azure). 8 10 9 11 ## Key Prefixes 10 12 ··· 20 22 | `recipe_feedback/` | JSON `RecipeFeedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 21 23 | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) | 22 24 | `email2user/` | Plain text user ID keyed by normalized email | `internal/users/storage.go` (`FindOrCreateFromClerk`) | `internal/users/storage.go` (`GetByEmail`) | 25 + | `wholefoods/stores/` | JSON `wholefoods.StoreSummaryResponse` keyed by Whole Foods store ID | `cmd/wholefoods` and `internal/wholefoods` cache helpers | `internal/wholefoods` location backend | 26 + | `wholefoods/store_url_map.json` | JSON object mapping store URL to Whole Foods store ID | `cmd/wholefoods` and `internal/wholefoods` cache helpers | `cmd/wholefoods` when `-stores` is not provided | 23 27 24 28 ## Notes 25 29 26 30 - Cache backend selection is in `internal/cache/azure.go` (`MakeCache`). 27 - - Local cache path is `cache/` when filesystem backend is used. 28 - - Blob names in Azure match the same key strings listed above. 31 + - Most app caches use the default cache created via `cache.MakeCache()` / `cache.EnsureCache("recipes")`. 32 + - Whole Foods uses a separate cache created via `cache.EnsureCache("wholefoods")`; it does not share the `recipes` container/directory. 33 + - Local cache paths are `recipes/` for most app data and `wholefoods/` for Whole Foods data when filesystem backend is used. 34 + - Blob names in Azure match the same key strings listed above inside their respective containers. 29 35 - Do not create nested keys under `recipe/<hash>` (for example `recipe/<hash>/wine`) because `FileCache` stores `recipe/<hash>` as a file path.
+1 -1
internal/ai/client.go
··· 339 339 var prevRecipesMsg strings.Builder 340 340 prevRecipesMsg.WriteString("Avoid recipes similar to these from the past 2 weeks:\n") 341 341 for _, recipe := range lastRecipes { 342 - prevRecipesMsg.WriteString(fmt.Sprintf("%s\n", recipe)) 342 + fmt.Fprintf(&prevRecipesMsg, "%s\n", recipe) 343 343 } 344 344 messages = append(messages, user(prevRecipesMsg.String())) 345 345 }
+7 -3
internal/cache/azure.go
··· 132 132 return nil 133 133 } 134 134 135 - // TODO take a config? let it set container or directory? 135 + // Legacy probably want to use Ensure cache for new ones. 136 136 func MakeCache() (ListCache, error) { 137 + return EnsureCache("recipes") 138 + } 139 + 140 + func EnsureCache(container string) (ListCache, error) { 137 141 _, ok := os.LookupEnv("AZURE_STORAGE_ACCOUNT_NAME") 138 142 if ok { 139 143 log.Println("Using Azure Blob Storage for cache") 140 - return NewBlobCache("recipes") 144 + return NewBlobCache(container) 141 145 } 142 - return NewFileCache("cache"), nil 146 + return NewFileCache(container), nil 143 147 }
+4
internal/cache/file.go
··· 58 58 59 59 func (fc *FileCache) List(_ context.Context, prefix string, _ string) ([]string, error) { 60 60 var keys []string 61 + if _, err := os.Stat(fc.Dir); os.IsNotExist(err) { 62 + return nil, nil 63 + } 64 + 61 65 err := filepath.Walk(fc.Dir, func(path string, info os.FileInfo, err error) error { 62 66 if err != nil { 63 67 return err
+18 -6
internal/config/config.go
··· 8 8 ) 9 9 10 10 type Config struct { 11 - AI AIConfig `json:"ai"` 12 - Kroger KrogerConfig `json:"kroger"` 13 - Walmart WalmartConfig `json:"walmart"` 14 - Mocks MockConfig `json:"mocks"` 15 - Clerk ClerkConfig `json:"clerk"` 16 - Admin AdminConfig `json:"admin"` 11 + AI AIConfig `json:"ai"` 12 + Kroger KrogerConfig `json:"kroger"` 13 + Walmart WalmartConfig `json:"walmart"` 14 + WholeFoods WholeFoodsConfig `json:"wholefoods"` 15 + Mocks MockConfig `json:"mocks"` 16 + Clerk ClerkConfig `json:"clerk"` 17 + Admin AdminConfig `json:"admin"` 17 18 } 18 19 19 20 type AIConfig struct { ··· 43 44 44 45 type AdminConfig struct { 45 46 Emails []string `json:"emails"` 47 + } 48 + 49 + type WholeFoodsConfig struct { 50 + Enable bool `json:"enable"` 51 + } 52 + 53 + func (c *WholeFoodsConfig) IsEnabled() bool { 54 + return c.Enable 46 55 } 47 56 48 57 // Config defines the required Walmart affiliate credentials and client options. ··· 98 107 }, 99 108 Admin: AdminConfig{ 100 109 Emails: parseAdminEmails(os.Getenv("ADMIN_EMAILS")), 110 + }, 111 + WholeFoods: WholeFoodsConfig{ 112 + Enable: os.Getenv("WHOLEFOODS_ENABLE") != "", 101 113 }, 102 114 Walmart: WalmartConfig{ 103 115 ConsumerID: os.Getenv("WALMART_CONSUMER_ID"),
+21
internal/locations/geo/distance.go
··· 1 + package geo 2 + 3 + import "math" 4 + 5 + // HaversineMiles returns great-circle distance between two latitude/longitude 6 + // points in statute miles. Inputs are decimal degrees. 7 + func HaversineMiles(lat1, lon1, lat2, lon2 float64) float64 { 8 + const earthRadiusMiles = 3958.7613 9 + toRadians := math.Pi / 180.0 10 + 11 + dLat := (lat2 - lat1) * toRadians 12 + dLon := (lon2 - lon1) * toRadians 13 + lat1Rad := lat1 * toRadians 14 + lat2Rad := lat2 * toRadians 15 + 16 + sinHalfDLat := math.Sin(dLat / 2.0) 17 + sinHalfDLon := math.Sin(dLon / 2.0) 18 + a := sinHalfDLat*sinHalfDLat + math.Cos(lat1Rad)*math.Cos(lat2Rad)*sinHalfDLon*sinHalfDLon 19 + c := 2.0 * math.Atan2(math.Sqrt(a), math.Sqrt(1.0-a)) 20 + return earthRadiusMiles * c 21 + }
+21 -29
internal/locations/locations.go
··· 5 5 "careme/internal/cache" 6 6 "careme/internal/config" 7 7 "careme/internal/kroger" 8 + "careme/internal/locations/geo" 8 9 locationtypes "careme/internal/locations/types" 9 10 "careme/internal/seasons" 10 11 "careme/internal/templates" 11 12 utypes "careme/internal/users/types" 12 13 "careme/internal/walmart" 14 + "careme/internal/wholefoods" 13 15 "context" 14 16 "encoding/json" 15 17 "errors" ··· 17 19 "html/template" 18 20 "io" 19 21 "log/slog" 20 - "math" 21 22 "net/http" 22 23 "net/url" 23 24 "sort" ··· 64 65 type Location = locationtypes.Location 65 66 66 67 type centroidByZip interface { 67 - ZipCentroidByZIP(zip string) (ZipCentroid, bool) 68 + ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) 68 69 } 69 70 70 71 func New(cfg *config.Config, c cache.Cache, centroids centroidByZip) (locationGetter, error) { ··· 91 92 } 92 93 backends = append(backends, wclient) 93 94 } 95 + if cfg.WholeFoods.IsEnabled() { 96 + slog.Info("initializing Whole Foods location backend") 97 + listCache, err := cache.EnsureCache(wholefoods.Container) 98 + if err != nil { 99 + return nil, fmt.Errorf("failed to create Whole Foods list cache: %w", err) 100 + } 101 + 102 + wfBackend, err := wholefoods.NewLocationBackend(context.Background(), listCache, centroids) 103 + if err != nil { 104 + return nil, fmt.Errorf("failed to create Whole Foods backend: %w", err) 105 + } 106 + backends = append(backends, wfBackend) 107 + } 94 108 return &locationStorage{ 95 109 client: backends, 96 110 zipCentroids: centroids, ··· 141 155 wg.Add(1) 142 156 go func(backend locationBackend) { 143 157 defer wg.Done() 158 + start := time.Now() 144 159 locations, err := backend.GetLocationsByZip(ctx, zipcode) 145 160 if err != nil { 146 161 slog.ErrorContext(ctx, "error fetching locations from backend", "error", err, "backend", fmt.Sprintf("%T", backend), "zip", zipcode) 147 162 errors <- err 148 163 return 149 164 } 150 - slog.InfoContext(ctx, "Got results for backend", "backend", fmt.Sprintf("%T", backend), "zip", zipcode, "count", len(locations)) 165 + slog.InfoContext(ctx, "Got results for backend", "backend", fmt.Sprintf("%T", backend), "zip", zipcode, "count", len(locations), "latencyMS", time.Since(start).Milliseconds()) 151 166 results <- locations 152 167 }(backend) 153 168 } ··· 244 259 return nil 245 260 } 246 261 247 - func sortLocationsByDistanceFromCentroid(locations []Location, requestedCentroid ZipCentroid, zipCentroids centroidByZip) { 262 + func sortLocationsByDistanceFromCentroid(locations []Location, requestedCentroid locationtypes.ZipCentroid, zipCentroids centroidByZip) { 248 263 sort.SliceStable(locations, func(i, j int) bool { 249 264 leftDistance := locationDistanceTo(requestedCentroid, locations[i], zipCentroids) 250 265 rightDistance := locationDistanceTo(requestedCentroid, locations[j], zipCentroids) ··· 252 267 }) 253 268 } 254 269 255 - func locationDistanceTo(target ZipCentroid, loc Location, zipCentroids centroidByZip) float64 { 270 + func locationDistanceTo(target locationtypes.ZipCentroid, loc Location, zipCentroids centroidByZip) float64 { 256 271 lat, lon := locationCoordinates(loc, zipCentroids) 257 - return haversineMiles(target.Lat, target.Lon, lat, lon) 272 + return geo.HaversineMiles(target.Lat, target.Lon, lat, lon) 258 273 } 259 274 260 275 func locationCoordinates(loc Location, zipCentroids centroidByZip) (float64, float64) { ··· 265 280 //do we actualyl want to fall back? 266 281 centroid, _ := zipCentroids.ZipCentroidByZIP(loc.ZipCode) 267 282 return centroid.Lat, centroid.Lon 268 - } 269 - 270 - // haversineMiles returns great-circle distance between two latitude/longitude 271 - // points in statute miles. Inputs are decimal degrees. 272 - func haversineMiles(lat1, lon1, lat2, lon2 float64) float64 { 273 - const earthRadiusMiles = 3958.7613 274 - toRadians := math.Pi / 180.0 275 - 276 - // Convert deltas and absolute latitudes to radians for trig functions. 277 - dLat := (lat2 - lat1) * toRadians 278 - dLon := (lon2 - lon1) * toRadians 279 - lat1Rad := lat1 * toRadians 280 - lat2Rad := lat2 * toRadians 281 - 282 - // Standard Haversine formula: 283 - // a = sin²(Δφ/2) + cos φ1 * cos φ2 * sin²(Δλ/2) 284 - // c = 2 * atan2(√a, √(1-a)) 285 - // d = R * c 286 - sinHalfDLat := math.Sin(dLat / 2.0) 287 - sinHalfDLon := math.Sin(dLon / 2.0) 288 - a := sinHalfDLat*sinHalfDLat + math.Cos(lat1Rad)*math.Cos(lat2Rad)*sinHalfDLon*sinHalfDLon 289 - c := 2.0 * math.Atan2(math.Sqrt(a), math.Sqrt(1.0-a)) 290 - return earthRadiusMiles * c 291 283 } 292 284 293 285 func (l *locationServer) Ready(ctx context.Context) error {
+5
internal/locations/types/location.go
··· 12 12 Lon *float64 `json:"lon,omitempty"` 13 13 CachedAt time.Time `json:"cached_at,omitempty"` 14 14 } 15 + 16 + type ZipCentroid struct { 17 + Lat float64 18 + Lon float64 19 + }
+85
internal/locations/wholefoods_test.go
··· 1 + package locations 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/config" 6 + "careme/internal/wholefoods" 7 + "context" 8 + "os" 9 + "testing" 10 + ) 11 + 12 + func TestNewAddsWholeFoodsBackendWhenEnabled(t *testing.T) { 13 + cacheStore := cache.NewInMemoryCache() 14 + oldWD, err := os.Getwd() 15 + if err != nil { 16 + t.Fatalf("Getwd returned error: %v", err) 17 + } 18 + tempDir := t.TempDir() 19 + if err := os.Chdir(tempDir); err != nil { 20 + t.Fatalf("Chdir returned error: %v", err) 21 + } 22 + t.Cleanup(func() { 23 + _ = os.Chdir(oldWD) 24 + }) 25 + 26 + unsetEnvForTest(t, "AZURE_STORAGE_ACCOUNT_NAME") 27 + unsetEnvForTest(t, "AZURE_STORAGE_PRIMARY_ACCOUNT_KEY") 28 + 29 + listCache, err := cache.EnsureCache("wholefoods") 30 + if err != nil { 31 + t.Fatalf("EnsureCache returned error: %v", err) 32 + } 33 + if err := wholefoods.CacheStoreSummary(context.Background(), listCache, &wholefoods.StoreSummaryResponse{ 34 + StoreID: 10216, 35 + DisplayName: "Westlake", 36 + PrimaryLocation: wholefoods.StoreLocation{ 37 + Address: wholefoods.StoreAddress{ 38 + StreetAddressLine1: "2210 Westlake Ave", 39 + State: "WA", 40 + ZipCode: "98121", 41 + }, 42 + Latitude: 47.618249, 43 + Longitude: -122.337898, 44 + }, 45 + }); err != nil { 46 + t.Fatalf("CacheStoreSummary returned error: %v", err) 47 + } 48 + 49 + storage, err := New(&config.Config{ 50 + WholeFoods: config.WholeFoodsConfig{Enable: true}, 51 + }, cacheStore, LoadCentroids()) 52 + if err != nil { 53 + t.Fatalf("New returned error: %v", err) 54 + } 55 + 56 + locStorage, ok := storage.(*locationStorage) 57 + if !ok { 58 + t.Fatalf("expected *locationStorage, got %T", storage) 59 + } 60 + 61 + var found bool 62 + for _, backend := range locStorage.client { 63 + if _, ok := backend.(*wholefoods.LocationBackend); ok { 64 + found = true 65 + break 66 + } 67 + } 68 + if !found { 69 + t.Fatalf("expected Whole Foods backend to be registered") 70 + } 71 + } 72 + 73 + func unsetEnvForTest(t *testing.T, key string) { 74 + t.Helper() 75 + 76 + value, ok := os.LookupEnv(key) 77 + if err := os.Unsetenv(key); err != nil { 78 + t.Fatalf("Unsetenv(%q) returned error: %v", key, err) 79 + } 80 + if ok { 81 + t.Cleanup(func() { 82 + _ = os.Setenv(key, value) 83 + }) 84 + } 85 + }
+8 -11
internal/locations/zip_centroids.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "careme/internal/locations/geo" 6 + locationtypes "careme/internal/locations/types" 5 7 _ "embed" 6 8 "encoding/csv" 7 9 "errors" ··· 9 11 "strings" 10 12 ) 11 13 12 - type ZipCentroid struct { 13 - Lat float64 14 - Lon float64 15 - } 16 - 17 14 type zipCentroidIndex struct { 18 - centroids map[string]ZipCentroid 15 + centroids map[string]locationtypes.ZipCentroid 19 16 } 20 17 21 18 func (z zipCentroidIndex) Len() int { ··· 54 51 return zipCentroidIndex{}, errors.New("empty centroid dataset") 55 52 } 56 53 57 - data := make(map[string]ZipCentroid, len(rows)-1) 54 + data := make(map[string]locationtypes.ZipCentroid, len(rows)-1) 58 55 for i := 1; i < len(rows); i++ { 59 56 row := rows[i] 60 57 if len(row) < 3 { ··· 68 65 if err != nil { 69 66 continue 70 67 } 71 - data[row[0]] = ZipCentroid{Lat: lat, Lon: lon} 68 + data[row[0]] = locationtypes.ZipCentroid{Lat: lat, Lon: lon} 72 69 } 73 70 return zipCentroidIndex{centroids: data}, nil 74 71 } 75 72 76 - func (z zipCentroidIndex) ZipCentroidByZIP(zip string) (ZipCentroid, bool) { 73 + func (z zipCentroidIndex) ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) { 77 74 zip5, ok := normalizeZIP(zip) 78 75 if !ok { 79 - return ZipCentroid{}, false 76 + return locationtypes.ZipCentroid{}, false 80 77 } 81 78 82 79 centroid, ok := z.centroids[zip5] ··· 91 88 nearestZip := "" 92 89 nearestDistance := 0.0 93 90 for zip, centroid := range z.centroids { 94 - distance := haversineMiles(lat, lon, centroid.Lat, centroid.Lon) 91 + distance := geo.HaversineMiles(lat, lon, centroid.Lat, centroid.Lon) 95 92 if nearestZip == "" || distance < nearestDistance { 96 93 nearestZip = zip 97 94 nearestDistance = distance
+2 -1
internal/locations/zip_centroids_test.go
··· 1 1 package locations 2 2 3 3 import ( 4 + locationtypes "careme/internal/locations/types" 4 5 "testing" 5 6 ) 6 7 ··· 55 56 func TestNearestZIPToCoordinates(t *testing.T) { 56 57 t.Parallel() 57 58 58 - centroids := zipCentroidIndex{map[string]ZipCentroid{ 59 + centroids := zipCentroidIndex{map[string]locationtypes.ZipCentroid{ 59 60 "10001": {Lat: 40.7506, Lon: -73.9972}, 60 61 "94105": {Lat: 37.7898, Lon: -122.3942}, 61 62 "98101": {Lat: 47.6105, Lon: -122.3348},
+1
internal/wholefoods/beef.json
··· 1 + {"facets":[{"label":"Category","slug":"category","type":"single-select","refinements":[{"label":"All Products","slug":"all-products","count":14259,"isSelected":false,"disabled":false},{"label":"Produce","slug":"produce","count":355,"isSelected":false,"disabled":false,"refinements":[{"label":"Fresh Fruit","slug":"fresh-fruit","count":124,"isSelected":false,"disabled":false},{"label":"Fresh Herbs","slug":"fresh-herbs","count":43,"isSelected":false,"disabled":false},{"label":"Fresh Vegetables","slug":"fresh-vegetables","count":187,"isSelected":false,"disabled":false}]},{"label":"Dairy & Eggs","slug":"dairy-eggs","count":1048,"isSelected":false,"disabled":false,"refinements":[{"label":"Butter & Margarine","slug":"butter-margarine","count":28,"isSelected":false,"disabled":false},{"label":"Cheese","slug":"cheese","count":381,"isSelected":false,"disabled":false},{"label":"Yogurt","slug":"yogurt","count":305,"isSelected":false,"disabled":false},{"label":"Dairy Alternatives","slug":"dairy-alternatives","count":72,"isSelected":false,"disabled":false,"refinements":[{"label":"Almond","slug":"almond","count":16,"isSelected":false,"disabled":false},{"label":"Cashew","slug":"cashew","count":2,"isSelected":false,"disabled":false},{"label":"Oats","slug":"oats","count":12,"isSelected":false,"disabled":false},{"label":"Soy","slug":"soy","count":4,"isSelected":false,"disabled":false},{"label":"Other","slug":"other","count":9,"isSelected":false,"disabled":false}]},{"label":"Milk & Cream","slug":"milk-cream","count":227,"isSelected":false,"disabled":false}]},{"label":"Meat","slug":"meat","count":295,"isSelected":true,"disabled":false,"refinements":[{"label":"Beef","slug":"beef","count":18,"isSelected":true,"disabled":false},{"label":"Chicken","slug":"chicken","count":54,"isSelected":false,"disabled":false},{"label":"Pork","slug":"pork","count":9,"isSelected":false,"disabled":false},{"label":"Turkey","slug":"turkey","count":3,"isSelected":false,"disabled":false},{"label":"Goat, Lamb & Veal","slug":"goat-lamb-veal","count":3,"isSelected":false,"disabled":false},{"label":"Bacon","slug":"bacon","count":9,"isSelected":false,"disabled":false},{"label":"Hotdogs & Sausage","slug":"hotdogs-sausage","count":16,"isSelected":false,"disabled":false},{"label":"Game Meats","slug":"game-meats","count":1,"isSelected":false,"disabled":false},{"label":"Meat Alternatives","slug":"meat-alternatives","count":93,"isSelected":false,"disabled":false}]},{"label":"Prepared Foods","slug":"prepared-foods","count":351,"isSelected":false,"disabled":false,"refinements":[{"label":"Prepared Meals","slug":"prepared-meals","count":254,"isSelected":false,"disabled":false},{"label":"Prepared Soups & Salads","slug":"prepared-soups-salads","count":76,"isSelected":false,"disabled":false}]},{"label":"Pantry Essentials","slug":"pantry-essentials","count":2742,"isSelected":false,"disabled":false,"refinements":[{"label":"Baking","slug":"baking","count":346,"isSelected":false,"disabled":false},{"label":"Canned Goods","slug":"canned-goods","count":111,"isSelected":false,"disabled":false},{"label":"Cereal","slug":"cereal","count":213,"isSelected":false,"disabled":false},{"label":"Condiments & Dressings","slug":"condiments-dressings","count":331,"isSelected":false,"disabled":false},{"label":"Hot Cereal & Pancake Mixes","slug":"hot-cereal-pancake-mixes","count":71,"isSelected":false,"disabled":false},{"label":"Jam, Jellies & Nut Butters","slug":"jam-jellies-nut-butters","count":222,"isSelected":false,"disabled":false},{"label":"Pasta & Noodles","slug":"pasta-noodles","count":154,"isSelected":false,"disabled":false},{"label":"Rice & Grains","slug":"rice-grains","count":101,"isSelected":false,"disabled":false},{"label":"Sauces","slug":"sauces","count":205,"isSelected":false,"disabled":false},{"label":"Soups & Broths","slug":"soups-broths","count":203,"isSelected":false,"disabled":false},{"label":"Spices & Seasonings","slug":"spices-seasonings","count":87,"isSelected":false,"disabled":false}]},{"label":"Breads, Rolls & Bakery","slug":"breads-rolls-bakery","count":224,"isSelected":false,"disabled":false,"refinements":[{"label":"Breads","slug":"breads","count":134,"isSelected":false,"disabled":false},{"label":"Breakfast Bakery","slug":"breakfast-bakery","count":31,"isSelected":false,"disabled":false},{"label":"Rolls & Buns","slug":"rolls-buns","count":28,"isSelected":false,"disabled":false},{"label":"Tortillas & Flat Breads","slug":"tortillas-flat-breads","count":29,"isSelected":false,"disabled":false}]},{"label":"Desserts","slug":"desserts","count":202,"isSelected":false,"disabled":false,"refinements":[{"label":"Cookies","slug":"cookies","count":160,"isSelected":false,"disabled":false},{"label":"Pies & Tarts","slug":"pies-tarts","count":1,"isSelected":false,"disabled":false}]},{"label":"Body Care","slug":"body-care","count":347,"isSelected":false,"disabled":false,"refinements":[{"label":"Bath & Body","slug":"bath-body","count":128,"isSelected":false,"disabled":false},{"label":"Personal Care","slug":"personal-care","count":214,"isSelected":false,"disabled":false}]},{"label":"Supplements","slug":"supplements","count":929,"isSelected":false,"disabled":false,"refinements":[{"label":"Functional Foods","slug":"functional-foods","count":74,"isSelected":false,"disabled":false},{"label":"Functional Supplements","slug":"functional-supplements","count":85,"isSelected":false,"disabled":false},{"label":"Herbs & Homeopathy","slug":"herbs-homeopathy","count":64,"isSelected":false,"disabled":false},{"label":"Specialty Supplements","slug":"specialty-supplements","count":244,"isSelected":false,"disabled":false},{"label":"Sports Nutrition & Weight Management","slug":"sports-nutrition-weight-management","count":114,"isSelected":false,"disabled":false},{"label":"Vitamins & Minerals","slug":"vitamins-minerals","count":264,"isSelected":false,"disabled":false},{"label":"Wellness & Seasonal","slug":"wellness-seasonal","count":84,"isSelected":false,"disabled":false}]},{"label":"Frozen Foods","slug":"frozen-foods","count":697,"isSelected":false,"disabled":false,"refinements":[{"label":"Frozen Breakfast","slug":"frozen-breakfast","count":56,"isSelected":false,"disabled":false},{"label":"Frozen Entrées & Appetizers","slug":"frozen-entrees-appetizers","count":222,"isSelected":false,"disabled":false},{"label":"Frozen Fruits & Vegetables","slug":"frozen-fruits-vegetables","count":107,"isSelected":false,"disabled":false},{"label":"Frozen Pizza","slug":"frozen-pizza","count":58,"isSelected":false,"disabled":false},{"label":"Ice Cream & Frozen Desserts","slug":"ice-cream-frozen-desserts","count":253,"isSelected":false,"disabled":false,"refinements":[{"label":"Almond","slug":"almond","count":16,"isSelected":false,"disabled":false},{"label":"Other","slug":"other","count":9,"isSelected":false,"disabled":false}]}]},{"label":"Snacks, Chips, Salsas & Dips","slug":"snacks-chips-salsas-dips","count":1736,"isSelected":false,"disabled":false,"refinements":[{"label":"Candy & Chocolate","slug":"candy-chocolate","count":436,"isSelected":false,"disabled":false},{"label":"Chips","slug":"chips","count":182,"isSelected":false,"disabled":false},{"label":"Crackers","slug":"crackers","count":180,"isSelected":false,"disabled":false},{"label":"Jerky","slug":"jerky","count":62,"isSelected":false,"disabled":false},{"label":"Nutrition & Granola Bars","slug":"nutrition-granola-bars","count":461,"isSelected":false,"disabled":false},{"label":"Nuts, Seeds & Dried Fruit","slug":"nuts-seeds-dried-fruit","count":210,"isSelected":false,"disabled":false},{"label":"Popcorn, Puffs & Rice Cakes","slug":"popcorn-puffs-rice-cakes","count":89,"isSelected":false,"disabled":false},{"label":"Salsas, Dips & Spreads","slug":"salsas-dips-spreads","count":116,"isSelected":false,"disabled":false}]},{"label":"Seafood","slug":"seafood","count":35,"isSelected":false,"disabled":false,"refinements":[{"label":"Fish","slug":"fish","count":14,"isSelected":false,"disabled":false},{"label":"Shellfish","slug":"shellfish","count":8,"isSelected":false,"disabled":false}]},{"label":"Beverages","slug":"beverages","count":1354,"isSelected":false,"disabled":false,"refinements":[{"label":"Coffee ","slug":"coffee","count":320,"isSelected":false,"disabled":false,"refinements":[{"label":"Ground","slug":"ground","count":56,"isSelected":false,"disabled":false},{"label":"Instant","slug":"instant","count":8,"isSelected":false,"disabled":false},{"label":"Whole Bean","slug":"whole-bean","count":115,"isSelected":false,"disabled":false}]},{"label":"Juice","slug":"juice","count":293,"isSelected":false,"disabled":false},{"label":"Soft Drinks","slug":"soft-drinks","count":83,"isSelected":false,"disabled":false},{"label":"Sports, Energy & Nutritional Drinks","slug":"sports-energy-nutritional-drinks","count":193,"isSelected":false,"disabled":false},{"label":"Tea","slug":"tea","count":249,"isSelected":false,"disabled":false},{"label":"Water, Seltzer & Sparkling Water","slug":"water-seltzer-sparkling-water","count":195,"isSelected":false,"disabled":false}]},{"label":"Wine, Beer & Spirits","slug":"wine-beer-spirits","count":960,"isSelected":false,"disabled":false,"refinements":[{"label":"Beer","slug":"beer","count":251,"isSelected":false,"disabled":false,"refinements":[{"label":"Wheat Beer","slug":"wheat-beer","count":1,"isSelected":false,"disabled":false},{"label":"Hard Kombucha","slug":"hard-kombucha","count":12,"isSelected":false,"disabled":false},{"label":"Hard Cider","slug":"hard-cider","count":34,"isSelected":false,"disabled":false},{"label":"Hard Seltzer","slug":"hard-seltzer","count":10,"isSelected":false,"disabled":false},{"label":"Other Beer","slug":"other-beer","count":7,"isSelected":false,"disabled":false}]},{"label":"Spirits","slug":"spirits","count":122,"isSelected":false,"disabled":false,"refinements":[{"label":"Vodka","slug":"vodka","count":10,"isSelected":false,"disabled":false},{"label":"Whiskey","slug":"whiskey","count":25,"isSelected":false,"disabled":false},{"label":"Gin","slug":"gin","count":7,"isSelected":false,"disabled":false},{"label":"Liquers","slug":"liqueurs","count":7,"isSelected":false,"disabled":false},{"label":"Coolers & Cocktails","slug":"coolers-cocktails","count":45,"isSelected":false,"disabled":false},{"label":"Tequila","slug":"tequila","count":15,"isSelected":false,"disabled":false},{"label":"Rum","slug":"rum","count":5,"isSelected":false,"disabled":false},{"label":"Brandy & Cognac","slug":"brandy-cognac","count":3,"isSelected":false,"disabled":false},{"label":"Other Spirits","slug":"other-spirits","count":2,"isSelected":false,"disabled":false}]},{"label":"Wine","slug":"wine","count":587,"isSelected":false,"disabled":false,"refinements":[{"label":"Red Wine","slug":"red-wine","count":237,"isSelected":false,"disabled":false},{"label":"White Wine","slug":"white-wine","count":160,"isSelected":false,"disabled":false},{"label":"Rosé","slug":"rose","count":37,"isSelected":false,"disabled":false},{"label":"Sparkling Wine","slug":"sparkling-wine","count":41,"isSelected":false,"disabled":false},{"label":"Dessert, Fruit & Other Wine","slug":"dessert-fruit-other-wine","count":67,"isSelected":false,"disabled":false}]}]},{"label":"Beauty","slug":"beauty","count":348,"isSelected":false,"disabled":false,"refinements":[{"label":"Cosmetics","slug":"cosmetics","count":46,"isSelected":false,"disabled":false},{"label":"Facial Care","slug":"facial-care","count":141,"isSelected":false,"disabled":false},{"label":"Hair Care","slug":"hair-care","count":159,"isSelected":false,"disabled":false}]},{"label":"Floral","slug":"floral","count":45,"isSelected":false,"disabled":false},{"label":"Household","slug":"household","count":197,"isSelected":false,"disabled":false,"refinements":[{"label":"Cleaners","slug":"cleaners","count":131,"isSelected":false,"disabled":false},{"label":"Paper & Household Essentials","slug":"paper-household-essentials","count":56,"isSelected":false,"disabled":false}]},{"label":"Baby & Child","slug":"baby-child","count":158,"isSelected":false,"disabled":false},{"label":"Lifestyle","slug":"lifestyle","count":133,"isSelected":false,"disabled":false,"refinements":[{"label":"Apparel & Accessories","slug":"apparel-accessories","count":4,"isSelected":false,"disabled":false},{"label":"Cards & Party","slug":"cards-party","count":1,"isSelected":false,"disabled":false},{"label":"Home & Kitchen","slug":"home-kitchen","count":116,"isSelected":false,"disabled":false},{"label":"Toys & Games","slug":"toys-games","count":1,"isSelected":false,"disabled":false}]},{"label":"Pet","slug":"pet","count":112,"isSelected":false,"disabled":false,"refinements":[{"label":"Accessories","slug":"accessories","count":4,"isSelected":false,"disabled":false},{"label":"Cat Food","slug":"cat-food","count":39,"isSelected":false,"disabled":false},{"label":"Dog Food","slug":"dog-food","count":69,"isSelected":false,"disabled":false}]}]},{"label":"Featured","slug":"featured","type":"multi-select","refinements":[{"label":"Sales & Deals","slug":"on-sale","count":2,"isSelected":false,"disabled":false},{"label":"365 by Whole Foods Market","slug":"365-by-whole-foods-market","count":3,"isSelected":false,"disabled":false}]},{"label":"Dietary Preference","slug":"diets","type":"multi-select","refinements":[{"label":"Organic","slug":"organic","count":3,"isSelected":false,"disabled":false}]}],"breadcrumb":[{"label":"Meat","slug":"meat"},{"label":"Beef","slug":"beef"}],"results":[{"regularPrice":10.49,"name":"Organic Ground Beef 93% Lean/7% Fat, 16 OZ","slug":"organic-rancher-organic-ground-beef-93-lean7-fat-16-oz-b09439skw3","brand":"Organic Rancher","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/51pdCPs06-S.jpg","store":10216,"isLocal":false},{"regularPrice":17.99,"name":"Beef Loin Flank Steak","slug":"meat-beef-loin-flank-steak-b0gc8b1gqw","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41hCq39q93L.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":9.49,"name":"Steakhouse Beef Burger","slug":"meat-steakhouse-beef-burger-b07fzjmr28","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41U4bu6k9qL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":8.49,"name":"365 By Whole Foods Market, Ground Beef, 90% Lean/10% Fat, Step 1, 16 Ounce","slug":"365-by-whole-foods-market-365-by-whole-foods-market-ground-beef-90-lean10-fat-step-1-16-ounce-b0cbcx2fsm","brand":"365 by Whole Foods Market","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41p01Sx5XBL.jpg","store":10216,"isLocal":false},{"regularPrice":7.49,"name":"365 By Whole Foods Market, Ground Beef, 80/20, Step 1, 16 Ounce","slug":"365-by-whole-foods-market-365-by-whole-foods-market-ground-beef-8020-step-1-16-ounce-b0cbcx4thf","brand":"365 by Whole Foods Market","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/410aJFe5noL.jpg","store":10216,"isLocal":false},{"regularPrice":8.49,"name":"80/20 Ground Beef","slug":"meat-8020-ground-beef-b07ns2rkbx","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41RYnSBMCqL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":15.99,"name":"Carne Asada Beef Fajitas","slug":"meat-carne-asada-beef-fajitas-b07q44v7bd","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41sVNo36jZL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":11.99,"name":"Bone-In Beef Chuck Short Rib","slug":"meat-bonein-beef-chuck-short-rib-b0g4rwytdj","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41IIC8DjTYL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":12.99,"name":"Superior Beef Burger","slug":"meat-superior-beef-burger-b07jg9fl9s","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41rCDwXo0jL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":18.99,"name":"Beef New York Strip Steak, 12 OZ","slug":"country-natural-beef-beef-new-york-strip-steak-12-oz-b0938xcpvl","brand":"Country Natural Beef","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/31rID1F5bQL.jpg","store":10216,"isLocal":false},{"regularPrice":20.99,"name":"Ground Beef 80% Lean/20% Fat Value Pack, 48 OZ","slug":"365-by-whole-foods-market-ground-beef-80-lean20-fat-value-pack-48-oz-b0cbcxdlvk","brand":"365 by Whole Foods Market","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41fp9nBGjQL.jpg","store":10216,"isLocal":false},{"regularPrice":11.99,"name":"Beef Chuck Flat Cut Brisket","slug":"meat-beef-chuck-flat-cut-brisket-b0g4s5tnf1","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/410Y3H1vYHL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":33.49,"name":"Bone-In Beef Loin Ny Strip Steak","slug":"meat-bonein-beef-loin-ny-strip-steak-b0gh1mgqlm","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41bTriJt-cL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":11.99,"name":"Boneless Beef Chuck Roast","slug":"meat-boneless-beef-chuck-roast-b07qrryhlm","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41qHIyrctQL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":13.99,"salePrice":12.44,"incrementalSalePrice":11.2,"saleStartDate":"2026-03-04T00:00:00Z","saleEndDate":"2026-03-10T23:59:59Z","name":"Organic Beef New York Strip Steak, 8 OZ","slug":"organic-rancher-organic-beef-new-york-strip-steak-8-oz-b0943bdrkz","brand":"Organic Rancher","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41MAR3cFL1S.jpg","store":10216,"isLocal":false},{"regularPrice":34.49,"name":"Boneless Beef Ribeye Steak","slug":"meat-boneless-beef-ribeye-steak-b0gh1kyk4v","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41ob3e2qpBL.jpg","store":10216,"isLocal":false,"uom":"lb"},{"regularPrice":9.49,"salePrice":7.59,"incrementalSalePrice":6.83,"saleStartDate":"2026-03-06T00:00:00Z","saleEndDate":"2026-03-08T23:59:59Z","name":"Organic Ground Beef 85% Lean/15% Fat, 16 OZ","slug":"organic-rancher-organic-ground-beef-85-lean15-fat-16-oz-b09431kdp8","brand":"Organic Rancher","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/51M6tb82J6S.jpg","store":10216,"isLocal":false},{"regularPrice":11.99,"name":"Beef Chuck Stew","slug":"meat-beef-chuck-stew-b0fk5hx14d","brand":"MEAT","imageThumbnail":"https://m.media-amazon.com/images/S/assets.wholefoodsmarket.com/PIE/product/41T-ljkDD7L.jpg","store":10216,"isLocal":false,"uom":"lb"}],"meta":{"total":{"value":18,"relation":"EQUAL_TO"},"state":{"refinements":[{"label":"Meat","slug":"meat","filterSlug":"category"},{"label":"Beef","slug":"beef","filterSlug":"category"}],"sort":"featured"}}}
+132
internal/wholefoods/cache.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "careme/internal/cache" 5 + locationtypes "careme/internal/locations/types" 6 + "context" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "log/slog" 11 + "strconv" 12 + 13 + "github.com/samber/lo" 14 + lop "github.com/samber/lo/parallel" 15 + ) 16 + 17 + const ( 18 + Container = "wholefoods" 19 + //prefixes are a little redundant since we already have a container. Could simpify with reimport. 20 + StoreCachePrefix = "wholefoods/stores/" 21 + StoreURLMapCacheKey = "wholefoods/store_url_map.json" 22 + LocationIDPrefix = "wholefoods_" 23 + DefaultStoreSitemapURL = "https://www.wholefoodsmarket.com/sitemap/sitemap-stores.xml" 24 + ) 25 + 26 + type StoreReference struct { 27 + ID string `json:"id"` 28 + URL string `json:"url"` 29 + } 30 + 31 + func SaveStoreURLMap(ctx context.Context, c cache.Cache, refs []StoreReference) error { 32 + urlMap := make(map[string]string, len(refs)) 33 + for _, ref := range refs { 34 + if ref.URL == "" || ref.ID == "" { 35 + continue 36 + } 37 + urlMap[ref.URL] = ref.ID 38 + } 39 + 40 + raw, err := json.Marshal(urlMap) 41 + if err != nil { 42 + return fmt.Errorf("marshal store url map: %w", err) 43 + } 44 + if err := c.Put(ctx, StoreURLMapCacheKey, string(raw), cache.Unconditional()); err != nil { 45 + return fmt.Errorf("write store url map cache: %w", err) 46 + } 47 + return nil 48 + } 49 + 50 + func LoadStoreURLMap(ctx context.Context, c cache.Cache) (map[string]string, error) { 51 + reader, err := c.Get(ctx, StoreURLMapCacheKey) 52 + if err != nil { 53 + return nil, err 54 + } 55 + defer func() { 56 + _ = reader.Close() 57 + }() 58 + 59 + var urlMap map[string]string 60 + if err := json.NewDecoder(reader).Decode(&urlMap); err != nil { 61 + return nil, fmt.Errorf("decode store url map cache: %w", err) 62 + } 63 + return urlMap, nil 64 + } 65 + 66 + func CacheStoreSummary(ctx context.Context, c cache.Cache, summary *StoreSummaryResponse) error { 67 + if summary == nil { 68 + return errors.New("store summary is required") 69 + } 70 + 71 + raw, err := json.Marshal(summary) 72 + if err != nil { 73 + return fmt.Errorf("marshal store summary: %w", err) 74 + } 75 + 76 + key := StoreCachePrefix + strconv.Itoa(summary.StoreID) 77 + if err := c.Put(ctx, key, string(raw), cache.Unconditional()); err != nil { 78 + return fmt.Errorf("write store summary cache: %w", err) 79 + } 80 + return nil 81 + } 82 + 83 + // loadCachedStoreSummaries get all store summaries into memory. 84 + // its pretty intense and maybe we should just load latlong for index 85 + func loadCachedStoreSummaries(ctx context.Context, c cache.ListCache) ([]*StoreSummaryResponse, error) { 86 + keys, err := c.List(ctx, StoreCachePrefix, "") 87 + if err != nil { 88 + return nil, fmt.Errorf("list cached store summaries: %w", err) 89 + } 90 + 91 + summaries := lop.Map(keys, func(key string, i int) *StoreSummaryResponse { 92 + reader, err := c.Get(ctx, StoreCachePrefix+key) 93 + if err != nil { 94 + slog.WarnContext(ctx, "failed to read cached whole foods store summary", "key", key, "error", err) 95 + return nil 96 + } 97 + defer func() { 98 + _ = reader.Close() 99 + }() 100 + var summary StoreSummaryResponse 101 + if err := json.NewDecoder(reader).Decode(&summary); err != nil { 102 + slog.WarnContext(ctx, "failed to decode cached whole foods store summary", "key", key, "error", err) 103 + return nil 104 + } 105 + return &summary 106 + }) 107 + 108 + summaries = lo.Compact(summaries) 109 + 110 + if len(summaries) == 0 { 111 + return nil, fmt.Errorf("failed to load wholefoods locations") 112 + } 113 + 114 + return summaries, nil 115 + } 116 + 117 + // StoreSummaryToLocation converts a whole food type intoa generic locaitn. 118 + // Mostly vanilla except for prefixing name 119 + func storeSummaryToLocation(summary StoreSummaryResponse) locationtypes.Location { 120 + lat := summary.PrimaryLocation.Latitude 121 + lon := summary.PrimaryLocation.Longitude 122 + 123 + return locationtypes.Location{ 124 + ID: LocationIDPrefix + strconv.Itoa(summary.StoreID), 125 + Name: "Whole Foods " + summary.DisplayName, 126 + Address: summary.PrimaryLocation.Address.StreetAddressLine1, 127 + State: summary.PrimaryLocation.Address.State, 128 + ZipCode: summary.PrimaryLocation.Address.ZipCode, 129 + Lat: &lat, 130 + Lon: &lon, 131 + } 132 + }
+32
internal/wholefoods/cache_test.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "context" 6 + "testing" 7 + ) 8 + 9 + func TestStoreURLMapRoundTrip(t *testing.T) { 10 + t.Parallel() 11 + 12 + cacheStore := cache.NewInMemoryCache() 13 + refs := []StoreReference{ 14 + {ID: "10216", URL: "https://www.wholefoodsmarket.com/stores/westlake"}, 15 + {ID: "10224", URL: "https://www.wholefoodsmarket.com/stores/greenville"}, 16 + } 17 + 18 + if err := SaveStoreURLMap(context.Background(), cacheStore, refs); err != nil { 19 + t.Fatalf("SaveStoreURLMap returned error: %v", err) 20 + } 21 + 22 + urlMap, err := LoadStoreURLMap(context.Background(), cacheStore) 23 + if err != nil { 24 + t.Fatalf("LoadStoreURLMap returned error: %v", err) 25 + } 26 + if len(urlMap) != 2 { 27 + t.Fatalf("expected 2 url mappings, got %d", len(urlMap)) 28 + } 29 + if got := urlMap["https://www.wholefoodsmarket.com/stores/westlake"]; got != "10216" { 30 + t.Fatalf("unexpected cached store id: %q", got) 31 + } 32 + }
+226
internal/wholefoods/client.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "net/url" 11 + "strings" 12 + "time" 13 + ) 14 + 15 + const ( 16 + // DefaultBaseURL is the public Whole Foods Market website origin. 17 + DefaultBaseURL = "https://www.wholefoodsmarket.com" 18 + ) 19 + 20 + // Client calls the public Whole Foods category products endpoint. 21 + type Client struct { 22 + baseURL string 23 + httpClient *http.Client 24 + } 25 + 26 + // CategoryResponse matches the public category API payload shape used in wf-output/beef.json. 27 + type CategoryResponse struct { 28 + Facets []Facet `json:"facets"` 29 + Breadcrumb []Breadcrumb `json:"breadcrumb"` 30 + Results []Product `json:"results"` 31 + Meta Meta `json:"meta"` 32 + } 33 + 34 + type Facet struct { 35 + Label string `json:"label"` 36 + Slug string `json:"slug"` 37 + Type string `json:"type,omitempty"` 38 + Refinements []FacetRefinement `json:"refinements"` 39 + } 40 + 41 + type FacetRefinement struct { 42 + Label string `json:"label"` 43 + Slug string `json:"slug"` 44 + Count int `json:"count"` 45 + IsSelected bool `json:"isSelected"` 46 + Disabled bool `json:"disabled"` 47 + Refinements []FacetRefinement `json:"refinements,omitempty"` 48 + } 49 + 50 + type Breadcrumb struct { 51 + Label string `json:"label"` 52 + Slug string `json:"slug"` 53 + } 54 + 55 + type Product struct { 56 + RegularPrice float64 `json:"regularPrice"` 57 + SalePrice float64 `json:"salePrice,omitempty"` 58 + IncrementalSalePrice float64 `json:"incrementalSalePrice,omitempty"` 59 + SaleStartDate string `json:"saleStartDate,omitempty"` 60 + SaleEndDate string `json:"saleEndDate,omitempty"` 61 + Name string `json:"name"` 62 + Slug string `json:"slug"` 63 + Brand string `json:"brand"` 64 + ImageThumbnail string `json:"imageThumbnail"` 65 + Store int `json:"store"` 66 + IsLocal bool `json:"isLocal"` 67 + UOM string `json:"uom,omitempty"` 68 + } 69 + 70 + type Meta struct { 71 + Total Total `json:"total"` 72 + State State `json:"state"` 73 + } 74 + 75 + type Total struct { 76 + Value int `json:"value"` 77 + Relation string `json:"relation"` 78 + } 79 + 80 + type State struct { 81 + Refinements []StateRefinement `json:"refinements"` 82 + Sort string `json:"sort"` 83 + } 84 + 85 + type StateRefinement struct { 86 + Label string `json:"label"` 87 + Slug string `json:"slug"` 88 + FilterSlug string `json:"filterSlug"` 89 + } 90 + 91 + // StoreSummaryResponse matches the public store summary payload returned by /api/stores/{store}/summary. 92 + type StoreSummaryResponse struct { 93 + StoreID int `json:"storeId"` 94 + Token string `json:"token"` 95 + DisplayName string `json:"displayName"` 96 + Status string `json:"status"` 97 + Phone string `json:"phone"` 98 + StorePrimeEligibility bool `json:"storePrimeEligibility"` 99 + StoreOperationalGuidance string `json:"storeOperationalGuidance"` 100 + BU int `json:"bu"` 101 + Folder string `json:"folder"` 102 + OpenedAt string `json:"openedAt"` 103 + Links StoreSummaryLinks `json:"links"` 104 + PrimaryLocation StoreLocation `json:"primaryLocation"` 105 + Hours map[string]string `json:"hours"` 106 + Holidays map[string]any `json:"holidays"` 107 + } 108 + 109 + type StoreSummaryLinks struct { 110 + Details string `json:"Details"` 111 + Directions string `json:"Directions"` 112 + Sales string `json:"Sales"` 113 + PrimeNowPickUpAndDelivery string `json:"PrimeNowPickUpAndDelivery"` 114 + MapURLDesktop string `json:"MapUrlDesktop"` 115 + MapURLTablet string `json:"MapUrlTablet"` 116 + MapURLMobile string `json:"MapUrlMobile"` 117 + } 118 + 119 + type StoreLocation struct { 120 + Address StoreAddress `json:"address"` 121 + Latitude float64 `json:"latitude"` 122 + Longitude float64 `json:"longitude"` 123 + } 124 + 125 + type StoreAddress struct { 126 + StreetAddressLine1 string `json:"STREET_ADDRESS_LINE1"` 127 + City string `json:"CITY"` 128 + State string `json:"STATE"` 129 + PostalCode string `json:"POSTAL_CODE"` 130 + ZipCode string `json:"ZIP_CODE"` 131 + Country string `json:"COUNTRY"` 132 + } 133 + 134 + // NewClient creates a Whole Foods client with a default base URL and timeout. 135 + func NewClient(httpClient *http.Client) *Client { 136 + return NewClientWithBaseURL(DefaultBaseURL, httpClient) 137 + } 138 + 139 + // NewClientWithBaseURL creates a Whole Foods client for the provided base URL. 140 + func NewClientWithBaseURL(baseURL string, httpClient *http.Client) *Client { 141 + baseURL = strings.TrimSpace(baseURL) 142 + if baseURL == "" { 143 + baseURL = DefaultBaseURL 144 + } 145 + if httpClient == nil { 146 + httpClient = &http.Client{Timeout: 20 * time.Second} 147 + } 148 + 149 + return &Client{ 150 + baseURL: strings.TrimRight(baseURL, "/"), 151 + httpClient: httpClient, 152 + } 153 + } 154 + 155 + // Category fetches a category page payload like /api/products/category/beef?store=10216. 156 + func (c *Client) Category(ctx context.Context, queryterm, store string) (*CategoryResponse, error) { 157 + queryterm = strings.TrimSpace(queryterm) 158 + if queryterm == "" { 159 + return nil, errors.New("queryterm is required") 160 + } 161 + 162 + store = strings.TrimSpace(store) 163 + if store == "" { 164 + return nil, errors.New("store is required") 165 + } 166 + 167 + endpoint, err := url.Parse(c.baseURL + "/api/products/category/" + url.PathEscape(queryterm)) 168 + if err != nil { 169 + return nil, fmt.Errorf("parse category URL: %w", err) 170 + } 171 + 172 + params := endpoint.Query() 173 + params.Set("store", store) 174 + endpoint.RawQuery = params.Encode() 175 + 176 + var decoded CategoryResponse 177 + if err := c.getJSON(ctx, endpoint.String(), &decoded); err != nil { 178 + return nil, err 179 + } 180 + return &decoded, nil 181 + } 182 + 183 + // StoreSummary fetches a store summary payload like /api/stores/10216/summary. 184 + func (c *Client) StoreSummary(ctx context.Context, store string) (*StoreSummaryResponse, error) { 185 + store = strings.TrimSpace(store) 186 + if store == "" { 187 + return nil, errors.New("store is required") 188 + } 189 + 190 + endpoint := c.baseURL + "/api/stores/" + url.PathEscape(store) + "/summary" 191 + 192 + var decoded StoreSummaryResponse 193 + if err := c.getJSON(ctx, endpoint, &decoded); err != nil { 194 + return nil, err 195 + } 196 + return &decoded, nil 197 + } 198 + 199 + func (c *Client) getJSON(ctx context.Context, endpoint string, dest any) error { 200 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 201 + if err != nil { 202 + return fmt.Errorf("build request: %w", err) 203 + } 204 + req.Header.Set("Accept", "application/json") 205 + 206 + resp, err := c.httpClient.Do(req) 207 + if err != nil { 208 + return fmt.Errorf("request %q: %w", endpoint, err) 209 + } 210 + defer func() { 211 + _ = resp.Body.Close() 212 + }() 213 + 214 + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) 215 + if err != nil { 216 + return fmt.Errorf("read response: %w", err) 217 + } 218 + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { 219 + return fmt.Errorf("request failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) 220 + } 221 + 222 + if err := json.Unmarshal(body, dest); err != nil { 223 + return fmt.Errorf("decode response: %w", err) 224 + } 225 + return nil 226 + }
+169
internal/wholefoods/client_test.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "os" 8 + "strings" 9 + "testing" 10 + ) 11 + 12 + func TestCategory_BuildsRequestAndDecodesFixture(t *testing.T) { 13 + t.Parallel() 14 + 15 + fixture := loadFixture(t, "beef.json") 16 + 17 + var capturedReq *http.Request 18 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 + capturedReq = r 20 + w.Header().Set("Content-Type", "application/json") 21 + _, _ = w.Write(fixture) 22 + })) 23 + t.Cleanup(server.Close) 24 + 25 + client := NewClientWithBaseURL(server.URL, server.Client()) 26 + 27 + resp, err := client.Category(context.Background(), "beef", "10216") 28 + if err != nil { 29 + t.Fatalf("Category returned error: %v", err) 30 + } 31 + 32 + if capturedReq == nil { 33 + t.Fatal("expected request to be captured") 34 + } 35 + if capturedReq.URL.Path != "/api/products/category/beef" { 36 + t.Fatalf("unexpected path: %s", capturedReq.URL.Path) 37 + } 38 + if got := capturedReq.URL.Query().Get("store"); got != "10216" { 39 + t.Fatalf("unexpected store query value: %q", got) 40 + } 41 + if got := capturedReq.Header.Get("Accept"); got != "application/json" { 42 + t.Fatalf("unexpected Accept header: %q", got) 43 + } 44 + 45 + if len(resp.Facets) != 3 { 46 + t.Fatalf("unexpected facets count: %d", len(resp.Facets)) 47 + } 48 + if len(resp.Breadcrumb) != 2 { 49 + t.Fatalf("unexpected breadcrumb count: %d", len(resp.Breadcrumb)) 50 + } 51 + if got := resp.Breadcrumb[1].Slug; got != "beef" { 52 + t.Fatalf("unexpected breadcrumb slug: %q", got) 53 + } 54 + if got := resp.Meta.Total.Value; got != 18 { 55 + t.Fatalf("unexpected total value: %d", got) 56 + } 57 + if len(resp.Results) != 18 { 58 + t.Fatalf("unexpected results count: %d", len(resp.Results)) 59 + } 60 + if got := resp.Results[0].Name; got != "Organic Ground Beef 93% Lean/7% Fat, 16 OZ" { 61 + t.Fatalf("unexpected first result name: %q", got) 62 + } 63 + if got := resp.Results[14].SalePrice; got != 12.44 { 64 + t.Fatalf("unexpected sale price: %v", got) 65 + } 66 + } 67 + 68 + func TestCategory_RequiresQuerytermAndStore(t *testing.T) { 69 + t.Parallel() 70 + 71 + client := NewClient(nil) 72 + 73 + _, err := client.Category(context.Background(), "", "10216") 74 + if err == nil || !strings.Contains(err.Error(), "queryterm is required") { 75 + t.Fatalf("unexpected queryterm error: %v", err) 76 + } 77 + 78 + _, err = client.Category(context.Background(), "beef", "") 79 + if err == nil || !strings.Contains(err.Error(), "store is required") { 80 + t.Fatalf("unexpected store error: %v", err) 81 + } 82 + } 83 + 84 + func TestCategory_StatusError(t *testing.T) { 85 + t.Parallel() 86 + 87 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 + http.Error(w, "nope", http.StatusBadGateway) 89 + })) 90 + t.Cleanup(server.Close) 91 + 92 + client := NewClientWithBaseURL(server.URL, server.Client()) 93 + 94 + _, err := client.Category(context.Background(), "beef", "10216") 95 + if err == nil { 96 + t.Fatal("expected error") 97 + } 98 + if !strings.Contains(err.Error(), "status 502") { 99 + t.Fatalf("unexpected error: %v", err) 100 + } 101 + } 102 + 103 + func TestStoreSummary_BuildsRequestAndDecodesResponse(t *testing.T) { 104 + t.Parallel() 105 + 106 + fixture := []byte(`{"storeId":10216,"token":"westlake","displayName":"Westlake","status":"Open","phone":"(206) 621-9700","storePrimeEligibility":true,"storeOperationalGuidance":"","bu":10216,"folder":"westlake","openedAt":"2006-11-08T12:00:00Z","links":{"Details":"/stores/westlake","Directions":"https://www.google.com/maps/dir/?api=1&destination=47.618249,-122.337898","Sales":"/sales-flyer?store-id=10216","PrimeNowPickUpAndDelivery":"https://www.wholefoods.com/grocery?ref_=US_TRF_ALL_UFG_WFM_REFER_0417726","MapUrlDesktop":"https://maps.googleapis.com/maps/api/staticmap?zoom=16&size=780x543","MapUrlTablet":"https://maps.googleapis.com/maps/api/staticmap?zoom=16&size=780x458","MapUrlMobile":"https://maps.googleapis.com/maps/api/staticmap?zoom=17&size=780x228"},"primaryLocation":{"address":{"STREET_ADDRESS_LINE1":"2210 Westlake Ave","CITY":"Seattle","STATE":"WA","POSTAL_CODE":"98121","ZIP_CODE":"98121","COUNTRY":"United States of America"},"latitude":47.618249,"longitude":-122.337898},"hours":{"Open":"8 am – 10 pm today","Sat":"8 am – 10 pm","Sun":"8 am – 10 pm","Mon":"8 am – 10 pm","Tue":"8 am – 10 pm","Wed":"8 am – 10 pm","Thu":"8 am – 10 pm"},"holidays":{}}`) 107 + 108 + var capturedReq *http.Request 109 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 110 + capturedReq = r 111 + w.Header().Set("Content-Type", "application/json") 112 + _, _ = w.Write(fixture) 113 + })) 114 + t.Cleanup(server.Close) 115 + 116 + client := NewClientWithBaseURL(server.URL, server.Client()) 117 + 118 + resp, err := client.StoreSummary(context.Background(), "10216") 119 + if err != nil { 120 + t.Fatalf("StoreSummary returned error: %v", err) 121 + } 122 + 123 + if capturedReq == nil { 124 + t.Fatal("expected request to be captured") 125 + } 126 + if capturedReq.URL.Path != "/api/stores/10216/summary" { 127 + t.Fatalf("unexpected path: %s", capturedReq.URL.Path) 128 + } 129 + if got := capturedReq.Header.Get("Accept"); got != "application/json" { 130 + t.Fatalf("unexpected Accept header: %q", got) 131 + } 132 + 133 + if got := resp.StoreID; got != 10216 { 134 + t.Fatalf("unexpected store id: %d", got) 135 + } 136 + if got := resp.DisplayName; got != "Westlake" { 137 + t.Fatalf("unexpected display name: %q", got) 138 + } 139 + if got := resp.PrimaryLocation.Address.City; got != "Seattle" { 140 + t.Fatalf("unexpected city: %q", got) 141 + } 142 + if got := resp.PrimaryLocation.Latitude; got != 47.618249 { 143 + t.Fatalf("unexpected latitude: %v", got) 144 + } 145 + if got := resp.Hours["Open"]; got != "8 am – 10 pm today" { 146 + t.Fatalf("unexpected open hours: %q", got) 147 + } 148 + } 149 + 150 + func TestStoreSummary_RequiresStore(t *testing.T) { 151 + t.Parallel() 152 + 153 + client := NewClient(nil) 154 + 155 + _, err := client.StoreSummary(context.Background(), "") 156 + if err == nil || !strings.Contains(err.Error(), "store is required") { 157 + t.Fatalf("unexpected store error: %v", err) 158 + } 159 + } 160 + 161 + func loadFixture(t *testing.T, name string) []byte { 162 + t.Helper() 163 + 164 + data, err := os.ReadFile(name) 165 + if err != nil { 166 + t.Fatalf("read fixture %s: %v", name, err) 167 + } 168 + return data 169 + }
+83
internal/wholefoods/discovery.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "context" 5 + "encoding/xml" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "regexp" 10 + "strings" 11 + ) 12 + 13 + type sitemapURLSet struct { 14 + URLs []struct { 15 + Loc string `xml:"loc"` 16 + } `xml:"url"` 17 + } 18 + 19 + var storeIDRe = regexp.MustCompile(`store-id="(\d+)"`) 20 + 21 + func FetchSitemap(ctx context.Context, client *http.Client, sitemapURL string) ([]string, error) { 22 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sitemapURL, nil) 23 + if err != nil { 24 + return nil, fmt.Errorf("build sitemap request: %w", err) 25 + } 26 + req.Header.Set("User-Agent", "Mozilla/5.0") 27 + 28 + resp, err := client.Do(req) 29 + if err != nil { 30 + return nil, fmt.Errorf("get sitemap: %w", err) 31 + } 32 + defer func() { 33 + _ = resp.Body.Close() 34 + }() 35 + 36 + if resp.StatusCode != http.StatusOK { 37 + return nil, fmt.Errorf("get sitemap: status %s", resp.Status) 38 + } 39 + 40 + var sitemap sitemapURLSet 41 + if err := xml.NewDecoder(resp.Body).Decode(&sitemap); err != nil { 42 + return nil, fmt.Errorf("decode sitemap: %w", err) 43 + } 44 + 45 + urls := make([]string, 0, len(sitemap.URLs)) 46 + for _, item := range sitemap.URLs { 47 + if loc := strings.TrimSpace(item.Loc); loc != "" { 48 + urls = append(urls, loc) 49 + } 50 + } 51 + return urls, nil 52 + } 53 + 54 + func FetchStoreIDFromPage(ctx context.Context, client *http.Client, pageURL string) (string, error) { 55 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) 56 + if err != nil { 57 + return "", fmt.Errorf("build store page request: %w", err) 58 + } 59 + req.Header.Set("User-Agent", "Mozilla/5.0") 60 + 61 + resp, err := client.Do(req) 62 + if err != nil { 63 + return "", fmt.Errorf("get store page: %w", err) 64 + } 65 + defer func() { 66 + _ = resp.Body.Close() 67 + }() 68 + 69 + if resp.StatusCode != http.StatusOK { 70 + return "", fmt.Errorf("get store page: status %s", resp.Status) 71 + } 72 + 73 + body, err := io.ReadAll(resp.Body) 74 + if err != nil { 75 + return "", fmt.Errorf("read store page: %w", err) 76 + } 77 + 78 + matches := storeIDRe.FindSubmatch(body) 79 + if len(matches) < 2 { 80 + return "", fmt.Errorf("store-id not found") 81 + } 82 + return string(matches[1]), nil 83 + }
+113
internal/wholefoods/locations.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/locations/geo" 6 + locationtypes "careme/internal/locations/types" 7 + "context" 8 + "fmt" 9 + "log/slog" 10 + "sort" 11 + "strings" 12 + 13 + "github.com/samber/lo" 14 + ) 15 + 16 + const maxLocationDistanceMiles = 20.0 17 + 18 + type centroidByZip interface { 19 + ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) 20 + } 21 + 22 + type LocationBackend struct { 23 + zipLookup centroidByZip 24 + byID map[string]locationtypes.Location 25 + } 26 + 27 + func NewLocationBackend(ctx context.Context, c cache.ListCache, zipLookup centroidByZip) (*LocationBackend, error) { 28 + if c == nil { 29 + return nil, fmt.Errorf("list cache is required") 30 + } 31 + if zipLookup == nil { 32 + return nil, fmt.Errorf("zip centroid lookup is required") 33 + } 34 + 35 + //Is this too much? should we just fetch a single blob that is all coordinates -> store ids and lazily fetch stores? 36 + summaries, err := loadCachedStoreSummaries(ctx, c) 37 + if err != nil { 38 + return nil, err 39 + } 40 + 41 + byID := make(map[string]locationtypes.Location, len(summaries)) 42 + for _, summary := range summaries { 43 + loc := storeSummaryToLocation(*summary) 44 + byID[loc.ID] = loc 45 + } 46 + 47 + return &LocationBackend{ 48 + zipLookup: zipLookup, 49 + byID: byID, 50 + }, nil 51 + } 52 + 53 + func (b *LocationBackend) IsID(locationID string) bool { 54 + _, ok := parseLocationID(locationID) 55 + return ok 56 + } 57 + 58 + func (b *LocationBackend) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 59 + normalized, ok := parseLocationID(locationID) 60 + if !ok { 61 + return nil, fmt.Errorf("whole foods location id %q is invalid", locationID) 62 + } 63 + 64 + loc, exists := b.byID[normalized] 65 + if !exists { 66 + return nil, fmt.Errorf("whole foods location %q not found", locationID) 67 + } 68 + 69 + copy := loc 70 + return &copy, nil 71 + } 72 + 73 + func (b *LocationBackend) GetLocationsByZip(ctx context.Context, zipcode string) ([]locationtypes.Location, error) { 74 + centroid, ok := b.zipLookup.ZipCentroidByZIP(strings.TrimSpace(zipcode)) 75 + if !ok { 76 + slog.WarnContext(ctx, "requested zip has no centroid; returning unsorted locations without distance filter", "zip", zipcode) 77 + //fall back to sort by zip? 78 + return nil, nil 79 + } 80 + 81 + type ranked struct { 82 + location locationtypes.Location 83 + distance float64 84 + } 85 + var rankedLocations []ranked 86 + for _, loc := range b.byID { 87 + if loc.Lat == nil || loc.Lon == nil { 88 + continue 89 + } 90 + distance := geo.HaversineMiles(centroid.Lat, centroid.Lon, *loc.Lat, *loc.Lon) 91 + if distance > maxLocationDistanceMiles { 92 + continue 93 + } 94 + rankedLocations = append(rankedLocations, ranked{location: loc, distance: distance}) 95 + } 96 + 97 + sort.SliceStable(rankedLocations, func(i, j int) bool { 98 + return rankedLocations[i].distance < rankedLocations[j].distance 99 + }) 100 + 101 + return lo.Map(rankedLocations, func(r ranked, _ int) locationtypes.Location { 102 + return r.location 103 + }), nil 104 + } 105 + 106 + func parseLocationID(locationID string) (string, bool) { 107 + if !strings.HasPrefix(locationID, LocationIDPrefix) { 108 + return "", false 109 + } 110 + 111 + storeID := strings.TrimPrefix(locationID, LocationIDPrefix) 112 + return LocationIDPrefix + storeID, true 113 + }
+153
internal/wholefoods/locations_test.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "careme/internal/cache" 5 + locationtypes "careme/internal/locations/types" 6 + "context" 7 + "strings" 8 + "testing" 9 + ) 10 + 11 + func TestNewLocationBackendBuildsIndexAndLookup(t *testing.T) { 12 + t.Parallel() 13 + 14 + cacheStore := cache.NewInMemoryCache() 15 + if err := CacheStoreSummary(context.Background(), cacheStore, westlakeSummary()); err != nil { 16 + t.Fatalf("CacheStoreSummary returned error: %v", err) 17 + } 18 + 19 + backend, err := NewLocationBackend(context.Background(), cacheStore, staticZIPLookup{ 20 + "98101": {Lat: 47.6101, Lon: -122.3344}, 21 + }) 22 + if err != nil { 23 + t.Fatalf("NewLocationBackend returned error: %v", err) 24 + } 25 + 26 + if !backend.IsID("wholefoods_10216") { 27 + t.Fatalf("expected wholefoods id to be recognized") 28 + } 29 + 30 + loc, err := backend.GetLocationByID(context.Background(), "wholefoods_10216") 31 + if err != nil { 32 + t.Fatalf("GetLocationByID returned error: %v", err) 33 + } 34 + if loc.Name != "Whole Foods Westlake" || loc.ZipCode != "98121" { 35 + t.Fatalf("unexpected location: %+v", loc) 36 + } 37 + } 38 + 39 + func TestLocationBackendGetLocationsByZipUsesDistance(t *testing.T) { 40 + t.Parallel() 41 + 42 + cacheStore := cache.NewInMemoryCache() 43 + if err := CacheStoreSummary(context.Background(), cacheStore, westlakeSummary()); err != nil { 44 + t.Fatalf("cache westlake summary: %v", err) 45 + } 46 + if err := CacheStoreSummary(context.Background(), cacheStore, farStoreSummary()); err != nil { 47 + t.Fatalf("cache far store summary: %v", err) 48 + } 49 + 50 + backend, err := NewLocationBackend(context.Background(), cacheStore, staticZIPLookup{ 51 + "98101": {Lat: 47.6101, Lon: -122.3344}, 52 + }) 53 + if err != nil { 54 + t.Fatalf("NewLocationBackend returned error: %v", err) 55 + } 56 + 57 + locs, err := backend.GetLocationsByZip(context.Background(), "98101") 58 + if err != nil { 59 + t.Fatalf("GetLocationsByZip returned error: %v", err) 60 + } 61 + if len(locs) != 1 { 62 + t.Fatalf("expected 1 nearby location, got %d", len(locs)) 63 + } 64 + if locs[0].ID != "wholefoods_10216" { 65 + t.Fatalf("unexpected location id: %q", locs[0].ID) 66 + } 67 + } 68 + 69 + func TestLocationBackendReturnsAllWhenZipUnknown(t *testing.T) { 70 + t.Parallel() 71 + 72 + cacheStore := cache.NewInMemoryCache() 73 + if err := CacheStoreSummary(context.Background(), cacheStore, westlakeSummary()); err != nil { 74 + t.Fatalf("cache westlake summary: %v", err) 75 + } 76 + if err := CacheStoreSummary(context.Background(), cacheStore, farStoreSummary()); err != nil { 77 + t.Fatalf("cache far store summary: %v", err) 78 + } 79 + 80 + backend, err := NewLocationBackend(context.Background(), cacheStore, staticZIPLookup{}) 81 + if err != nil { 82 + t.Fatalf("NewLocationBackend returned error: %v", err) 83 + } 84 + 85 + locs, err := backend.GetLocationsByZip(context.Background(), "unknown") 86 + if err != nil { 87 + t.Fatalf("GetLocationsByZip returned error: %v", err) 88 + } 89 + if len(locs) != 0 { 90 + t.Fatalf("expected no locations when zip centroid is unknown, got %d", len(locs)) 91 + } 92 + } 93 + 94 + func TestNewLocationBackendErrorsWhenNoCachedSummaries(t *testing.T) { 95 + t.Parallel() 96 + 97 + cacheStore := cache.NewInMemoryCache() 98 + 99 + _, err := NewLocationBackend(context.Background(), cacheStore, staticZIPLookup{}) 100 + if err == nil { 101 + t.Fatal("expected NewLocationBackend to return an error") 102 + } 103 + if !strings.Contains(err.Error(), "failed to load wholefoods locations") { 104 + t.Fatalf("expected missing summaries error, got %v", err) 105 + } 106 + } 107 + 108 + type staticZIPLookup map[string]coords 109 + 110 + type coords struct { 111 + Lat float64 112 + Lon float64 113 + } 114 + 115 + func (s staticZIPLookup) ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) { 116 + coord, ok := s[zip] 117 + if !ok { 118 + return locationtypes.ZipCentroid{}, false 119 + } 120 + return locationtypes.ZipCentroid{Lat: coord.Lat, Lon: coord.Lon}, true 121 + } 122 + 123 + func westlakeSummary() *StoreSummaryResponse { 124 + return &StoreSummaryResponse{ 125 + StoreID: 10216, 126 + DisplayName: "Westlake", 127 + PrimaryLocation: StoreLocation{ 128 + Address: StoreAddress{ 129 + StreetAddressLine1: "2210 Westlake Ave", 130 + State: "WA", 131 + ZipCode: "98121", 132 + }, 133 + Latitude: 47.618249, 134 + Longitude: -122.337898, 135 + }, 136 + } 137 + } 138 + 139 + func farStoreSummary() *StoreSummaryResponse { 140 + return &StoreSummaryResponse{ 141 + StoreID: 10153, 142 + DisplayName: "Portland", 143 + PrimaryLocation: StoreLocation{ 144 + Address: StoreAddress{ 145 + StreetAddressLine1: "1210 NW Couch St", 146 + State: "OR", 147 + ZipCode: "97209", 148 + }, 149 + Latitude: 45.5231, 150 + Longitude: -122.6824, 151 + }, 152 + } 153 + }