cooked: accept decimal preview portions

Amolith created

Change summary

internal/cooked/client.go                         | 53 +++++++++++
internal/cooked/client_preview_validation_test.go | 76 +++++++++++++++++
2 files changed, 129 insertions(+)

Detailed changes

internal/cooked/client.go 🔗

@@ -12,9 +12,11 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"math/big"
 	"net/http"
 	"net/http/cookiejar"
 	"net/url"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -98,6 +100,28 @@ type RecipeTextPreview struct {
 	Portions int    `json:"portions"`
 }
 
+// UnmarshalJSON accepts Cooked's integer-valued decimal portions, such as 1.0.
+func (p *RecipeTextPreview) UnmarshalJSON(data []byte) error {
+	type raw RecipeTextPreview
+	var response struct {
+		raw
+		Portions json.Number `json:"portions"`
+	}
+	if err := json.Unmarshal(data, &response); err != nil {
+		return err
+	}
+
+	portions, err := jsonNumberToInt(response.Portions)
+	if err != nil {
+		return fmt.Errorf("portions: %w", err)
+	}
+
+	*p = RecipeTextPreview(response.raw)
+	p.Portions = portions
+
+	return nil
+}
+
 // RecipeURLImport is the result of importing a recipe URL.
 type RecipeURLImport struct {
 	RecipeID string
@@ -387,6 +411,35 @@ func validateRecipeCards(recipes []RecipeCard) error {
 	return nil
 }
 
+func jsonNumberToInt(number json.Number) (int, error) {
+	text := number.String()
+	if text == "" {
+		return 0, fmt.Errorf("missing")
+	}
+
+	rational, ok := new(big.Rat).SetString(text)
+	if !ok {
+		return 0, fmt.Errorf("invalid JSON number %q", text)
+	}
+	if !rational.IsInt() {
+		return 0, fmt.Errorf("must be integer-valued, got %s", text)
+	}
+
+	integer := rational.Num()
+	if !integer.IsInt64() {
+		return 0, fmt.Errorf("out of range, got %s", text)
+	}
+
+	value := integer.Int64()
+	maxInt := int64(1<<(strconv.IntSize-1) - 1)
+	minInt := -maxInt - 1
+	if value < minInt || value > maxInt {
+		return 0, fmt.Errorf("out of range for int, got %s", text)
+	}
+
+	return int(value), nil
+}
+
 func (c *Client) doAuthenticated(
 	ctx context.Context,
 	method, path string,

internal/cooked/client_preview_validation_test.go 🔗

@@ -0,0 +1,76 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package cooked
+
+import (
+	"context"
+	"net/http"
+	"strings"
+	"testing"
+)
+
+func TestPreviewRecipeTextACIDAPIClientResponses1AcceptsIntegerDecimalPortions(t *testing.T) {
+	var previewCalls int
+	client, closeServer := newTestClient(t, rawPreviewRecipeTextTestHandler(
+		t,
+		&previewCalls,
+		"{\"title\":\"Pasta\",\"markdown\":\"# Pasta\",\"portions\":1.0}",
+	))
+	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.Portions != 1 {
+		t.Fatalf("preview portions = %d, want 1", preview.Portions)
+	}
+}
+
+func TestPreviewRecipeTextACIDAPIClientResponses1RejectsFractionalPortions(t *testing.T) {
+	var previewCalls int
+	client, closeServer := newTestClient(t, rawPreviewRecipeTextTestHandler(
+		t,
+		&previewCalls,
+		"{\"title\":\"Pasta\",\"markdown\":\"# Pasta\",\"portions\":1.5}",
+	))
+	defer closeServer()
+
+	_, err := client.PreviewRecipeText(context.Background(), "Pasta", "Boil pasta.")
+	if err == nil {
+		t.Fatal("PreviewRecipeText() error = nil, want fractional portions error")
+	}
+	if !strings.Contains(err.Error(), "must be integer-valued") {
+		t.Fatalf("PreviewRecipeText() error = %v, want integer-valued portions error", err)
+	}
+	if previewCalls != 1 {
+		t.Fatalf("preview calls = %d, want 1", previewCalls)
+	}
+}
+
+func rawPreviewRecipeTextTestHandler(t *testing.T, previewCalls *int, 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 "/api/new/from-text/preview":
+			requireMethod(t, r, http.MethodPost, "preview")
+			requireSessionCookie(t, r)
+			requirePreviewRecipeTextRequest(t, r)
+			(*previewCalls)++
+			w.Header().Set("Content-Type", "application/json")
+			if _, err := w.Write([]byte(body)); err != nil {
+				t.Fatalf("write raw preview response: %v", err)
+			}
+		default:
+			t.Fatalf("unexpected path %s", r.URL.Path)
+		}
+	}
+}