recipes: save prepared recipe client

Amolith created

Change summary

internal/cooked/client.go      | 28 +++++++++++++++++
internal/cooked/client_test.go | 59 +++++++++++++++++++++++++++++++++++
2 files changed, 86 insertions(+), 1 deletion(-)

Detailed changes

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"`

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,