From 2f22d262dd302c14c82d942d06c612fffd169d18 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 19:43:31 -0600 Subject: [PATCH] cooked: validate recipe list responses --- internal/cooked/client.go | 20 +++++ .../cooked/client_recipe_validation_test.go | 86 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 internal/cooked/client_recipe_validation_test.go diff --git a/internal/cooked/client.go b/internal/cooked/client.go index 098330d4e09f2b12e297a2b0d5ce55c0b7ba8674..bc9747c079888f96c82c28f2ab0abb5d96d59f50 100644 --- a/internal/cooked/client.go +++ b/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, diff --git a/internal/cooked/client_recipe_validation_test.go b/internal/cooked/client_recipe_validation_test.go new file mode 100644 index 0000000000000000000000000000000000000000..50c9fb3d69a180e2ffd91b62960ca9857c2ae87a --- /dev/null +++ b/internal/cooked/client_recipe_validation_test.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) + } + } +}