From 245a7c87f3d356028e67453a4669257b44d8bbcc Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 14:44:20 -0600 Subject: [PATCH] recipes: save prepared recipe client --- internal/cooked/client.go | 28 ++++++++++++++++ internal/cooked/client_test.go | 59 +++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/internal/cooked/client.go b/internal/cooked/client.go index 6a773ec55cd08c239d7c073b3ff93aabee140f7f..d4fb3b4295bf060893e5000e7c670258c226f594 100644 --- a/internal/cooked/client.go +++ b/internal/cooked/client.go @@ -186,6 +186,24 @@ func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (Rec return preview, nil } +// SavePreparedRecipe logs in when needed and saves prepared recipe markdown as a new recipe. +func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) { + body, err := json.Marshal(savePreparedRecipeRequest{Title: title, Description: markdown, Portions: portions}) + if err != nil { + return "", fmt.Errorf("encode Cooked prepared recipe save request: %w", err) + } + + var response saveRecipeResponse + decodeSave := func(decoder *json.Decoder) error { + return decoder.Decode(&response) + } + if err := c.doAuthenticated(ctx, http.MethodPost, "/api/recipe/import/save", body, decodeSave); err != nil { + return "", err + } + + return response.RecipeID, nil +} + func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) { var response recipeListResponse decodeRecipes := func(decoder *json.Decoder) error { @@ -335,6 +353,16 @@ type previewRecipeTextRequest struct { RecipeText string `json:"recipe-text"` } +type savePreparedRecipeRequest struct { + Title string `json:"title"` + Description string `json:"description"` + Portions int `json:"portions"` +} + +type saveRecipeResponse struct { + RecipeID string `json:"recipe-id"` +} + type loginRequest struct { Username string `json:"username"` Password string `json:"password"` diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index 2632fc92b521a566424839c40be36b124e7d4ea2..427fc7a3a484bd679b85f83b4dfbef83f624cb92 100644 --- a/internal/cooked/client_test.go +++ b/internal/cooked/client_test.go @@ -126,6 +126,24 @@ func TestPreviewRecipeTextACIDRecipesPreviewText1To4PreviewsWithoutSaving(t *tes } } +func TestSavePreparedRecipeACIDRecipesSave2And9And13SavesMarkdownAsDescription(t *testing.T) { + var saveCalls int + client, closeServer := newTestClient(t, savePreparedRecipeTestHandler(t, &saveCalls)) + defer closeServer() + + recipeID, err := client.SavePreparedRecipe(context.Background(), "Pasta", "# Pasta\n\n1. Boil pasta.", 2) + if err != nil { + t.Fatalf("SavePreparedRecipe() error = %v", err) + } + + if saveCalls != 1 { + t.Fatalf("save calls = %d, want 1", saveCalls) + } + if recipeID != "recipe-1" { + t.Fatalf("recipe ID = %q, want recipe-1", recipeID) + } +} + func TestUserPathEscapesAuthenticatedUsername(t *testing.T) { client := &Client{authenticatedUsername: "user/name"} @@ -285,6 +303,25 @@ func previewRecipeTextTestHandler(t *testing.T, previewCalls *int) http.HandlerF } } +func savePreparedRecipeTestHandler(t *testing.T, saveCalls *int) 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/recipe/import/save": + requireMethod(t, r, http.MethodPost, "save prepared recipe") + requireSessionCookie(t, r) + requireSavePreparedRecipeRequest(t, r) + (*saveCalls)++ + writeJSON(t, w, saveRecipeResponse{RecipeID: "recipe-1"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + } +} + func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) { t.Helper() @@ -297,6 +334,24 @@ func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) { } } +func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) { + t.Helper() + + var request savePreparedRecipeRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode save prepared recipe request: %v", err) + } + if request.Title != "Pasta" { + t.Fatalf("save title = %q, want Pasta", request.Title) + } + if request.Description != "# Pasta\n\n1. Boil pasta." { + t.Fatalf("save description = %q, want markdown", request.Description) + } + if request.Portions != 2 { + t.Fatalf("save portions = %d, want 2", request.Portions) + } +} + func requireLoginRequest(t *testing.T, r *http.Request) { t.Helper() requireMethod(t, r, http.MethodPost, "login") @@ -390,7 +445,9 @@ func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) { } } -func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview]( +func writeJSON[ + T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | saveRecipeResponse, +]( t *testing.T, w http.ResponseWriter, value T,