@@ -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,
@@ -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)
+ }
+ }
+}