@@ -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 {
@@ -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)
+ }
+ }
+}
@@ -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"},
})
}