shopping-list: add ingredients client

Amolith created

Change summary

internal/cooked/client.go      | 40 +++++++++++++++++++
internal/cooked/client_test.go | 75 ++++++++++++++++++++++++++++++++++++
2 files changed, 115 insertions(+)

Detailed changes

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"`
 }

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()