cooked: validate recipe content responses

Amolith created

Change summary

internal/cooked/client.go                        | 14 ++++
internal/cooked/client_recipe_validation_test.go | 54 ++++++++++++++++++
2 files changed, 68 insertions(+)

Detailed changes

internal/cooked/client.go 🔗

@@ -284,6 +284,9 @@ func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (Recipe
 	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeContent); err != nil {
 		return RecipeContent{}, err
 	}
+	if err := validateRecipeContent(content); err != nil {
+		return RecipeContent{}, err
+	}
 
 	return content, nil
 }
@@ -422,6 +425,17 @@ func validateRecipeCards(recipes []RecipeCard) error {
 	return nil
 }
 
+func validateRecipeContent(content RecipeContent) error {
+	if strings.TrimSpace(content.Content) == "" {
+		return fmt.Errorf("cooked recipe content response missing content")
+	}
+	if content.Portions < 1 {
+		return fmt.Errorf("cooked recipe content response missing or invalid portions")
+	}
+
+	return nil
+}
+
 func jsonNumberToInt(number json.Number) (int, error) {
 	text := number.String()
 	if text == "" {

internal/cooked/client_recipe_validation_test.go 🔗

@@ -126,6 +126,39 @@ func TestSavePreparedRecipeACIDAPIClientResponses1RejectsMissingOrBlankRecipeID(
 	}
 }
 
+// api-client.RESPONSES.1 recipes.READ.7 recipes.READ.8
+func TestReadRecipeContentACIDAPIClientResponses1RejectsMissingContentOrPortions(t *testing.T) {
+	tests := []struct {
+		name    string
+		body    string
+		wantErr string
+	}{
+		{name: "missing content", body: `{"portions":2}`, wantErr: "missing content"},
+		{name: "blank content", body: `{"content":"   ","portions":2}`, wantErr: "missing content"},
+		{name: "missing portions", body: `{"content":"# Pasta"}`, wantErr: "missing or invalid portions"},
+		{name: "zero portions", body: `{"content":"# Pasta","portions":0}`, wantErr: "missing or invalid portions"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var contentCalls int
+			client, closeServer := newTestClient(t, rawRecipeContentTestHandler(t, &contentCalls, tt.body))
+			defer closeServer()
+
+			_, err := client.ReadRecipeContent(context.Background(), "recipe/with space")
+			if err == nil {
+				t.Fatal("ReadRecipeContent() error = nil, want invalid recipe content error")
+			}
+			if !strings.Contains(err.Error(), tt.wantErr) {
+				t.Fatalf("ReadRecipeContent() error = %v, want %q", err, tt.wantErr)
+			}
+			if contentCalls != 1 {
+				t.Fatalf("content calls = %d, want 1", contentCalls)
+			}
+		})
+	}
+}
+
 func rawRecipeListTestHandler(t *testing.T, path, body string) http.HandlerFunc {
 	t.Helper()
 
@@ -144,6 +177,27 @@ func rawRecipeListTestHandler(t *testing.T, path, body string) http.HandlerFunc
 	}
 }
 
+func rawRecipeContentTestHandler(t *testing.T, contentCalls *int, body string) http.HandlerFunc {
+	t.Helper()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.EscapedPath() {
+		case "/api/public/login":
+			writeLoginResponse(t, w)
+		case "/api/recipe/recipe%2Fwith%20space/content":
+			requireMethod(t, r, http.MethodGet, "content")
+			requireSessionCookie(t, r)
+			(*contentCalls)++
+			w.Header().Set("Content-Type", "application/json")
+			if _, err := w.Write([]byte(body)); err != nil {
+				t.Fatalf("write raw recipe content response: %v", err)
+			}
+		default:
+			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
+		}
+	}
+}
+
 func rawSavePreparedRecipeTestHandler(t *testing.T, saveCalls *int, body string) http.HandlerFunc {
 	t.Helper()