shopping-list: update product group client

Amolith created

Change summary

internal/cooked/client.go      | 31 ++++++++++++++++
internal/cooked/client_test.go | 66 ++++++++++++++++++++++++++++++++++++
2 files changed, 97 insertions(+)

Detailed changes

internal/cooked/client.go 🔗

@@ -62,6 +62,14 @@ type AddShoppingListResult struct {
 	Ingredients []string
 }
 
+// ShoppingListProductGroupUpdate is a full shopping-list product-group update.
+type ShoppingListProductGroupUpdate struct {
+	Name     string
+	Quantity string
+	AisleID  string
+	Selected bool
+}
+
 // RecipeCard is a saved recipe summary returned by Cooked list and search endpoints.
 type RecipeCard struct {
 	ID           string `json:"id"`
@@ -183,6 +191,22 @@ func (c *Client) ReplaceShoppingListSelection(ctx context.Context, ids []string)
 	)
 }
 
+// UpdateShoppingListProductGroup logs in when needed and updates a shopping-list product group.
+func (c *Client) UpdateShoppingListProductGroup(
+	ctx context.Context,
+	productGroupID string,
+	update ShoppingListProductGroupUpdate,
+) error {
+	body, err := json.Marshal(updateShoppingListProductGroupRequest(update))
+	if err != nil {
+		return fmt.Errorf("encode Cooked shopping-list product-group update request: %w", err)
+	}
+
+	path := "/api/user/{username}/shopping-list/product-groups/" + url.PathEscape(productGroupID)
+
+	return c.doAuthenticated(ctx, http.MethodPut, path, body, 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{}
@@ -442,6 +466,13 @@ type replaceShoppingListSelectionRequest struct {
 	Selected []string `json:"selected"`
 }
 
+type updateShoppingListProductGroupRequest struct {
+	Name     string `json:"name"`
+	Quantity string `json:"quantity"`
+	AisleID  string `json:"aisle-id"`
+	Selected bool   `json:"selected"`
+}
+
 type recipeListResponse struct {
 	Recipes []RecipeCard `json:"recipes"`
 }

internal/cooked/client_test.go 🔗

@@ -244,6 +244,30 @@ func TestReplaceShoppingListSelectionACIDShoppingListSelection1And8ReplacesSelec
 	}
 }
 
+func TestUpdateShoppingListProductGroupACIDShoppingListUpdateItem1UpdatesProductGroup(t *testing.T) {
+	var updateCalls int
+	client, closeServer := newTestClient(t, updateShoppingListProductGroupTestHandler(t, &updateCalls))
+	defer closeServer()
+
+	err := client.UpdateShoppingListProductGroup(
+		context.Background(),
+		"product/with space",
+		ShoppingListProductGroupUpdate{
+			Name:     "Pasta",
+			Quantity: "250g",
+			AisleID:  "pantry",
+			Selected: false,
+		},
+	)
+	if err != nil {
+		t.Fatalf("UpdateShoppingListProductGroup() error = %v", err)
+	}
+
+	if updateCalls != 1 {
+		t.Fatalf("update calls = %d, want 1", updateCalls)
+	}
+}
+
 func TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
 	client := &Client{authenticatedUsername: "user/name"}
 
@@ -565,6 +589,25 @@ func replaceShoppingListSelectionTestHandler(t *testing.T, replaceCalls *int) ht
 	}
 }
 
+func updateShoppingListProductGroupTestHandler(t *testing.T, updateCalls *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/returned-user/shopping-list/product-groups/product%2Fwith%20space":
+			requireMethod(t, r, http.MethodPut, "update shopping-list product group")
+			requireSessionCookie(t, r)
+			requireUpdateShoppingListProductGroupRequest(t, r)
+			(*updateCalls)++
+			w.WriteHeader(http.StatusNoContent)
+		default:
+			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
+		}
+	}
+}
+
 func requireRemoveShoppingListProductGroupsRequest(t *testing.T, r *http.Request) {
 	t.Helper()
 
@@ -593,6 +636,29 @@ func requireReplaceShoppingListSelectionRequest(t *testing.T, r *http.Request) {
 	}
 }
 
+func requireUpdateShoppingListProductGroupRequest(t *testing.T, r *http.Request) {
+	t.Helper()
+
+	var request struct {
+		Name     string `json:"name"`
+		Quantity string `json:"quantity"`
+		AisleID  string `json:"aisle-id"`
+		Selected *bool  `json:"selected"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+		t.Fatalf("decode update shopping-list product group request: %v", err)
+	}
+	if request.Name != "Pasta" || request.Quantity != "250g" || request.AisleID != "pantry" {
+		t.Fatalf("update request = %#v, want configured name, quantity, and aisle", request)
+	}
+	if request.Selected == nil {
+		t.Fatal("update request selected missing, want explicit false")
+	}
+	if *request.Selected {
+		t.Fatal("update request selected = true, want false")
+	}
+}
+
 func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
 	t.Helper()