@@ -56,6 +56,12 @@ type ProductGroup struct {
Selected bool `json:"selected"`
}
+// AddShoppingListResult is the result of adding ingredients to a shopping list.
+type AddShoppingListResult struct {
+ AddedCount int
+ Ingredients []string
+}
+
// RecipeCard is a saved recipe summary returned by Cooked list and search endpoints.
type RecipeCard struct {
ID string `json:"id"`
@@ -127,6 +133,30 @@ func (c *Client) ClearShoppingList(ctx context.Context) error {
return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list", nil, nil)
}
+// AddShoppingListIngredients logs in when needed and adds ingredients to the shopping list.
+func (c *Client) AddShoppingListIngredients(
+ ctx context.Context,
+ ingredients, recipeID string,
+) (AddShoppingListResult, error) {
+ body, err := json.Marshal(addShoppingListRequest{Ingredients: ingredients, RecipeID: recipeID})
+ if err != nil {
+ return AddShoppingListResult{}, fmt.Errorf("encode Cooked shopping-list add request: %w", err)
+ }
+
+ var response addShoppingListResponse
+ decodeAdd := func(decoder *json.Decoder) error {
+ return decoder.Decode(&response)
+ }
+ if err := c.doAuthenticated(ctx, http.MethodPut, "/api/user/shopping-list", body, decodeAdd); err != nil {
+ return AddShoppingListResult{}, err
+ }
+ if response.AddedCount == nil {
+ return AddShoppingListResult{}, fmt.Errorf("add shopping-list ingredients response missing added count")
+ }
+
+ return AddShoppingListResult{AddedCount: *response.AddedCount, Ingredients: response.Ingredients}, nil
+}
+
// ListRecipes logs in when needed and returns one page of saved recipes.
func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) {
query := url.Values{}
@@ -368,6 +398,16 @@ type shoppingListResponse struct {
Recipes []string `json:"recipes"`
}
+type addShoppingListRequest struct {
+ Ingredients string `json:"ingredients"`
+ RecipeID string `json:"recipe-id,omitempty"`
+}
+
+type addShoppingListResponse struct {
+ AddedCount *int `json:"added-count"`
+ Ingredients []string `json:"ingredients"`
+}
+
type recipeListResponse struct {
Recipes []RecipeCard `json:"recipes"`
}
@@ -189,6 +189,31 @@ func TestClearShoppingListACIDShoppingListClear1ClearsAllItems(t *testing.T) {
}
}
+func TestAddShoppingListIngredientsACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
+ var addCalls int
+ client, closeServer := newTestClient(t, addShoppingListIngredientsTestHandler(t, &addCalls))
+ defer closeServer()
+
+ result, err := client.AddShoppingListIngredients(
+ context.Background(),
+ "200g pasta\n1 cup tomato sauce",
+ "recipe-1",
+ )
+ if err != nil {
+ t.Fatalf("AddShoppingListIngredients() error = %v", err)
+ }
+
+ if addCalls != 1 {
+ t.Fatalf("add calls = %d, want 1", addCalls)
+ }
+ if result.AddedCount != 2 {
+ t.Fatalf("added count = %d, want 2", result.AddedCount)
+ }
+ if len(result.Ingredients) != 2 || result.Ingredients[0] != "200g pasta" {
+ t.Fatalf("added ingredients = %#v, want two added ingredients", result.Ingredients)
+ }
+}
+
func TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
client := &Client{authenticatedUsername: "user/name"}
@@ -422,6 +447,56 @@ func clearShoppingListTestHandler(t *testing.T, clearCalls *int) http.HandlerFun
}
}
+func addShoppingListIngredientsTestHandler(t *testing.T, addCalls *int) http.HandlerFunc {
+ t.Helper()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/api/public/login":
+ writeLoginResponse(t, w)
+ case "/api/user/shopping-list":
+ requireMethod(t, r, http.MethodPut, "add shopping-list ingredients")
+ requireSessionCookie(t, r)
+ requireAddShoppingListIngredientsRequest(t, r)
+ (*addCalls)++
+ writeAddShoppingListIngredientsResponse(t, w)
+ default:
+ t.Fatalf("unexpected path %s", r.URL.EscapedPath())
+ }
+ }
+}
+
+func requireAddShoppingListIngredientsRequest(t *testing.T, r *http.Request) {
+ t.Helper()
+
+ var request struct {
+ Ingredients string `json:"ingredients"`
+ RecipeID string `json:"recipe-id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ t.Fatalf("decode add shopping-list ingredients request: %v", err)
+ }
+ if request.Ingredients != "200g pasta\n1 cup tomato sauce" {
+ t.Fatalf("ingredients = %q, want newline-separated ingredients", request.Ingredients)
+ }
+ if request.RecipeID != "recipe-1" {
+ t.Fatalf("recipe ID = %q, want recipe-1", request.RecipeID)
+ }
+}
+
+func writeAddShoppingListIngredientsResponse(t *testing.T, w http.ResponseWriter) {
+ t.Helper()
+
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write([]byte(`{
+ "success": true,
+ "added-count": 2,
+ "ingredients": ["200g pasta", "1 cup tomato sauce"]
+ }`)); err != nil {
+ t.Fatalf("write add shopping-list ingredients response: %v", err)
+ }
+}
+
func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
t.Helper()