cooked: reject empty shopping-list adds

Amolith created

Change summary

features/cooked-mcp/shopping-list.feature.yaml |  1 
internal/cooked/client.go                      |  5 +
internal/cooked/client_test.go                 | 66 ++++++++++++++++---
3 files changed, 59 insertions(+), 13 deletions(-)

Detailed changes

features/cooked-mcp/shopping-list.feature.yaml 🔗

@@ -21,6 +21,7 @@ components:
       3: Adding ingredients may associate the ingredients with a saved recipe ID or import draft ID.
       4: Adding ingredients reports how many ingredients Cooked added.
       5: Adding ingredients accepts simple read-output lines that place a numeric quantity after an em dash separator.
+      6: Adding nonblank ingredients fails when Cooked reports that zero ingredients were added.
   UPDATE_ITEM:
     requirements:
       1: The change_shopping_list tool updates a shopping-list product group.

internal/cooked/client.go 🔗

@@ -191,6 +191,11 @@ func (c *Client) AddShoppingListIngredients(
 	if response.AddedCount == nil {
 		return AddShoppingListResult{}, fmt.Errorf("add shopping-list ingredients response missing added count")
 	}
+	if strings.TrimSpace(ingredients) != "" && *response.AddedCount == 0 {
+		return AddShoppingListResult{}, fmt.Errorf(
+			"add shopping-list ingredients response returned added-count 0 for nonblank ingredients",
+		)
+	}
 
 	return AddShoppingListResult{AddedCount: *response.AddedCount, Ingredients: response.Ingredients}, nil
 }

internal/cooked/client_test.go 🔗

@@ -10,6 +10,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"strings"
 	"testing"
 )
 
@@ -279,6 +280,29 @@ func TestAddShoppingListIngredientsACIDShoppingListAdd1To4AddsIngredients(t *tes
 	}
 }
 
+func TestAddShoppingListIngredientsACIDShoppingListAdd6RejectsZeroAddedCount(t *testing.T) {
+	var addCalls int
+	client, closeServer := newTestClient(t, addShoppingListIngredientsResponseTestHandler(
+		t,
+		&addCalls,
+		"200g pasta",
+		"",
+		`{"success":true,"added-count":0,"ingredients":[]}`,
+	))
+	defer closeServer()
+
+	_, err := client.AddShoppingListIngredients(context.Background(), "200g pasta", "")
+	if err == nil {
+		t.Fatal("AddShoppingListIngredients() error = nil, want zero added-count error")
+	}
+	if !strings.Contains(err.Error(), "added-count 0") {
+		t.Fatalf("AddShoppingListIngredients() error = %v, want added-count 0 error", err)
+	}
+	if addCalls != 1 {
+		t.Fatalf("add calls = %d, want 1", addCalls)
+	}
+}
+
 func TestRemoveShoppingListProductGroupsACIDShoppingListRemove1RemovesProductGroups(t *testing.T) {
 	var removeCalls int
 	client, closeServer := newTestClient(t, removeShoppingListProductGroupsTestHandler(t, &removeCalls))
@@ -613,6 +637,22 @@ func clearShoppingListTestHandler(t *testing.T, clearCalls *int) http.HandlerFun
 func addShoppingListIngredientsTestHandler(t *testing.T, addCalls *int) http.HandlerFunc {
 	t.Helper()
 
+	return addShoppingListIngredientsResponseTestHandler(
+		t,
+		addCalls,
+		"200g pasta\n1 cup tomato sauce",
+		"recipe-1",
+		`{"success":true,"added-count":2,"ingredients":["200g pasta","1 cup tomato sauce"]}`,
+	)
+}
+
+func addShoppingListIngredientsResponseTestHandler(
+	t *testing.T,
+	addCalls *int,
+	expectedIngredients, expectedRecipeID, response string,
+) http.HandlerFunc {
+	t.Helper()
+
 	return func(w http.ResponseWriter, r *http.Request) {
 		switch r.URL.EscapedPath() {
 		case "/api/public/login":
@@ -620,16 +660,20 @@ func addShoppingListIngredientsTestHandler(t *testing.T, addCalls *int) http.Han
 		case "/api/user/shopping-list":
 			requireMethod(t, r, http.MethodPut, "add shopping-list ingredients")
 			requireSessionCookie(t, r)
-			requireAddShoppingListIngredientsRequest(t, r)
+			requireAddShoppingListIngredientsRequest(t, r, expectedIngredients, expectedRecipeID)
 			(*addCalls)++
-			writeAddShoppingListIngredientsResponse(t, w)
+			writeAddShoppingListIngredientsResponse(t, w, response)
 		default:
 			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
 		}
 	}
 }
 
-func requireAddShoppingListIngredientsRequest(t *testing.T, r *http.Request) {
+func requireAddShoppingListIngredientsRequest(
+	t *testing.T,
+	r *http.Request,
+	expectedIngredients, expectedRecipeID string,
+) {
 	t.Helper()
 
 	var request struct {
@@ -639,23 +683,19 @@ func requireAddShoppingListIngredientsRequest(t *testing.T, r *http.Request) {
 	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.Ingredients != expectedIngredients {
+		t.Fatalf("ingredients = %q, want %q", request.Ingredients, expectedIngredients)
 	}
-	if request.RecipeID != "recipe-1" {
-		t.Fatalf("recipe ID = %q, want recipe-1", request.RecipeID)
+	if request.RecipeID != expectedRecipeID {
+		t.Fatalf("recipe ID = %q, want %q", request.RecipeID, expectedRecipeID)
 	}
 }
 
-func writeAddShoppingListIngredientsResponse(t *testing.T, w http.ResponseWriter) {
+func writeAddShoppingListIngredientsResponse(t *testing.T, w http.ResponseWriter, response string) {
 	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 {
+	if _, err := w.Write([]byte(response)); err != nil {
 		t.Fatalf("write add shopping-list ingredients response: %v", err)
 	}
 }