cooked: validate recipe list responses

Amolith created

Change summary

internal/cooked/client.go                        | 20 ++++
internal/cooked/client_recipe_validation_test.go | 86 ++++++++++++++++++
2 files changed, 106 insertions(+)

Detailed changes

internal/cooked/client.go 🔗

@@ -363,10 +363,30 @@ func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, err
 	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil {
 		return nil, err
 	}
+	if err := validateRecipeCards(response.Recipes); err != nil {
+		return nil, err
+	}
 
 	return response.Recipes, nil
 }
 
+func validateRecipeCards(recipes []RecipeCard) error {
+	if recipes == nil {
+		return fmt.Errorf("cooked recipe list response missing recipes")
+	}
+
+	for index, recipe := range recipes {
+		if strings.TrimSpace(recipe.ID) == "" {
+			return fmt.Errorf("cooked recipe list response recipe %d missing id", index)
+		}
+		if strings.TrimSpace(recipe.Title) == "" {
+			return fmt.Errorf("cooked recipe list response recipe %d missing title", index)
+		}
+	}
+
+	return nil
+}
+
 func (c *Client) doAuthenticated(
 	ctx context.Context,
 	method, path string,

internal/cooked/client_recipe_validation_test.go 🔗

@@ -0,0 +1,86 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package cooked
+
+import (
+	"context"
+	"net/http"
+	"testing"
+)
+
+func TestListRecipesACIDAPIClientResponses1AcceptsEmptyRecipesList(t *testing.T) {
+	client, closeServer := newTestClient(t, rawRecipeListTestHandler(
+		t,
+		"/api/user/returned-user/recipes",
+		"{\"recipes\":[]}",
+	))
+	defer closeServer()
+
+	recipes, err := client.ListRecipes(context.Background(), 1, 10)
+	if err != nil {
+		t.Fatalf("ListRecipes() error = %v", err)
+	}
+	if len(recipes) != 0 {
+		t.Fatalf("recipes length = %d, want 0", len(recipes))
+	}
+}
+
+func TestListRecipesACIDAPIClientResponses1RejectsMissingRecipesField(t *testing.T) {
+	client, closeServer := newTestClient(t, rawRecipeListTestHandler(
+		t,
+		"/api/user/returned-user/recipes",
+		`{}`,
+	))
+	defer closeServer()
+
+	_, err := client.ListRecipes(context.Background(), 1, 10)
+	if err == nil {
+		t.Fatal("ListRecipes() error = nil, want missing recipes error")
+	}
+}
+
+func TestSearchRecipesACIDAPIClientResponses1RejectsRecipeCardsWithoutIDOrTitle(t *testing.T) {
+	tests := []struct {
+		name string
+		body string
+	}{
+		{name: "missing id", body: "{\"recipes\":[{\"title\":\"Pasta\"}]}"},
+		{name: "blank title", body: "{\"recipes\":[{\"id\":\"recipe-1\",\"title\":\"   \"}]}"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, closeServer := newTestClient(t, rawRecipeListTestHandler(
+				t,
+				"/api/user/returned-user/recipes/search",
+				tt.body,
+			))
+			defer closeServer()
+
+			_, err := client.SearchRecipes(context.Background(), "pasta", 1)
+			if err == nil {
+				t.Fatal("SearchRecipes() error = nil, want invalid recipe card error")
+			}
+		})
+	}
+}
+
+func rawRecipeListTestHandler(t *testing.T, path, 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 path:
+			w.Header().Set("Content-Type", "application/json")
+			if _, err := w.Write([]byte(body)); err != nil {
+				t.Fatalf("write raw recipe list response: %v", err)
+			}
+		default:
+			t.Fatalf("unexpected path %s", r.URL.Path)
+		}
+	}
+}