ai cooking
0
fork

Configure Feed

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

got some wegmans data (#411)

* got some wegmans data

* bring wegmans into compliance

* fumpt

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
4e3a4bb4 bb7fd09b

+866 -1
+134
cmd/wegmans/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "flag" 7 + "fmt" 8 + "log" 9 + "log/slog" 10 + "net/http" 11 + "strconv" 12 + "time" 13 + 14 + "careme/internal/cache" 15 + "careme/internal/logsetup" 16 + "careme/internal/wegmans" 17 + ) 18 + 19 + type storeClient interface { 20 + StoreSummary(ctx context.Context, storeNumber int) (*wegmans.StoreSummary, error) 21 + } 22 + 23 + type syncConfig struct { 24 + startID int 25 + endID int 26 + delay time.Duration 27 + } 28 + 29 + type syncStats struct { 30 + Synced int 31 + Missing int 32 + Skipped int 33 + Failures int 34 + } 35 + 36 + func main() { 37 + var ( 38 + baseURL string 39 + timeoutSec int 40 + delayMS int 41 + startID int 42 + endID int 43 + ) 44 + 45 + flag.StringVar(&baseURL, "base-url", wegmans.DefaultBaseURL, "Wegmans base URL") 46 + flag.IntVar(&timeoutSec, "timeout", 20, "HTTP timeout in seconds") 47 + flag.IntVar(&delayMS, "delay-ms", 0, "delay between store probes in milliseconds") 48 + flag.IntVar(&startID, "start-id", 0, "first numeric Wegmans store id to probe") 49 + flag.IntVar(&endID, "end-id", 150, "last numeric Wegmans store id to probe") 50 + flag.Parse() 51 + 52 + ctx := context.Background() 53 + closeLogger, err := logsetup.Configure(ctx) 54 + if err != nil { 55 + log.Fatalf("failed to configure logging: %v", err) 56 + } 57 + defer closeLogger() 58 + 59 + if startID < 0 { 60 + log.Fatalf("start-id must be non-negative") 61 + } 62 + if endID < startID { 63 + log.Fatalf("end-id must be greater than or equal to start-id") 64 + } 65 + 66 + cacheStore, err := cache.EnsureCache(wegmans.Container) 67 + if err != nil { 68 + log.Fatalf("failed to create cache: %v", err) 69 + } 70 + 71 + httpClient := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} 72 + client := wegmans.NewClientWithBaseURL(baseURL, httpClient) 73 + 74 + stats, err := syncStores(ctx, cacheStore, client, syncConfig{ 75 + startID: startID, 76 + endID: endID, 77 + delay: time.Duration(delayMS) * time.Millisecond, 78 + }) 79 + if err != nil { 80 + log.Fatalf("failed to sync wegmans stores: %v", err) 81 + } 82 + 83 + fmt.Printf("synced %d Wegmans store summaries (%d missing, %d skipped, %d failures)\n", stats.Synced, stats.Missing, stats.Skipped, stats.Failures) 84 + } 85 + 86 + func syncStores(ctx context.Context, cacheStore cache.ListCache, client storeClient, cfg syncConfig) (syncStats, error) { 87 + if cacheStore == nil { 88 + return syncStats{}, errors.New("cache store is required") 89 + } 90 + if client == nil { 91 + return syncStats{}, errors.New("wegmans client is required") 92 + } 93 + if cfg.startID < 0 { 94 + return syncStats{}, errors.New("start id must be non-negative") 95 + } 96 + if cfg.endID < cfg.startID { 97 + return syncStats{}, errors.New("end id must be greater than or equal to start id") 98 + } 99 + 100 + var stats syncStats 101 + for storeNumber := cfg.startID; storeNumber <= cfg.endID; storeNumber++ { 102 + cacheKey := wegmans.StoreCachePrefix + strconv.Itoa(storeNumber) 103 + exists, err := cacheStore.Exists(ctx, cacheKey) 104 + if err != nil { 105 + return stats, fmt.Errorf("check cached summary for store %d: %w", storeNumber, err) 106 + } 107 + if exists { 108 + stats.Skipped++ 109 + continue 110 + } 111 + 112 + summary, err := client.StoreSummary(ctx, storeNumber) 113 + switch { 114 + case errors.Is(err, wegmans.ErrStoreNotFound): 115 + stats.Missing++ 116 + case err != nil: 117 + stats.Failures++ 118 + slog.Warn("failed to fetch wegmans store summary", "store_number", storeNumber, "error", err) 119 + default: 120 + if err := wegmans.CacheStoreSummary(ctx, cacheStore, summary); err != nil { 121 + stats.Failures++ 122 + slog.Warn("failed to cache wegmans store summary", "store_number", storeNumber, "error", err) 123 + break 124 + } 125 + stats.Synced++ 126 + } 127 + 128 + if cfg.delay > 0 && storeNumber < cfg.endID { 129 + time.Sleep(cfg.delay) 130 + } 131 + } 132 + 133 + return stats, nil 134 + }
+98
cmd/wegmans/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + 8 + "careme/internal/cache" 9 + "careme/internal/wegmans" 10 + ) 11 + 12 + func TestSyncStoresCachesSummariesAndTracksMissing(t *testing.T) { 13 + t.Parallel() 14 + 15 + cacheStore := cache.NewInMemoryCache() 16 + client := fakeStoreClient{ 17 + summaries: map[int]*wegmans.StoreSummary{ 18 + 1: { 19 + ID: "wegmans_1", 20 + StoreNumber: 1, 21 + Name: "Wegmans Test", 22 + Address: "1 Main St", 23 + City: "Testville", 24 + State: "NY", 25 + ZipCode: "10001", 26 + }, 27 + }, 28 + missing: map[int]bool{ 29 + 0: true, 30 + }, 31 + } 32 + 33 + stats, err := syncStores(context.Background(), cacheStore, client, syncConfig{ 34 + startID: 0, 35 + endID: 1, 36 + }) 37 + if err != nil { 38 + t.Fatalf("syncStores returned error: %v", err) 39 + } 40 + if stats.Synced != 1 || stats.Missing != 1 || stats.Failures != 0 { 41 + t.Fatalf("unexpected stats: %+v", stats) 42 + } 43 + 44 + keys, err := cacheStore.List(context.Background(), wegmans.StoreCachePrefix, "") 45 + if err != nil { 46 + t.Fatalf("List returned error: %v", err) 47 + } 48 + if len(keys) != 1 || keys[0] != "1" { 49 + t.Fatalf("unexpected cached keys: %v", keys) 50 + } 51 + } 52 + 53 + func TestSyncStoresSkipsCachedSummaries(t *testing.T) { 54 + t.Parallel() 55 + 56 + cacheStore := cache.NewInMemoryCache() 57 + if err := wegmans.CacheStoreSummary(context.Background(), cacheStore, &wegmans.StoreSummary{ 58 + ID: "wegmans_1", 59 + StoreNumber: 1, 60 + Name: "Wegmans Test", 61 + Address: "1 Main St", 62 + State: "NY", 63 + ZipCode: "10001", 64 + }); err != nil { 65 + t.Fatalf("CacheStoreSummary returned error: %v", err) 66 + } 67 + 68 + stats, err := syncStores(context.Background(), cacheStore, fakeStoreClient{}, syncConfig{ 69 + startID: 1, 70 + endID: 1, 71 + }) 72 + if err != nil { 73 + t.Fatalf("syncStores returned error: %v", err) 74 + } 75 + if stats.Skipped != 1 { 76 + t.Fatalf("expected 1 skipped store, got %+v", stats) 77 + } 78 + } 79 + 80 + type fakeStoreClient struct { 81 + summaries map[int]*wegmans.StoreSummary 82 + missing map[int]bool 83 + err error 84 + } 85 + 86 + func (f fakeStoreClient) StoreSummary(_ context.Context, storeNumber int) (*wegmans.StoreSummary, error) { 87 + if f.err != nil { 88 + return nil, f.err 89 + } 90 + if f.missing[storeNumber] { 91 + return nil, wegmans.ErrStoreNotFound 92 + } 93 + summary, ok := f.summaries[storeNumber] 94 + if !ok { 95 + return nil, errors.New("unexpected store number") 96 + } 97 + return summary, nil 98 + }
+13 -1
docs/cache-layout.md
··· 5 5 - Local filesystem under `aldi/` (ALDI cache) 6 6 - Local filesystem under `albertsons/` (Albertsons-family cache) 7 7 - Local filesystem under `publix/` (Publix cache) 8 + - Local filesystem under `wegmans/` (Wegmans cache) 8 9 - Local filesystem under `heb/` (HEB cache) 9 10 - Local filesystem under `publix/` (Publix cache) 10 11 - Local filesystem under `wholefoods/` (Whole Foods cache) ··· 14 15 - Azure Blob container `aldi` (ALDI cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 15 16 - Azure Blob container `albertsons` (Albertsons-family cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 16 17 - Azure Blob container `publix` (Publix cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 18 + - Azure Blob container `wegmans` (Wegmans cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 17 19 - Azure Blob container `heb` (HEB cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 18 20 - Azure Blob container `publix` (Publix cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 19 21 - Azure Blob container `wholefoods` (Whole Foods cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) ··· 44 46 | `publix/stores/` | JSON `publix.StoreSummary` keyed by numeric Publix store ID | `cmd/publix` and `internal/publix` cache helpers | `internal/publix` location backend | 45 47 | `publix/store_url_map.json` | JSON object mapping numeric Publix store ID to canonical location URL | `cmd/publix` and `internal/publix` cache helpers | `cmd/publix` incremental sync | 46 48 | `publix/missing_store_ids.json` | JSON array of numeric Publix store IDs known to redirect back to `/locations` | `cmd/publix` and `internal/publix` cache helpers | `cmd/publix` incremental sync | 49 + | `wegmans/stores/` | JSON `wegmans.StoreSummary` keyed by numeric Wegmans store ID | `cmd/wegmans` and `internal/wegmans` cache helpers | `internal/wegmans` location backend | 47 50 | `wholefoods/stores/` | JSON `wholefoods.StoreSummaryResponse` keyed by Whole Foods store ID | `cmd/wholefoods` and `internal/wholefoods` cache helpers | `internal/wholefoods` location backend | 48 51 | `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 | 49 52 ··· 53 56 - Most app caches use the default cache created via `cache.MakeCache()` / `cache.EnsureCache("recipes")`. 54 57 - ALDI locations use a separate cache created via `cache.EnsureCache("aldi")`. 55 58 - Albertsons-family locations use a separate cache created via `cache.EnsureCache("albertsons")`. 59 + - Wegmans locations use a separate cache created via `cache.EnsureCache("wegmans")`. 56 60 - HEB locations use a separate cache created via `cache.EnsureCache("heb")`. 57 61 - Publix uses a separate cache created via `cache.EnsureCache("publix")`; it does not share the `recipes` container/directory. 58 62 - Recipe images use a separate cache created via `cache.EnsureCache("recipe-images")`; they do not share the main `recipes` container/directory. 59 63 - Whole Foods uses a separate cache created via `cache.EnsureCache("wholefoods")`; it does not share the `recipes` container/directory. 60 - - Local cache paths are `recipes/` for most app data, `recipe-images/` for recipe images, `aldi/` for ALDI data, `albertsons/` for Albertsons-family data, `heb/` for HEB data, `publix/` for Publix data, and `wholefoods/` for Whole Foods data when filesystem backend is used. 64 + - Local cache paths when filesystem backend is used. are 65 + - `recipes/` for most app data, 66 + - `recipe-images/` for recipe images, 67 + - `aldi/` for ALDI data, 68 + - `albertsons/` for Albertsons-family data, 69 + - `heb/` for HEB data, 70 + - `publix/` for Publix data, 71 + - `wegmans/` for Wegmans data 72 + - `wholefoods/` for Whole Foods data 61 73 - Blob names in Azure match the same key strings listed above inside their respective containers. 62 74 - Staple `ingredients/` cache keys derive from location ID, date, and a versioned backend staple signature (for example `kroger-staples-v1` or `wholefoods-staples-v1`), so Kroger and Whole Foods locations do not share staple caches and staple-definition changes can invalidate caches intentionally. 63 75 - Recipe image cache keys are stable per recipe hash, so prompt or model changes do not orphan previously generated images.
+12
internal/config/config.go
··· 23 23 Albertsons AlbertsonsConfig `json:"albertsons"` 24 24 Publix PublixConfig `json:"publix"` 25 25 HEB HEBConfig `json:"heb"` 26 + Wegmans WegmansConfig `json:"wegmans"` 26 27 Mocks MockConfig `json:"mocks"` 27 28 Clerk ClerkConfig `json:"clerk"` 28 29 Admin AdminConfig `json:"admin"` ··· 97 98 return c.Enable 98 99 } 99 100 101 + type WegmansConfig struct { 102 + Enable bool `json:"enable"` 103 + } 104 + 105 + func (c *WegmansConfig) IsEnabled() bool { 106 + return c.Enable 107 + } 108 + 100 109 // Config defines the required Walmart affiliate credentials and client options. 101 110 type WalmartConfig struct { 102 111 ConsumerID string ··· 161 170 }, 162 171 HEB: HEBConfig{ 163 172 Enable: envEnabled("HEB_ENABLE"), 173 + }, 174 + Wegmans: WegmansConfig{ 175 + Enable: envEnabled("WEGMANS_ENABLE"), 164 176 }, 165 177 Walmart: WalmartConfig{ 166 178 ConsumerID: os.Getenv("WALMART_CONSUMER_ID"),
+4
internal/locations/storage.go
··· 20 20 "careme/internal/logsetup" 21 21 "careme/internal/publix" 22 22 "careme/internal/walmart" 23 + "careme/internal/wegmans" 23 24 "careme/internal/wholefoods" 24 25 25 26 locationtypes "careme/internal/locations/types" ··· 95 96 }, 96 97 func(ctx context.Context) (locationBackend, error) { 97 98 return heb.NewLocationBackendFromConfig(ctx, cfg, centroids) 99 + }, 100 + func(ctx context.Context) (locationBackend, error) { 101 + return wegmans.NewLocationBackend(ctx, cfg, centroids) 98 102 }, 99 103 } 100 104
+80
internal/wegmans/cache.go
··· 1 + package wegmans 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "log/slog" 9 + "strconv" 10 + 11 + "careme/internal/cache" 12 + locationtypes "careme/internal/locations/types" 13 + 14 + "github.com/samber/lo" 15 + lop "github.com/samber/lo/parallel" 16 + ) 17 + 18 + func CacheStoreSummary(ctx context.Context, c cache.Cache, summary *StoreSummary) error { 19 + if summary == nil { 20 + return errors.New("store summary is required") 21 + } 22 + if summary.StoreNumber == 0 { 23 + return errors.New("store summary store number is required") 24 + } 25 + 26 + raw, err := json.Marshal(summary) 27 + if err != nil { 28 + return fmt.Errorf("marshal store summary: %w", err) 29 + } 30 + 31 + if err := c.Put(ctx, StoreCachePrefix+strconv.Itoa(summary.StoreNumber), string(raw), cache.Unconditional()); err != nil { 32 + return fmt.Errorf("write store summary cache: %w", err) 33 + } 34 + return nil 35 + } 36 + 37 + func loadCachedStoreSummaries(ctx context.Context, c cache.ListCache) ([]*StoreSummary, error) { 38 + keys, err := c.List(ctx, StoreCachePrefix, "") 39 + if err != nil { 40 + return nil, fmt.Errorf("list cached store summaries: %w", err) 41 + } 42 + 43 + summaries := lop.Map(keys, func(key string, _ int) *StoreSummary { 44 + reader, err := c.Get(ctx, StoreCachePrefix+key) 45 + if err != nil { 46 + slog.WarnContext(ctx, "failed to read cached wegmans store summary", "key", key, "error", err) 47 + return nil 48 + } 49 + defer func() { 50 + _ = reader.Close() 51 + }() 52 + 53 + var summary StoreSummary 54 + if err := json.NewDecoder(reader).Decode(&summary); err != nil { 55 + slog.WarnContext(ctx, "failed to decode cached wegmans store summary", "key", key, "error", err) 56 + return nil 57 + } 58 + return &summary 59 + }) 60 + 61 + summaries = lo.Compact(summaries) 62 + if len(summaries) == 0 { 63 + return nil, fmt.Errorf("failed to load wegmans locations") 64 + } 65 + 66 + return summaries, nil 67 + } 68 + 69 + func StoreSummaryToLocation(summary StoreSummary) locationtypes.Location { 70 + return locationtypes.Location{ 71 + ID: summary.ID, 72 + Name: summary.Name, 73 + Address: summary.Address, 74 + State: summary.State, 75 + ZipCode: summary.ZipCode, 76 + Lat: summary.Lat, 77 + Lon: summary.Lon, 78 + Chain: Container, 79 + } 80 + }
+192
internal/wegmans/client.go
··· 1 + package wegmans 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "strconv" 13 + "strings" 14 + "time" 15 + ) 16 + 17 + const ( 18 + Container = "wegmans" 19 + StoreCachePrefix = "wegmans/stores/" 20 + LocationIDPrefix = "wegmans_" 21 + DefaultBaseURL = "https://www.wegmans.com" 22 + ) 23 + 24 + var ErrStoreNotFound = errors.New("wegmans store not found") 25 + 26 + type Client struct { 27 + baseURL string 28 + httpClient *http.Client 29 + } 30 + 31 + type StoreResponse struct { 32 + ID int `json:"id"` 33 + StoreNumber int `json:"storeNumber"` 34 + Name string `json:"name"` 35 + City string `json:"city"` 36 + StateAbbreviation string `json:"stateAbbreviation"` 37 + Zip string `json:"zip"` 38 + StreetAddress string `json:"streetAddress"` 39 + Latitude float64 `json:"latitude"` 40 + Longitude float64 `json:"longitude"` 41 + } 42 + 43 + type StoreSummary struct { 44 + ID string `json:"id"` 45 + StoreNumber int `json:"store_number"` 46 + Name string `json:"name"` 47 + Address string `json:"address"` 48 + City string `json:"city"` 49 + State string `json:"state"` 50 + ZipCode string `json:"zip_code"` 51 + Lat *float64 `json:"lat,omitempty"` 52 + Lon *float64 `json:"lon,omitempty"` 53 + } 54 + 55 + func NewClient(httpClient *http.Client) *Client { 56 + return NewClientWithBaseURL(DefaultBaseURL, httpClient) 57 + } 58 + 59 + func NewClientWithBaseURL(baseURL string, httpClient *http.Client) *Client { 60 + baseURL = strings.TrimSpace(baseURL) 61 + if baseURL == "" { 62 + baseURL = DefaultBaseURL 63 + } 64 + if httpClient == nil { 65 + httpClient = &http.Client{Timeout: 20 * time.Second} 66 + } 67 + 68 + return &Client{ 69 + baseURL: strings.TrimRight(baseURL, "/"), 70 + httpClient: httpClient, 71 + } 72 + } 73 + 74 + func (c *Client) StoreSummary(ctx context.Context, storeNumber int) (*StoreSummary, error) { 75 + if storeNumber < 0 { 76 + return nil, fmt.Errorf("store number %d must be non-negative", storeNumber) 77 + } 78 + 79 + endpoint := c.baseURL + "/api/stores/store-number/" + url.PathEscape(strconv.Itoa(storeNumber)) 80 + 81 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 82 + if err != nil { 83 + return nil, fmt.Errorf("build request: %w", err) 84 + } 85 + req.Header.Set("Accept", "application/json") 86 + 87 + resp, err := c.httpClient.Do(req) 88 + if err != nil { 89 + return nil, fmt.Errorf("fetch %s: %w", endpoint, err) 90 + } 91 + defer func() { 92 + _ = resp.Body.Close() 93 + }() 94 + 95 + if resp.StatusCode == http.StatusNotFound { 96 + return nil, ErrStoreNotFound 97 + } 98 + if resp.StatusCode == http.StatusInternalServerError { 99 + // dumb but this seems to be true 100 + return nil, ErrStoreNotFound 101 + } 102 + 103 + if resp.StatusCode != http.StatusOK { 104 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 105 + return nil, fmt.Errorf("fetch %s: status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) 106 + } 107 + 108 + var payload StoreResponse 109 + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { 110 + return nil, fmt.Errorf("decode %s: %w", endpoint, err) 111 + } 112 + slog.Info("fetched wegmans store summary", "store_number", storeNumber, "name", payload.Name) 113 + summary, err := normalizeStore(payload) 114 + if err != nil { 115 + return nil, fmt.Errorf("normalize wegmans store %d: %w", storeNumber, err) 116 + } 117 + return summary, nil 118 + } 119 + 120 + func normalizeStore(payload StoreResponse) (*StoreSummary, error) { 121 + storeNumber := payload.StoreNumber 122 + if storeNumber == 0 { 123 + storeNumber = payload.ID 124 + } 125 + if storeNumber == 0 { 126 + return nil, errors.New("missing store number") 127 + } 128 + 129 + name := strings.TrimSpace(payload.Name) 130 + if name == "" { 131 + return nil, errors.New("missing store name") 132 + } 133 + 134 + address := strings.TrimSpace(payload.StreetAddress) 135 + if address == "" { 136 + return nil, errors.New("missing street address") 137 + } 138 + 139 + state := strings.ToUpper(strings.TrimSpace(payload.StateAbbreviation)) 140 + if state == "" { 141 + return nil, errors.New("missing state abbreviation") 142 + } 143 + 144 + zipCode := normalizeZIP(payload.Zip) 145 + if zipCode == "" { 146 + return nil, errors.New("missing zip code") 147 + } 148 + 149 + summary := &StoreSummary{ 150 + ID: LocationIDPrefix + strconv.Itoa(storeNumber), 151 + StoreNumber: storeNumber, 152 + Name: normalizeName(name), 153 + Address: address, 154 + City: strings.TrimSpace(payload.City), 155 + State: state, 156 + ZipCode: zipCode, 157 + } 158 + 159 + if payload.Latitude != 0 && payload.Longitude != 0 { 160 + lat := payload.Latitude 161 + lon := payload.Longitude 162 + summary.Lat = &lat 163 + summary.Lon = &lon 164 + } 165 + 166 + return summary, nil 167 + } 168 + 169 + func normalizeName(name string) string { 170 + name = strings.TrimSpace(name) 171 + if name == "" { 172 + return "Wegmans" 173 + } 174 + if strings.HasPrefix(strings.ToLower(name), "wegmans") { 175 + return name 176 + } 177 + return "Wegmans " + name 178 + } 179 + 180 + func normalizeZIP(raw string) string { 181 + raw = strings.TrimSpace(raw) 182 + if raw == "" { 183 + return raw 184 + } 185 + if dash := strings.IndexByte(raw, '-'); dash >= 0 { 186 + raw = raw[:dash] 187 + } 188 + if len(raw) > 5 { 189 + raw = raw[:5] 190 + } 191 + return raw 192 + }
+109
internal/wegmans/client_test.go
··· 1 + package wegmans 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "io" 9 + "net/http" 10 + "testing" 11 + ) 12 + 13 + func TestStoreSummaryBuildsRequestAndNormalizesResponse(t *testing.T) { 14 + t.Parallel() 15 + 16 + var capturedReq *http.Request 17 + client := NewClientWithBaseURL("https://example.com", &http.Client{ 18 + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { 19 + capturedReq = r 20 + body, err := json.Marshal(StoreResponse{ 21 + ID: 69, 22 + StoreNumber: 69, 23 + Name: "Erie West", 24 + City: "Erie", 25 + StateAbbreviation: "PA", 26 + Zip: "16506-1234", 27 + StreetAddress: "5028 West Ridge Road", 28 + Latitude: 42.06996, 29 + Longitude: -80.1919, 30 + }) 31 + if err != nil { 32 + return nil, err 33 + } 34 + return &http.Response{ 35 + StatusCode: http.StatusOK, 36 + Header: http.Header{"Content-Type": []string{"application/json"}}, 37 + Body: io.NopCloser(bytes.NewReader(body)), 38 + }, nil 39 + }), 40 + }) 41 + summary, err := client.StoreSummary(context.Background(), 69) 42 + if err != nil { 43 + t.Fatalf("StoreSummary returned error: %v", err) 44 + } 45 + 46 + if capturedReq == nil { 47 + t.Fatal("expected request to be captured") 48 + } 49 + if capturedReq.URL.Path != "/api/stores/store-number/69" { 50 + t.Fatalf("unexpected path: %s", capturedReq.URL.Path) 51 + } 52 + if got := capturedReq.Header.Get("Accept"); got != "application/json" { 53 + t.Fatalf("unexpected Accept header: %q", got) 54 + } 55 + 56 + if got := summary.ID; got != "wegmans_69" { 57 + t.Fatalf("unexpected summary id: %q", got) 58 + } 59 + if got := summary.Name; got != "Wegmans Erie West" { 60 + t.Fatalf("unexpected summary name: %q", got) 61 + } 62 + if got := summary.ZipCode; got != "16506" { 63 + t.Fatalf("unexpected zip code: %q", got) 64 + } 65 + if summary.Lat == nil || summary.Lon == nil { 66 + t.Fatalf("expected coordinates to be populated: %+v", summary) 67 + } 68 + } 69 + 70 + func TestStoreSummaryReturnsNotFoundOn404(t *testing.T) { 71 + t.Parallel() 72 + 73 + client := NewClientWithBaseURL("https://example.com", &http.Client{ 74 + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { 75 + return &http.Response{ 76 + StatusCode: http.StatusNotFound, 77 + Body: io.NopCloser(bytes.NewReader(nil)), 78 + }, nil 79 + }), 80 + }) 81 + _, err := client.StoreSummary(context.Background(), 0) 82 + if !errors.Is(err, ErrStoreNotFound) { 83 + t.Fatalf("expected ErrStoreNotFound, got %v", err) 84 + } 85 + } 86 + 87 + func TestNormalizeStoreRequiresName(t *testing.T) { 88 + t.Parallel() 89 + 90 + _, err := normalizeStore(StoreResponse{ 91 + ID: 69, 92 + StoreNumber: 69, 93 + StateAbbreviation: "PA", 94 + Zip: "16506", 95 + StreetAddress: "5028 West Ridge Road", 96 + }) 97 + if err == nil { 98 + t.Fatal("expected error") 99 + } 100 + if got := err.Error(); got != "missing store name" { 101 + t.Fatalf("unexpected error: %v", err) 102 + } 103 + } 104 + 105 + type roundTripFunc func(*http.Request) (*http.Response, error) 106 + 107 + func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { 108 + return f(r) 109 + }
+94
internal/wegmans/locations.go
··· 1 + package wegmans 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "careme/internal/cache" 9 + "careme/internal/config" 10 + "careme/internal/locations/nearby" 11 + 12 + locationtypes "careme/internal/locations/types" 13 + ) 14 + 15 + type centroidByZip interface { 16 + ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) 17 + } 18 + 19 + type LocationBackend struct { 20 + zipLookup centroidByZip 21 + byID map[string]locationtypes.Location 22 + } 23 + 24 + func NewLocationBackend(ctx context.Context, cfg *config.Config, zipLookup centroidByZip) (*LocationBackend, error) { 25 + // check enabled 26 + if !cfg.Wegmans.IsEnabled() { 27 + return nil, locationtypes.DisabledBackendError("Wegmans") 28 + } 29 + 30 + if zipLookup == nil { 31 + return nil, fmt.Errorf("zip centroid lookup is required") 32 + } 33 + 34 + listCache, err := cache.EnsureCache(Container) 35 + if err != nil { 36 + return nil, fmt.Errorf("create HEB list cache: %w", err) 37 + } 38 + 39 + return newLocationBackend(ctx, listCache, zipLookup) 40 + } 41 + 42 + func newLocationBackend(ctx context.Context, c cache.ListCache, zipLookup centroidByZip) (*LocationBackend, error) { 43 + summaries, err := loadCachedStoreSummaries(ctx, c) 44 + if err != nil { 45 + return nil, err 46 + } 47 + 48 + byID := make(map[string]locationtypes.Location, len(summaries)) 49 + for _, summary := range summaries { 50 + loc := StoreSummaryToLocation(*summary) 51 + byID[loc.ID] = loc 52 + } 53 + 54 + return &LocationBackend{ 55 + zipLookup: zipLookup, 56 + byID: byID, 57 + }, nil 58 + } 59 + 60 + func (b *LocationBackend) IsID(locationID string) bool { 61 + return IsID(locationID) 62 + } 63 + 64 + func (*LocationBackend) HasInventory(string) bool { 65 + return false 66 + } 67 + 68 + func (b *LocationBackend) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 69 + locationID = strings.TrimSpace(locationID) 70 + if !IsID(locationID) { 71 + return nil, fmt.Errorf("wegmans location id %q is invalid", locationID) 72 + } 73 + 74 + loc, exists := b.byID[locationID] 75 + if !exists { 76 + return nil, fmt.Errorf("wegmans location %q not found", locationID) 77 + } 78 + 79 + copy := loc 80 + return &copy, nil 81 + } 82 + 83 + func (b *LocationBackend) GetLocationsByZip(ctx context.Context, zipcode string) ([]locationtypes.Location, error) { 84 + candidates := make([]locationtypes.Location, 0, len(b.byID)) 85 + for _, loc := range b.byID { 86 + candidates = append(candidates, loc) 87 + } 88 + return nearby.FilterAndSortByZip(ctx, b.zipLookup, zipcode, candidates, nearby.MaxLocationDistanceMiles), nil 89 + } 90 + 91 + func IsID(locationID string) bool { 92 + locationID = strings.TrimSpace(locationID) 93 + return strings.HasPrefix(locationID, LocationIDPrefix) && len(locationID) > len(LocationIDPrefix) 94 + }
+130
internal/wegmans/locations_test.go
··· 1 + package wegmans 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + "testing" 7 + 8 + "careme/internal/cache" 9 + 10 + locationtypes "careme/internal/locations/types" 11 + ) 12 + 13 + func TestNewLocationBackendBuildsIndexAndLookup(t *testing.T) { 14 + t.Parallel() 15 + 16 + cacheStore := cache.NewInMemoryCache() 17 + if err := CacheStoreSummary(t.Context(), cacheStore, nearbySummary()); err != nil { 18 + t.Fatalf("CacheStoreSummary returned error: %v", err) 19 + } 20 + 21 + backend, err := newLocationBackend(t.Context(), cacheStore, staticZIPLookup{ 22 + "16506": {Lat: 42.0817, Lon: -80.1753}, 23 + }) 24 + if err != nil { 25 + t.Fatalf("NewLocationBackend returned error: %v", err) 26 + } 27 + 28 + if !backend.IsID("wegmans_69") { 29 + t.Fatalf("expected Wegmans id to be recognized") 30 + } 31 + 32 + loc, err := backend.GetLocationByID(t.Context(), "wegmans_69") 33 + if err != nil { 34 + t.Fatalf("GetLocationByID returned error: %v", err) 35 + } 36 + if loc.Name != "Wegmans Erie West" || loc.ZipCode != "16506" || loc.Chain != "wegmans" { 37 + t.Fatalf("unexpected location: %+v", loc) 38 + } 39 + } 40 + 41 + func TestLocationBackendGetLocationsByZipUsesDistance(t *testing.T) { 42 + t.Parallel() 43 + 44 + cacheStore := cache.NewInMemoryCache() 45 + if err := CacheStoreSummary(t.Context(), cacheStore, nearbySummary()); err != nil { 46 + t.Fatalf("cache nearby summary: %v", err) 47 + } 48 + if err := CacheStoreSummary(t.Context(), cacheStore, farSummary()); err != nil { 49 + t.Fatalf("cache far summary: %v", err) 50 + } 51 + 52 + backend, err := newLocationBackend(t.Context(), cacheStore, staticZIPLookup{ 53 + "16506": {Lat: 42.0817, Lon: -80.1753}, 54 + }) 55 + if err != nil { 56 + t.Fatalf("NewLocationBackend returned error: %v", err) 57 + } 58 + 59 + locs, err := backend.GetLocationsByZip(context.Background(), "16506") 60 + if err != nil { 61 + t.Fatalf("GetLocationsByZip returned error: %v", err) 62 + } 63 + if len(locs) != 1 { 64 + t.Fatalf("expected 1 nearby location, got %d", len(locs)) 65 + } 66 + if locs[0].ID != "wegmans_69" { 67 + t.Fatalf("unexpected location id: %q", locs[0].ID) 68 + } 69 + } 70 + 71 + func TestNewLocationBackendErrorsWhenNoCachedSummaries(t *testing.T) { 72 + t.Parallel() 73 + 74 + cacheStore := cache.NewInMemoryCache() 75 + 76 + _, err := newLocationBackend(t.Context(), cacheStore, staticZIPLookup{}) 77 + if err == nil { 78 + t.Fatal("expected NewLocationBackend to return an error") 79 + } 80 + if !strings.Contains(err.Error(), "failed to load wegmans locations") { 81 + t.Fatalf("expected missing summaries error, got %v", err) 82 + } 83 + } 84 + 85 + type staticZIPLookup map[string]coords 86 + 87 + type coords struct { 88 + Lat float64 89 + Lon float64 90 + } 91 + 92 + func (s staticZIPLookup) ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) { 93 + coord, ok := s[zip] 94 + if !ok { 95 + return locationtypes.ZipCentroid{}, false 96 + } 97 + return locationtypes.ZipCentroid{Lat: coord.Lat, Lon: coord.Lon}, true 98 + } 99 + 100 + func nearbySummary() *StoreSummary { 101 + lat := 42.06996 102 + lon := -80.1919 103 + return &StoreSummary{ 104 + ID: "wegmans_69", 105 + StoreNumber: 69, 106 + Name: "Wegmans Erie West", 107 + Address: "5028 West Ridge Road", 108 + City: "Erie", 109 + State: "PA", 110 + ZipCode: "16506", 111 + Lat: &lat, 112 + Lon: &lon, 113 + } 114 + } 115 + 116 + func farSummary() *StoreSummary { 117 + lat := 40.7177 118 + lon := -73.8458 119 + return &StoreSummary{ 120 + ID: "wegmans_101", 121 + StoreNumber: 101, 122 + Name: "Wegmans Forest Hills", 123 + Address: "109-14 Horace Harding Expy", 124 + City: "Forest Hills", 125 + State: "NY", 126 + ZipCode: "11375", 127 + Lat: &lat, 128 + Lon: &lon, 129 + } 130 + }