diff --git a/internal/cooked/client.go b/internal/cooked/client.go index 8a4451d801ac719960c72300cf4f8d7b0f626131..741b995553695d20e23f11a3fa7c880871e4b3aa 100644 --- a/internal/cooked/client.go +++ b/internal/cooked/client.go @@ -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"` } diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index c69b39a4592cf90e7f4036dc1bc38f1f88eaf70d..719ba50a6b9edd803a2b8d5f388210859800f1e2 100644 --- a/internal/cooked/client_test.go +++ b/internal/cooked/client_test.go @@ -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()