cooked: validate shopping-list responses

Amolith created

Change summary

internal/cooked/client.go                               |  70 ++++++
internal/cooked/client_shopping_list_validation_test.go | 111 +++++++++++
internal/cooked/client_test.go                          |  26 +-
3 files changed, 191 insertions(+), 16 deletions(-)

Detailed changes

internal/cooked/client.go 🔗

@@ -162,8 +162,14 @@ func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
 		return ShoppingList{}, err
 	}
 
-	response.ShoppingList.Recipes = response.Recipes
-	return response.ShoppingList, nil
+	if err := validateShoppingListResponse(response); err != nil {
+		return ShoppingList{}, err
+	}
+
+	shoppingList := *response.ShoppingList
+	shoppingList.Recipes = response.Recipes
+
+	return shoppingList, nil
 }
 
 // ClearShoppingList logs in when needed and clears the authenticated user's shopping list.
@@ -436,6 +442,62 @@ func validateRecipeContent(content RecipeContent) error {
 	return nil
 }
 
+func validateShoppingListResponse(response shoppingListResponse) error {
+	if response.ShoppingList == nil {
+		return fmt.Errorf("cooked shopping-list response missing shopping-list")
+	}
+	if response.ShoppingList.Aisles == nil {
+		return fmt.Errorf("cooked shopping-list response missing aisles")
+	}
+
+	for aisleIndex, aisle := range response.ShoppingList.Aisles {
+		if err := validateShoppingListAisle(aisleIndex, aisle); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func validateShoppingListAisle(aisleIndex int, aisle Aisle) error {
+	if strings.TrimSpace(aisle.ID) == "" {
+		return fmt.Errorf("cooked shopping-list response aisle %d missing id", aisleIndex)
+	}
+	if strings.TrimSpace(aisle.Name) == "" {
+		return fmt.Errorf("cooked shopping-list response aisle %d missing name", aisleIndex)
+	}
+	if aisle.ProductGroups == nil {
+		return fmt.Errorf("cooked shopping-list response aisle %d missing product groups", aisleIndex)
+	}
+
+	for productIndex, product := range aisle.ProductGroups {
+		if err := validateShoppingListProductGroup(aisleIndex, productIndex, product); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func validateShoppingListProductGroup(aisleIndex, productIndex int, product ProductGroup) error {
+	if strings.TrimSpace(product.ID) == "" {
+		return fmt.Errorf(
+			"cooked shopping-list response aisle %d product group %d missing id",
+			aisleIndex,
+			productIndex,
+		)
+	}
+	if strings.TrimSpace(product.Name) == "" {
+		return fmt.Errorf(
+			"cooked shopping-list response aisle %d product group %d missing name",
+			aisleIndex,
+			productIndex,
+		)
+	}
+
+	return nil
+}
+
 func jsonNumberToInt(number json.Number) (int, error) {
 	text := number.String()
 	if text == "" {
@@ -589,8 +651,8 @@ func (c *Client) userPath(path string) string {
 type responseDecoder func(*json.Decoder) error
 
 type shoppingListResponse struct {
-	ShoppingList ShoppingList `json:"shopping-list"`
-	Recipes      []string     `json:"recipes"`
+	ShoppingList *ShoppingList `json:"shopping-list"`
+	Recipes      []string      `json:"recipes"`
 }
 
 type addShoppingListRequest struct {

internal/cooked/client_shopping_list_validation_test.go 🔗

@@ -0,0 +1,111 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package cooked
+
+import (
+	"context"
+	"net/http"
+	"strings"
+	"testing"
+)
+
+// api-client.RESPONSES.1 shopping-list.READ.1
+func TestReadShoppingListACIDAPIClientResponses1AcceptsEmptyShoppingList(t *testing.T) {
+	var readCalls int
+	client, closeServer := newTestClient(t, rawShoppingListTestHandler(
+		t,
+		&readCalls,
+		`{"shopping-list":{"aisles":[]},"recipes":[]}`,
+	))
+	defer closeServer()
+
+	shoppingList, err := client.ReadShoppingList(context.Background())
+	if err != nil {
+		t.Fatalf("ReadShoppingList() error = %v", err)
+	}
+	if readCalls != 1 {
+		t.Fatalf("read calls = %d, want 1", readCalls)
+	}
+	if len(shoppingList.Aisles) != 0 {
+		t.Fatalf("aisles length = %d, want 0", len(shoppingList.Aisles))
+	}
+}
+
+// api-client.RESPONSES.1 api-client.RESPONSES.3 shopping-list.READ.1 shopping-list.READ.2 shopping-list.READ.3 shopping-list.READ.4 shopping-list.READ.5 shopping-list.READ.8
+func TestReadShoppingListACIDAPIClientResponses1RejectsInvalidShoppingListShape(t *testing.T) {
+	tests := []struct {
+		name    string
+		body    string
+		wantErr string
+	}{
+		{name: "missing shopping-list", body: `{"recipes":[]}`, wantErr: "missing shopping-list"},
+		{name: "missing aisles", body: `{"shopping-list":{},"recipes":[]}`, wantErr: "missing aisles"},
+		{
+			name:    "blank aisle id",
+			body:    `{"shopping-list":{"aisles":[{"aisle-id":"   ","aisle-name":"Pantry","product-groups":[]}]},"recipes":[]}`,
+			wantErr: "aisle 0 missing id",
+		},
+		{
+			name:    "blank aisle name",
+			body:    `{"shopping-list":{"aisles":[{"aisle-id":"pantry","aisle-name":"   ","product-groups":[]}]},"recipes":[]}`,
+			wantErr: "aisle 0 missing name",
+		},
+		{
+			name:    "missing product groups",
+			body:    `{"shopping-list":{"aisles":[{"aisle-id":"pantry","aisle-name":"Pantry"}]},"recipes":[]}`,
+			wantErr: "aisle 0 missing product groups",
+		},
+		{
+			name:    "blank product group id",
+			body:    `{"shopping-list":{"aisles":[{"aisle-id":"pantry","aisle-name":"Pantry","product-groups":[{"id":"   ","name":"Pasta","quantity":"200g","selected":false}]}]},"recipes":[]}`,
+			wantErr: "aisle 0 product group 0 missing id",
+		},
+		{
+			name:    "blank product group name",
+			body:    `{"shopping-list":{"aisles":[{"aisle-id":"pantry","aisle-name":"Pantry","product-groups":[{"id":"pasta","name":"   ","quantity":"200g","selected":false}]}]},"recipes":[]}`,
+			wantErr: "aisle 0 product group 0 missing name",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var readCalls int
+			client, closeServer := newTestClient(t, rawShoppingListTestHandler(t, &readCalls, tt.body))
+			defer closeServer()
+
+			_, err := client.ReadShoppingList(context.Background())
+			if err == nil {
+				t.Fatal("ReadShoppingList() error = nil, want invalid shopping-list shape error")
+			}
+			if !strings.Contains(err.Error(), tt.wantErr) {
+				t.Fatalf("ReadShoppingList() error = %v, want %q", err, tt.wantErr)
+			}
+			if readCalls != 1 {
+				t.Fatalf("read calls = %d, want 1", readCalls)
+			}
+		})
+	}
+}
+
+func rawShoppingListTestHandler(t *testing.T, readCalls *int, body string) http.HandlerFunc {
+	t.Helper()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/api/public/login":
+			writeLoginResponse(t, w)
+		case "/api/user/returned-user/shopping-list":
+			requireMethod(t, r, http.MethodGet, "read shopping list")
+			requireSessionCookie(t, r)
+			(*readCalls)++
+			w.Header().Set("Content-Type", "application/json")
+			if _, err := w.Write([]byte(body)); err != nil {
+				t.Fatalf("write raw shopping-list response: %v", err)
+			}
+		default:
+			t.Fatalf("unexpected path %s", r.URL.Path)
+		}
+	}
+}

internal/cooked/client_test.go 🔗

@@ -916,19 +916,21 @@ func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
 func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
 	t.Helper()
 
-	writeJSON(t, w, shoppingListResponse{
-		ShoppingList: ShoppingList{
-			Aisles: []Aisle{{
-				ID:   "pantry",
-				Name: "Pantry",
-				ProductGroups: []ProductGroup{{
-					ID:       "pasta",
-					Name:     "Pasta",
-					Quantity: "200g",
-				}},
+	shoppingList := ShoppingList{
+		Aisles: []Aisle{{
+			ID:   "pantry",
+			Name: "Pantry",
+			ProductGroups: []ProductGroup{{
+				ID:       "pasta",
+				Name:     "Pasta",
+				Quantity: "200g",
 			}},
-		},
-		Recipes: []string{"recipe-id"},
+		}},
+	}
+
+	writeJSON(t, w, shoppingListResponse{
+		ShoppingList: &shoppingList,
+		Recipes:      []string{"recipe-id"},
 	})
 }