diff --git a/internal/cooked/client.go b/internal/cooked/client.go index e2fed2cd0f0d86fa60c6fe3a1fef444655af1c66..24ed5025b2d5cf9756fdc09f7bfbb86b587f7d39 100644 --- a/internal/cooked/client.go +++ b/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 { diff --git a/internal/cooked/client_shopping_list_validation_test.go b/internal/cooked/client_shopping_list_validation_test.go new file mode 100644 index 0000000000000000000000000000000000000000..619dad26050c2c382bf0b843ed90c44e72266e72 --- /dev/null +++ b/internal/cooked/client_shopping_list_validation_test.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) + } + } +} diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index 845c7e1eac05fc8fd12e8b295dd4fb5eb2ed9a0b..0c2dbb79f79c99a15b5972cd0e02a2d74106f4e1 100644 --- a/internal/cooked/client_test.go +++ b/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"}, }) }