recipes: import recipe URL client

Amolith created

Change summary

internal/cooked/client.go      | 37 +++++++++++++++
internal/cooked/client_test.go | 86 +++++++++++++++++++++++++++++++++++
2 files changed, 122 insertions(+), 1 deletion(-)

Detailed changes

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"`
 }

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,