recipes: preview recipe text client

Amolith created

Change summary

internal/cooked/client.go      | 30 +++++++++++++++++
internal/cooked/client_test.go | 61 +++++++++++++++++++++++++++++++++++
2 files changed, 90 insertions(+), 1 deletion(-)

Detailed changes

internal/cooked/client.go 🔗

@@ -77,6 +77,13 @@ type RecipeContent struct {
 	Portions int    `json:"portions"`
 }
 
+// RecipeTextPreview is Cooked's preview of raw recipe text before saving.
+type RecipeTextPreview struct {
+	Title    string `json:"title"`
+	Markdown string `json:"markdown"`
+	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()
@@ -161,6 +168,24 @@ func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (Recipe
 	return content, nil
 }
 
+// PreviewRecipeText logs in when needed and previews raw recipe text without saving it.
+func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (RecipeTextPreview, error) {
+	body, err := json.Marshal(previewRecipeTextRequest{RecipeName: title, RecipeText: text})
+	if err != nil {
+		return RecipeTextPreview{}, fmt.Errorf("encode Cooked recipe text preview request: %w", err)
+	}
+
+	var preview RecipeTextPreview
+	decodePreview := func(decoder *json.Decoder) error {
+		return decoder.Decode(&preview)
+	}
+	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new/from-text/preview", body, decodePreview); err != nil {
+		return RecipeTextPreview{}, err
+	}
+
+	return preview, nil
+}
+
 func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
 	var response recipeListResponse
 	decodeRecipes := func(decoder *json.Decoder) error {
@@ -305,6 +330,11 @@ type recipeListResponse struct {
 	Recipes []RecipeCard `json:"recipes"`
 }
 
+type previewRecipeTextRequest struct {
+	RecipeName string `json:"recipe-name"`
+	RecipeText string `json:"recipe-text"`
+}
+
 type loginRequest struct {
 	Username string `json:"username"`
 	Password string `json:"password"`

internal/cooked/client_test.go 🔗

@@ -102,6 +102,30 @@ func TestReadRecipeContentACIDRecipesRead7And8ReadsContentAndPortions(t *testing
 	}
 }
 
+func TestPreviewRecipeTextACIDRecipesPreviewText1To4PreviewsWithoutSaving(t *testing.T) {
+	var previewCalls int
+	client, closeServer := newTestClient(t, previewRecipeTextTestHandler(t, &previewCalls))
+	defer closeServer()
+
+	preview, err := client.PreviewRecipeText(context.Background(), "Pasta", "Boil pasta.")
+	if err != nil {
+		t.Fatalf("PreviewRecipeText() error = %v", err)
+	}
+
+	if previewCalls != 1 {
+		t.Fatalf("preview calls = %d, want 1", previewCalls)
+	}
+	if preview.Title != "Pasta with Tomato Sauce" {
+		t.Fatalf("preview title = %q, want Pasta with Tomato Sauce", preview.Title)
+	}
+	if preview.Markdown != "# Pasta\n\n1. Boil pasta." {
+		t.Fatalf("preview markdown = %q, want recipe markdown", preview.Markdown)
+	}
+	if preview.Portions != 2 {
+		t.Fatalf("preview portions = %d, want 2", preview.Portions)
+	}
+}
+
 func TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
 	client := &Client{authenticatedUsername: "user/name"}
 
@@ -238,6 +262,41 @@ func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
 	}
 }
 
+func previewRecipeTextTestHandler(t *testing.T, previewCalls *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/new/from-text/preview":
+			requireMethod(t, r, http.MethodPost, "preview")
+			requireSessionCookie(t, r)
+			requirePreviewRecipeTextRequest(t, r)
+			(*previewCalls)++
+			writeJSON(t, w, RecipeTextPreview{
+				Title:    "Pasta with Tomato Sauce",
+				Markdown: "# Pasta\n\n1. Boil pasta.",
+				Portions: 2,
+			})
+		default:
+			t.Fatalf("unexpected path %s", r.URL.Path)
+		}
+	}
+}
+
+func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
+	t.Helper()
+
+	var request previewRecipeTextRequest
+	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+		t.Fatalf("decode preview request: %v", err)
+	}
+	if request.RecipeName != "Pasta" || request.RecipeText != "Boil pasta." {
+		t.Fatalf("preview request = %#v, want configured title/text", request)
+	}
+}
+
 func requireLoginRequest(t *testing.T, r *http.Request) {
 	t.Helper()
 	requireMethod(t, r, http.MethodPost, "login")
@@ -331,7 +390,7 @@ func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
 	}
 }
 
-func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent](
+func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview](
 	t *testing.T,
 	w http.ResponseWriter,
 	value T,