diff --git a/internal/cooked/client.go b/internal/cooked/client.go index 62f29b93dcff688f4cc09d0d2fa8c951a9a770b9..c312953f7be9fa991b376e3b6fc5039238058a3d 100644 --- a/internal/cooked/client.go +++ b/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, diff --git a/internal/cooked/client_preview_validation_test.go b/internal/cooked/client_preview_validation_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9a43453a857d463ac25d61023ec811ce734c31ef --- /dev/null +++ b/internal/cooked/client_preview_validation_test.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) + } + } +}