ai cooking
0
fork

Configure Feed

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

we seem to have loaded aldis locations (#357)

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

authored by

Paul Miller
paul miller
and committed by
GitHub
c71179e0 15e81d3f

+942 -1
+62
cmd/aldi/main.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/aldi" 5 + "careme/internal/cache" 6 + "context" 7 + "flag" 8 + "fmt" 9 + "log" 10 + "log/slog" 11 + "net/http" 12 + "time" 13 + ) 14 + 15 + type summaryClient interface { 16 + StoreSummaries(ctx context.Context) ([]*aldi.StoreSummary, error) 17 + } 18 + 19 + func main() { 20 + var ( 21 + baseURL string 22 + widgetKey string 23 + timeoutSec int 24 + ) 25 + 26 + flag.StringVar(&baseURL, "base-url", aldi.DefaultBaseURL, "ALDI locator base URL") 27 + flag.StringVar(&widgetKey, "widget-key", aldi.DefaultWidgetKey, "ALDI locator widget key") 28 + flag.IntVar(&timeoutSec, "timeout", 20, "HTTP timeout in seconds") 29 + flag.Parse() 30 + 31 + cacheStore, err := cache.EnsureCache(aldi.Container) 32 + if err != nil { 33 + log.Fatalf("failed to create cache: %v", err) 34 + } 35 + 36 + httpClient := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} 37 + client := aldi.NewClientWithBaseURL(baseURL, widgetKey, httpClient) 38 + 39 + synced, err := syncLocations(context.Background(), cacheStore, client) 40 + if err != nil { 41 + log.Fatalf("failed to sync ALDI store summaries: %v", err) 42 + } 43 + 44 + fmt.Printf("synced %d ALDI store summaries\n", synced) 45 + } 46 + 47 + func syncLocations(ctx context.Context, cacheStore cache.Cache, client summaryClient) (int, error) { 48 + summaries, err := client.StoreSummaries(ctx) 49 + if err != nil { 50 + return 0, err 51 + } 52 + 53 + var synced int 54 + for _, summary := range summaries { 55 + if err := aldi.CacheStoreSummary(ctx, cacheStore, summary); err != nil { 56 + slog.Warn("failed to cache ALDI store summary", "location_id", summary.ID, "error", err) 57 + continue 58 + } 59 + synced++ 60 + } 61 + return synced, nil 62 + }
+53
cmd/aldi/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/aldi" 5 + "careme/internal/cache" 6 + "context" 7 + "testing" 8 + ) 9 + 10 + func TestSyncLocationsCachesSummaries(t *testing.T) { 11 + t.Parallel() 12 + 13 + cacheStore := cache.NewInMemoryCache() 14 + client := fakeSummaryClient{ 15 + summaries: []*aldi.StoreSummary{ 16 + { 17 + ID: "aldi_F100", 18 + StoreID: 5757831, 19 + Identifier: "F100", 20 + Name: "ALDI 201 W Division St", 21 + Address: "201 W Division St", 22 + City: "Chicago", 23 + State: "IL", 24 + ZipCode: "60610", 25 + }, 26 + }, 27 + } 28 + 29 + synced, err := syncLocations(context.Background(), cacheStore, client) 30 + if err != nil { 31 + t.Fatalf("syncLocations returned error: %v", err) 32 + } 33 + if synced != 1 { 34 + t.Fatalf("expected 1 synced summary, got %d", synced) 35 + } 36 + 37 + keys, err := cacheStore.List(context.Background(), aldi.StoreCachePrefix, "") 38 + if err != nil { 39 + t.Fatalf("List returned error: %v", err) 40 + } 41 + if len(keys) != 1 || keys[0] != "aldi_F100" { 42 + t.Fatalf("unexpected cached keys: %v", keys) 43 + } 44 + } 45 + 46 + type fakeSummaryClient struct { 47 + summaries []*aldi.StoreSummary 48 + err error 49 + } 50 + 51 + func (f fakeSummaryClient) StoreSummaries(_ context.Context) ([]*aldi.StoreSummary, error) { 52 + return f.summaries, f.err 53 + }
+5 -1
docs/cache-layout.md
··· 2 2 3 3 This project stores cache entries in: 4 4 - Local filesystem under `cache/` (default app cache) 5 + - Local filesystem under `aldi/` (ALDI cache) 5 6 - Local filesystem under `albertsons/` (Albertsons-family cache) 6 7 - Local filesystem under `wholefoods/` (Whole Foods cache) 7 8 - Azure Blob container `recipes` (default app cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 9 + - Azure Blob container `aldi` (ALDI cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 8 10 - Azure Blob container `albertsons` (Albertsons-family cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 9 11 - Azure Blob container `wholefoods` (Whole Foods cache when `AZURE_STORAGE_ACCOUNT_NAME` is set) 10 12 ··· 24 26 | `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`) | 25 27 | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) | 26 28 | `email2user/` | Plain text user ID keyed by normalized email | `internal/users/storage.go` (`FindOrCreateFromClerk`) | `internal/users/storage.go` (`GetByEmail`) | 29 + | `aldi/stores/` | JSON `aldi.StoreSummary` keyed by prefixed ALDI location ID | `cmd/aldi` and `internal/aldi` cache helpers | `internal/aldi` location backend | 27 30 | `albertsons/stores/` | JSON `albertsons.StoreSummary` keyed by prefixed Albertsons-family location ID | `cmd/albertsons` and `internal/albertsons` cache helpers | `internal/albertsons` location backend | 28 31 | `albertsons/store_url_map.json` | JSON object mapping store URL to prefixed Albertsons-family location ID | `cmd/albertsons` and `internal/albertsons` cache helpers | `cmd/albertsons` incremental sync | 29 32 | `wholefoods/stores/` | JSON `wholefoods.StoreSummaryResponse` keyed by Whole Foods store ID | `cmd/wholefoods` and `internal/wholefoods` cache helpers | `internal/wholefoods` location backend | ··· 33 36 34 37 - Cache backend selection is in `internal/cache/azure.go` (`MakeCache`). 35 38 - Most app caches use the default cache created via `cache.MakeCache()` / `cache.EnsureCache("recipes")`. 39 + - ALDI locations use a separate cache created via `cache.EnsureCache("aldi")`. 36 40 - Albertsons-family locations use a separate cache created via `cache.EnsureCache("albertsons")`. 37 41 - Whole Foods uses a separate cache created via `cache.EnsureCache("wholefoods")`; it does not share the `recipes` container/directory. 38 - - Local cache paths are `recipes/` for most app data, `albertsons/` for Albertsons-family data, and `wholefoods/` for Whole Foods data when filesystem backend is used. 42 + - Local cache paths are `recipes/` for most app data, `aldi/` for ALDI data, `albertsons/` for Albertsons-family data, and `wholefoods/` for Whole Foods data when filesystem backend is used. 39 43 - Blob names in Azure match the same key strings listed above inside their respective containers. 40 44 - 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. 41 45 - Do not create nested keys under `recipe/<hash>` (for example `recipe/<hash>/wine`) because `FileCache` stores `recipe/<hash>` as a file path.
+76
internal/aldi/cache.go
··· 1 + package aldi 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 + 12 + "github.com/samber/lo" 13 + lop "github.com/samber/lo/parallel" 14 + ) 15 + 16 + func CacheStoreSummary(ctx context.Context, c cache.Cache, summary *StoreSummary) error { 17 + if summary == nil { 18 + return errors.New("store summary is required") 19 + } 20 + 21 + raw, err := json.Marshal(summary) 22 + if err != nil { 23 + return fmt.Errorf("marshal store summary: %w", err) 24 + } 25 + 26 + if err := c.Put(ctx, StoreCachePrefix+summary.ID, string(raw), cache.Unconditional()); err != nil { 27 + return fmt.Errorf("write store summary cache: %w", err) 28 + } 29 + return nil 30 + } 31 + 32 + func loadCachedStoreSummaries(ctx context.Context, c cache.ListCache) ([]*StoreSummary, error) { 33 + keys, err := c.List(ctx, StoreCachePrefix, "") 34 + if err != nil { 35 + return nil, fmt.Errorf("list cached store summaries: %w", err) 36 + } 37 + 38 + summaries := lop.Map(keys, func(key string, _ int) *StoreSummary { 39 + reader, err := c.Get(ctx, StoreCachePrefix+key) 40 + if err != nil { 41 + slog.WarnContext(ctx, "failed to read cached ALDI store summary", "key", key, "error", err) 42 + return nil 43 + } 44 + defer func() { 45 + _ = reader.Close() 46 + }() 47 + 48 + var summary StoreSummary 49 + if err := json.NewDecoder(reader).Decode(&summary); err != nil { 50 + slog.WarnContext(ctx, "failed to decode cached ALDI store summary", "key", key, "error", err) 51 + return nil 52 + } 53 + return &summary 54 + }) 55 + 56 + summaries = lo.Compact(summaries) 57 + if len(summaries) == 0 { 58 + return nil, fmt.Errorf("failed to load aldi locations") 59 + } 60 + slog.InfoContext(ctx, "loaded ALDI locations", "count", len(summaries)) 61 + 62 + return summaries, nil 63 + } 64 + 65 + func storeSummaryToLocation(summary StoreSummary) locationtypes.Location { 66 + return locationtypes.Location{ 67 + ID: summary.ID, 68 + Name: summary.Name, 69 + Address: summary.Address, 70 + State: summary.State, 71 + ZipCode: summary.ZipCode, 72 + Lat: summary.Lat, 73 + Lon: summary.Lon, 74 + Chain: Container, 75 + } 76 + }
+247
internal/aldi/client.go
··· 1 + package aldi 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "net/url" 11 + "strconv" 12 + "strings" 13 + "time" 14 + ) 15 + 16 + const ( 17 + Container = "aldi" 18 + StoreCachePrefix = "aldi/stores/" 19 + LocationIDPrefix = "aldi_" 20 + DefaultBaseURL = "https://locator.uberall.com" 21 + DefaultWidgetKey = "LETA2YVm6txbe0b9lS297XdxDX4qVQ" //what is this? 22 + DefaultLanguage = "en_US" 23 + DefaultCountry = "US" 24 + ) 25 + 26 + type Client struct { 27 + BaseURL string 28 + WidgetKey string 29 + Language string 30 + Country string 31 + HTTPClient *http.Client 32 + } 33 + 34 + type SourceLocation struct { 35 + ID int64 `json:"id"` 36 + Identifier string `json:"identifier"` 37 + Name string `json:"name"` 38 + StreetAndNumber string `json:"streetAndNumber"` 39 + AddressExtra *string `json:"addressExtra"` 40 + City string `json:"city"` 41 + Province string `json:"province"` 42 + Zip string `json:"zip"` 43 + Lat float64 `json:"lat"` 44 + Lng float64 `json:"lng"` 45 + } 46 + 47 + type StoreSummary struct { 48 + ID string `json:"id"` 49 + StoreID int64 `json:"store_id"` 50 + Identifier string `json:"identifier"` 51 + Name string `json:"name"` 52 + Address string `json:"address"` 53 + City string `json:"city"` 54 + State string `json:"state"` 55 + ZipCode string `json:"zip_code"` 56 + Lat *float64 `json:"lat,omitempty"` 57 + Lon *float64 `json:"lon,omitempty"` 58 + } 59 + 60 + type allLocationsResponse struct { 61 + Status string `json:"status"` 62 + Message string `json:"message"` 63 + Response struct { 64 + Locations []SourceLocation `json:"locations"` 65 + } `json:"response"` 66 + } 67 + 68 + func NewClient(httpClient *http.Client) *Client { 69 + return NewClientWithBaseURL(DefaultBaseURL, DefaultWidgetKey, httpClient) 70 + } 71 + 72 + func NewClientWithBaseURL(baseURL, widgetKey string, httpClient *http.Client) *Client { 73 + baseURL = strings.TrimSpace(baseURL) 74 + if baseURL == "" { 75 + baseURL = DefaultBaseURL 76 + } 77 + widgetKey = strings.TrimSpace(widgetKey) 78 + if widgetKey == "" { 79 + widgetKey = DefaultWidgetKey 80 + } 81 + if httpClient == nil { 82 + httpClient = &http.Client{Timeout: 20 * time.Second} 83 + } 84 + 85 + return &Client{ 86 + BaseURL: strings.TrimRight(baseURL, "/"), 87 + WidgetKey: widgetKey, 88 + Language: DefaultLanguage, 89 + Country: DefaultCountry, 90 + HTTPClient: httpClient, 91 + } 92 + } 93 + 94 + func (c *Client) AllLocations(ctx context.Context) ([]SourceLocation, error) { 95 + if strings.TrimSpace(c.WidgetKey) == "" { 96 + return nil, errors.New("widget key is required") 97 + } 98 + 99 + endpoint, err := url.Parse(c.BaseURL + "/api/locators/" + url.PathEscape(c.WidgetKey) + "/locations/all") 100 + if err != nil { 101 + return nil, fmt.Errorf("parse locations URL: %w", err) 102 + } 103 + 104 + params := endpoint.Query() 105 + params.Set("language", valueOrDefault(c.Language, DefaultLanguage)) 106 + params.Set("country", valueOrDefault(c.Country, DefaultCountry)) 107 + endpoint.RawQuery = params.Encode() 108 + 109 + var response allLocationsResponse 110 + if err := c.getJSON(ctx, endpoint.String(), &response); err != nil { 111 + return nil, err 112 + } 113 + return response.Response.Locations, nil 114 + } 115 + 116 + func (c *Client) StoreSummaries(ctx context.Context) ([]*StoreSummary, error) { 117 + locations, err := c.AllLocations(ctx) 118 + if err != nil { 119 + return nil, err 120 + } 121 + 122 + summaries := make([]*StoreSummary, 0, len(locations)) 123 + for _, location := range locations { 124 + summary, err := normalizeLocation(location) 125 + if err != nil { 126 + return nil, fmt.Errorf("normalize ALDI location %s: %w", locationIdentifier(location), err) 127 + } 128 + summaries = append(summaries, summary) 129 + } 130 + return summaries, nil 131 + } 132 + 133 + func normalizeLocation(location SourceLocation) (*StoreSummary, error) { 134 + identifier := strings.TrimSpace(location.Identifier) 135 + if identifier == "" { 136 + return nil, errors.New("missing identifier") 137 + } 138 + 139 + address := joinAddress(location.StreetAndNumber, valueOrEmpty(location.AddressExtra)) 140 + if address == "" { 141 + return nil, errors.New("missing street address") 142 + } 143 + 144 + state := normalizeState(location.Province) 145 + if state == "" { 146 + return nil, errors.New("missing province") 147 + } 148 + 149 + zipCode := strings.TrimSpace(location.Zip) 150 + if zipCode == "" { 151 + return nil, errors.New("missing zip code") 152 + } 153 + 154 + name := strings.TrimSpace(location.Name) 155 + if name == "" || strings.EqualFold(name, "ALDI") { 156 + name = "ALDI" 157 + } 158 + if name == "ALDI" { 159 + name = strings.TrimSpace(name + " " + address) 160 + } 161 + 162 + summary := &StoreSummary{ 163 + ID: LocationIDPrefix + identifier, 164 + StoreID: location.ID, 165 + Identifier: identifier, 166 + Name: name, 167 + Address: address, 168 + City: strings.TrimSpace(location.City), 169 + State: state, 170 + ZipCode: zipCode, 171 + } 172 + 173 + if location.Lat != 0 && location.Lng != 0 { 174 + lat := location.Lat 175 + lon := location.Lng 176 + summary.Lat = &lat 177 + summary.Lon = &lon 178 + } 179 + 180 + return summary, nil 181 + } 182 + 183 + func joinAddress(streetAndNumber, addressExtra string) string { 184 + address := strings.TrimSpace(streetAndNumber) 185 + extra := strings.TrimSpace(addressExtra) 186 + if address == "" { 187 + return extra 188 + } 189 + if extra == "" { 190 + return address 191 + } 192 + if strings.Contains(strings.ToLower(address), strings.ToLower(extra)) { 193 + return address 194 + } 195 + return address + ", " + extra 196 + } 197 + 198 + func valueOrEmpty(value *string) string { 199 + if value == nil { 200 + return "" 201 + } 202 + return strings.TrimSpace(*value) 203 + } 204 + 205 + func valueOrDefault(value, fallback string) string { 206 + value = strings.TrimSpace(value) 207 + if value == "" { 208 + return fallback 209 + } 210 + return value 211 + } 212 + 213 + func locationIdentifier(location SourceLocation) string { 214 + if strings.TrimSpace(location.Identifier) != "" { 215 + return strings.TrimSpace(location.Identifier) 216 + } 217 + if location.ID != 0 { 218 + return strconv.FormatInt(location.ID, 10) 219 + } 220 + return "unknown" 221 + } 222 + 223 + func (c *Client) getJSON(ctx context.Context, endpoint string, dest any) error { 224 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 225 + if err != nil { 226 + return fmt.Errorf("build request: %w", err) 227 + } 228 + req.Header.Set("Accept", "application/json") 229 + 230 + resp, err := c.HTTPClient.Do(req) 231 + if err != nil { 232 + return fmt.Errorf("fetch %s: %w", endpoint, err) 233 + } 234 + defer func() { 235 + _ = resp.Body.Close() 236 + }() 237 + 238 + if resp.StatusCode != http.StatusOK { 239 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 240 + return fmt.Errorf("fetch %s: status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) 241 + } 242 + 243 + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { 244 + return fmt.Errorf("decode %s: %w", endpoint, err) 245 + } 246 + return nil 247 + }
+120
internal/aldi/client_test.go
··· 1 + package aldi 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + ) 11 + 12 + func TestStoreSummariesBuildsRequestAndNormalizesResponse(t *testing.T) { 13 + t.Parallel() 14 + 15 + var capturedReq *http.Request 16 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 + capturedReq = r 18 + w.Header().Set("Content-Type", "application/json") 19 + if err := json.NewEncoder(w).Encode(map[string]any{ 20 + "status": "SUCCESS", 21 + "response": map[string]any{ 22 + "locations": []SourceLocation{ 23 + { 24 + ID: 5757831, 25 + Identifier: "F100", 26 + Name: "ALDI", 27 + StreetAndNumber: "201 W Division St", 28 + City: "Chicago", 29 + Province: "Illinois", 30 + Zip: "60610", 31 + Lat: 41.894989, 32 + Lng: -87.629197, 33 + }, 34 + }, 35 + }, 36 + }); err != nil { 37 + t.Fatalf("encode response: %v", err) 38 + } 39 + })) 40 + t.Cleanup(server.Close) 41 + 42 + client := NewClientWithBaseURL(server.URL, "test-key", server.Client()) 43 + 44 + summaries, err := client.StoreSummaries(context.Background()) 45 + if err != nil { 46 + t.Fatalf("StoreSummaries returned error: %v", err) 47 + } 48 + 49 + if capturedReq == nil { 50 + t.Fatal("expected request to be captured") 51 + } 52 + if capturedReq.URL.Path != "/api/locators/test-key/locations/all" { 53 + t.Fatalf("unexpected path: %s", capturedReq.URL.Path) 54 + } 55 + if got := capturedReq.URL.Query().Get("language"); got != DefaultLanguage { 56 + t.Fatalf("unexpected language query value: %q", got) 57 + } 58 + if got := capturedReq.URL.Query().Get("country"); got != DefaultCountry { 59 + t.Fatalf("unexpected country query value: %q", got) 60 + } 61 + if got := capturedReq.URL.Query().Get("fieldMask"); got != "" { 62 + t.Fatalf("expected no fieldMask query value, got %q", got) 63 + } 64 + if got := capturedReq.Header.Get("Accept"); got != "application/json" { 65 + t.Fatalf("unexpected Accept header: %q", got) 66 + } 67 + 68 + if len(summaries) != 1 { 69 + t.Fatalf("expected 1 summary, got %d", len(summaries)) 70 + } 71 + if got := summaries[0].ID; got != "aldi_F100" { 72 + t.Fatalf("unexpected summary id: %q", got) 73 + } 74 + if got := summaries[0].State; got != "IL" { 75 + t.Fatalf("unexpected state: %q", got) 76 + } 77 + if got := summaries[0].Name; got != "ALDI 201 W Division St" { 78 + t.Fatalf("unexpected name: %q", got) 79 + } 80 + } 81 + 82 + func TestStoreSummariesIncludesAddressExtra(t *testing.T) { 83 + t.Parallel() 84 + 85 + extra := "Ste 300" 86 + summary, err := normalizeLocation(SourceLocation{ 87 + ID: 5757831, 88 + Identifier: "F216", 89 + Name: "ALDI", 90 + StreetAndNumber: "1951 S. Mccall Rd #300", 91 + AddressExtra: &extra, 92 + City: "Englewood", 93 + Province: "FL", 94 + Zip: "34224", 95 + }) 96 + if err != nil { 97 + t.Fatalf("normalizeLocation returned error: %v", err) 98 + } 99 + 100 + if got := summary.Address; got != "1951 S. Mccall Rd #300, Ste 300" { 101 + t.Fatalf("unexpected address: %q", got) 102 + } 103 + } 104 + 105 + func TestStoreSummariesRequiresIdentifier(t *testing.T) { 106 + t.Parallel() 107 + 108 + _, err := normalizeLocation(SourceLocation{ 109 + Name: "ALDI", 110 + StreetAndNumber: "201 W Division St", 111 + Province: "IL", 112 + Zip: "60610", 113 + }) 114 + if err == nil { 115 + t.Fatal("expected error") 116 + } 117 + if !strings.Contains(err.Error(), "missing identifier") { 118 + t.Fatalf("unexpected error: %v", err) 119 + } 120 + }
+76
internal/aldi/locations.go
··· 1 + package aldi 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/locations/nearby" 6 + locationtypes "careme/internal/locations/types" 7 + "context" 8 + "fmt" 9 + "strings" 10 + ) 11 + 12 + type centroidByZip interface { 13 + ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) 14 + } 15 + 16 + type LocationBackend struct { 17 + zipLookup centroidByZip 18 + byID map[string]locationtypes.Location 19 + } 20 + 21 + func NewLocationBackend(ctx context.Context, c cache.ListCache, zipLookup centroidByZip) (*LocationBackend, error) { 22 + if c == nil { 23 + return nil, fmt.Errorf("list cache is required") 24 + } 25 + if zipLookup == nil { 26 + return nil, fmt.Errorf("zip centroid lookup is required") 27 + } 28 + 29 + summaries, err := loadCachedStoreSummaries(ctx, c) 30 + if err != nil { 31 + return nil, err 32 + } 33 + 34 + byID := make(map[string]locationtypes.Location, len(summaries)) 35 + for _, summary := range summaries { 36 + loc := storeSummaryToLocation(*summary) 37 + byID[loc.ID] = loc 38 + } 39 + 40 + return &LocationBackend{ 41 + zipLookup: zipLookup, 42 + byID: byID, 43 + }, nil 44 + } 45 + 46 + func (b *LocationBackend) IsID(locationID string) bool { 47 + return IsID(locationID) 48 + } 49 + 50 + func (b *LocationBackend) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 51 + locationID = strings.TrimSpace(locationID) 52 + if !IsID(locationID) { 53 + return nil, fmt.Errorf("ALDI location id %q is invalid", locationID) 54 + } 55 + 56 + loc, exists := b.byID[locationID] 57 + if !exists { 58 + return nil, fmt.Errorf("ALDI location %q not found", locationID) 59 + } 60 + 61 + copy := loc 62 + return &copy, nil 63 + } 64 + 65 + func (b *LocationBackend) GetLocationsByZip(ctx context.Context, zipcode string) ([]locationtypes.Location, error) { 66 + candidates := make([]locationtypes.Location, 0, len(b.byID)) 67 + for _, loc := range b.byID { 68 + candidates = append(candidates, loc) 69 + } 70 + return nearby.FilterAndSortByZip(ctx, b.zipLookup, zipcode, candidates, nearby.MaxLocationDistanceMiles), nil 71 + } 72 + 73 + func IsID(locationID string) bool { 74 + locationID = strings.TrimSpace(locationID) 75 + return strings.HasPrefix(locationID, LocationIDPrefix) && strings.TrimPrefix(locationID, LocationIDPrefix) != "" 76 + }
+133
internal/aldi/locations_test.go
··· 1 + package aldi 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, nearbySummary()); err != nil { 16 + t.Fatalf("CacheStoreSummary returned error: %v", err) 17 + } 18 + 19 + backend, err := NewLocationBackend(context.Background(), cacheStore, staticZIPLookup{ 20 + "60610": {Lat: 41.9033, Lon: -87.6313}, 21 + }) 22 + if err != nil { 23 + t.Fatalf("NewLocationBackend returned error: %v", err) 24 + } 25 + 26 + if !backend.IsID("aldi_F100") { 27 + t.Fatalf("expected ALDI id to be recognized") 28 + } 29 + 30 + loc, err := backend.GetLocationByID(context.Background(), "aldi_F100") 31 + if err != nil { 32 + t.Fatalf("GetLocationByID returned error: %v", err) 33 + } 34 + if loc.Name != "ALDI 201 W Division St" || loc.ZipCode != "60610" || loc.Chain != "aldi" { 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, nearbySummary()); err != nil { 44 + t.Fatalf("cache nearby summary: %v", err) 45 + } 46 + if err := CacheStoreSummary(context.Background(), cacheStore, farSummary()); err != nil { 47 + t.Fatalf("cache far summary: %v", err) 48 + } 49 + 50 + backend, err := NewLocationBackend(context.Background(), cacheStore, staticZIPLookup{ 51 + "60610": {Lat: 41.9033, Lon: -87.6313}, 52 + }) 53 + if err != nil { 54 + t.Fatalf("NewLocationBackend returned error: %v", err) 55 + } 56 + 57 + locs, err := backend.GetLocationsByZip(context.Background(), "60610") 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 != "aldi_F100" { 65 + t.Fatalf("unexpected location id: %q", locs[0].ID) 66 + } 67 + if locs[0].Chain != "aldi" { 68 + t.Fatalf("unexpected location chain: %q", locs[0].Chain) 69 + } 70 + } 71 + 72 + func TestNewLocationBackendErrorsWhenNoCachedSummaries(t *testing.T) { 73 + t.Parallel() 74 + 75 + cacheStore := cache.NewInMemoryCache() 76 + 77 + _, err := NewLocationBackend(context.Background(), cacheStore, staticZIPLookup{}) 78 + if err == nil { 79 + t.Fatal("expected NewLocationBackend to return an error") 80 + } 81 + if !strings.Contains(err.Error(), "failed to load aldi locations") { 82 + t.Fatalf("expected missing summaries error, got %v", err) 83 + } 84 + } 85 + 86 + type staticZIPLookup map[string]coords 87 + 88 + type coords struct { 89 + Lat float64 90 + Lon float64 91 + } 92 + 93 + func (s staticZIPLookup) ZipCentroidByZIP(zip string) (locationtypes.ZipCentroid, bool) { 94 + coord, ok := s[zip] 95 + if !ok { 96 + return locationtypes.ZipCentroid{}, false 97 + } 98 + return locationtypes.ZipCentroid{Lat: coord.Lat, Lon: coord.Lon}, true 99 + } 100 + 101 + func nearbySummary() *StoreSummary { 102 + lat := 41.894989 103 + lon := -87.629197 104 + return &StoreSummary{ 105 + ID: "aldi_F100", 106 + StoreID: 5757831, 107 + Identifier: "F100", 108 + Name: "ALDI 201 W Division St", 109 + Address: "201 W Division St", 110 + City: "Chicago", 111 + State: "IL", 112 + ZipCode: "60610", 113 + Lat: &lat, 114 + Lon: &lon, 115 + } 116 + } 117 + 118 + func farSummary() *StoreSummary { 119 + lat := 33.920675 120 + lon := -84.379135 121 + return &StoreSummary{ 122 + ID: "aldi_F216", 123 + StoreID: 5757832, 124 + Identifier: "F216", 125 + Name: "ALDI 3333 Buford Hwy", 126 + Address: "3333 Buford Hwy", 127 + City: "Brookhaven", 128 + State: "GA", 129 + ZipCode: "30329", 130 + Lat: &lat, 131 + Lon: &lon, 132 + } 133 + }
+71
internal/aldi/states.go
··· 1 + package aldi 2 + 3 + import "strings" 4 + 5 + var stateAbbreviations = map[string]string{ 6 + "ALABAMA": "AL", 7 + "ALASKA": "AK", 8 + "ARIZONA": "AZ", 9 + "ARKANSAS": "AR", 10 + "CALIFORNIA": "CA", 11 + "COLORADO": "CO", 12 + "CONNECTICUT": "CT", 13 + "DELAWARE": "DE", 14 + "DISTRICT OF COLUMBIA": "DC", 15 + "FLORIDA": "FL", 16 + "GEORGIA": "GA", 17 + "HAWAII": "HI", 18 + "IDAHO": "ID", 19 + "ILLINOIS": "IL", 20 + "INDIANA": "IN", 21 + "IOWA": "IA", 22 + "KANSAS": "KS", 23 + "KENTUCKY": "KY", 24 + "LOUISIANA": "LA", 25 + "MAINE": "ME", 26 + "MARYLAND": "MD", 27 + "MASSACHUSETTS": "MA", 28 + "MICHIGAN": "MI", 29 + "MINNESOTA": "MN", 30 + "MISSISSIPPI": "MS", 31 + "MISSOURI": "MO", 32 + "MONTANA": "MT", 33 + "NEBRASKA": "NE", 34 + "NEVADA": "NV", 35 + "NEW HAMPSHIRE": "NH", 36 + "NEW JERSEY": "NJ", 37 + "NEW MEXICO": "NM", 38 + "NEW YORK": "NY", 39 + "NORTH CAROLINA": "NC", 40 + "NORTH DAKOTA": "ND", 41 + "OHIO": "OH", 42 + "OKLAHOMA": "OK", 43 + "OREGON": "OR", 44 + "PENNSYLVANIA": "PA", 45 + "RHODE ISLAND": "RI", 46 + "SOUTH CAROLINA": "SC", 47 + "SOUTH DAKOTA": "SD", 48 + "TENNESSEE": "TN", 49 + "TEXAS": "TX", 50 + "UTAH": "UT", 51 + "VERMONT": "VT", 52 + "VIRGINIA": "VA", 53 + "WASHINGTON": "WA", 54 + "WEST VIRGINIA": "WV", 55 + "WISCONSIN": "WI", 56 + "WYOMING": "WY", 57 + } 58 + 59 + func normalizeState(province string) string { 60 + province = strings.TrimSpace(province) 61 + if province == "" { 62 + return "" 63 + } 64 + if len(province) == 2 { 65 + return strings.ToUpper(province) 66 + } 67 + if state, ok := stateAbbreviations[strings.ToUpper(province)]; ok { 68 + return state 69 + } 70 + return province 71 + }
+12
internal/config/config.go
··· 11 11 AI AIConfig `json:"ai"` 12 12 Kroger KrogerConfig `json:"kroger"` 13 13 Walmart WalmartConfig `json:"walmart"` 14 + Aldi AldiConfig `json:"aldi"` 14 15 WholeFoods WholeFoodsConfig `json:"wholefoods"` 15 16 Albertsons AlbertsonsConfig `json:"albertsons"` 16 17 Mocks MockConfig `json:"mocks"` ··· 52 53 } 53 54 54 55 func (c *WholeFoodsConfig) IsEnabled() bool { 56 + return c.Enable 57 + } 58 + 59 + type AldiConfig struct { 60 + Enable bool `json:"enable"` 61 + } 62 + 63 + func (c *AldiConfig) IsEnabled() bool { 55 64 return c.Enable 56 65 } 57 66 ··· 116 125 }, 117 126 Admin: AdminConfig{ 118 127 Emails: parseAdminEmails(os.Getenv("ADMIN_EMAILS")), 128 + }, 129 + Aldi: AldiConfig{ 130 + Enable: os.Getenv("ALDI_ENABLE") != "", 119 131 }, 120 132 WholeFoods: WholeFoodsConfig{ 121 133 Enable: os.Getenv("WHOLEFOODS_ENABLE") != "",
+73
internal/locations/aldi_test.go
··· 1 + package locations 2 + 3 + import ( 4 + "careme/internal/aldi" 5 + "careme/internal/cache" 6 + "careme/internal/config" 7 + "context" 8 + "os" 9 + "testing" 10 + ) 11 + 12 + func TestNewAddsALDIBackendWhenEnabled(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(aldi.Container) 30 + if err != nil { 31 + t.Fatalf("EnsureCache returned error: %v", err) 32 + } 33 + 34 + lat := 41.894989 35 + lon := -87.629197 36 + if err := aldi.CacheStoreSummary(context.Background(), listCache, &aldi.StoreSummary{ 37 + ID: "aldi_F100", 38 + StoreID: 5757831, 39 + Identifier: "F100", 40 + Name: "ALDI 201 W Division St", 41 + Address: "201 W Division St", 42 + City: "Chicago", 43 + State: "IL", 44 + ZipCode: "60610", 45 + Lat: &lat, 46 + Lon: &lon, 47 + }); err != nil { 48 + t.Fatalf("CacheStoreSummary returned error: %v", err) 49 + } 50 + 51 + storage, err := New(&config.Config{ 52 + Aldi: config.AldiConfig{Enable: true}, 53 + }, cacheStore, LoadCentroids()) 54 + if err != nil { 55 + t.Fatalf("New returned error: %v", err) 56 + } 57 + 58 + locStorage, ok := storage.(*locationStorage) 59 + if !ok { 60 + t.Fatalf("expected *locationStorage, got %T", storage) 61 + } 62 + 63 + var found bool 64 + for _, backend := range locStorage.client { 65 + if _, ok := backend.(*aldi.LocationBackend); ok { 66 + found = true 67 + break 68 + } 69 + } 70 + if !found { 71 + t.Fatalf("expected ALDI backend to be registered") 72 + } 73 + }
+14
internal/locations/locations.go
··· 2 2 3 3 import ( 4 4 "careme/internal/albertsons" 5 + "careme/internal/aldi" 5 6 "careme/internal/auth" 6 7 "careme/internal/cache" 7 8 "careme/internal/config" ··· 92 93 return nil, fmt.Errorf("failed to create Walmart client: %w", err) 93 94 } 94 95 backends = append(backends, wclient) 96 + } 97 + if cfg.Aldi.IsEnabled() { 98 + slog.Info("initializing ALDI location backend") 99 + listCache, err := cache.EnsureCache(aldi.Container) 100 + if err != nil { 101 + return nil, fmt.Errorf("failed to create ALDI list cache: %w", err) 102 + } 103 + 104 + aldiBackend, err := aldi.NewLocationBackend(context.Background(), listCache, centroids) 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to create ALDI backend: %w", err) 107 + } 108 + backends = append(backends, aldiBackend) 95 109 } 96 110 if cfg.WholeFoods.IsEnabled() { 97 111 slog.Info("initializing Whole Foods location backend")