diff --git a/internal/cooked/client.go b/internal/cooked/client.go index 8141aa02535fff0bdde7a65fd3cf568a4f8d22ea..7207ab6b9f911c08126dd2d34dd1f24b4a0f16f1 100644 --- a/internal/cooked/client.go +++ b/internal/cooked/client.go @@ -98,6 +98,12 @@ type RecipeTextPreview struct { Portions int `json:"portions"` } +// RecipeURLImport is the result of importing a recipe URL. +type RecipeURLImport struct { + RecipeID string + DraftID string +} + // NewClient returns a Cooked client with an in-memory cookie jar. func NewClient(baseURL *url.URL, username, password string) (*Client, error) { jar, err := newCookieJar() @@ -301,6 +307,28 @@ func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown str return c.doAuthenticated(ctx, http.MethodPost, path, body, nil) } +// ImportRecipeURL logs in when needed and starts a recipe URL import. +func (c *Client) ImportRecipeURL(ctx context.Context, recipeURL string) (RecipeURLImport, error) { + body, err := json.Marshal(importRecipeURLRequest{URL: recipeURL}) + if err != nil { + return RecipeURLImport{}, fmt.Errorf("encode Cooked recipe URL import request: %w", err) + } + + var response importRecipeURLResponse + decodeImport := func(decoder *json.Decoder) error { + return decoder.Decode(&response) + } + if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new", body, decodeImport); err != nil { + return RecipeURLImport{}, err + } + + if strings.TrimSpace(response.RecipeID) == "" && strings.TrimSpace(response.ExtractionID) == "" { + return RecipeURLImport{}, fmt.Errorf("import recipe URL response missing recipe or draft ID") + } + + return RecipeURLImport{RecipeID: response.RecipeID, DraftID: response.ExtractionID}, nil +} + // DeleteRecipe logs in when needed and deletes an existing recipe. func (c *Client) DeleteRecipe(ctx context.Context, recipeID string) error { path := "/api/recipe/" + url.PathEscape(recipeID) @@ -493,6 +521,15 @@ type updateRecipeContentRequest struct { Portions int `json:"portions"` } +type importRecipeURLRequest struct { + URL string `json:"url"` +} + +type importRecipeURLResponse struct { + RecipeID string `json:"recipe-id"` + ExtractionID string `json:"extraction-id"` +} + type saveRecipeResponse struct { RecipeID string `json:"recipe-id"` } diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index a9db0b0b415b438db6403c6f5534fb951ae01bb8..611fff15cdbba58beefe6634069b8b6a160b10d9 100644 --- a/internal/cooked/client_test.go +++ b/internal/cooked/client_test.go @@ -159,6 +159,52 @@ func TestUpdateRecipeContentACIDRecipesSave8And9And13UpdatesExistingRecipe(t *te } } +func TestImportRecipeURLACIDRecipesSave3And4ImportsURLAndReturnsSavedRecipeID(t *testing.T) { + var importCalls int + client, closeServer := newTestClient(t, importRecipeURLTestHandler(t, &importCalls, importRecipeURLResponse{ + RecipeID: "recipe-1", + })) + defer closeServer() + + result, err := client.ImportRecipeURL(context.Background(), "https://example.com/recipe") + if err != nil { + t.Fatalf("ImportRecipeURL() error = %v", err) + } + + if importCalls != 1 { + t.Fatalf("import calls = %d, want 1", importCalls) + } + if result.RecipeID != "recipe-1" { + t.Fatalf("recipe ID = %q, want recipe-1", result.RecipeID) + } + if result.DraftID != "" { + t.Fatalf("draft ID = %q, want empty", result.DraftID) + } +} + +func TestImportRecipeURLACIDRecipesSave3And5And6ImportsURLAndReturnsDraftIDWithoutSaving(t *testing.T) { + var importCalls int + client, closeServer := newTestClient(t, importRecipeURLTestHandler(t, &importCalls, importRecipeURLResponse{ + ExtractionID: "draft-1", + })) + defer closeServer() + + result, err := client.ImportRecipeURL(context.Background(), "https://example.com/recipe") + if err != nil { + t.Fatalf("ImportRecipeURL() error = %v", err) + } + + if importCalls != 1 { + t.Fatalf("import calls = %d, want 1", importCalls) + } + if result.RecipeID != "" { + t.Fatalf("recipe ID = %q, want empty", result.RecipeID) + } + if result.DraftID != "draft-1" { + t.Fatalf("draft ID = %q, want draft-1", result.DraftID) + } +} + func TestDeleteRecipeDeletesSavedRecipe(t *testing.T) { var deleteCalls int client, closeServer := newTestClient(t, deleteRecipeTestHandler(t, &deleteCalls)) @@ -465,6 +511,31 @@ func updateRecipeContentTestHandler(t *testing.T, updateCalls *int) http.Handler } } +func importRecipeURLTestHandler( + t *testing.T, + importCalls *int, + response importRecipeURLResponse, +) 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": + requireMethod(t, r, http.MethodPost, "import recipe URL") + requireSessionCookie(t, r) + requireImportRecipeURLRequest(t, r) + (*importCalls)++ + writeJSON(t, w, response) + case "/api/extract/draft-1/save": + t.Fatal("unexpected draft save call") + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + } +} + func deleteRecipeTestHandler(t *testing.T, deleteCalls *int) http.HandlerFunc { t.Helper() @@ -704,6 +775,18 @@ func requireUpdateRecipeContentRequest(t *testing.T, r *http.Request) { } } +func requireImportRecipeURLRequest(t *testing.T, r *http.Request) { + t.Helper() + + var request importRecipeURLRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode import recipe URL request: %v", err) + } + if request.URL != "https://example.com/recipe" { + t.Fatalf("import recipe URL = %q, want configured URL", request.URL) + } +} + func requireLoginRequest(t *testing.T, r *http.Request) { t.Helper() requireMethod(t, r, http.MethodPost, "login") @@ -798,7 +881,8 @@ func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) { } func writeJSON[ - T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | saveRecipeResponse, + T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | + saveRecipeResponse | importRecipeURLResponse, ]( t *testing.T, w http.ResponseWriter,