Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/repository/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import (
type RecipeRepo interface {
GetUserRecipes(userID uint, page, pageSize int) ([]models.Recipe, int64, error)
GetRecipeByID(recipeID uint) (*models.Recipe, error)
// GetUserRecipeByCanonical returns the user's existing non-deleted recipe for
// the given canonical entry, or (nil, nil) if none. Used to keep URL imports
// idempotent per-user (saving the same recipe twice returns the original
// instead of creating a duplicate).
GetUserRecipeByCanonical(userID, canonicalID uint) (*models.Recipe, error)
CreateRecipe(recipe *models.Recipe) error
DeleteRecipe(recipeID uint) error
UpdateRecipeTitle(recipe *models.Recipe, title string) error
Expand Down
23 changes: 23 additions & 0 deletions internal/repository/recipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,29 @@ func (r *RecipeRepository) GetRecipeByID(recipeID uint) (*models.Recipe, error)
return &recipe, nil
}

// GetUserRecipeByCanonical returns the user's oldest non-deleted recipe for the
// given canonical entry, or (nil, nil) if none. Preloads the same associations as
// GetRecipeByID so the result can be passed straight to ToRecipeResponse. Used to
// make URL imports idempotent per-user (no duplicate rows on a repeat save).
func (r *RecipeRepository) GetUserRecipeByCanonical(userID, canonicalID uint) (*models.Recipe, error) {
var recipe models.Recipe
err := r.DB.Preload("Hashtags").
Preload("Canonical").
Preload("CreatedBy", func(db *gorm.DB) *gorm.DB {
return db.Select("ID", "Username")
}).
Where("created_by_id = ? AND canonical_id = ?", userID, canonicalID).
Order("created_at ASC").
First(&recipe).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &recipe, nil
}

// CreateRecipe creates a new recipe.
func (r *RecipeRepository) CreateRecipe(recipe *models.Recipe) error {
// Start a new transaction
Expand Down
15 changes: 15 additions & 0 deletions internal/service/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,21 @@ func (s *ImportService) createImportedRecipe(ctx context.Context, recipeDef *mod
return nil, 0, fmt.Errorf("recipe title is required")
}

// Idempotent URL import: if this user already saved this canonical recipe,
// return the existing one instead of creating a duplicate. URL imports carry a
// canonicalID; vision/copypasta/video imports pass nil (each is a distinct
// capture) and fork/regen/generate don't come through here — so this only
// dedupes true URL re-saves (e.g. an agent calling save_recipe twice).
if canonicalID != nil {
if existing, err := s.RecipeRepo.GetUserRecipeByCanonical(user.ID, *canonicalID); err != nil {
log.Warn("dedup lookup failed; proceeding with import", zap.Error(err))
} else if existing != nil {
log.Info("import matches an existing saved recipe; returning it (no duplicate)",
zap.Uint("recipe_id", existing.ID), zap.Uint("canonical_id", *canonicalID))
return s.RecipeService.ToRecipeResponse(existing), existing.ID, nil
}
}

// Idempotent safety net: ensure every imported recipe carries normalized
// measurement fields even if it reached here without going through a
// builder (e.g. the manual path). Detach the ingredient slice first so a
Expand Down
45 changes: 45 additions & 0 deletions internal/service/import_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,51 @@ func TestImportFromText_Success(t *testing.T) {
}
}

// TestImportIdempotent_SameCanonicalNoDuplicate guards the per-user save-dedup:
// saving the same URL/canonical twice must return the existing recipe, not create
// a second row (e.g. an agent calling save_recipe more than once).
func TestImportIdempotent_SameCanonicalNoDuplicate(t *testing.T) {
repo := testutil.NewMockRecipeRepo()
svc := newTestImportService(repo, nil, nil)
user := testutil.TestUser()

def := &models.RecipeDef{
Title: "One Pan Salmon Dinner",
Ingredients: models.Ingredients{{Name: "salmon"}, {Name: "potatoes"}},
Instructions: []string{"Roast everything on a sheet pan."},
}
canonicalID := uint(4242)

resp1, id1, err := svc.createImportedRecipe(context.Background(), def, user, models.RecipeTypeImportLink, "https://example.com/salmon", "", &canonicalID, nil, "")
if err != nil {
t.Fatalf("first import error: %v", err)
}
if len(repo.Recipes) != 1 {
t.Fatalf("after first import, recipes = %d, want 1", len(repo.Recipes))
}

// Same canonical again → existing recipe returned, no new row.
resp2, id2, err := svc.createImportedRecipe(context.Background(), def, user, models.RecipeTypeImportLink, "https://example.com/salmon", "", &canonicalID, nil, "")
if err != nil {
t.Fatalf("second import error: %v", err)
}
if len(repo.Recipes) != 1 {
t.Errorf("after second import, recipes = %d, want 1 (no duplicate)", len(repo.Recipes))
}
if id2 != id1 || resp2.ID != resp1.ID {
t.Errorf("second import returned id %d / %q, want the existing %d / %q", id2, resp2.ID, id1, resp1.ID)
}

// A different canonical for the same user still creates a new recipe.
otherCanonical := uint(9999)
if _, _, err := svc.createImportedRecipe(context.Background(), def, user, models.RecipeTypeImportLink, "https://example.com/other", "", &otherCanonical, nil, ""); err != nil {
t.Fatalf("third import error: %v", err)
}
if len(repo.Recipes) != 2 {
t.Errorf("after a different canonical, recipes = %d, want 2", len(repo.Recipes))
}
}

func TestImportFromText_MetricUser(t *testing.T) {
repo := testutil.NewMockRecipeRepo()
mockText := &testutil.MockTextProvider{
Expand Down
19 changes: 19 additions & 0 deletions internal/testutil/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,13 +282,32 @@ func (m *MockRecipeRepo) GetRecipeByID(recipeID uint) (*models.Recipe, error) {
return r, nil
}

func (m *MockRecipeRepo) GetUserRecipeByCanonical(userID, canonicalID uint) (*models.Recipe, error) {
m.mu.Lock()
defer m.mu.Unlock()
var match *models.Recipe
for _, r := range m.Recipes {
if r.CreatedByID == userID && r.CanonicalID != nil && *r.CanonicalID == canonicalID {
if match == nil || r.ID < match.ID {
match = r
}
}
}
return match, nil
}

func (m *MockRecipeRepo) CreateRecipe(recipe *models.Recipe) error {
if m.CreateRecipeErr != nil {
return m.CreateRecipeErr
}
m.mu.Lock()
defer m.mu.Unlock()

// Mirror GORM: populate the FK from the association so lookups by
// created_by_id (e.g. GetUserRecipeByCanonical) behave like the real DB.
if recipe.CreatedByID == 0 && recipe.CreatedBy != nil {
recipe.CreatedByID = recipe.CreatedBy.ID
}
recipe.ID = m.NextID
m.NextID++
m.Recipes[recipe.ID] = recipe
Expand Down
Loading