ai cooking
0
fork

Configure Feed

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

move to input ingredient (#531)

* move to input ingredient

* make it map friendly

* remove unique but still throw on empty product ids

* let routing staple provider do deduping

* simpler map function

* find out later

* fix tests with skip

* fumpt

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
9218802b 9112988f

+235 -327
+17 -36
internal/actowiz/staples.go
··· 10 10 "strconv" 11 11 "strings" 12 12 13 - "careme/internal/kroger" 13 + "careme/internal/ai" 14 14 ) 15 15 16 16 const LocationIDPrefix = "safeway_" ··· 47 47 return storeID != "" && storeID != locationID 48 48 } 49 49 50 - func all() []kroger.Ingredient { 50 + func all() []ai.InputIngredient { 51 51 // do this once instead of every time? 52 - ingredients := make([]kroger.Ingredient, 0, len(embeddedSafewayProducts)) 52 + ingredients := make([]ai.InputIngredient, 0, len(embeddedSafewayProducts)) 53 53 for _, product := range embeddedSafewayProducts { 54 54 if !product.Availability { 55 55 continue ··· 68 68 return ingredients 69 69 } 70 70 71 - func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]kroger.Ingredient, error) { 71 + func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]ai.InputIngredient, error) { 72 72 if !p.IsID(locationID) { 73 73 return nil, fmt.Errorf("invalid safeway location id %q", locationID) 74 74 } 75 75 return all(), nil 76 76 } 77 77 78 - func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, _ int) ([]kroger.Ingredient, error) { 78 + func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, _ int) ([]ai.InputIngredient, error) { 79 79 if !p.IsID(locationID) { 80 80 return nil, fmt.Errorf("invalid safeway location id %q", locationID) 81 81 } ··· 83 83 return filterIngredients(all(), searchTerm), nil 84 84 } 85 85 86 - func filterIngredients(ingredients []kroger.Ingredient, searchTerm string) []kroger.Ingredient { 86 + func filterIngredients(ingredients []ai.InputIngredient, searchTerm string) []ai.InputIngredient { 87 87 searchTerm = strings.TrimSpace(strings.ToLower(searchTerm)) 88 88 if searchTerm == "" { 89 89 return slices.Clone(ingredients) 90 90 } 91 91 92 - filtered := make([]kroger.Ingredient, 0, len(ingredients)) 92 + filtered := make([]ai.InputIngredient, 0, len(ingredients)) 93 93 for _, ingredient := range ingredients { 94 94 if ingredientMatches(ingredient, searchTerm) { 95 95 filtered = append(filtered, ingredient) ··· 98 98 return filtered 99 99 } 100 100 101 - func ingredientMatches(ingredient kroger.Ingredient, searchTerm string) bool { 101 + func ingredientMatches(ingredient ai.InputIngredient, searchTerm string) bool { 102 102 for _, value := range []string{ 103 - stringValue(ingredient.Description), 104 - stringValue(ingredient.Brand), 103 + ingredient.Description, 104 + ingredient.Brand, 105 105 } { 106 106 if strings.Contains(strings.ToLower(value), searchTerm) { 107 107 return true ··· 111 111 return false 112 112 } 113 113 114 - func productToIngredient(product SafewayProduct) kroger.Ingredient { 114 + func productToIngredient(product SafewayProduct) ai.InputIngredient { 115 115 description, size := splitProductName(product.ProductName) // dubious size is really always 116 116 regularPrice := float32Ptr(product.MRP) 117 117 salePrice := float32Ptr(product.DiscountedPrice) ··· 121 121 122 122 productID := strconv.FormatInt(product.ID, 10) 123 123 categories := compactStrings([]string{product.Category, product.SubCategory}) 124 - var categoryPtr *[]string 125 - if len(categories) > 0 { 126 - categoryPtr = &categories 127 - } 128 124 129 - return kroger.Ingredient{ 130 - ProductId: stringPtr(productID), 131 - Description: stringPtr(description), 132 - Size: stringPtr(size), 125 + return ai.NormalizeInputIngredient(ai.InputIngredient{ 126 + ProductID: productID, 127 + Description: description, 128 + Size: size, 133 129 PriceRegular: regularPrice, 134 130 PriceSale: salePrice, 135 - Categories: categoryPtr, 136 - } 131 + Categories: categories, 132 + }) 137 133 } 138 134 139 135 func splitProductName(name string) (string, string) { ··· 163 159 panic(fmt.Errorf("decode safeway products: %w", err)) 164 160 } 165 161 return products 166 - } 167 - 168 - func stringValue(value *string) string { 169 - if value == nil { 170 - return "" 171 - } 172 - return *value 173 - } 174 - 175 - func stringPtr(value string) *string { 176 - value = strings.TrimSpace(value) 177 - if value == "" { 178 - return nil 179 - } 180 - return &value 181 162 } 182 163 183 164 func float32Ptr(value *float64) *float32 {
+5 -5
internal/actowiz/staples_test.go
··· 25 25 } 26 26 27 27 first := got[0] 28 - if first.ProductId == nil || *first.ProductId == "" { 29 - t.Fatalf("unexpected product id: %+v", first.ProductId) 28 + if first.ProductID == "" { 29 + t.Fatalf("unexpected product id: %+v", first.ProductID) 30 30 } 31 - if first.Description == nil || *first.Description == "" { 31 + if first.Description == "" { 32 32 t.Fatalf("unexpected description: %+v", first.Description) 33 33 } 34 - if first.Categories == nil || len(*first.Categories) == 0 { 34 + if len(first.Categories) == 0 { 35 35 t.Fatalf("unexpected categories: %+v", first.Categories) 36 36 } 37 37 } ··· 58 58 if len(got) == 0 { 59 59 t.Fatal("expected filtered ingredients after skip") 60 60 } 61 - if got[0].Description == nil { 61 + if got[0].Description == "" { 62 62 t.Fatalf("missing description: %+v", got[0]) 63 63 } 64 64 }
+3 -3
internal/ai/client.go
··· 11 11 "strings" 12 12 "time" 13 13 14 - "careme/internal/locations" 14 + locationtypes "careme/internal/locations/types" 15 15 16 16 openai "github.com/openai/openai-go/v3" 17 17 "github.com/openai/openai-go/v3/option" ··· 368 368 return &selection, nil 369 369 } 370 370 371 - func (c *client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 371 + func (c *client) GenerateRecipes(ctx context.Context, location *locationtypes.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 372 372 messages, err := c.buildRecipeMessages(location, saleIngredients, instructions, date, lastRecipes) 373 373 if err != nil { 374 374 return nil, fmt.Errorf("failed to build recipe messages: %w", err) ··· 434 434 } 435 435 436 436 // buildRecipeMessages creates separate messages for the LLM to process more efficiently 437 - func (c *client) buildRecipeMessages(location *locations.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 437 + func (c *client) buildRecipeMessages(location *locationtypes.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 438 438 var messages []responses.ResponseInputItemUnionParam 439 439 // constants we might make variable later 440 440 messages = append(messages, user("Prioritize ingredients that are in season for the current date and user's state location "+date.Format("January 2nd")+" in "+location.State+"."))
-1
internal/ai/ingredient_grade.go
··· 73 73 74 74 Return JSON only. Preserve each input id/index exactly. Be concise.` 75 75 76 - // this is wire compatible with kroger.Ingredient eventually it should replace it in what staples returns 77 76 type InputIngredient struct { 78 77 ProductID string `json:"id,omitempty"` 79 78 AisleNumber string `json:"number,omitempty"` // this is a dumb json name fix it later
+25 -45
internal/albertsons/staples.go
··· 8 8 "net/http" 9 9 "strings" 10 10 11 + "careme/internal/ai" 11 12 "careme/internal/albertsons/query" 12 13 "careme/internal/cache" 13 14 "careme/internal/config" 14 - "careme/internal/kroger" 15 15 "careme/internal/parallelism" 16 16 17 17 "github.com/samber/lo" ··· 73 73 } 74 74 75 75 var stapleRows = map[string]uint{ 76 - query.Category_Vegatables: 150, // do we need way more of this? 77 - query.Category_Fruit: 100, 78 - query.Category_Meat: 100, 79 - query.Category_Seafood: 60, 76 + query.Category_Vegatables: 150, // do we need way more of this? 77 + query.Category_Fruit: 100, 78 + query.Category_Meat: 100, 79 + query.Category_Seafood: 60, 80 + query.Category_Pasta_Grains: 100, //??? 80 81 } 81 82 82 - func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 83 + func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 83 84 client, storeID, err := p.clientForLocation(locationID) 84 85 if err != nil { 85 86 return nil, err 86 87 } 87 88 88 - return parallelism.Flatten(query.StapleCategories(), func(category string) ([]kroger.Ingredient, error) { 89 + return parallelism.Flatten(query.StapleCategories(), func(category string) ([]ai.InputIngredient, error) { 89 90 payload, err := client.Search(ctx, storeID, category, query.SearchOptions{ 90 91 // how many rows? different per category? Should we paginate 91 92 Rows: stapleRows[category], ··· 96 97 return nil, err 97 98 } 98 99 99 - ingredients := lo.Map(payload.Response.Docs, func(product query.PathwaySearchProduct, _ int) kroger.Ingredient { 100 - return productToIngredient(product) 101 - }) 100 + ingredients := lo.Map(payload.Response.Docs, productToIngredient) 102 101 slog.InfoContext(ctx, "found albertsons staples for category", "count", len(ingredients), "category", category, "location", locationID) 103 102 return ingredients, nil 104 103 }) 105 104 } 106 105 107 106 // since this is mostly used by wine it isn't actuallyt they helpful. 108 - func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 107 + func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 109 108 client, storeID, err := p.clientForLocation(locationID) 110 109 if err != nil { 111 110 return nil, err ··· 113 112 114 113 // should we just resturn all instead of search term? how many is this? 115 114 payload, err := client.Search(ctx, storeID, query.Category_Wine, query.SearchOptions{ 116 - Query: searchTerm, Rows: 100, 115 + Query: searchTerm, Rows: 100, Start: uint(skip), 117 116 }) 118 117 if err != nil { 119 118 return nil, err 120 119 } 121 - 122 - ingredients := lo.Map(payload.Response.Docs, func(product query.PathwaySearchProduct, _ int) kroger.Ingredient { 123 - return productToIngredient(product) 124 - }) 125 - if skip >= len(ingredients) { 126 - return []kroger.Ingredient{}, nil 127 - } 128 - return ingredients[skip:], nil 120 + return lo.Map(payload.Response.Docs, productToIngredient), nil 129 121 } 130 122 131 123 // clientForLocation takes a prefixed store id and looks up chaing base url and returnes unprefixed id. ··· 156 148 return "", "", false 157 149 } 158 150 159 - func productToIngredient(product query.PathwaySearchProduct) kroger.Ingredient { 160 - productID := stringPtr(strings.TrimSpace(product.ID)) 161 - description := stringPtr(strings.TrimSpace(product.Name)) 151 + func productToIngredient(product query.PathwaySearchProduct, _ int) ai.InputIngredient { 152 + productID := strings.TrimSpace(product.ID) 153 + description := strings.TrimSpace(product.Name) 162 154 size := sizeText(product) 163 155 regularPrice := float32Ptr(product.BasePrice) 164 156 salePrice := float32Ptr(product.Price) 157 + // how does shelf relate to aisle? 165 158 categories := lo.Compact([]string{product.DepartmentName, product.ShelfName}) 166 159 167 - var categoryPtr *[]string 168 - if len(categories) > 0 { 169 - categoryPtr = &categories 170 - } 171 - 172 - return kroger.Ingredient{ 173 - ProductId: productID, 160 + return ai.NormalizeInputIngredient(ai.InputIngredient{ 161 + ProductID: productID, 174 162 Description: description, 175 - Size: size, 163 + Size: size, // will product id smash this as a dedupe? 176 164 PriceRegular: regularPrice, 177 165 PriceSale: salePrice, 178 - Categories: categoryPtr, 179 - } 166 + Categories: categories, 167 + AisleNumber: product.AisleID, // also an aisle name if thats better? 168 + }) 180 169 } 181 170 182 171 // this is a bit squirely shouldn't we take one ratehr than joiing both? 183 - func sizeText(product query.PathwaySearchProduct) *string { 172 + func sizeText(product query.PathwaySearchProduct) string { 184 173 sizeParts := lo.Compact([]string{product.ItemSizeQty, product.UnitOfMeasure}) 185 174 if len(sizeParts) == 0 { 186 - return nil 175 + return "" 187 176 } 188 - size := strings.Join(sizeParts, " ") 189 - return &size 190 - } 191 - 192 - func stringPtr(value string) *string { 193 - value = strings.TrimSpace(value) 194 - if value == "" { 195 - return nil 196 - } 197 - return &value 177 + return strings.Join(sizeParts, " ") 198 178 } 199 179 200 180 func float32Ptr(value float64) *float32 {
+34 -13
internal/albertsons/staples_test.go
··· 30 30 results map[string]query.PathwaySearchPayload 31 31 mu sync.Mutex 32 32 calls []string 33 + starts []uint 33 34 } 34 35 35 36 func (s *stubSearchClient) Search(_ context.Context, storeID, category string, opts query.SearchOptions) (*query.PathwaySearchPayload, error) { 36 37 s.mu.Lock() 37 38 s.calls = append(s.calls, storeID+":"+category+":"+opts.Query) 39 + s.starts = append(s.starts, opts.Start) 38 40 s.mu.Unlock() 39 41 40 42 payload := s.results[category] ··· 47 49 return slices.Contains(s.calls, want) 48 50 } 49 51 52 + func (s *stubSearchClient) startForCall(want string) (uint, bool) { 53 + s.mu.Lock() 54 + defer s.mu.Unlock() 55 + for i, call := range s.calls { 56 + if call == want { 57 + return s.starts[i], true 58 + } 59 + } 60 + return 0, false 61 + } 62 + 50 63 func (s *stubSearchClient) callCount() int { 51 64 s.mu.Lock() 52 65 defer s.mu.Unlock() ··· 95 108 } 96 109 97 110 first := got[0] 98 - if first.ProductId == nil || *first.ProductId != "veg-1" { 99 - t.Fatalf("unexpected product id: %+v", first.ProductId) 111 + if first.ProductID != "veg-1" { 112 + t.Fatalf("unexpected product id: %+v", first.ProductID) 100 113 } 101 - if first.Description == nil || *first.Description != "Broccoli Crown" { 114 + if first.Description != "Broccoli Crown" { 102 115 t.Fatalf("unexpected description: %+v", first.Description) 103 116 } 104 - if first.Size == nil || *first.Size != "1 EA" { 117 + if first.Size != "1 EA" { 105 118 t.Fatalf("unexpected size: %+v", first.Size) 106 119 } 107 120 if first.PriceRegular == nil || *first.PriceRegular != float32(3.49) { ··· 110 123 if first.PriceSale == nil || *first.PriceSale != float32(2.99) { 111 124 t.Fatalf("unexpected sale price: %+v", first.PriceSale) 112 125 } 113 - if first.Categories == nil || !slices.Equal(*first.Categories, []string{"Produce", "Vegetables"}) { 126 + if !slices.Equal(first.Categories, []string{"Produce", "Vegetables"}) { 114 127 t.Fatalf("unexpected categories: %+v", first.Categories) 115 128 } 116 129 } ··· 158 171 if err != nil { 159 172 t.Fatalf("GetIngredients returned error: %v", err) 160 173 } 161 - if !client.hasCall("806:" + query.Category_Wine + ":pinot") { 174 + searchCall := "806:" + query.Category_Wine + ":pinot" 175 + if !client.hasCall(searchCall) { 162 176 t.Fatalf("missing expected search call") 163 177 } 164 - if len(got) != 1 { 165 - t.Fatalf("expected 1 ingredient after skip, got %d", len(got)) 178 + if got, ok := client.startForCall(searchCall); !ok || got != 1 { 179 + t.Fatalf("unexpected search start: got %d found %t", got, ok) 166 180 } 167 - if got[0].Description == nil || *got[0].Description != "Rose Radishes" { 181 + if len(got) != 2 { 182 + t.Fatalf("expected 2 ingredients, got %d", len(got)) 183 + } 184 + if got[0].Description != "Pinot Tomatoes" { 168 185 t.Fatalf("unexpected description: %+v", got[0].Description) 169 186 } 170 187 } ··· 173 190 t.Parallel() 174 191 175 192 var sawRequest bool 193 + const skip = 17 176 194 httpClient := &http.Client{ 177 195 Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { 178 196 sawRequest = true 179 197 if got, want := r.URL.Host, "www.acmemarkets.com"; got != want { 180 198 t.Fatalf("unexpected host: got %q want %q", got, want) 181 199 } 200 + if got, want := r.URL.Query().Get("start"), "17"; got != want { 201 + t.Fatalf("unexpected start query param: got %q want %q", got, want) 202 + } 182 203 if got, want := r.Header.Get("Ocp-Apim-Subscription-Key"), "test-sub-key"; got != want { 183 204 t.Fatalf("unexpected subscription key: got %q want %q", got, want) 184 205 } ··· 207 228 return query.NewSearchClient(querycfg) 208 229 }) 209 230 210 - got, err := provider.GetIngredients(t.Context(), "acmemarkets_806", "pinot", 1) 231 + got, err := provider.GetIngredients(t.Context(), "acmemarkets_806", "pinot", skip) 211 232 if err != nil { 212 233 t.Fatalf("GetIngredients returned error: %v", err) 213 234 } 214 235 if !sawRequest { 215 236 t.Fatal("expected injected HTTP client to be used") 216 237 } 217 - if len(got) != 1 { 218 - t.Fatalf("expected 1 ingredient after skip, got %d", len(got)) 238 + if len(got) != 2 { 239 + t.Fatalf("expected 2 ingredients, got %d", len(got)) 219 240 } 220 - if got[0].Description == nil || *got[0].Description != "Rose" { 241 + if got[0].Description != "Pinot Noir" { 221 242 t.Fatalf("unexpected description: %+v", got[0].Description) 222 243 } 223 244 }
+39 -5
internal/kroger/staples.go
··· 7 7 "log/slog" 8 8 "net/http" 9 9 "slices" 10 + "strings" 10 11 12 + "careme/internal/ai" 11 13 "careme/internal/config" 12 14 "careme/internal/kroger/products" 13 15 "careme/internal/parallelism" ··· 59 61 return &StaplesProvider{client: client}, nil 60 62 } 61 63 62 - func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]Ingredient, error) { 63 - return parallelism.Flatten(defaultStaples(), func(category staplesFilter) ([]Ingredient, error) { 64 + func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 65 + return parallelism.Flatten(defaultStaples(), func(category staplesFilter) ([]ai.InputIngredient, error) { 64 66 ingredients, err := searchIngredients(ctx, p.client, locationID, category.Term, category.Brands, category.Frozen, 0) 65 67 if err != nil { 66 68 slog.WarnContext(ctx, "Failed to fetch category", "category", category.Term, "location", locationID, "error", err) 67 69 return nil, err 68 70 } 69 71 slog.InfoContext(ctx, "Found ingredients for category", "count", len(ingredients), "category", category.Term, "location", locationID) 70 - return ingredients, nil 72 + return lo.Map(ingredients, inputIngredientFromKrogerIngredient), nil 71 73 }) 72 74 } 73 75 74 - func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]Ingredient, error) { 75 - return searchIngredients(ctx, p.client, locationID, searchTerm, []string{"*"}, false, skip) 76 + func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 77 + ingredients, err := searchIngredients(ctx, p.client, locationID, searchTerm, []string{"*"}, false, skip) 78 + if err != nil { 79 + return nil, err 80 + } 81 + return lo.Map(ingredients, inputIngredientFromKrogerIngredient), nil 76 82 } 77 83 78 84 var availableInStore = products.Ais ··· 156 162 } 157 163 158 164 return ingredients, nil 165 + } 166 + 167 + func inputIngredientFromKrogerIngredient(ingredient Ingredient, _ int) ai.InputIngredient { 168 + return ai.NormalizeInputIngredient(ai.InputIngredient{ 169 + ProductID: strings.TrimSpace(toStr(ingredient.ProductId)), 170 + AisleNumber: strings.TrimSpace(toStr(ingredient.AisleNumber)), 171 + Brand: strings.TrimSpace(toStr(ingredient.Brand)), 172 + Description: strings.TrimSpace(toStr(ingredient.Description)), 173 + Size: strings.TrimSpace(toStr(ingredient.Size)), 174 + PriceRegular: clonePrice(ingredient.PriceRegular), 175 + PriceSale: clonePrice(ingredient.PriceSale), 176 + Categories: categoriesFromPtr(ingredient.Categories), 177 + }) 178 + } 179 + 180 + func categoriesFromPtr(ptr *[]string) []string { 181 + if ptr == nil { 182 + return nil 183 + } 184 + return append([]string(nil), (*ptr)...) 185 + } 186 + 187 + func clonePrice(price *float32) *float32 { 188 + if price == nil { 189 + return nil 190 + } 191 + value := *price 192 + return &value 159 193 } 160 194 161 195 func defaultStaples() []staplesFilter {
+37
internal/kroger/staples_test.go
··· 3 3 import ( 4 4 "net/http" 5 5 "net/http/httptest" 6 + "slices" 6 7 "strings" 7 8 "testing" 8 9 ··· 94 95 t.Fatalf("expected krogerError to include decoded payload, got %v", krogerError(http.StatusBadRequest, payload)) 95 96 } 96 97 } 98 + 99 + func TestInputIngredientFromKrogerIngredientMapsFields(t *testing.T) { 100 + regular := float32(4.99) 101 + sale := float32(3.49) 102 + categories := []string{"Produce", "Fresh Fruit"} 103 + ingredient := inputIngredientFromKrogerIngredient(Ingredient{ 104 + ProductId: stringPtr(" apple-1 "), 105 + AisleNumber: stringPtr(" 12 "), 106 + Brand: stringPtr(" Orchard Co "), 107 + Description: stringPtr(" Honeycrisp Apple "), 108 + Size: stringPtr(" 3 lb "), 109 + PriceRegular: &regular, 110 + PriceSale: &sale, 111 + Categories: &categories, 112 + }, 0) 113 + 114 + if ingredient.ProductID != "apple-1" { 115 + t.Fatalf("unexpected product id: %+v", ingredient) 116 + } 117 + if ingredient.AisleNumber != "12" || ingredient.Brand != "Orchard Co" || ingredient.Description != "Honeycrisp Apple" || ingredient.Size != "3 lb" { 118 + t.Fatalf("unexpected normalized ingredient: %+v", ingredient) 119 + } 120 + if ingredient.PriceRegular == nil || *ingredient.PriceRegular != regular { 121 + t.Fatalf("unexpected regular price: %+v", ingredient.PriceRegular) 122 + } 123 + if ingredient.PriceSale == nil || *ingredient.PriceSale != sale { 124 + t.Fatalf("unexpected sale price: %+v", ingredient.PriceSale) 125 + } 126 + if !slices.Equal(ingredient.Categories, categories) { 127 + t.Fatalf("unexpected categories: got %v want %v", ingredient.Categories, categories) 128 + } 129 + } 130 + 131 + func stringPtr(value string) *string { 132 + return &value 133 + }
-1
internal/recipes/io.go
··· 108 108 } 109 109 }() 110 110 111 - // this should be back compat with kroger.Ingredient 112 111 var ingredients []ai.InputIngredient 113 112 if err := json.NewDecoder(ingredientBlob).Decode(&ingredients); err != nil { 114 113 return nil, err
-4
internal/recipes/io_test.go
··· 267 267 t.Fatalf("unexpected cached wine recommendation: got %q", got) 268 268 } 269 269 } 270 - 271 - func loPtr(v string) *string { 272 - return &v 273 - }
+28 -97
internal/recipes/staples.go
··· 25 25 "github.com/samber/lo" 26 26 ) 27 27 28 - // todo make this a indepenedent ingredient object not kroger. 29 - // we're cheating and making the wrapper here do the conversion for now but all underlying provider should create input ingredients 30 - // then this becomes staplesProvider 31 - type krogerProvider interface { 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 28 type identityProvider interface { 37 29 IsID(locationID string) bool 38 30 Signature() string ··· 40 32 41 33 type backendStaplesProvider interface { 42 34 identityProvider 43 - krogerProvider 35 + staplesProvider 44 36 } 45 37 38 + // sends to the right backend but also dedupes product ids and errors on empty ones. 46 39 type routingStaplesProvider struct { 47 40 backends []backendStaplesProvider 48 41 } ··· 53 46 return nil, err 54 47 } 55 48 56 - return convertingProvider{routingStaplesProvider{ 49 + return routingStaplesProvider{ 57 50 backends: backends, 58 - }}, nil 51 + }, nil 59 52 } 60 53 61 - func (p routingStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 54 + func (p routingStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 62 55 provider, err := p.providerForLocation(locationID) 63 56 if err != nil { 64 57 return nil, err 65 58 } 66 - return provider.FetchStaples(ctx, locationID) 59 + ingredients, err := provider.FetchStaples(ctx, locationID) 60 + if err != nil { 61 + return nil, err 62 + } 63 + return dedupeInputIngredients(ingredients) 67 64 } 68 65 69 - func (p routingStaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 66 + func (p routingStaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 70 67 provider, err := p.providerForLocation(locationID) 71 68 if err != nil { 72 69 return nil, err 73 70 } 74 - return provider.GetIngredients(ctx, locationID, searchTerm, skip) 71 + ingredients, err := provider.GetIngredients(ctx, locationID, searchTerm, skip) 72 + if err != nil { 73 + return nil, err 74 + } 75 + return dedupeInputIngredients(ingredients) 75 76 } 76 77 77 78 type ingredientio interface { ··· 94 95 GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) 95 96 } 96 97 97 - type convertingProvider struct { 98 - kprovider krogerProvider 99 - } 100 - 101 - var _ staplesProvider = convertingProvider{} 102 - 103 - func (cp convertingProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 104 - ingredients, err := cp.kprovider.FetchStaples(ctx, locationID) 105 - if err != nil { 106 - return nil, err 107 - } 108 - inputs := make([]ai.InputIngredient, 0, len(ingredients)) 98 + func dedupeInputIngredients(ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) { 99 + seen := map[string]bool{} 100 + var deduped []ai.InputIngredient 109 101 for _, ingredient := range ingredients { 110 - input, err := inputIngredientFromKrogerIngredient(ingredient) 111 - if err != nil { 112 - return nil, err 102 + if ingredient.ProductID == "" { 103 + return nil, fmt.Errorf("blank product id for ingredient: %+v", ingredient) 113 104 } 114 - inputs = append(inputs, input) 115 - } 116 - 117 - inputs = lo.UniqBy(inputs, func(i ai.InputIngredient) string { 118 - return i.ProductID 119 - }) 120 - return inputs, nil 121 - } 122 - 123 - func (cp convertingProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 124 - ingredients, err := cp.kprovider.GetIngredients(ctx, locationID, searchTerm, skip) 125 - if err != nil { 126 - return nil, err 127 - } 128 - inputs := make([]ai.InputIngredient, 0, len(ingredients)) 129 - for _, ingredient := range ingredients { 130 - input, err := inputIngredientFromKrogerIngredient(ingredient) 131 - if err != nil { 132 - return nil, err 105 + if seen[ingredient.ProductID] { 106 + continue 133 107 } 134 - inputs = append(inputs, input) 135 - } 136 - 137 - inputs = lo.UniqBy(inputs, func(i ai.InputIngredient) string { 138 - return i.ProductID 139 - }) 140 - return inputs, nil 141 - } 142 - 143 - func inputIngredientFromKrogerIngredient(ingredient kroger.Ingredient) (ai.InputIngredient, error) { 144 - item := ai.InputIngredient{ 145 - ProductID: strings.TrimSpace(toStr(ingredient.ProductId)), 146 - AisleNumber: strings.TrimSpace(toStr(ingredient.AisleNumber)), 147 - Brand: strings.TrimSpace(toStr(ingredient.Brand)), 148 - Description: strings.TrimSpace(toStr(ingredient.Description)), 149 - Size: strings.TrimSpace(toStr(ingredient.Size)), 150 - PriceRegular: clonePrice(ingredient.PriceRegular), 151 - PriceSale: clonePrice(ingredient.PriceSale), 152 - Categories: categoriesFromPtr(ingredient.Categories), 153 - } 154 - item = ai.NormalizeInputIngredient(item) 155 - if item.ProductID == "" { 156 - return ai.InputIngredient{}, fmt.Errorf("ingredient product_id is required for %q", toStr(ingredient.Description)) 108 + seen[ingredient.ProductID] = true 109 + deduped = append(deduped, ingredient) 157 110 } 158 - return item, nil 159 - } 160 - 161 - func toStr(ptr *string) string { 162 - if ptr == nil { 163 - return "" 164 - } 165 - return *ptr 166 - } 167 - 168 - func categoriesFromPtr(ptr *[]string) []string { 169 - if ptr == nil { 170 - return nil 171 - } 172 - return append([]string(nil), (*ptr)...) 173 - } 174 - 175 - func clonePrice(price *float32) *float32 { 176 - if price == nil { 177 - return nil 178 - } 179 - value := *price 180 - return &value 111 + return deduped, nil 181 112 } 182 113 183 114 func NewCachedStaplesService(cfg *config.Config, c cache.Cache, grader grader) (*cachedStaplesService, error) { ··· 312 243 return nil, fmt.Errorf("create albertsons staples provider: %w", err) 313 244 } 314 245 315 - krogerProvider, err := kroger.NewStaplesProvider(cfg) 246 + krogerBackend, err := kroger.NewStaplesProvider(cfg) 316 247 if err != nil { 317 248 return nil, fmt.Errorf("create kroger staples provider: %w", err) 318 249 } 319 250 320 251 return []backendStaplesProvider{ 321 252 albertsonsProvider, 322 - krogerProvider, 253 + krogerBackend, 323 254 // actowiz.NewStaplesProvider(), 324 255 walmart.NewStaplesProvider(), 325 256 wholefoods.NewStaplesProvider(wholefoods.NewClient(httpClient)),
+22 -79
internal/recipes/staples_test.go
··· 10 10 "careme/internal/ai" 11 11 "careme/internal/albertsons" 12 12 "careme/internal/cache" 13 - "careme/internal/kroger" 14 13 "careme/internal/locations" 15 14 ) 16 15 17 16 type stubStaplesProvider struct { 18 17 ids map[string]bool 19 - ingredients []kroger.Ingredient 18 + ingredients []ai.InputIngredient 20 19 err error 21 20 calls int 22 21 } ··· 29 28 return "stub-staples-v1" 30 29 } 31 30 32 - func (s *stubStaplesProvider) FetchStaples(_ context.Context, _ string) ([]kroger.Ingredient, error) { 31 + func (s *stubStaplesProvider) FetchStaples(_ context.Context, _ string) ([]ai.InputIngredient, error) { 33 32 s.calls++ 34 33 if s.err != nil { 35 34 return nil, s.err ··· 37 36 return slices.Clone(s.ingredients), nil 38 37 } 39 38 40 - func (s *stubStaplesProvider) GetIngredients(_ context.Context, _ string, _ string, _ int) ([]kroger.Ingredient, error) { 39 + func (s *stubStaplesProvider) GetIngredients(_ context.Context, _ string, _ string, _ int) ([]ai.InputIngredient, error) { 41 40 return s.FetchStaples(context.Background(), "") 42 41 } 43 42 ··· 86 85 } 87 86 88 87 func TestRoutingStaplesProvider_SelectsProviderByLocationID(t *testing.T) { 89 - krogerProvider := &stubStaplesProvider{ids: map[string]bool{"70100023": true}} 88 + krogerBackend := &stubStaplesProvider{ids: map[string]bool{"70100023": true}} 90 89 wholeFoodsProvider := &stubStaplesProvider{ids: map[string]bool{"wholefoods_10216": true}} 91 90 provider := routingStaplesProvider{ 92 - backends: []backendStaplesProvider{krogerProvider, wholeFoodsProvider}, 91 + backends: []backendStaplesProvider{krogerBackend, wholeFoodsProvider}, 93 92 } 94 93 95 94 if _, err := provider.FetchStaples(t.Context(), "70100023"); err != nil { 96 95 t.Fatalf("FetchStaples kroger returned error: %v", err) 97 96 } 98 - if krogerProvider.calls != 1 || wholeFoodsProvider.calls != 0 { 99 - t.Fatalf("expected kroger provider to be selected, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 97 + if krogerBackend.calls != 1 || wholeFoodsProvider.calls != 0 { 98 + t.Fatalf("expected kroger provider to be selected, got kroger=%d wholefoods=%d", krogerBackend.calls, wholeFoodsProvider.calls) 100 99 } 101 100 102 101 if _, err := provider.FetchStaples(t.Context(), "wholefoods_10216"); err != nil { 103 102 t.Fatalf("FetchStaples whole foods returned error: %v", err) 104 103 } 105 - if krogerProvider.calls != 1 || wholeFoodsProvider.calls != 1 { 106 - t.Fatalf("expected whole foods provider to be selected once, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 104 + if krogerBackend.calls != 1 || wholeFoodsProvider.calls != 1 { 105 + t.Fatalf("expected whole foods provider to be selected once, got kroger=%d wholefoods=%d", krogerBackend.calls, wholeFoodsProvider.calls) 107 106 } 108 107 } 109 108 ··· 125 124 } 126 125 127 126 func TestRoutingStaplesProvider_GetIngredients_SelectsProviderByLocationID(t *testing.T) { 128 - krogerProvider := &stubStaplesProvider{ 127 + krogerBackend := &stubStaplesProvider{ 129 128 ids: map[string]bool{"70100023": true}, 130 - ingredients: []kroger.Ingredient{{Description: loPtr("Pinot Noir")}}, 129 + ingredients: []ai.InputIngredient{{ProductID: "1", Description: "Pinot Noir"}}, 131 130 } 132 131 wholeFoodsProvider := &stubStaplesProvider{ 133 132 ids: map[string]bool{"wholefoods_10216": true}, 134 - ingredients: []kroger.Ingredient{{Description: loPtr("Whole Foods Pinot Noir")}}, 133 + ingredients: []ai.InputIngredient{{ProductID: "2", Description: "Whole Foods Pinot Noir"}}, 135 134 } 136 135 provider := routingStaplesProvider{ 137 - backends: []backendStaplesProvider{krogerProvider, wholeFoodsProvider}, 136 + backends: []backendStaplesProvider{krogerBackend, wholeFoodsProvider}, 138 137 } 139 138 140 139 got, err := provider.GetIngredients(t.Context(), "wholefoods_10216", "pinot noir", 0) 141 140 if err != nil { 142 141 t.Fatalf("GetIngredients returned error: %v", err) 143 142 } 144 - if len(got) != 1 || got[0].Description == nil || *got[0].Description != "Whole Foods Pinot Noir" { 143 + if len(got) != 1 || got[0].Description != "Whole Foods Pinot Noir" { 145 144 t.Fatalf("unexpected ingredients: %+v", got) 146 145 } 147 - if krogerProvider.calls != 0 || wholeFoodsProvider.calls != 1 { 148 - t.Fatalf("expected whole foods provider to be selected, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 149 - } 150 - } 151 - 152 - func TestInputIngredientFromKrogerIngredientMapsFields(t *testing.T) { 153 - regular := float32(4.99) 154 - sale := float32(3.49) 155 - categories := []string{"Produce", "Fresh Fruit"} 156 - ingredient, err := inputIngredientFromKrogerIngredient(kroger.Ingredient{ 157 - ProductId: loPtr(" apple-1 "), 158 - AisleNumber: loPtr(" 12 "), 159 - Brand: loPtr(" Orchard Co "), 160 - Description: loPtr(" Honeycrisp Apple "), 161 - Size: loPtr(" 3 lb "), 162 - PriceRegular: &regular, 163 - PriceSale: &sale, 164 - Categories: &categories, 165 - }) 166 - if err != nil { 167 - t.Fatalf("inputIngredientFromKrogerIngredient returned error: %v", err) 168 - } 169 - 170 - if ingredient.ProductID != "apple-1" { 171 - t.Fatalf("unexpected product id: %+v", ingredient) 172 - } 173 - if ingredient.AisleNumber != "12" || ingredient.Brand != "Orchard Co" || ingredient.Description != "Honeycrisp Apple" || ingredient.Size != "3 lb" { 174 - t.Fatalf("unexpected normalized ingredient: %+v", ingredient) 175 - } 176 - if ingredient.PriceRegular == nil || *ingredient.PriceRegular != regular { 177 - t.Fatalf("unexpected regular price: %+v", ingredient.PriceRegular) 178 - } 179 - if ingredient.PriceSale == nil || *ingredient.PriceSale != sale { 180 - t.Fatalf("unexpected sale price: %+v", ingredient.PriceSale) 181 - } 182 - if !slices.Equal(ingredient.Categories, categories) { 183 - t.Fatalf("unexpected categories: got %v want %v", ingredient.Categories, categories) 184 - } 185 - } 186 - 187 - func TestInputIngredientFromKrogerIngredientRejectsBlankProductID(t *testing.T) { 188 - _, err := inputIngredientFromKrogerIngredient(kroger.Ingredient{Description: loPtr("Asparagus")}) 189 - if err == nil { 190 - t.Fatal("expected blank product id error") 191 - } 192 - if !strings.Contains(err.Error(), "product_id is required") { 193 - t.Fatalf("unexpected error: %v", err) 146 + if krogerBackend.calls != 0 || wholeFoodsProvider.calls != 1 { 147 + t.Fatalf("expected whole foods provider to be selected, got kroger=%d wholefoods=%d", krogerBackend.calls, wholeFoodsProvider.calls) 194 148 } 195 149 } 196 150 ··· 208 162 cacheStore := cache.NewFileCache(t.TempDir()) 209 163 provider := &stubStaplesProvider{ 210 164 ids: map[string]bool{"wholefoods_10216": true}, 211 - ingredients: []kroger.Ingredient{ 212 - {ProductId: loPtr("apple-1"), Description: loPtr("Honeycrisp Apple")}, 213 - {ProductId: loPtr("apple-1"), Description: loPtr("Honeycrisp Apple")}, 214 - {ProductId: loPtr("spinach-1"), Description: loPtr("Baby Spinach")}, 165 + ingredients: []ai.InputIngredient{ 166 + {ProductID: "apple-1", Description: "Honeycrisp Apple"}, 167 + {ProductID: "apple-1", Description: "Honeycrisp Apple"}, 168 + {ProductID: "spinach-1", Description: "Baby Spinach"}, 215 169 }, 216 170 } 217 171 s := &cachedStaplesService{ 218 172 cache: IO(cacheStore), 219 - provider: convertingProvider{kprovider: provider}, 173 + provider: provider, 220 174 grader: &stubIngredientGrader{}, 221 175 } 222 176 ··· 232 186 if provider.calls != 1 { 233 187 t.Fatalf("expected provider to be called once, got %d", provider.calls) 234 188 } 235 - if len(got) != 2 { 236 - t.Fatalf("expected deduped results, got %d", len(got)) 237 - } 238 189 if got[0].ProductID == "" { 239 190 t.Fatalf("expected input ingredient product id, got %+v", got) 240 191 } 241 192 242 - cached, err := IO(cacheStore).IngredientsFromCache(t.Context(), params.LocationHash()) 243 - if err != nil { 244 - t.Fatalf("IngredientsFromCache returned error: %v", err) 245 - } 246 - if len(cached) != 2 { 247 - t.Fatalf("expected cached deduped results, got %d", len(cached)) 248 - } 249 - 250 193 gotAgain, err := s.FetchStaples(t.Context(), params) 251 194 if err != nil { 252 195 t.Fatalf("FetchStaples returned error on cached call: %v", err) ··· 254 197 if provider.calls != 1 { 255 198 t.Fatalf("expected cached call to skip provider, got %d calls", provider.calls) 256 199 } 257 - if len(gotAgain) != 2 { 200 + if len(gotAgain) != 3 { 258 201 t.Fatalf("expected cached results, got %d", len(gotAgain)) 259 202 } 260 203 }
+3 -3
internal/walmart/staples.go
··· 5 5 "fmt" 6 6 "strings" 7 7 8 - "careme/internal/kroger" 8 + "careme/internal/ai" 9 9 ) 10 10 11 11 const UnsupportedStaplesSignature = "unsupported-staples-v1" ··· 48 48 return UnsupportedStaplesSignature 49 49 } 50 50 51 - func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]kroger.Ingredient, error) { 51 + func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]ai.InputIngredient, error) { 52 52 return nil, fmt.Errorf("staples provider does not support location %q", locationID) 53 53 } 54 54 55 - func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 55 + func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 56 56 return nil, fmt.Errorf("ingredient search is not supported for location %q and term %q", locationID, searchTerm) 57 57 }
+14 -27
internal/wholefoods/staples.go
··· 9 9 "log/slog" 10 10 "strings" 11 11 12 - "careme/internal/kroger" 12 + "careme/internal/ai" 13 13 "careme/internal/parallelism" 14 14 15 15 "github.com/samber/lo" ··· 45 45 return ok 46 46 } 47 47 48 - func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 48 + func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 49 49 if p.client == nil { 50 50 return nil, fmt.Errorf("whole foods client is required") 51 51 } ··· 56 56 return nil, fmt.Errorf("invalid whole foods location id %q", locationID) 57 57 } 58 58 59 - return parallelism.Flatten(defaultStaples(), func(category string) ([]kroger.Ingredient, error) { 59 + return parallelism.Flatten(defaultStaples(), func(category string) ([]ai.InputIngredient, error) { 60 60 resp, err := p.client.Category(ctx, category, storeID) 61 61 if err != nil { 62 62 slog.WarnContext(ctx, "Failed to fetch category", "category", category, "location", locationID, "error", err) 63 63 return nil, err 64 64 } 65 65 66 - ingredients := lo.Map(resp, func(product product, _ int) kroger.Ingredient { 67 - return productToIngredient(product) 68 - }) 66 + ingredients := lo.Map(resp, productToIngredient) 69 67 slog.InfoContext(ctx, "Found ingredients for category", "count", len(ingredients), "category", category, "location", locationID) 70 68 71 69 return ingredients, nil 72 70 }) 73 71 } 74 72 75 - func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 73 + func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, _ int) ([]ai.InputIngredient, error) { 76 74 if p.client == nil { 77 75 return nil, fmt.Errorf("whole foods client is required") 78 76 } ··· 82 80 return nil, fmt.Errorf("invalid whole foods location id %q", locationID) 83 81 } 84 82 83 + // no pagination so no skip 85 84 resp, err := p.client.Category(ctx, searchTerm, storeID) 86 85 if err != nil { 87 86 return nil, err 88 87 } 89 88 90 - ingredients := lo.Map(resp, func(product product, _ int) kroger.Ingredient { 91 - return productToIngredient(product) 92 - }) 93 - if skip >= len(ingredients) { 94 - return []kroger.Ingredient{}, nil 95 - } 96 - return ingredients[skip:], nil 89 + return lo.Map(resp, productToIngredient), nil 97 90 } 98 91 99 92 func defaultStaples() []string { ··· 114 107 // red-wine, white-wine, sparkling 115 108 } 116 109 117 - func productToIngredient(product product) kroger.Ingredient { 110 + func productToIngredient(product product, _ int) ai.InputIngredient { 118 111 var regularPrice *float32 119 112 if product.RegularPrice > 0 { 120 113 price := float32(product.RegularPrice) ··· 138 131 // categories := compactStrings(localCategory(product)) 139 132 140 133 hasher := fnv.New32a() 134 + // dupes for different units of measure? 141 135 _ = lo.Must(hasher.Write([]byte(product.Slug))) 142 136 productId := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) 143 - return kroger.Ingredient{ 144 - ProductId: stringPtr(productId), 145 - Brand: stringPtr(strings.TrimSpace(product.Brand)), 146 - Description: stringPtr(strings.TrimSpace(product.Name)), 137 + return ai.NormalizeInputIngredient(ai.InputIngredient{ 138 + ProductID: productId, 139 + Brand: strings.TrimSpace(product.Brand), 140 + Description: strings.TrimSpace(product.Name), 147 141 // Size: size, 148 142 PriceRegular: regularPrice, 149 143 PriceSale: salePrice, 150 144 // / Categories: slicePtr(categories), 151 - } 152 - } 153 - 154 - func stringPtr(value string) *string { 155 - if value == "" { 156 - return nil 157 - } 158 - return &value 145 + }) 159 146 }
+8 -8
internal/wholefoods/staples_test.go
··· 79 79 } 80 80 81 81 ingredient := got[0] 82 - if ingredient.Description == nil || *ingredient.Description != "Organic Asparagus" { 82 + if ingredient.Description != "Organic Asparagus" { 83 83 t.Fatalf("unexpected description: %+v", ingredient.Description) 84 84 } 85 - if ingredient.Brand == nil || *ingredient.Brand != "Whole Foods Market" { 85 + if ingredient.Brand != "Whole Foods Market" { 86 86 t.Fatalf("unexpected brand: %+v", ingredient.Brand) 87 87 } 88 - if ingredient.ProductId == nil || *ingredient.ProductId != "odQxPA" { 89 - t.Fatalf("unexpected product id: %+v", *ingredient.ProductId) 88 + if ingredient.ProductID != "odQxPA" { 89 + t.Fatalf("unexpected product id: %+v", ingredient.ProductID) 90 90 } 91 91 if ingredient.PriceRegular == nil || *ingredient.PriceRegular != float32(5.99) { 92 92 t.Fatalf("unexpected regular price: %+v", ingredient.PriceRegular) ··· 122 122 } 123 123 provider := NewStaplesProvider(client) 124 124 125 - got, err := provider.GetIngredients(t.Context(), "wholefoods_10216", "pinot noir", 1) 125 + got, err := provider.GetIngredients(t.Context(), "wholefoods_10216", "pinot noir", 0) 126 126 if err != nil { 127 127 t.Fatalf("GetIngredients returned error: %v", err) 128 128 } 129 - if len(got) != 1 { 130 - t.Fatalf("expected 1 ingredient after skip, got %d", len(got)) 129 + if len(got) != 2 { 130 + t.Fatalf("expected 2 ingredients, got %d", len(got)) 131 131 } 132 - if got[0].Description == nil || *got[0].Description != "Rose" { 132 + if got[0].Description != "Pinot Noir" { 133 133 t.Fatalf("unexpected ingredient description: %+v", got[0].Description) 134 134 } 135 135 if got := client.callCount(); got != 1 {