ai cooking
0
fork

Configure Feed

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

wholefood staples (#337)

* wholefood staples

* remove unused

* push to packages

* missed some adds

* whoops another add missed

* no invalid location ids

* walmart stub

* identity providers

* internal identiy providers

* share parallelism flatten

* we got wholefoods produce

* basically working

* some new product clients

* make old hash match

* hide kroger filter

* hide a method

* remove get ingredients from generator

* manual review

* hash slug for product id

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
3ffe1777 f926522d

+1560 -306
+11 -24
cmd/ingredients/main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "careme/internal/cache" 5 4 "careme/internal/config" 6 5 "careme/internal/recipes" 7 6 "context" 8 7 "flag" 9 8 "fmt" 10 9 "log" 11 - "strings" 12 10 ) 13 11 14 12 func main() { 15 - var ingredient string 13 + var searchTerm string 16 14 var location string 17 - flag.StringVar(&ingredient, "ingredient", "", "Ingredient to filter recipes") 18 - flag.StringVar(&ingredient, "i", "", "Ingredient to filter recipes") 15 + flag.StringVar(&searchTerm, "ingredient", "", "Search term for ingredient lookup") 16 + flag.StringVar(&searchTerm, "i", "", "Search term for ingredient lookup") 19 17 flag.StringVar(&location, "location", "", "Location for recipe sourcing (e.g., 70100023)") 20 18 flag.StringVar(&location, "l", "", "Location for recipe sourcing (short form)") 21 19 flag.Parse() 22 20 ctx := context.Background() 23 - cache, err := cache.MakeCache() 24 - if err != nil { 25 - log.Fatalf("failed to create cache: %s", err) 26 - } 27 21 28 22 cfg, err := config.Load() 29 23 if err != nil { 30 24 log.Fatalf("failed to load configuration: %s", err) 31 25 } 32 26 33 - generator, err := recipes.NewGenerator(cfg, recipes.IO(cache)) 27 + sp, err := recipes.NewStaplesProvider(cfg) 34 28 if err != nil { 35 29 log.Fatalf("failed to create recipe generator: %s", err) 36 30 } 37 31 38 - g, ok := generator.(*recipes.Generator) 39 - if !ok { 40 - log.Fatalf("failed to cast generator to *recipes.Generator") 41 - } 42 - 43 - f := recipes.Filter(ingredient, []string{"*"}, false /*frozen*/) 44 - ings, err := g.GetIngredients(ctx, location, f, 0) 32 + ings, err := sp.GetIngredients(ctx, location, searchTerm, 0) 45 33 if err != nil { 46 34 log.Fatalf("failed to get ingredients: %s", err) 47 35 } 48 36 49 37 for _, i := range ings { 50 - fmt.Printf("%s - %s:(%s))\n", toString(i.Brand), toString(i.Description), strings.Join(toSlice(i.Categories), ",")) 38 + fmt.Printf("%s: %s - %s:($%s) size: %s\n", toString(i.ProductId), toString(i.Brand), toString(i.Description), toFloat(i.PriceRegular), toString(i.Size)) 51 39 } 52 - 53 40 } 54 41 55 - func toSlice(s *[]string) []string { 42 + func toString(s *string) string { 56 43 if s == nil { 57 - return nil 44 + return "" 58 45 } 59 46 return *s 60 47 } 61 48 62 - func toString(s *string) string { 63 - if s == nil { 49 + func toFloat(f *float32) string { 50 + if f == nil { 64 51 return "" 65 52 } 66 - return *s 53 + return fmt.Sprintf("%.2f", *f) 67 54 }
+8 -18
cmd/producecheck/main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "careme/internal/cache" 5 4 "careme/internal/config" 6 5 "careme/internal/kroger" 7 - "careme/internal/recipes" 8 6 "context" 9 7 "flag" 10 8 "fmt" ··· 43 41 log.Fatalf("failed to load config: %v", err) 44 42 } 45 43 46 - cacheStore, err := cache.MakeCache() 44 + client, err := kroger.FromConfig(cfg) 47 45 if err != nil { 48 - log.Fatalf("failed to create cache: %v", err) 49 - } 50 - 51 - generator, err := recipes.NewGenerator(cfg, recipes.IO(cacheStore)) 52 - if err != nil { 53 - log.Fatalf("failed to create recipe generator: %v", err) 54 - } 55 - g, ok := generator.(*recipes.Generator) 56 - if !ok { 57 - log.Fatalf("failed to cast generator to *recipes.Generator") 46 + log.Fatalf("failed to create Kroger client: %v", err) 58 47 } 59 48 60 - missing, results, err := checkProduceAvailability(ctx, g, locationID, produce) 49 + missing, results, err := checkProduceAvailability(ctx, client, locationID, produce) 61 50 if err != nil { 62 51 log.Fatalf("availability check failed: %v", err) 63 52 } ··· 96 85 matchedDescriptions []string 97 86 } 98 87 99 - func checkProduceAvailability(ctx context.Context, g *recipes.Generator, locationID string, produce []string) ([]string, int, error) { 88 + func checkProduceAvailability(ctx context.Context, client kroger.ClientWithResponsesInterface, locationID string, produce []string) ([]string, int, error) { 100 89 101 - filters := recipes.Produce() 90 + //TODO expand this to other staple providers 91 + filters := kroger.ProduceFilters() 102 92 type filterResult struct { 103 93 filter string 104 94 ingredients []kroger.Ingredient ··· 108 98 results := make([]filterResult, len(filters)) 109 99 var wg sync.WaitGroup 110 100 wg.Add(len(filters)) 101 + kprovider := kroger.NewStaplesProvider(client) 111 102 for i, filter := range filters { 112 - i, filter := i, filter 113 103 go func() { 114 104 defer wg.Done() 115 - filterIngredients, err := g.GetIngredients(ctx, locationID, filter, 0) 105 + filterIngredients, err := kprovider.GetIngredients(ctx, locationID, filter.Term, 0) 116 106 results[i] = filterResult{ 117 107 filter: filter.Term, 118 108 ingredients: filterIngredients,
+2 -1
docs/cache-layout.md
··· 14 14 | --- | --- | --- | --- | 15 15 | `shoppinglist/` | JSON `ai.ShoppingList` keyed by shopping hash | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`FromCache`) | 16 16 | `ingredients/` | JSON `[]kroger.Ingredient` keyed by location hash (staples) or by wine style/date/location hash (wine candidate cache) | `internal/recipes/io.go` (`SaveIngredients`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | `internal/recipes/io.go` (`IngredientsFromCache`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | 17 - | `params/` | JSON `generatorParams` keyed by shopping hash | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 17 + | `params/` | JSON `generatorParams` keyed by shopping hash; params no longer embed the resolved staple filter list | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 18 18 | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveRecipes`) | `internal/recipes/io.go` (`SingleFromCache`) | 19 19 | `wine_recommendations/` | Plain text wine recommendation keyed by recipe hash | `internal/recipes/wine.go` (`SaveWine`) via `internal/recipes/server.go` (`handleWine`) | `internal/recipes/wine.go` (`WineFromCache`) via `internal/recipes/server.go` (`handleWine`) | 20 20 | `recipe_selection/` | JSON `recipeSelection` (`saved_hashes`, `dismissed_hashes`, `updated_at`) keyed by `<user_id>/<origin_hash>` | `internal/recipes/selection.go` (`saveRecipeSelection`) via `internal/recipes/server.go` (`handleSaveRecipe`, `handleDismissRecipe`) | `internal/recipes/selection.go` (`loadRecipeSelection`) via `internal/recipes/server.go` (`handleRegenerate`, `handleFinalize`, `handleRecipes`) | ··· 32 32 - Whole Foods uses a separate cache created via `cache.EnsureCache("wholefoods")`; it does not share the `recipes` container/directory. 33 33 - Local cache paths are `recipes/` for most app data and `wholefoods/` for Whole Foods data when filesystem backend is used. 34 34 - Blob names in Azure match the same key strings listed above inside their respective containers. 35 + - 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. 35 36 - Do not create nested keys under `recipe/<hash>` (for example `recipe/<hash>/wine`) because `FileCache` stores `recipe/<hash>` as a file path.
+2 -2
internal/ingredients/server_test.go
··· 16 16 cacheStore := cache.NewInMemoryCache() 17 17 rio := recipes.IO(cacheStore) 18 18 params := recipes.DefaultParams( 19 - &locations.Location{ID: "loc-1", Name: "Store 1"}, 19 + &locations.Location{ID: "70000003", Name: "Store 1"}, 20 20 time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC), 21 21 ) 22 22 if err := rio.SaveParams(t.Context(), params); err != nil { ··· 51 51 cacheStore := cache.NewInMemoryCache() 52 52 rio := recipes.IO(cacheStore) 53 53 params := recipes.DefaultParams( 54 - &locations.Location{ID: "loc-2", Name: "Store 2"}, 54 + &locations.Location{ID: "70000004", Name: "Store 2"}, 55 55 time.Date(2026, 1, 26, 0, 0, 0, 0, time.UTC), 56 56 ) 57 57 if err := rio.SaveParams(t.Context(), params); err != nil {
+206
internal/kroger/staples.go
··· 1 + package kroger 2 + 3 + import ( 4 + "careme/internal/parallelism" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "slices" 11 + "strconv" 12 + 13 + "github.com/samber/lo" 14 + ) 15 + 16 + var defaultStaplesSignature = string(lo.Must(json.Marshal(defaultStaples()))) 17 + 18 + type staplesFilter struct { 19 + Term string `json:"term,omitempty"` 20 + Brands []string `json:"brands,omitempty"` 21 + Frozen bool `json:"frozen,omitempty"` 22 + } 23 + 24 + type identityProvider struct{} 25 + 26 + func NewIdentityProvider() identityProvider { 27 + return identityProvider{} 28 + } 29 + 30 + func (p identityProvider) Signature() string { 31 + return defaultStaplesSignature 32 + } 33 + 34 + func (p identityProvider) IsID(locationID string) bool { 35 + if locationID == "" { 36 + return false 37 + } 38 + for i := 0; i < len(locationID); i++ { 39 + if locationID[i] < '0' || locationID[i] > '9' { 40 + return false 41 + } 42 + } 43 + return true 44 + } 45 + 46 + // internal? 47 + type StaplesProvider struct { 48 + identityProvider 49 + client ClientWithResponsesInterface 50 + } 51 + 52 + func NewStaplesProvider(client ClientWithResponsesInterface) StaplesProvider { 53 + return StaplesProvider{client: client} 54 + } 55 + 56 + func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]Ingredient, error) { 57 + return parallelism.Flatten(defaultStaples(), func(category staplesFilter) ([]Ingredient, error) { 58 + 59 + ingredients, err := searchIngredients(ctx, p.client, locationID, category.Term, category.Brands, category.Frozen, 0) 60 + slog.InfoContext(ctx, "Found ingredients for category", "count", len(ingredients), "category", category.Term, "location", locationID) 61 + return ingredients, err 62 + }) 63 + 64 + } 65 + 66 + func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]Ingredient, error) { 67 + return searchIngredients(ctx, p.client, locationID, searchTerm, []string{"*"}, false, skip) 68 + } 69 + 70 + func searchIngredients(ctx context.Context, client ClientWithResponsesInterface, locationID, term string, brands []string, frozen bool, skip int) ([]Ingredient, error) { 71 + limit := 50 72 + limitStr := strconv.Itoa(limit) 73 + startStr := strconv.Itoa(skip) 74 + products, err := client.ProductSearchWithResponse(ctx, &ProductSearchParams{ 75 + FilterLocationId: &locationID, 76 + FilterTerm: &term, 77 + FilterLimit: &limitStr, 78 + FilterStart: &startStr, 79 + }) 80 + if err != nil { 81 + return nil, fmt.Errorf("kroger product search request failed: %w", err) 82 + } 83 + if err := requireSuccess(products.StatusCode(), products.JSON500); err != nil { 84 + return nil, err 85 + } 86 + 87 + var ingredients []Ingredient 88 + 89 + for _, product := range *products.JSON200.Data { 90 + wildcard := len(brands) > 0 && brands[0] == "*" 91 + 92 + if product.Brand != nil && !slices.Contains(brands, toStr(product.Brand)) && !wildcard { 93 + continue 94 + } 95 + if slices.Contains(*product.Categories, "Frozen") && !frozen { 96 + continue 97 + } 98 + for _, item := range *product.Items { 99 + if item.Price == nil { 100 + continue 101 + } 102 + 103 + var aisle *string 104 + if product.AisleLocations != nil && len(*product.AisleLocations) > 0 { 105 + aisle = (*product.AisleLocations)[0].Number 106 + } 107 + 108 + ingredients = append(ingredients, Ingredient{ 109 + ProductId: product.ProductId, 110 + Brand: product.Brand, 111 + Description: product.Description, 112 + Size: item.Size, 113 + PriceRegular: item.Price.Regular, 114 + PriceSale: item.Price.Promo, 115 + Categories: product.Categories, 116 + AisleNumber: aisle, 117 + }) 118 + 119 + //DO we care about these? 120 + /*"taxonomies": [ 121 + { 122 + "department": {}, 123 + "commodity": {}, 124 + "subCommodity": {} 125 + } 126 + ],*/ 127 + //Taxonomy: product., 128 + // CountryOrigin: product.CountryOrigin, 129 + // Favorite: item.Favorite, 130 + // InventoryStockLevel: item.InventoryStockLevel), 131 + } 132 + } 133 + 134 + // recursion is pretty dumb pagination 135 + // kroger limites us to 250 136 + if len(*products.JSON200.Data) == limit && skip < 250 { 137 + page, err := searchIngredients(ctx, client, locationID, term, brands, frozen, skip+limit) 138 + if err == nil { 139 + ingredients = append(ingredients, page...) 140 + } 141 + } 142 + 143 + return ingredients, nil 144 + } 145 + 146 + func defaultStaples() []staplesFilter { 147 + return append(ProduceFilters(), []staplesFilter{ 148 + { 149 + Term: "beef", 150 + Brands: []string{"Simple Truth", "Kroger"}, 151 + }, 152 + { 153 + Term: "chicken", 154 + Brands: []string{"Foster Farms", "Draper Valley", "Simple Truth"}, 155 + }, 156 + { 157 + Term: "fish", 158 + }, 159 + { 160 + Term: "pork", 161 + Brands: []string{"PORK", "Kroger", "Harris Teeter"}, 162 + }, 163 + { 164 + Term: "shellfish", 165 + Brands: []string{"Sand Bar", "Kroger"}, 166 + Frozen: true, 167 + }, 168 + { 169 + Term: "lamb", 170 + Brands: []string{"Simple Truth"}, 171 + }, 172 + }...) 173 + } 174 + 175 + func ProduceFilters() []staplesFilter { 176 + return []staplesFilter{ 177 + { 178 + Term: "fresh vegatable", 179 + Brands: []string{"*"}, 180 + }, 181 + { 182 + Term: "fresh produce", 183 + Brands: []string{"*"}, 184 + }, 185 + } 186 + } 187 + 188 + func krogerError(statusCode int, payload any) error { 189 + output, _ := json.Marshal(payload) 190 + return fmt.Errorf("got %d code from kroger : %s", statusCode, string(output)) 191 + } 192 + 193 + func requireSuccess(statusCode int, payload any) error { 194 + if statusCode == http.StatusOK { 195 + return nil 196 + } 197 + return krogerError(statusCode, payload) 198 + } 199 + 200 + func mustJSONSignature(value any) string { 201 + signature, err := json.Marshal(value) 202 + if err != nil { 203 + panic(fmt.Errorf("marshal staples signature: %w", err)) 204 + } 205 + return string(signature) 206 + }
+12
internal/kroger/staples_test.go
··· 1 + package kroger 2 + 3 + import "testing" 4 + 5 + func TestIdentityProviderSignature_UsesJSONStaples(t *testing.T) { 6 + got := NewIdentityProvider().Signature() 7 + want := mustJSONSignature(defaultStaples()) 8 + 9 + if got != want { 10 + t.Fatalf("unexpected signature: got %q want %q", got, want) 11 + } 12 + }
+1 -1
internal/recipes/buttons_test.go
··· 37 37 }, 38 38 } 39 39 40 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 40 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 41 41 p := DefaultParams(&loc, time.Now()) 42 42 w := httptest.NewRecorder() 43 43 FormatShoppingListHTML(p, multiRecipeList, true, w)
+19 -138
internal/recipes/generator.go
··· 6 6 "careme/internal/config" 7 7 "careme/internal/kroger" 8 8 "careme/internal/locations" 9 + "careme/internal/parallelism" 9 10 "context" 10 11 "encoding/base64" 11 - "encoding/json" 12 12 "errors" 13 13 "fmt" 14 14 "hash/fnv" 15 15 "io" 16 16 "log/slog" 17 - "net/http" 18 - "slices" 19 - "strconv" 20 17 "strings" 21 18 "time" 22 19 ··· 38 35 } 39 36 40 37 type Generator struct { 41 - config *config.Config 42 - aiClient aiClient 43 - krogerClient kroger.ClientWithResponsesInterface // probably need only subset 44 - io ingredientio 38 + config *config.Config 39 + aiClient aiClient 40 + staplesProvider staplesProvider 41 + io ingredientio 45 42 } 46 43 47 44 func NewGenerator(cfg *config.Config, io ingredientio) (generator, error) { ··· 49 46 return mock{}, nil 50 47 } 51 48 52 - client, err := kroger.FromConfig(cfg) 49 + stapesProvider, err := NewStaplesProvider(cfg) 53 50 if err != nil { 54 - return nil, err 51 + return nil, fmt.Errorf("failed to create staples provider: %w", err) 55 52 } 53 + 56 54 return &Generator{ 57 - io: io, 58 - config: cfg, 59 - aiClient: ai.NewClient(cfg.AI.APIKey, "TODOMODEL"), 60 - krogerClient: client, 55 + io: io, 56 + config: cfg, 57 + aiClient: ai.NewClient(cfg.AI.APIKey, "TODOMODEL"), 58 + staplesProvider: stapesProvider, 61 59 }, nil 62 60 } 63 61 64 62 func (g *Generator) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 65 63 var styles []string 64 + //THIS is broken for wholefoods until we put in product search or just fetch all red/whiteines. 66 65 for _, style := range recipe.WineStyles { 67 66 style = strings.TrimSpace(style) 68 67 if style != "" { //would this ever happen? 69 68 styles = append(styles, style) 70 69 } 71 70 } 71 + 72 72 if len(styles) == 0 { 73 73 return &ai.WineSelection{Commentary: "no wines styles for recipe", Wines: []ai.Ingredient{}}, nil 74 74 } 75 75 dateStr := date.Format("2006-01-02") 76 76 logger := slog.With("location", location, "date", dateStr) 77 - wines, err := asParallel(styles, func(style string) ([]kroger.Ingredient, error) { 77 + 78 + wines, err := parallelism.Flatten(styles, func(style string) ([]kroger.Ingredient, error) { 78 79 cacheKey := wineIngredientsCacheKey(style, location, date) 79 80 winesOfStyle, err := g.io.IngredientsFromCache(ctx, cacheKey) 80 81 if err == nil { ··· 85 86 logger.ErrorContext(ctx, "Failed to read cached wines for style", "style", style, "error", err) 86 87 } 87 88 88 - winesOfStyle, err = g.GetIngredients(ctx, location, Filter(style, []string{"*"}, false), 0) 89 + winesOfStyle, err = g.staplesProvider.GetIngredients(ctx, location, style, 0) 89 90 if err != nil { 90 91 slog.ErrorContext(ctx, "Failed to get ingredients for wine style", "style", style, "error", err) 91 92 return nil, fmt.Errorf("failed to get ingredients for style %q: %w", style, err) ··· 163 164 return g.aiClient.AskQuestion(ctx, question, conversationID) 164 165 } 165 166 166 - type filter struct { 167 - Term string `json:"term,omitempty"` 168 - Brands []string `json:"brands,omitempty"` 169 - Frozen bool `json:"frozen,omitempty"` 170 - } 171 - 172 - func Filter(term string, brands []string, frozen bool) filter { 173 - return filter{ 174 - Term: term, 175 - Brands: brands, 176 - Frozen: frozen, 177 - } 178 - } 179 - 180 167 // calls get ingredients for a number of "staples" basically fresh produce and vegatbles. 181 168 // tries to filter to no brand or certain brands to avoid shelved products 182 169 func (g *Generator) GetStaples(ctx context.Context, p *generatorParams) ([]kroger.Ingredient, error) { 183 170 lochash := p.LocationHash() 184 - var ingredients []kroger.Ingredient 185 171 186 172 if cachedIngredients, err := g.io.IngredientsFromCache(ctx, lochash); err == nil { 187 173 slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash, "count", len(cachedIngredients)) ··· 190 176 slog.ErrorContext(ctx, "failed to read cached ingredients", "location", p.String(), "error", err) 191 177 } 192 178 193 - ingredients, err := asParallel(p.Staples, func(category filter) ([]kroger.Ingredient, error) { 194 - cingredients, err := g.GetIngredients(ctx, p.Location.ID, category, 0) 195 - if err != nil { 196 - slog.ErrorContext(ctx, "failed to get ingredients", "category", category.Term, "location", p.Location.ID, "error", err) 197 - return nil, err 198 - } 199 - slog.InfoContext(ctx, "Found ingredients for category", "count", len(cingredients), "category", category.Term, "location", p.Location.ID, "runningtotal", len(ingredients)) 200 - return cingredients, nil 201 - }) 179 + ingredients, err := g.staplesProvider.FetchStaples(ctx, p.Location) 202 180 if err != nil { 203 181 return nil, fmt.Errorf("failed to get ingredients for staples: %w", err) 204 182 } ··· 212 190 return ingredients, nil 213 191 } 214 192 193 + // TODO should we be going off product id instead? 215 194 func uniqueByDescription(ingredients []kroger.Ingredient) []kroger.Ingredient { 216 195 return lo.UniqBy(ingredients, func(i kroger.Ingredient) string { 217 196 return toStr(i.Description) 218 197 }) 219 - } 220 - 221 - // move to krogrer client as everyone will be differnt here? 222 - func (g *Generator) GetIngredients(ctx context.Context, location string, f filter, skip int) ([]kroger.Ingredient, error) { 223 - limit := 50 224 - limitStr := strconv.Itoa(limit) 225 - startStr := strconv.Itoa(skip) 226 - // brand := "empty" doesn't work have to check for nil 227 - // fulfillment := "ais" drmatically shortens? 228 - // wrapped this in a retry and it did nothng 229 - products, err := g.krogerClient.ProductSearchWithResponse(ctx, &kroger.ProductSearchParams{ 230 - FilterLocationId: &location, 231 - FilterTerm: &f.Term, 232 - FilterLimit: &limitStr, 233 - FilterStart: &startStr, 234 - // FilterBrand: &brand, 235 - // FilterFulfillment: &fulfillment, 236 - }) 237 - if err != nil { 238 - return nil, fmt.Errorf("kroger product search request failed: %w", err) 239 - } 240 - if products.StatusCode() != http.StatusOK { 241 - output, _ := json.Marshal(products.JSON500) // handle other errors? 242 - return nil, fmt.Errorf("got %d code from kroger : %s", products.StatusCode(), string(output)) 243 - } 244 - 245 - var ingredients []kroger.Ingredient 246 - 247 - for _, product := range *products.JSON200.Data { 248 - wildcard := len(f.Brands) > 0 && f.Brands[0] == "*" 249 - 250 - if product.Brand != nil && !slices.Contains(f.Brands, toStr(product.Brand)) && !wildcard { 251 - continue 252 - } 253 - // end up with a bunch of frozen chicken with out this. 254 - if slices.Contains(*product.Categories, "Frozen") && !f.Frozen { 255 - continue 256 - } 257 - for _, item := range *product.Items { 258 - if item.Price == nil { 259 - // todo what does this mean? 260 - continue 261 - } 262 - 263 - var aisle *string 264 - if product.AisleLocations != nil && len(*product.AisleLocations) > 0 { 265 - aisle = (*product.AisleLocations)[0].Number 266 - } 267 - 268 - // does just giving the model json work better here? 269 - ingredient := kroger.Ingredient{ 270 - ProductId: product.ProductId, //chat gpt act 271 - Brand: product.Brand, 272 - Description: product.Description, 273 - Size: item.Size, 274 - PriceRegular: item.Price.Regular, 275 - PriceSale: item.Price.Promo, 276 - Categories: product.Categories, 277 - AisleNumber: aisle, 278 - 279 - /*"taxonomies": [ 280 - { 281 - "department": {}, 282 - "commodity": {}, 283 - "subCommodity": {} 284 - } 285 - ],*/ 286 - //Taxonomy: product., 287 - // CountryOrigin: product.CountryOrigin, 288 - // AisleNumber: product.AisleLocations[0].Number, 289 - // Favorite: item.Favorite, 290 - // InventoryStockLevel: item.InventoryStockLevel), 291 - } 292 - 293 - /*if product.AisleLocations != nil && len(*product.AisleLocations) > 0 { 294 - ingredient.AisleNumber = (*product.AisleLocations)[0].Number 295 - }*/ 296 - 297 - ingredients = append(ingredients, ingredient) 298 - // strings.Join(*product.Categories, ", "), 299 - 300 - } 301 - } 302 - 303 - // Debug level? 304 - // slog.InfoContext(ctx, "got", "ingredients", len(ingredients), "products", len(*products.JSON200.Data), "term", f.Term, "brands", f.Brands, "location", location, "skip", skip) 305 - 306 - // recursion is pretty dumb pagination 307 - // kroger limites us to 250 308 - if len(*products.JSON200.Data) == limit && skip < 250 { // fence post error 309 - page, err := g.GetIngredients(ctx, location, f, skip+limit) 310 - if err != nil { 311 - return ingredients, nil 312 - } 313 - ingredients = append(ingredients, page...) 314 - } 315 - 316 - return ingredients, nil 317 198 } 318 199 319 200 func (g *Generator) Ready(ctx context.Context) error {
+21 -2
internal/recipes/generator_hash_test.go
··· 48 48 } 49 49 50 50 func TestGeneratorParamsLocationHashStableForDifferentHours(t *testing.T) { 51 - loc := &locations.Location{ID: "loc-456", Name: "Another", Address: "2 Test Ave", State: "TS"} 51 + loc := &locations.Location{ID: "23456789", Name: "Another", Address: "2 Test Ave", State: "TS"} 52 52 d1 := time.Date(2025, 9, 17, 0, 0, 0, 0, time.UTC) 53 53 d2 := time.Date(2025, 9, 17, 12, 0, 0, 0, time.UTC) 54 54 ··· 68 68 } 69 69 } 70 70 71 + func TestGeneratorParamsLocationHash_DiffersAcrossStoreBackends(t *testing.T) { 72 + krogerParams := DefaultParams(&locations.Location{ID: "10216", Name: "Kroger 10216"}, time.Date(2025, 9, 17, 0, 0, 0, 0, time.UTC)) 73 + wholeFoodsParams := DefaultParams(&locations.Location{ID: "wholefoods_10216", Name: "Whole Foods 10216"}, time.Date(2025, 9, 17, 0, 0, 0, 0, time.UTC)) 74 + 75 + if got, want := krogerParams.LocationHash() != wholeFoodsParams.LocationHash(), true; got != want { 76 + t.Fatalf("expected location hashes to differ across store backends: kroger=%s wholefoods=%s", krogerParams.LocationHash(), wholeFoodsParams.LocationHash()) 77 + } 78 + } 79 + 71 80 func TestNormalizeLegacyRecipeHash(t *testing.T) { 72 - p := DefaultParams(&locations.Location{ID: "loc-legacy", Name: "Legacy Store"}, time.Date(2025, 9, 17, 0, 0, 0, 0, time.UTC)) 81 + p := DefaultParams(&locations.Location{ID: "34567890", Name: "Legacy Store"}, time.Date(2025, 9, 17, 0, 0, 0, 0, time.UTC)) 73 82 hash := p.Hash() 74 83 legacyHash, ok := legacyRecipeHash(hash) 75 84 if !ok { ··· 88 97 t.Fatalf("expected canonical hash %q not to be treated as legacy", hash) 89 98 } 90 99 } 100 + 101 + func TestStaplesSignatureForLocation_PanicsForUnknownLocation(t *testing.T) { 102 + defer func() { 103 + if recover() == nil { 104 + t.Fatal("expected panic for unknown location") 105 + } 106 + }() 107 + 108 + _ = staplesSignatureForLocation("loc-unknown") 109 + }
+2 -11
internal/recipes/generator_test.go
··· 10 10 "time" 11 11 ) 12 12 13 - type panicKrogerClient struct { 14 - kroger.ClientWithResponsesInterface 15 - } 16 - 17 - func (panicKrogerClient) ProductSearchWithResponse(ctx context.Context, params *kroger.ProductSearchParams, reqEditors ...kroger.RequestEditorFn) (*kroger.ProductSearchResponse, error) { 18 - panic("unexpected call to ProductSearchWithResponse") 19 - } 20 - 21 13 type captureWineQuestionAIClient struct { 22 14 question string 23 15 answer string ··· 89 81 }, 90 82 } 91 83 g := &Generator{ 92 - io: IO(cacheStore), 93 - aiClient: aiStub, 94 - krogerClient: panicKrogerClient{}, 84 + io: IO(cacheStore), 85 + aiClient: aiStub, 95 86 } 96 87 97 88 got, err := g.PickAWine(t.Context(), conversation, location, ai.Recipe{
+11 -11
internal/recipes/html_test.go
··· 55 55 } 56 56 57 57 func TestFormatShoppingListHTML_ValidHTML(t *testing.T) { 58 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 58 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 59 59 p := DefaultParams(&loc, time.Now()) 60 60 w := httptest.NewRecorder() 61 61 FormatShoppingListHTML(p, list, true, w) ··· 79 79 } 80 80 81 81 func TestFormatMail_ValidHTML(t *testing.T) { 82 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 82 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 83 83 p := DefaultParams(&loc, time.Now()) 84 84 w := httptest.NewRecorder() 85 85 FormatShoppingListHTML(p, list, true, w) ··· 92 92 } 93 93 94 94 func TestFormatShoppingListHTML_IncludesClarityScript(t *testing.T) { 95 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 95 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 96 96 p := DefaultParams(&loc, time.Now()) 97 97 98 98 templates.Clarityproject = "test456" ··· 108 108 } 109 109 110 110 func TestFormatShoppingListHTML_NoClarityWhenEmpty(t *testing.T) { 111 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 111 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 112 112 p := DefaultParams(&loc, time.Now()) 113 113 templates.Clarityproject = "" 114 114 w := httptest.NewRecorder() ··· 119 119 } 120 120 121 121 func TestFormatShoppingListHTML_IncludesGoogleTagScript(t *testing.T) { 122 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 122 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 123 123 p := DefaultParams(&loc, time.Now()) 124 124 125 125 prev := templates.GoogleTagID ··· 139 139 } 140 140 141 141 func TestFormatShoppingListHTML_NoGoogleTagWhenEmpty(t *testing.T) { 142 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 142 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 143 143 p := DefaultParams(&loc, time.Now()) 144 144 prev := templates.GoogleTagID 145 145 t.Cleanup(func() { ··· 154 154 } 155 155 156 156 func TestFormatShoppingListHTML_HomePageLink(t *testing.T) { 157 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 157 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 158 158 p := DefaultParams(&loc, time.Now()) 159 159 w := httptest.NewRecorder() 160 160 FormatShoppingListHTML(p, list, true, w) ··· 170 170 } 171 171 172 172 func TestFormatRecipeHTML_NoFinalizeOrRegenerate(t *testing.T) { 173 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 173 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 174 174 p := DefaultParams(&loc, time.Now()) 175 175 p.ConversationID = "convo123" 176 176 w := httptest.NewRecorder() ··· 233 233 } 234 234 235 235 func TestFormatRecipeHTML_HidesQuestionInputWhenSignedOut(t *testing.T) { 236 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 236 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 237 237 p := DefaultParams(&loc, time.Now()) 238 238 p.ConversationID = "convo123" 239 239 w := httptest.NewRecorder() ··· 251 251 } 252 252 253 253 func TestFormatRecipeHTML_RendersCachedWineRecommendation(t *testing.T) { 254 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 254 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 255 255 p := DefaultParams(&loc, time.Now()) 256 256 p.ConversationID = "convo123" 257 257 w := httptest.NewRecorder() ··· 284 284 } 285 285 286 286 func TestFormatShoppingListHTMLForHash_RendersWinePickerAndWineIngredients(t *testing.T) { 287 - loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"} 287 + loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 288 288 p := DefaultParams(&loc, time.Now()) 289 289 multi := ai.ShoppingList{ 290 290 Recipes: []ai.Recipe{
+2 -2
internal/recipes/mock_test.go
··· 9 9 10 10 func TestMockGenerateRecipes_Returns3Recipes(t *testing.T) { 11 11 m := mock{} 12 - loc := &locations.Location{ID: "test-loc", Name: "Test Location", Address: "123 Test St", State: "TS"} 12 + loc := &locations.Location{ID: "70000002", Name: "Test Location", Address: "123 Test St", State: "TS"} 13 13 params := DefaultParams(loc, time.Now()) 14 14 15 15 result, err := m.GenerateRecipes(context.Background(), params) ··· 45 45 46 46 func TestMockGenerateRecipes_ReturnsRandomRecipes(t *testing.T) { 47 47 m := mock{} 48 - loc := &locations.Location{ID: "test-loc", Name: "Test Location", Address: "123 Test St", State: "TS"} 48 + loc := &locations.Location{ID: "70000002", Name: "Test Location", Address: "123 Test St", State: "TS"} 49 49 params := DefaultParams(loc, time.Now()) 50 50 51 51 // Generate recipes multiple times and check that we get different combinations
+3 -3
internal/recipes/parallel.go internal/parallelism/flatten.go
··· 1 - package recipes 1 + package parallelism 2 2 3 3 import ( 4 4 "errors" ··· 6 6 lop "github.com/samber/lo/parallel" 7 7 ) 8 8 9 - // we need to make a bunch of calls and merge results but not lose track of errors. 10 - func asParallel[T any, T2 any](items []T, fn func(T) ([]T2, error)) ([]T2, error) { 9 + // Flatten runs fn for each item concurrently, merging all returned slices and errors. 10 + func Flatten[T any, T2 any](items []T, fn func(T) ([]T2, error)) ([]T2, error) { 11 11 if len(items) == 0 { 12 12 return []T2{}, nil 13 13 }
+5 -5
internal/recipes/parallel_test.go internal/parallelism/flatten_test.go
··· 1 - package recipes 1 + package parallelism 2 2 3 3 import ( 4 4 "errors" ··· 6 6 "testing" 7 7 ) 8 8 9 - func TestAsParallel_MergesResultsAndErrors(t *testing.T) { 9 + func TestFlatten_MergesResultsAndErrors(t *testing.T) { 10 10 errOne := errors.New("err one") 11 11 errTwo := errors.New("err two") 12 12 13 - got, err := asParallel([]int{1, 2, 3, 4}, func(i int) ([]string, error) { 13 + got, err := Flatten([]int{1, 2, 3, 4}, func(i int) ([]string, error) { 14 14 switch i { 15 15 case 1: 16 16 return []string{"a", "b"}, nil ··· 38 38 } 39 39 } 40 40 41 - func TestAsParallel_EmptyInput(t *testing.T) { 42 - got, err := asParallel([]string{}, func(s string) ([]int, error) { 41 + func TestFlatten_EmptyInput(t *testing.T) { 42 + got, err := Flatten([]string{}, func(s string) ([]int, error) { 43 43 return []int{1}, nil 44 44 }) 45 45 if err != nil {
+2 -50
internal/recipes/params.go
··· 6 6 "careme/internal/locations" 7 7 "context" 8 8 "encoding/base64" 9 - "encoding/json" 10 9 "errors" 11 10 "fmt" 12 11 "hash/fnv" ··· 30 29 type generatorParams struct { 31 30 Location *locations.Location `json:"location,omitempty"` 32 31 Date time.Time `json:"date,omitempty"` 33 - Staples []filter `json:"staples,omitempty"` 34 32 // People int 35 33 //per round instuctions 36 34 Instructions string `json:"instructions,omitempty"` ··· 50 48 Date: date, // shave time 51 49 Location: l, 52 50 // People: 2, 53 - Staples: DefaultStaples(), 54 51 } 55 52 } 56 53 ··· 64 61 fnv := fnv.New64a() 65 62 lo.Must(io.WriteString(fnv, g.Location.ID)) 66 63 lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02"))) 67 - bytes := lo.Must(json.Marshal(g.Staples)) //should we remove this so this is stable when we change staples? 68 - lo.Must(fnv.Write(bytes)) 64 + lo.Must(io.WriteString(fnv, staplesSignatureForLocation(g.Location.ID))) 69 65 lo.Must(io.WriteString(fnv, g.Instructions)) // rethink this? if they're all in convo should we have one id and ability to walk back? 70 66 lo.Must(io.WriteString(fnv, g.Directive)) 71 67 for _, saved := range g.Saved { ··· 82 78 fnv := fnv.New64a() 83 79 lo.Must(io.WriteString(fnv, g.Location.ID)) 84 80 lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02"))) 85 - bytes := lo.Must(json.Marshal(g.Staples)) // excited fro this to break in some weird way 86 - lo.Must(fnv.Write(bytes)) 81 + lo.Must(io.WriteString(fnv, staplesSignatureForLocation(g.Location.ID))) 87 82 return base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 88 83 } 89 84 ··· 130 125 p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id")) 131 126 132 127 return p, nil 133 - } 134 - 135 - func DefaultStaples() []filter { 136 - return append(Produce(), []filter{ 137 - { 138 - Term: "beef", 139 - Brands: []string{"Simple Truth", "Kroger"}, 140 - }, 141 - { 142 - Term: "chicken", 143 - Brands: []string{"Foster Farms", "Draper Valley", "Simple Truth"}, //"Simple Truth"? do these vary in every state? 144 - }, 145 - { 146 - Term: "fish", 147 - }, 148 - { 149 - Term: "pork", // Kroger? 150 - Brands: []string{"PORK", "Kroger", "Harris Teeter"}, 151 - }, 152 - { 153 - Term: "shellfish", 154 - Brands: []string{"Sand Bar", "Kroger"}, 155 - Frozen: true, // remove after 500 sadness? 156 - }, 157 - { 158 - Term: "lamb", 159 - Brands: []string{"Simple Truth"}, 160 - }, 161 - }...) 162 - } 163 - 164 - func Produce() []filter { 165 - return []filter{ 166 - { 167 - Term: "fresh vegatable", 168 - Brands: []string{"*"}, 169 - }, 170 - { 171 - Term: "fresh produce", 172 - Brands: []string{"*"}, 173 - }, 174 - } 175 - 176 128 } 177 129 178 130 func resolveStoreTimeLocation(ctx context.Context, l *locations.Location) (*time.Location, error) {
+16 -16
internal/recipes/server_test.go
··· 61 61 } 62 62 63 63 func TestHandleRecipes_RedirectsLegacyHashToCanonicalHash(t *testing.T) { 64 - p := DefaultParams(&locations.Location{ID: "loc-123", Name: "Test"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 64 + p := DefaultParams(&locations.Location{ID: "70000123", Name: "Test"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 65 65 hash := p.Hash() 66 66 legacyHash, ok := legacyRecipeHash(hash) 67 67 if !ok { ··· 91 91 } 92 92 93 93 func TestHandleRecipes_RedirectsLegacyHashAndPreservesQuery(t *testing.T) { 94 - p := DefaultParams(&locations.Location{ID: "loc-abc", Name: "Test"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 94 + p := DefaultParams(&locations.Location{ID: "70000456", Name: "Test"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 95 95 hash := p.Hash() 96 96 legacyHash, ok := legacyRecipeHash(hash) 97 97 if !ok { ··· 121 121 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 122 122 storage := users.NewStorage(cacheStore) 123 123 location := &locations.Location{ 124 - ID: "store-1", 124 + ID: "70001001", 125 125 Name: "Test Store", 126 126 ZipCode: "94105", 127 127 } ··· 143 143 t.Fatalf("failed to save user directive: %v", err) 144 144 } 145 145 146 - req := httptest.NewRequest(http.MethodGet, "/recipes?location=store-1&date=2026-03-06&instructions=make+it+vegetarian", nil) 146 + req := httptest.NewRequest(http.MethodGet, "/recipes?location=70001001&date=2026-03-06&instructions=make+it+vegetarian", nil) 147 147 expectedParams, err := s.ParseQueryArgs(t.Context(), req) 148 148 if err != nil { 149 149 t.Fatalf("failed to build expected params: %v", err) ··· 193 193 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 194 194 storage := users.NewStorage(cacheStore) 195 195 location := &locations.Location{ 196 - ID: "store-1", 196 + ID: "70001001", 197 197 Name: "Test Store", 198 198 ZipCode: "94105", 199 199 } ··· 211 211 t.Fatalf("failed to seed user: %v", err) 212 212 } 213 213 214 - req := httptest.NewRequest(http.MethodGet, "/recipes?location=store-1&date=2026-03-06&instructions=make+it+vegetarian", nil) 214 + req := httptest.NewRequest(http.MethodGet, "/recipes?location=70001001&date=2026-03-06&instructions=make+it+vegetarian", nil) 215 215 runRequest := func(t *testing.T, directive string) string { 216 216 t.Helper() 217 217 ··· 268 268 } 269 269 270 270 p := DefaultParams( 271 - &locations.Location{ID: "loc-legacy-origin", Name: "Canonical Test Store"}, 271 + &locations.Location{ID: "70002001", Name: "Canonical Test Store"}, 272 272 time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC), 273 273 ) 274 274 p.ConversationID = "conv-canonical" ··· 329 329 } 330 330 331 331 p := DefaultParams( 332 - &locations.Location{ID: "loc-legacy-origin-missing-params", Name: "Ignored"}, 332 + &locations.Location{ID: "70002002", Name: "Ignored"}, 333 333 time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC), 334 334 ) 335 335 canonicalHash := p.Hash() ··· 378 378 } 379 379 380 380 p := DefaultParams( 381 - &locations.Location{ID: "loc-wine-single", Name: "Wine Store"}, 381 + &locations.Location{ID: "70003001", Name: "Wine Store"}, 382 382 time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), 383 383 ) 384 384 p.ConversationID = "conv-wine-single" ··· 662 662 generator: g, 663 663 } 664 664 665 - p := DefaultParams(&locations.Location{ID: "loc-wine", Name: "Wine Test Store"}, time.Now()) 665 + p := DefaultParams(&locations.Location{ID: "70003002", Name: "Wine Test Store"}, time.Now()) 666 666 p.ConversationID = "conv-wine" 667 667 originHash := p.Hash() 668 668 if err := s.SaveParams(t.Context(), p); err != nil { ··· 722 722 generator: g, 723 723 } 724 724 725 - p := DefaultParams(&locations.Location{ID: "loc-wine", Name: "Wine Test Store"}, time.Now()) 725 + p := DefaultParams(&locations.Location{ID: "70003002", Name: "Wine Test Store"}, time.Now()) 726 726 p.ConversationID = "conv-wine" 727 727 originHash := p.Hash() 728 728 if err := s.SaveParams(t.Context(), p); err != nil { ··· 776 776 generator: g1, 777 777 } 778 778 779 - p := DefaultParams(&locations.Location{ID: "loc-wine", Name: "Wine Test Store"}, time.Now()) 779 + p := DefaultParams(&locations.Location{ID: "70003002", Name: "Wine Test Store"}, time.Now()) 780 780 p.ConversationID = "conv-wine" 781 781 originHash := p.Hash() 782 782 if err := s1.SaveParams(t.Context(), p); err != nil { ··· 1126 1126 } 1127 1127 t.Cleanup(s.Wait) 1128 1128 1129 - p := DefaultParams(&locations.Location{ID: "loc-1", Name: "Store"}, time.Now()) 1129 + p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1130 1130 p.ConversationID = "conv-123" 1131 1131 originHash := p.Hash() 1132 1132 if err := s.SaveParams(t.Context(), p); err != nil { ··· 1206 1206 clerk: auth.DefaultMock(), 1207 1207 } 1208 1208 1209 - p := DefaultParams(&locations.Location{ID: "loc-1", Name: "Store"}, time.Now()) 1209 + p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1210 1210 p.ConversationID = "conv-123" 1211 1211 originHash := p.Hash() 1212 1212 if err := s.SaveParams(t.Context(), p); err != nil { ··· 1274 1274 1275 1275 savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} 1276 1276 dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 1277 - p := DefaultParams(&locations.Location{ID: "loc-1", Name: "Store"}, time.Now()) 1277 + p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1278 1278 p.Saved = []ai.Recipe{savedRecipe} 1279 1279 p.Dismissed = []ai.Recipe{dismissedRecipe} 1280 1280 originHash := p.Hash() ··· 1312 1312 1313 1313 savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} 1314 1314 dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 1315 - p := DefaultParams(&locations.Location{ID: "loc-1", Name: "Store"}, time.Now()) 1315 + p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1316 1316 p.Saved = []ai.Recipe{savedRecipe} 1317 1317 p.Dismissed = []ai.Recipe{dismissedRecipe} 1318 1318 originHash := p.Hash()
+103
internal/recipes/staples.go
··· 1 + package recipes 2 + 3 + import ( 4 + "careme/internal/config" 5 + "careme/internal/kroger" 6 + "careme/internal/locations" 7 + "careme/internal/walmart" 8 + "careme/internal/wholefoods" 9 + "context" 10 + "fmt" 11 + "testing" 12 + ) 13 + 14 + // todo make this a indepenedent ingredient object not kroger. 15 + type staplesProvider interface { 16 + FetchStaples(ctx context.Context, location *locations.Location) ([]kroger.Ingredient, error) 17 + GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) 18 + } 19 + 20 + type identityProvider interface { 21 + IsID(locationID string) bool 22 + Signature() string 23 + } 24 + 25 + type routingStaplesProvider struct { 26 + backends []backendStaplesProvider 27 + } 28 + 29 + type backendStaplesProvider interface { 30 + IsID(locationID string) bool 31 + Signature() string 32 + FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) 33 + GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) 34 + } 35 + 36 + func NewStaplesProvider(cfg *config.Config) (staplesProvider, error) { 37 + kclient, err := kroger.FromConfig(cfg) 38 + if err != nil { 39 + return nil, err 40 + } 41 + return routingStaplesProvider{ 42 + backends: defaultStaplesBackends(kclient), 43 + }, nil 44 + } 45 + 46 + func (p routingStaplesProvider) FetchStaples(ctx context.Context, location *locations.Location) ([]kroger.Ingredient, error) { 47 + if location == nil { 48 + return nil, fmt.Errorf("location is required") 49 + } 50 + 51 + provider, err := p.providerForLocation(location.ID) 52 + if err != nil { 53 + return nil, err 54 + } 55 + return provider.FetchStaples(ctx, location.ID) 56 + } 57 + 58 + func (p routingStaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 59 + provider, err := p.providerForLocation(locationID) 60 + if err != nil { 61 + return nil, err 62 + } 63 + return provider.GetIngredients(ctx, locationID, searchTerm, skip) 64 + } 65 + 66 + func staplesSignatureForLocation(locationID string) string { 67 + for _, provider := range defaultIdentityProviders() { 68 + if provider.IsID(locationID) { 69 + return provider.Signature() 70 + } 71 + } 72 + 73 + if testing.Testing() && locationID == "loc-123" { 74 + return kroger.NewIdentityProvider().Signature() 75 + } 76 + 77 + panic("unknown staples provider for location " + locationID) 78 + } 79 + 80 + func (p routingStaplesProvider) providerForLocation(locationID string) (backendStaplesProvider, error) { 81 + for _, backend := range p.backends { 82 + if backend.IsID(locationID) { 83 + return backend, nil 84 + } 85 + } 86 + return nil, fmt.Errorf("staples provider does not support location %q", locationID) 87 + } 88 + 89 + func defaultStaplesBackends(krogerClient kroger.ClientWithResponsesInterface) []backendStaplesProvider { 90 + return []backendStaplesProvider{ 91 + kroger.NewStaplesProvider(krogerClient), 92 + wholefoods.NewStaplesProvider(wholefoods.NewClient(nil)), 93 + walmart.NewStaplesProvider(), 94 + } 95 + } 96 + 97 + func defaultIdentityProviders() []identityProvider { 98 + return []identityProvider{ 99 + kroger.NewIdentityProvider(), 100 + wholefoods.NewIdentityProvider(), 101 + walmart.NewIdentityProvider(), 102 + } 103 + }
+173
internal/recipes/staples_test.go
··· 1 + package recipes 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/kroger" 6 + "careme/internal/locations" 7 + "context" 8 + "slices" 9 + "testing" 10 + "time" 11 + ) 12 + 13 + type stubStaplesProvider struct { 14 + ids map[string]bool 15 + ingredients []kroger.Ingredient 16 + err error 17 + calls int 18 + } 19 + 20 + func (s *stubStaplesProvider) IsID(locationID string) bool { 21 + return s.ids[locationID] 22 + } 23 + 24 + func (s *stubStaplesProvider) Signature() string { 25 + return "stub-staples-v1" 26 + } 27 + 28 + func (s *stubStaplesProvider) FetchStaples(_ context.Context, _ string) ([]kroger.Ingredient, error) { 29 + s.calls++ 30 + if s.err != nil { 31 + return nil, s.err 32 + } 33 + return slices.Clone(s.ingredients), nil 34 + } 35 + 36 + func (s *stubStaplesProvider) GetIngredients(_ context.Context, _ string, _ string, _ int) ([]kroger.Ingredient, error) { 37 + return s.FetchStaples(context.Background(), "") 38 + } 39 + 40 + type stubRoutingStaplesProvider struct { 41 + ingredients []kroger.Ingredient 42 + err error 43 + calls int 44 + } 45 + 46 + func (s *stubRoutingStaplesProvider) FetchStaples(_ context.Context, _ *locations.Location) ([]kroger.Ingredient, error) { 47 + s.calls++ 48 + if s.err != nil { 49 + return nil, s.err 50 + } 51 + return slices.Clone(s.ingredients), nil 52 + } 53 + 54 + func (s *stubRoutingStaplesProvider) GetIngredients(_ context.Context, _ string, _ string, _ int) ([]kroger.Ingredient, error) { 55 + s.calls++ 56 + if s.err != nil { 57 + return nil, s.err 58 + } 59 + return slices.Clone(s.ingredients), nil 60 + } 61 + 62 + func TestRoutingStaplesProvider_SelectsProviderByLocationID(t *testing.T) { 63 + krogerProvider := &stubStaplesProvider{ids: map[string]bool{"70100023": true}} 64 + wholeFoodsProvider := &stubStaplesProvider{ids: map[string]bool{"wholefoods_10216": true}} 65 + provider := routingStaplesProvider{ 66 + backends: []backendStaplesProvider{krogerProvider, wholeFoodsProvider}, 67 + } 68 + 69 + if _, err := provider.FetchStaples(t.Context(), &locations.Location{ID: "70100023"}); err != nil { 70 + t.Fatalf("FetchStaples kroger returned error: %v", err) 71 + } 72 + if krogerProvider.calls != 1 || wholeFoodsProvider.calls != 0 { 73 + t.Fatalf("expected kroger provider to be selected, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 74 + } 75 + 76 + if _, err := provider.FetchStaples(t.Context(), &locations.Location{ID: "wholefoods_10216"}); err != nil { 77 + t.Fatalf("FetchStaples whole foods returned error: %v", err) 78 + } 79 + if krogerProvider.calls != 1 || wholeFoodsProvider.calls != 1 { 80 + t.Fatalf("expected whole foods provider to be selected once, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 81 + } 82 + } 83 + 84 + func TestRoutingStaplesProvider_RejectsUnsupportedLocationBackend(t *testing.T) { 85 + provider := routingStaplesProvider{ 86 + backends: []backendStaplesProvider{ 87 + &stubStaplesProvider{ids: map[string]bool{"70100023": true}}, 88 + &stubStaplesProvider{ids: map[string]bool{"wholefoods_10216": true}}, 89 + }, 90 + } 91 + 92 + _, err := provider.FetchStaples(t.Context(), &locations.Location{ID: "walmart_3098"}) 93 + if err == nil { 94 + t.Fatal("expected unsupported backend error") 95 + } 96 + if got, want := err.Error(), `staples provider does not support location "walmart_3098"`; got != want { 97 + t.Fatalf("unexpected error: got %q want %q", got, want) 98 + } 99 + } 100 + 101 + func TestRoutingStaplesProvider_GetIngredients_SelectsProviderByLocationID(t *testing.T) { 102 + krogerProvider := &stubStaplesProvider{ 103 + ids: map[string]bool{"70100023": true}, 104 + ingredients: []kroger.Ingredient{{Description: loPtr("Pinot Noir")}}, 105 + } 106 + wholeFoodsProvider := &stubStaplesProvider{ 107 + ids: map[string]bool{"wholefoods_10216": true}, 108 + ingredients: []kroger.Ingredient{{Description: loPtr("Whole Foods Pinot Noir")}}, 109 + } 110 + provider := routingStaplesProvider{ 111 + backends: []backendStaplesProvider{krogerProvider, wholeFoodsProvider}, 112 + } 113 + 114 + got, err := provider.GetIngredients(t.Context(), "wholefoods_10216", "pinot noir", 0) 115 + if err != nil { 116 + t.Fatalf("GetIngredients returned error: %v", err) 117 + } 118 + if len(got) != 1 || got[0].Description == nil || *got[0].Description != "Whole Foods Pinot Noir" { 119 + t.Fatalf("unexpected ingredients: %+v", got) 120 + } 121 + if krogerProvider.calls != 0 || wholeFoodsProvider.calls != 1 { 122 + t.Fatalf("expected whole foods provider to be selected, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 123 + } 124 + } 125 + 126 + func TestGetStaples_UsesProviderAndCachesWholeFoodsResults(t *testing.T) { 127 + cacheStore := cache.NewFileCache(t.TempDir()) 128 + provider := &stubRoutingStaplesProvider{ 129 + ingredients: []kroger.Ingredient{ 130 + {Description: loPtr("Honeycrisp Apple")}, 131 + {Description: loPtr("Honeycrisp Apple")}, 132 + {Description: loPtr("Baby Spinach")}, 133 + }, 134 + } 135 + g := &Generator{ 136 + io: IO(cacheStore), 137 + staplesProvider: provider, 138 + } 139 + params := &generatorParams{ 140 + Location: &locations.Location{ID: "wholefoods_10216", Name: "Westlake"}, 141 + Date: time.Date(2026, 3, 8, 0, 0, 0, 0, time.UTC), 142 + } 143 + 144 + got, err := g.GetStaples(t.Context(), params) 145 + if err != nil { 146 + t.Fatalf("GetStaples returned error: %v", err) 147 + } 148 + if provider.calls != 1 { 149 + t.Fatalf("expected provider to be called once, got %d", provider.calls) 150 + } 151 + if len(got) != 2 { 152 + t.Fatalf("expected deduped results, got %d", len(got)) 153 + } 154 + 155 + cached, err := IO(cacheStore).IngredientsFromCache(t.Context(), params.LocationHash()) 156 + if err != nil { 157 + t.Fatalf("IngredientsFromCache returned error: %v", err) 158 + } 159 + if len(cached) != 2 { 160 + t.Fatalf("expected cached deduped results, got %d", len(cached)) 161 + } 162 + 163 + gotAgain, err := g.GetStaples(t.Context(), params) 164 + if err != nil { 165 + t.Fatalf("GetStaples returned error on cached call: %v", err) 166 + } 167 + if provider.calls != 1 { 168 + t.Fatalf("expected cached call to skip provider, got %d calls", provider.calls) 169 + } 170 + if len(gotAgain) != 2 { 171 + t.Fatalf("expected cached results, got %d", len(gotAgain)) 172 + } 173 + }
+3 -3
internal/sitemap/sitemap_test.go
··· 23 23 hashes := make([]string, 0, 3) 24 24 for i := range 3 { 25 25 loc := &locations.Location{ 26 - ID: fmt.Sprintf("store-%d", i), 26 + ID: fmt.Sprintf("7000500%d", i), 27 27 Name: "Test Store", 28 28 Address: "123 Test St", 29 29 } ··· 73 73 t.Chdir(t.TempDir()) 74 74 75 75 cacheStore := cache.NewFileCache(".") 76 - params := recipes.DefaultParams(&locations.Location{ID: "store", Name: "Store"}, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) 76 + params := recipes.DefaultParams(&locations.Location{ID: "70006001", Name: "Store"}, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) 77 77 hash := params.Hash() 78 78 79 79 if err := cacheStore.Put(context.Background(), "shoppinglist/"+hash, `{"mock":"legacy"}`, cache.Unconditional()); err != nil { ··· 109 109 t.Chdir(t.TempDir()) 110 110 111 111 cacheStore := cache.NewFileCache(".") 112 - params := recipes.DefaultParams(&locations.Location{ID: "store", Name: "Store"}, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) 112 + params := recipes.DefaultParams(&locations.Location{ID: "70006002", Name: "Store"}, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) 113 113 hash := params.Hash() 114 114 115 115 if err := cacheStore.Put(context.Background(), hash, `{"mock":"legacy-root-shopping-list"}`, cache.Unconditional()); err != nil {
+1
internal/walmart/client.go
··· 30 30 31 31 // Client calls Walmart Affiliates APIs with signed headers. 32 32 type Client struct { 33 + identityProvider 33 34 consumerID string 34 35 keyVersion string 35 36 privateKey *rsa.PrivateKey
-17
internal/walmart/locations.go
··· 5 5 "context" 6 6 "fmt" 7 7 "strconv" 8 - "strings" 9 8 ) 10 - 11 - func (c *Client) IsID(locationID string) bool { 12 - const prefix = "walmart_" 13 - if !strings.HasPrefix(locationID, prefix) { 14 - return false 15 - } 16 - if len(locationID) == len(prefix) { 17 - return false 18 - } 19 - for i := len(prefix); i < len(locationID); i++ { 20 - if locationID[i] < '0' || locationID[i] > '9' { 21 - return false 22 - } 23 - } 24 - return true 25 - } 26 9 27 10 func (c *Client) GetLocationByID(_ context.Context, locationID string) (*locationtypes.Location, error) { 28 11 //depending on cache to protect us.
+53
internal/walmart/staples.go
··· 1 + package walmart 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "careme/internal/kroger" 9 + ) 10 + 11 + const UnsupportedStaplesSignature = "unsupported-staples-v1" 12 + 13 + type identityProvider struct{} 14 + 15 + func NewStaplesProvider() StaplesProvider { 16 + return StaplesProvider{} 17 + } 18 + 19 + type StaplesProvider struct { 20 + identityProvider 21 + } 22 + 23 + func NewIdentityProvider() identityProvider { 24 + return identityProvider{} 25 + } 26 + 27 + func (c identityProvider) IsID(locationID string) bool { 28 + const prefix = "walmart_" 29 + if !strings.HasPrefix(locationID, prefix) { 30 + return false 31 + } 32 + if len(locationID) == len(prefix) { 33 + return false 34 + } 35 + for i := len(prefix); i < len(locationID); i++ { 36 + if locationID[i] < '0' || locationID[i] > '9' { 37 + return false 38 + } 39 + } 40 + return true 41 + } 42 + 43 + func (p identityProvider) Signature() string { 44 + return UnsupportedStaplesSignature 45 + } 46 + 47 + func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]kroger.Ingredient, error) { 48 + return nil, fmt.Errorf("staples provider does not support location %q", locationID) 49 + } 50 + 51 + func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 52 + return nil, fmt.Errorf("ingredient search is not supported for location %q and term %q", locationID, searchTerm) 53 + }
+4 -2
internal/wholefoods/client.go
··· 64 64 ImageThumbnail string `json:"imageThumbnail"` 65 65 Store int `json:"store"` 66 66 IsLocal bool `json:"isLocal"` 67 - UOM string `json:"uom,omitempty"` 67 + //unit of measure. 68 + UOM string `json:"uom,omitempty"` 68 69 } 69 70 70 71 type Meta struct { ··· 153 154 } 154 155 155 156 // Category fetches a category page payload like /api/products/category/beef?store=10216. 157 + // TODO add pagination 158 + // /api/products/category/fish?store=10216&limit=60&offset=0 156 159 func (c *Client) Category(ctx context.Context, queryterm, store string) (*CategoryResponse, error) { 157 160 queryterm = strings.TrimSpace(queryterm) 158 161 if queryterm == "" { ··· 172 175 params := endpoint.Query() 173 176 params.Set("store", store) 174 177 endpoint.RawQuery = params.Encode() 175 - 176 178 var decoded CategoryResponse 177 179 if err := c.getJSON(ctx, endpoint.String(), &decoded); err != nil { 178 180 return nil, err
+256
internal/wholefoods/products.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "net/url" 9 + "strconv" 10 + "strings" 11 + ) 12 + 13 + const ( 14 + defaultProgramType = "GROCERY" 15 + defaultProductSearchSize = 30 16 + defaultProductSearchSort = "relevanceblender" 17 + ) 18 + 19 + // ProductSearchRequest configures a call to the Whole Foods product search API. 20 + type ProductSearchRequest struct { 21 + Text string 22 + OfferListingDiscriminator string 23 + Offset int 24 + Size int 25 + Sort string 26 + ProgramType string 27 + Filters string 28 + Categories []string 29 + } 30 + 31 + // ProductSearchResponse matches the public Whole Foods search payload returned by the WWOS RSI API. 32 + type ProductSearchResponse struct { 33 + MainResultSet ProductSearchResultSet `json:"mainResultSet"` 34 + } 35 + 36 + type ProductSearchResultSet struct { 37 + SearchResults []ProductSearchResult `json:"searchResults"` 38 + ApproximateTotalResultCount int `json:"approximateTotalResultCount"` 39 + AvailableTotalResultCount int `json:"availableTotalResultCount"` 40 + TotalResultCountPreVE int `json:"totalResultCountPreVE"` 41 + Keywords string `json:"keywords"` 42 + AugmentModifications []ProductSearchAugmentModification `json:"augmentModifications,omitempty"` 43 + } 44 + 45 + type ProductSearchResult struct { 46 + ASIN string `json:"asin"` 47 + InjectionSource string `json:"injectionSource"` 48 + IsAdultProduct bool `json:"isAdultProduct"` 49 + ProductGroup string `json:"productGroup,omitempty"` 50 + AmazonsChoiceExactLabel bool `json:"amazonsChoiceExactLabel"` 51 + } 52 + 53 + type ProductSearchAugmentModification struct { 54 + Action string `json:"action"` 55 + Type string `json:"type"` 56 + Source string `json:"source"` 57 + Metadata map[string]string `json:"metadata,omitempty"` 58 + } 59 + 60 + // ProductHydrationRequest configures a call to the Whole Foods product hydration API. 61 + type ProductHydrationRequest struct { 62 + OfferListingDiscriminator string 63 + ProgramType string 64 + ASINs []string 65 + } 66 + 67 + // ProductHydrationResponse matches the public Whole Foods WWOS product hydration payload. 68 + type ProductHydrationResponse []HydratedProduct 69 + 70 + type HydratedProduct struct { 71 + BrandName string `json:"brandName"` 72 + Name string `json:"name"` 73 + ASIN string `json:"asin"` 74 + ProgramType string `json:"programType"` 75 + Description string `json:"description"` 76 + About []string `json:"about"` 77 + ProductImages []string `json:"productImages"` 78 + Availability string `json:"availability"` 79 + PDPType string `json:"pdpType"` 80 + OfferDetails *HydratedOfferDetails `json:"offerDetails"` 81 + VariableUnitOfMeasure *HydratedVariableUnitOfMeasure `json:"variableUnitOfMeasure"` 82 + CTATag string `json:"ctaTag,omitempty"` 83 + DeliveryPromiseHTML string `json:"deliveryPromiseHtml,omitempty"` 84 + DietTypes []string `json:"dietTypes,omitempty"` 85 + Category HydratedCategory `json:"category"` 86 + } 87 + 88 + type HydratedOfferDetails struct { 89 + Price HydratedPrice `json:"price"` 90 + OfferListingID string `json:"offerListingId"` 91 + MaxOrderQuantity int `json:"maxOrderQuantity"` 92 + IsMaxQuantityRestricted bool `json:"isMaxQuantityRestricted"` 93 + } 94 + 95 + type HydratedPrice struct { 96 + CurrencyCode string `json:"currencyCode"` 97 + PriceAmount float64 `json:"priceAmount"` 98 + BasisPriceAmount *float64 `json:"basisPriceAmount"` 99 + Savings HydratedSavings `json:"savings"` 100 + PrimeBenefit HydratedPrimeBenefit `json:"primeBenefit"` 101 + } 102 + 103 + type HydratedSavings struct { 104 + CurrencyCode *string `json:"currencyCode"` 105 + SavingsAmount *float64 `json:"savingsAmount"` 106 + PercentSavings *float64 `json:"percentSavings"` 107 + } 108 + 109 + type HydratedPrimeBenefit struct { 110 + IsApplied *bool `json:"isApplied"` 111 + Text *string `json:"text"` 112 + CurrencyCode *string `json:"currencyCode"` 113 + PriceAmount *float64 `json:"priceAmount"` 114 + SavingsAmount *float64 `json:"savingsAmount"` 115 + } 116 + 117 + type HydratedVariableUnitOfMeasure struct { 118 + PricingUOM HydratedUnitOfMeasure `json:"pricingUom"` 119 + SellingUOM HydratedUnitOfMeasure `json:"sellingUom"` 120 + SelectorItemList []HydratedSelectorItem `json:"selectorItemList"` 121 + } 122 + 123 + type HydratedUnitOfMeasure struct { 124 + Dimension string `json:"dimension"` 125 + Unit string `json:"unit"` 126 + } 127 + 128 + type HydratedSelectorItem struct { 129 + SelectorPrice HydratedSelectorPrice `json:"selectorPrice"` 130 + SelectorSellingQuantityString string `json:"selectorSellingQuantityString"` 131 + SelectorSellingQuantityValue int `json:"selectorSellingQuantityValue"` 132 + } 133 + 134 + type HydratedSelectorPrice struct { 135 + BaseUnit *string `json:"baseUnit"` 136 + CurrencyCode string `json:"currencyCode"` 137 + PriceAmount float64 `json:"priceAmount"` 138 + } 139 + 140 + type HydratedCategory struct { 141 + ProductType string `json:"productType"` 142 + GLProductGroupSymbol string `json:"glProductGroupSymbol"` 143 + DisplayName string `json:"displayName"` 144 + } 145 + 146 + // ProductSearch fetches search results from 147 + // https://www.wholefoodsmarket.com/api/wwos/rsi/search?text=merlot&old=A04C&offset=0&size=30&sort=relevanceblender&programType=GROCERY&filters=&categories=18473610011 148 + // where the Whole Foods API uses the query parameter name "old" for the offer listing discriminator. 149 + func (c *Client) ProductSearch(ctx context.Context, req ProductSearchRequest) (*ProductSearchResponse, error) { 150 + text := strings.TrimSpace(req.Text) 151 + if text == "" { 152 + return nil, errors.New("text is required") 153 + } 154 + 155 + discriminator := strings.TrimSpace(req.OfferListingDiscriminator) 156 + if discriminator == "" { 157 + return nil, errors.New("offer listing discriminator is required") 158 + } 159 + 160 + if req.Offset < 0 { 161 + return nil, errors.New("offset must be >= 0") 162 + } 163 + if req.Size < 0 { 164 + return nil, errors.New("size must be >= 0") 165 + } 166 + 167 + size := req.Size 168 + if size == 0 { 169 + size = defaultProductSearchSize 170 + } 171 + 172 + sort := strings.TrimSpace(req.Sort) 173 + if sort == "" { 174 + sort = defaultProductSearchSort 175 + } 176 + 177 + programType := strings.TrimSpace(req.ProgramType) 178 + if programType == "" { 179 + programType = defaultProgramType 180 + } 181 + 182 + endpoint, err := url.Parse(c.baseURL + "/api/wwos/rsi/search") 183 + if err != nil { 184 + return nil, fmt.Errorf("parse product search URL: %w", err) 185 + } 186 + 187 + params := endpoint.Query() 188 + params.Set("text", text) 189 + params.Set("old", discriminator) 190 + params.Set("offset", strconv.Itoa(req.Offset)) 191 + params.Set("size", strconv.Itoa(size)) 192 + params.Set("sort", sort) 193 + params.Set("programType", programType) 194 + params.Set("filters", req.Filters) 195 + if categories := joinNonEmpty(req.Categories); categories != "" { 196 + params.Set("categories", categories) 197 + } 198 + endpoint.RawQuery = params.Encode() 199 + 200 + slog.InfoContext(ctx, "wf product search", "url", endpoint) 201 + var decoded ProductSearchResponse 202 + if err := c.getJSON(ctx, endpoint.String(), &decoded); err != nil { 203 + return nil, err 204 + } 205 + return &decoded, nil 206 + } 207 + 208 + // ProductHydration fetches hydrated product records from 209 + // https://www.wholefoodsmarket.com/api/wwos/products?offerListingDiscriminator=A04C&programType=GROCERY&asins=B06WVGV73Z%2CB07G4TKBFP 210 + // where the asins query parameter is a comma-separated list of Whole Foods ASINs. 211 + func (c *Client) ProductHydration(ctx context.Context, req ProductHydrationRequest) (ProductHydrationResponse, error) { 212 + discriminator := strings.TrimSpace(req.OfferListingDiscriminator) 213 + if discriminator == "" { 214 + return nil, errors.New("offer listing discriminator is required") 215 + } 216 + 217 + asins := joinNonEmpty(req.ASINs) 218 + if asins == "" { 219 + return nil, errors.New("at least one ASIN is required") 220 + } 221 + 222 + programType := strings.TrimSpace(req.ProgramType) 223 + if programType == "" { 224 + programType = defaultProgramType 225 + } 226 + 227 + endpoint, err := url.Parse(c.baseURL + "/api/wwos/products") 228 + if err != nil { 229 + return nil, fmt.Errorf("parse product hydration URL: %w", err) 230 + } 231 + 232 + params := endpoint.Query() 233 + params.Set("offerListingDiscriminator", discriminator) 234 + params.Set("programType", programType) 235 + params.Set("asins", asins) 236 + endpoint.RawQuery = params.Encode() 237 + 238 + slog.InfoContext(ctx, "wf product hydration", "url", endpoint) 239 + var decoded ProductHydrationResponse 240 + if err := c.getJSON(ctx, endpoint.String(), &decoded); err != nil { 241 + return nil, err 242 + } 243 + return decoded, nil 244 + } 245 + 246 + func joinNonEmpty(parts []string) string { 247 + filtered := make([]string, 0, len(parts)) 248 + for _, part := range parts { 249 + part = strings.TrimSpace(part) 250 + if part == "" { 251 + continue 252 + } 253 + filtered = append(filtered, part) 254 + } 255 + return strings.Join(filtered, ",") 256 + }
+307
internal/wholefoods/products_test.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + ) 10 + 11 + func TestProductSearch_BuildsRequestAndDecodesResponse(t *testing.T) { 12 + t.Parallel() 13 + 14 + fixture := []byte(`{ 15 + "mainResultSet": { 16 + "searchResults": [ 17 + { 18 + "asin": "B01N008341", 19 + "injectionSource": "keywords,phrasedoc,knn,behavioral", 20 + "isAdultProduct": false, 21 + "productGroup": "alcoholic_beverage_display_on_website", 22 + "amazonsChoiceExactLabel": true 23 + }, 24 + { 25 + "asin": "B06XH151S6", 26 + "injectionSource": "keywords,phrasedoc,knn,behavioral", 27 + "isAdultProduct": false, 28 + "productGroup": "alcoholic_beverage_display_on_website", 29 + "amazonsChoiceExactLabel": true 30 + } 31 + ], 32 + "approximateTotalResultCount": 59, 33 + "availableTotalResultCount": 59, 34 + "totalResultCountPreVE": 71, 35 + "keywords": "merlot", 36 + "augmentModifications": [ 37 + { 38 + "action": "add", 39 + "type": "qlb-relevance-lookup", 40 + "source": "A9", 41 + "metadata": { 42 + "add-segment": "QSModifyTransformer:qlb-relevance-lookup" 43 + } 44 + } 45 + ] 46 + } 47 + }`) 48 + 49 + var capturedReq *http.Request 50 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 + capturedReq = r 52 + w.Header().Set("Content-Type", "application/json") 53 + _, _ = w.Write(fixture) 54 + })) 55 + t.Cleanup(server.Close) 56 + 57 + client := NewClientWithBaseURL(server.URL, server.Client()) 58 + 59 + resp, err := client.ProductSearch(context.Background(), ProductSearchRequest{ 60 + Text: " merlot ", 61 + OfferListingDiscriminator: " A04C ", 62 + Categories: []string{"18473610011"}, 63 + }) 64 + if err != nil { 65 + t.Fatalf("ProductSearch returned error: %v", err) 66 + } 67 + 68 + if capturedReq == nil { 69 + t.Fatal("expected request to be captured") 70 + } 71 + if capturedReq.URL.Path != "/api/wwos/rsi/search" { 72 + t.Fatalf("unexpected path: %s", capturedReq.URL.Path) 73 + } 74 + if got := capturedReq.URL.Query().Get("text"); got != "merlot" { 75 + t.Fatalf("unexpected text query value: %q", got) 76 + } 77 + if got := capturedReq.URL.Query().Get("old"); got != "A04C" { 78 + t.Fatalf("unexpected old query value: %q", got) 79 + } 80 + if got := capturedReq.URL.Query().Get("offset"); got != "0" { 81 + t.Fatalf("unexpected offset query value: %q", got) 82 + } 83 + if got := capturedReq.URL.Query().Get("size"); got != "30" { 84 + t.Fatalf("unexpected size query value: %q", got) 85 + } 86 + if got := capturedReq.URL.Query().Get("sort"); got != "relevanceblender" { 87 + t.Fatalf("unexpected sort query value: %q", got) 88 + } 89 + if got := capturedReq.URL.Query().Get("programType"); got != "GROCERY" { 90 + t.Fatalf("unexpected programType query value: %q", got) 91 + } 92 + if got := capturedReq.URL.Query().Get("filters"); got != "" { 93 + t.Fatalf("unexpected filters query value: %q", got) 94 + } 95 + if got := capturedReq.URL.Query().Get("categories"); got != "18473610011" { 96 + t.Fatalf("unexpected categories query value: %q", got) 97 + } 98 + 99 + if got := resp.MainResultSet.Keywords; got != "merlot" { 100 + t.Fatalf("unexpected keywords: %q", got) 101 + } 102 + if got := resp.MainResultSet.ApproximateTotalResultCount; got != 59 { 103 + t.Fatalf("unexpected approximate total: %d", got) 104 + } 105 + if got := len(resp.MainResultSet.SearchResults); got != 2 { 106 + t.Fatalf("unexpected result count: %d", got) 107 + } 108 + if got := resp.MainResultSet.SearchResults[0].ASIN; got != "B01N008341" { 109 + t.Fatalf("unexpected first asin: %q", got) 110 + } 111 + if got := resp.MainResultSet.AugmentModifications[0].Metadata["add-segment"]; got != "QSModifyTransformer:qlb-relevance-lookup" { 112 + t.Fatalf("unexpected augment metadata: %q", got) 113 + } 114 + } 115 + 116 + func TestProductSearch_RequiresTextAndOfferListingDiscriminator(t *testing.T) { 117 + t.Parallel() 118 + 119 + client := NewClient(nil) 120 + 121 + _, err := client.ProductSearch(context.Background(), ProductSearchRequest{ 122 + OfferListingDiscriminator: "A04C", 123 + }) 124 + if err == nil || !strings.Contains(err.Error(), "text is required") { 125 + t.Fatalf("unexpected text error: %v", err) 126 + } 127 + 128 + _, err = client.ProductSearch(context.Background(), ProductSearchRequest{ 129 + Text: "merlot", 130 + }) 131 + if err == nil || !strings.Contains(err.Error(), "offer listing discriminator is required") { 132 + t.Fatalf("unexpected discriminator error: %v", err) 133 + } 134 + } 135 + 136 + func TestProductHydration_BuildsRequestAndDecodesResponse(t *testing.T) { 137 + t.Parallel() 138 + 139 + fixture := []byte(`[ 140 + { 141 + "brandName": "Justin", 142 + "name": "Justin Cabernet Sauvignon 2013, 750Ml", 143 + "asin": "B06WVGV73Z", 144 + "programType": "GROCERY", 145 + "description": "With aromas of black fruit and spice.", 146 + "about": [ 147 + "Cabernet Sauvignon, Paso Robles, California" 148 + ], 149 + "productImages": [ 150 + "https://m.media-amazon.com/images/I/41+VJwn0AeL.jpg" 151 + ], 152 + "availability": "IN_STOCK", 153 + "pdpType": "STANDARD", 154 + "offerDetails": { 155 + "price": { 156 + "currencyCode": "USD", 157 + "priceAmount": 31.99, 158 + "basisPriceAmount": null, 159 + "savings": { 160 + "currencyCode": null, 161 + "savingsAmount": null, 162 + "percentSavings": null 163 + }, 164 + "primeBenefit": { 165 + "isApplied": null, 166 + "text": null, 167 + "currencyCode": null, 168 + "priceAmount": null, 169 + "savingsAmount": null 170 + } 171 + }, 172 + "offerListingId": "listing-1", 173 + "maxOrderQuantity": 33, 174 + "isMaxQuantityRestricted": true 175 + }, 176 + "variableUnitOfMeasure": { 177 + "pricingUom": { 178 + "dimension": "COUNT", 179 + "unit": "UNITS" 180 + }, 181 + "sellingUom": { 182 + "dimension": "COUNT", 183 + "unit": "UNITS" 184 + }, 185 + "selectorItemList": [ 186 + { 187 + "selectorPrice": { 188 + "baseUnit": null, 189 + "currencyCode": "USD", 190 + "priceAmount": 31.99 191 + }, 192 + "selectorSellingQuantityString": "1", 193 + "selectorSellingQuantityValue": 1 194 + } 195 + ] 196 + }, 197 + "ctaTag": "UNRECOGNIZED_SIGN_IN", 198 + "deliveryPromiseHtml": "<div>$9.95 delivery</div>", 199 + "dietTypes": [], 200 + "category": { 201 + "productType": "WINE", 202 + "glProductGroupSymbol": "gl_wine", 203 + "displayName": "Alcoholic Beverage" 204 + } 205 + }, 206 + { 207 + "brandName": "OREGON TRAILS WINE COMPANY", 208 + "name": "Willamette Valley Pinot Noir", 209 + "asin": "B07G4TKBFP", 210 + "programType": "GROCERY", 211 + "description": "", 212 + "about": [], 213 + "productImages": [ 214 + "https://m.media-amazon.com/images/I/71BBLB8KFBL.jpg" 215 + ], 216 + "availability": "IN_STOCK", 217 + "pdpType": "STANDARD", 218 + "offerDetails": null, 219 + "variableUnitOfMeasure": null, 220 + "dietTypes": [], 221 + "category": { 222 + "productType": "WINE", 223 + "glProductGroupSymbol": "gl_wine", 224 + "displayName": "Alcoholic Beverage" 225 + } 226 + } 227 + ]`) 228 + 229 + var capturedReq *http.Request 230 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 231 + capturedReq = r 232 + w.Header().Set("Content-Type", "application/json") 233 + _, _ = w.Write(fixture) 234 + })) 235 + t.Cleanup(server.Close) 236 + 237 + client := NewClientWithBaseURL(server.URL, server.Client()) 238 + 239 + resp, err := client.ProductHydration(context.Background(), ProductHydrationRequest{ 240 + OfferListingDiscriminator: " A04C ", 241 + ASINs: []string{"B06WVGV73Z", " B07G4TKBFP "}, 242 + }) 243 + if err != nil { 244 + t.Fatalf("ProductHydration returned error: %v", err) 245 + } 246 + 247 + if capturedReq == nil { 248 + t.Fatal("expected request to be captured") 249 + } 250 + if capturedReq.URL.Path != "/api/wwos/products" { 251 + t.Fatalf("unexpected path: %s", capturedReq.URL.Path) 252 + } 253 + if got := capturedReq.URL.Query().Get("offerListingDiscriminator"); got != "A04C" { 254 + t.Fatalf("unexpected discriminator query value: %q", got) 255 + } 256 + if got := capturedReq.URL.Query().Get("programType"); got != "GROCERY" { 257 + t.Fatalf("unexpected programType query value: %q", got) 258 + } 259 + if got := capturedReq.URL.Query().Get("asins"); got != "B06WVGV73Z,B07G4TKBFP" { 260 + t.Fatalf("unexpected asins query value: %q", got) 261 + } 262 + 263 + if got := len(resp); got != 2 { 264 + t.Fatalf("unexpected response count: %d", got) 265 + } 266 + if got := resp[0].ASIN; got != "B06WVGV73Z" { 267 + t.Fatalf("unexpected first asin: %q", got) 268 + } 269 + if resp[0].OfferDetails == nil { 270 + t.Fatal("expected offer details to be decoded") 271 + } 272 + if got := resp[0].OfferDetails.Price.PriceAmount; got != 31.99 { 273 + t.Fatalf("unexpected first price amount: %v", got) 274 + } 275 + if resp[0].VariableUnitOfMeasure == nil { 276 + t.Fatal("expected variable unit of measure to be decoded") 277 + } 278 + if got := resp[0].VariableUnitOfMeasure.SelectorItemList[0].SelectorSellingQuantityValue; got != 1 { 279 + t.Fatalf("unexpected selector quantity: %d", got) 280 + } 281 + if got := resp[1].Category.GLProductGroupSymbol; got != "gl_wine" { 282 + t.Fatalf("unexpected category symbol: %q", got) 283 + } 284 + if resp[1].OfferDetails != nil { 285 + t.Fatal("expected nil offer details for second product") 286 + } 287 + } 288 + 289 + func TestProductHydration_RequiresOfferListingDiscriminatorAndASINs(t *testing.T) { 290 + t.Parallel() 291 + 292 + client := NewClient(nil) 293 + 294 + _, err := client.ProductHydration(context.Background(), ProductHydrationRequest{ 295 + ASINs: []string{"B06WVGV73Z"}, 296 + }) 297 + if err == nil || !strings.Contains(err.Error(), "offer listing discriminator is required") { 298 + t.Fatalf("unexpected discriminator error: %v", err) 299 + } 300 + 301 + _, err = client.ProductHydration(context.Background(), ProductHydrationRequest{ 302 + OfferListingDiscriminator: "A04C", 303 + }) 304 + if err == nil || !strings.Contains(err.Error(), "at least one ASIN is required") { 305 + t.Fatalf("unexpected asin error: %v", err) 306 + } 307 + }
+158
internal/wholefoods/staples.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "context" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "hash/fnv" 9 + "log/slog" 10 + "strings" 11 + 12 + "careme/internal/kroger" 13 + "careme/internal/parallelism" 14 + 15 + "github.com/samber/lo" 16 + ) 17 + 18 + var defaultStaplesSignature = lo.Must(json.Marshal(defaultStaples())) 19 + 20 + type CategoryClient interface { 21 + Category(ctx context.Context, queryterm, store string) (*CategoryResponse, error) 22 + } 23 + 24 + type identityProvider struct{} 25 + 26 + type StaplesProvider struct { 27 + identityProvider 28 + client CategoryClient 29 + } 30 + 31 + func NewStaplesProvider(client CategoryClient) StaplesProvider { 32 + return StaplesProvider{client: client} 33 + } 34 + 35 + func NewIdentityProvider() identityProvider { 36 + return identityProvider{} 37 + } 38 + 39 + func (p identityProvider) Signature() string { 40 + return string(defaultStaplesSignature) 41 + } 42 + 43 + func (p identityProvider) IsID(locationID string) bool { 44 + _, ok := parseLocationID(locationID) 45 + return ok 46 + } 47 + 48 + func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 49 + if p.client == nil { 50 + return nil, fmt.Errorf("whole foods client is required") 51 + } 52 + 53 + //should identity provider do this? 54 + storeID := strings.TrimPrefix(locationID, LocationIDPrefix) 55 + if storeID == locationID || storeID == "" { 56 + return nil, fmt.Errorf("invalid whole foods location id %q", locationID) 57 + } 58 + 59 + return parallelism.Flatten(defaultStaples(), func(category string) ([]kroger.Ingredient, error) { 60 + resp, err := p.client.Category(ctx, category, storeID) 61 + if err != nil { 62 + return nil, err 63 + } 64 + 65 + ingredients := lo.Map(resp.Results, func(product Product, _ int) kroger.Ingredient { 66 + return productToIngredient(product) 67 + }) 68 + slog.InfoContext(ctx, "Found ingredients for category", "count", len(ingredients), "category", category, "location", locationID) 69 + 70 + return ingredients, nil 71 + }) 72 + } 73 + 74 + func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 75 + if p.client == nil { 76 + return nil, fmt.Errorf("whole foods client is required") 77 + } 78 + 79 + storeID := strings.TrimPrefix(locationID, LocationIDPrefix) 80 + if storeID == locationID || storeID == "" { 81 + return nil, fmt.Errorf("invalid whole foods location id %q", locationID) 82 + } 83 + 84 + resp, err := p.client.Category(ctx, searchTerm, storeID) 85 + if err != nil { 86 + return nil, err 87 + } 88 + 89 + ingredients := lo.Map(resp.Results, func(product Product, _ int) kroger.Ingredient { 90 + return productToIngredient(product) 91 + }) 92 + if skip >= len(ingredients) { 93 + return []kroger.Ingredient{}, nil 94 + } 95 + return ingredients[skip:], nil 96 + } 97 + 98 + func defaultStaples() []string { 99 + return []string{ 100 + "fresh-vegetables", 101 + "fresh-herbs", 102 + "fresh-fruit", 103 + "beef", 104 + "chicken", 105 + "fish", 106 + "pork", 107 + "shellfish", 108 + "goat-lamb-veal", 109 + "game-meats", 110 + } 111 + //rice-grains? 112 + //pasta-noodles 113 + //red-wine, white-wine, sparkling 114 + } 115 + 116 + func productToIngredient(product Product) kroger.Ingredient { 117 + var regularPrice *float32 118 + if product.RegularPrice > 0 { 119 + price := float32(product.RegularPrice) 120 + regularPrice = &price 121 + } 122 + 123 + var salePrice *float32 124 + if product.SalePrice > 0 { 125 + price := float32(product.SalePrice) 126 + salePrice = &price 127 + } 128 + 129 + /* unit of measure is more around pricing than total size) 130 + TODO how should we normalize prices per units here and in kroger. 131 + var size *string 132 + sizeText := strings.TrimSpace(strings.Join(compactStrings(product.UOM), " ")) 133 + if sizeText != "" { 134 + size = &sizeText 135 + }*/ 136 + 137 + //categories := compactStrings(localCategory(product)) 138 + 139 + hasher := fnv.New32a() 140 + _ = lo.Must(hasher.Write([]byte(product.Slug))) 141 + productId := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) 142 + return kroger.Ingredient{ 143 + ProductId: stringPtr(productId), 144 + Brand: stringPtr(strings.TrimSpace(product.Brand)), 145 + Description: stringPtr(strings.TrimSpace(product.Name)), 146 + //Size: size, 147 + PriceRegular: regularPrice, 148 + PriceSale: salePrice, 149 + // / Categories: slicePtr(categories), 150 + } 151 + } 152 + 153 + func stringPtr(value string) *string { 154 + if value == "" { 155 + return nil 156 + } 157 + return &value 158 + }
+122
internal/wholefoods/staples_test.go
··· 1 + package wholefoods 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "slices" 7 + "testing" 8 + ) 9 + 10 + func TestIdentityProviderSignature_UsesJSONStaples(t *testing.T) { 11 + got := NewIdentityProvider().Signature() 12 + want, err := json.Marshal(defaultStaples()) 13 + if err != nil { 14 + t.Fatalf("marshal default staples: %v", err) 15 + } 16 + if got != string(want) { 17 + t.Fatalf("unexpected signature: got %q want %q", got, want) 18 + } 19 + 20 + if got != string(want) { 21 + t.Fatalf("unexpected signature: got %q want %q", got, want) 22 + } 23 + } 24 + 25 + type stubCategoryClient struct { 26 + results map[string][]Product 27 + errs map[string]error 28 + calls []string 29 + } 30 + 31 + func (s *stubCategoryClient) Category(_ context.Context, queryterm, store string) (*CategoryResponse, error) { 32 + s.calls = append(s.calls, store+":"+queryterm) 33 + if err := s.errs[queryterm]; err != nil { 34 + return nil, err 35 + } 36 + return &CategoryResponse{Results: slices.Clone(s.results[queryterm])}, nil 37 + } 38 + 39 + func TestStaplesProvider_MapsProductsToIngredients(t *testing.T) { 40 + client := &stubCategoryClient{ 41 + results: map[string][]Product{ 42 + "fresh-vegetables": { 43 + { 44 + Name: "Organic Asparagus", 45 + Slug: "organic-asparagus", 46 + Brand: "Whole Foods Market", 47 + Store: 10216, 48 + UOM: "1 lb", 49 + RegularPrice: 5.99, 50 + SalePrice: 4.49, 51 + }, 52 + }, 53 + }, 54 + } 55 + provider := NewStaplesProvider(client) 56 + 57 + got, err := provider.FetchStaples(t.Context(), "wholefoods_10216") 58 + if err != nil { 59 + t.Fatalf("FetchStaples returned error: %v", err) 60 + } 61 + if len(got) == 0 { 62 + t.Fatal("expected ingredients, got none") 63 + } 64 + 65 + ingredient := got[0] 66 + if ingredient.Description == nil || *ingredient.Description != "Organic Asparagus" { 67 + t.Fatalf("unexpected description: %+v", ingredient.Description) 68 + } 69 + if ingredient.Brand == nil || *ingredient.Brand != "Whole Foods Market" { 70 + t.Fatalf("unexpected brand: %+v", ingredient.Brand) 71 + } 72 + if ingredient.ProductId == nil || *ingredient.ProductId != "odQxPA" { 73 + t.Fatalf("unexpected product id: %+v", *ingredient.ProductId) 74 + } 75 + if ingredient.PriceRegular == nil || *ingredient.PriceRegular != float32(5.99) { 76 + t.Fatalf("unexpected regular price: %+v", ingredient.PriceRegular) 77 + } 78 + if ingredient.PriceSale == nil || *ingredient.PriceSale != float32(4.49) { 79 + t.Fatalf("unexpected sale price: %+v", ingredient.PriceSale) 80 + } 81 + if len(client.calls) != len(defaultStaples()) { 82 + t.Fatalf("expected %d category calls, got %d", len(defaultStaples()), len(client.calls)) 83 + } 84 + } 85 + 86 + func TestStaplesProvider_InvalidLocationID(t *testing.T) { 87 + provider := NewStaplesProvider(&stubCategoryClient{}) 88 + 89 + _, err := provider.FetchStaples(t.Context(), "10216") 90 + if err == nil { 91 + t.Fatal("expected invalid location error") 92 + } 93 + if got, want := err.Error(), `invalid whole foods location id "10216"`; got != want { 94 + t.Fatalf("unexpected error: got %q want %q", got, want) 95 + } 96 + } 97 + 98 + func TestStaplesProvider_GetIngredients_UsesSearchTerm(t *testing.T) { 99 + client := &stubCategoryClient{ 100 + results: map[string][]Product{ 101 + "pinot noir": { 102 + {Name: "Pinot Noir", Slug: "pinot-noir", Brand: "WFM", Store: 10216}, 103 + {Name: "Rose", Slug: "rose", Brand: "WFM", Store: 10216}, 104 + }, 105 + }, 106 + } 107 + provider := NewStaplesProvider(client) 108 + 109 + got, err := provider.GetIngredients(t.Context(), "wholefoods_10216", "pinot noir", 1) 110 + if err != nil { 111 + t.Fatalf("GetIngredients returned error: %v", err) 112 + } 113 + if len(got) != 1 { 114 + t.Fatalf("expected 1 ingredient after skip, got %d", len(got)) 115 + } 116 + if got[0].Description == nil || *got[0].Description != "Rose" { 117 + t.Fatalf("unexpected ingredient description: %+v", got[0].Description) 118 + } 119 + if len(client.calls) != 1 || client.calls[0] != "10216:pinot noir" { 120 + t.Fatalf("unexpected category calls: %v", client.calls) 121 + } 122 + }
+57
scrapehelpers/log-wf-api.js
··· 1 + const { chromium } = require("playwright"); 2 + 3 + (async () => { 4 + const browser = await chromium.launch({ headless: false }); 5 + const context = await browser.newContext(); 6 + const page = await context.newPage(); 7 + 8 + page.on("request", req => { 9 + const url = req.url(); 10 + const rt = req.resourceType(); 11 + 12 + const interesting = 13 + url.includes("wholefoodsmarket.com") || 14 + url.includes("wholefoods.com") || 15 + url.includes("/_next/") || 16 + rt === "xhr" || 17 + rt === "fetch" || 18 + req.isNavigationRequest(); 19 + 20 + if (!interesting) return; 21 + 22 + console.log("\n=== REQUEST ==="); 23 + console.log("type:", rt); 24 + console.log("method:", req.method()); 25 + console.log("url:", url); 26 + 27 + const body = req.postData(); 28 + if (body) { 29 + console.log("body:", body.slice(0, 2000)); 30 + } 31 + }); 32 + 33 + page.on("response", async res => { 34 + const url = res.url(); 35 + const ct = res.headers()["content-type"] || ""; 36 + 37 + const interesting = 38 + url.includes("wholefoodsmarket.com") || 39 + url.includes("wholefoods.com") || 40 + url.includes("/_next/") || 41 + ct.includes("json") || 42 + ct.includes("html"); 43 + 44 + if (!interesting) return; 45 + 46 + console.log("\n=== RESPONSE ==="); 47 + console.log("status:", res.status()); 48 + console.log("url:", url); 49 + console.log("content-type:", ct); 50 + }); 51 + 52 + await page.goto("https://www.wholefoodsmarket.com/grocery/search?k=syrah", { 53 + waitUntil: "domcontentloaded" 54 + }); 55 + 56 + console.log("Interact manually now."); 57 + })();