recipes: fetch recipe content

Amolith created

Change summary

internal/cooked/client.go      | 20 +++++++++++++
internal/cooked/client_test.go | 52 +++++++++++++++++++++++++++++++++++
2 files changed, 71 insertions(+), 1 deletion(-)

Detailed changes

internal/cooked/client.go 🔗

@@ -71,6 +71,12 @@ type RecipeMetadata struct {
 	EditPermission bool     `json:"edit-permission"`
 }
 
+// RecipeContent is the Markdown-style body and portions for a Cooked recipe.
+type RecipeContent struct {
+	Content  string `json:"content"`
+	Portions int    `json:"portions"`
+}
+
 // NewClient returns a Cooked client with an in-memory cookie jar.
 func NewClient(baseURL *url.URL, username, password string) (*Client, error) {
 	jar, err := newCookieJar()
@@ -135,6 +141,20 @@ func (c *Client) ReadRecipeMetadata(ctx context.Context, recipeID string) (Recip
 	return metadata, nil
 }
 
+// ReadRecipeContent logs in when needed and returns recipe content and portions.
+func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (RecipeContent, error) {
+	var content RecipeContent
+	decodeContent := func(decoder *json.Decoder) error {
+		return decoder.Decode(&content)
+	}
+	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
+	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeContent); err != nil {
+		return RecipeContent{}, err
+	}
+
+	return content, nil
+}
+
 func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
 	var response recipeListResponse
 	decodeRecipes := func(decoder *json.Decoder) error {

internal/cooked/client_test.go 🔗

@@ -241,6 +241,56 @@ func TestReadRecipeMetadataACIDRecipesRead6ReadsMetadata(t *testing.T) {
 	}
 }
 
+func TestReadRecipeContentACIDRecipesRead7And8ReadsContentAndPortions(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.EscapedPath() {
+		case "/api/public/login":
+			http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
+			writeJSON(t, w, loginResponse{Username: "returned-user"})
+		case "/api/recipe/recipe%2Fwith%20space/content":
+			if r.Method != http.MethodGet {
+				t.Fatalf("content method = %s, want GET", r.Method)
+			}
+			cookie, err := r.Cookie("cooked_session")
+			if err != nil {
+				t.Fatalf("missing session cookie: %v", err)
+			}
+			if cookie.Value != "session-value" {
+				t.Fatalf("session cookie = %q, want session-value", cookie.Value)
+			}
+
+			writeJSON(t, w, RecipeContent{
+				Content:  "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
+				Portions: 2,
+			})
+		default:
+			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
+		}
+	}))
+	defer server.Close()
+
+	baseURL, err := url.Parse(server.URL)
+	if err != nil {
+		t.Fatalf("parse server URL: %v", err)
+	}
+	client, err := NewClient(baseURL, "configured-user", "configured-password")
+	if err != nil {
+		t.Fatalf("NewClient() error = %v", err)
+	}
+
+	content, err := client.ReadRecipeContent(context.Background(), "recipe/with space")
+	if err != nil {
+		t.Fatalf("ReadRecipeContent() error = %v", err)
+	}
+
+	if content.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." {
+		t.Fatalf("content = %q, want recipe markdown", content.Content)
+	}
+	if content.Portions != 2 {
+		t.Fatalf("portions = %d, want 2", content.Portions)
+	}
+}
+
 func TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
 	client := &Client{authenticatedUsername: "user/name"}
 
@@ -267,7 +317,7 @@ func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
 	}
 }
 
-func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse](t *testing.T, w http.ResponseWriter, value T) {
+func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent](t *testing.T, w http.ResponseWriter, value T) {
 	t.Helper()
 
 	w.Header().Set("Content-Type", "application/json")