//go:build integration package recipe import ( "context" "encoding/json" "testing" "github.com/food-ai/backend/internal/testutil" ) func TestRecipeRepository_Upsert_Insert(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() id := 10001 cuisine := "italian" diff := "easy" cookTime := 30 servings := 4 rec := &Recipe{ Source: "spoonacular", SpoonacularID: &id, Title: "Pasta Carbonara", Cuisine: &cuisine, Difficulty: &diff, CookTimeMin: &cookTime, Servings: &servings, Ingredients: json.RawMessage(`[{"name":"pasta","amount":200,"unit":"g"}]`), Steps: json.RawMessage(`[{"number":1,"description":"Boil pasta"}]`), Tags: json.RawMessage(`["italian"]`), } got, err := repo.Upsert(ctx, rec) if err != nil { t.Fatalf("upsert: %v", err) } if got.ID == "" { t.Error("expected non-empty ID") } if got.Title != "Pasta Carbonara" { t.Errorf("title: want Pasta Carbonara, got %s", got.Title) } if got.SpoonacularID == nil || *got.SpoonacularID != id { t.Errorf("spoonacular_id: want %d, got %v", id, got.SpoonacularID) } } func TestRecipeRepository_Upsert_ConflictUpdates(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() id := 20001 cuisine := "mexican" diff := "medium" first := &Recipe{ Source: "spoonacular", SpoonacularID: &id, Title: "Tacos", Cuisine: &cuisine, Difficulty: &diff, Ingredients: json.RawMessage(`[]`), Steps: json.RawMessage(`[]`), Tags: json.RawMessage(`[]`), } got1, err := repo.Upsert(ctx, first) if err != nil { t.Fatalf("first upsert: %v", err) } second := &Recipe{ Source: "spoonacular", SpoonacularID: &id, Title: "Beef Tacos", Cuisine: &cuisine, Difficulty: &diff, Ingredients: json.RawMessage(`[{"name":"beef","amount":300,"unit":"g"}]`), Steps: json.RawMessage(`[]`), Tags: json.RawMessage(`[]`), } got2, err := repo.Upsert(ctx, second) if err != nil { t.Fatalf("second upsert: %v", err) } if got1.ID != got2.ID { t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID) } if got2.Title != "Beef Tacos" { t.Errorf("title not updated: got %s", got2.Title) } } func TestRecipeRepository_GetByID_Found(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() id := 30001 diff := "easy" rec := &Recipe{ Source: "spoonacular", SpoonacularID: &id, Title: "Greek Salad", Difficulty: &diff, Ingredients: json.RawMessage(`[]`), Steps: json.RawMessage(`[]`), Tags: json.RawMessage(`["vegetarian"]`), } saved, err := repo.Upsert(ctx, rec) if err != nil { t.Fatalf("upsert: %v", err) } got, err := repo.GetByID(ctx, saved.ID) if err != nil { t.Fatalf("get by id: %v", err) } if got == nil { t.Fatal("expected non-nil result") } if got.Title != "Greek Salad" { t.Errorf("want Greek Salad, got %s", got.Title) } } func TestRecipeRepository_GetByID_NotFound(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() got, err := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000") if err != nil { t.Fatalf("unexpected error: %v", err) } if got != nil { t.Error("expected nil for non-existent ID") } } func TestRecipeRepository_ListUntranslated_Pagination(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() diff := "easy" for i := 0; i < 5; i++ { spID := 40000 + i _, err := repo.Upsert(ctx, &Recipe{ Source: "spoonacular", SpoonacularID: &spID, Title: "Recipe " + string(rune('A'+i)), Difficulty: &diff, Ingredients: json.RawMessage(`[]`), Steps: json.RawMessage(`[]`), Tags: json.RawMessage(`[]`), }) if err != nil { t.Fatalf("upsert recipe %d: %v", i, err) } } untranslated, err := repo.ListUntranslated(ctx, 3, 0) if err != nil { t.Fatalf("list untranslated: %v", err) } if len(untranslated) != 3 { t.Errorf("expected 3 results with limit=3, got %d", len(untranslated)) } } func TestRecipeRepository_UpdateTranslation(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() id := 50001 diff := "medium" saved, err := repo.Upsert(ctx, &Recipe{ Source: "spoonacular", SpoonacularID: &id, Title: "Chicken Tikka Masala", Difficulty: &diff, Ingredients: json.RawMessage(`[]`), Steps: json.RawMessage(`[{"number":1,"description":"Heat oil"}]`), Tags: json.RawMessage(`[]`), }) if err != nil { t.Fatalf("upsert: %v", err) } titleRu := "Курица Тикка Масала" descRu := "Классическое индийское блюдо" stepsRu := json.RawMessage(`[{"number":1,"description":"Heat oil","description_ru":"Разогрейте масло"}]`) if err := repo.UpdateTranslation(ctx, saved.ID, &titleRu, &descRu, stepsRu); err != nil { t.Fatalf("update translation: %v", err) } got, err := repo.GetByID(ctx, saved.ID) if err != nil { t.Fatalf("get by id: %v", err) } if got.TitleRu == nil || *got.TitleRu != titleRu { t.Errorf("expected title_ru=%q, got %v", titleRu, got.TitleRu) } if got.DescriptionRu == nil || *got.DescriptionRu != descRu { t.Errorf("expected description_ru=%q, got %v", descRu, got.DescriptionRu) } var steps []RecipeStep if err := json.Unmarshal(got.Steps, &steps); err != nil { t.Fatalf("unmarshal steps: %v", err) } if len(steps) == 0 || steps[0].DescriptionRu == nil || *steps[0].DescriptionRu != "Разогрейте масло" { t.Errorf("expected description_ru in steps, got %v", steps) } } func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() diff := "easy" // Insert untranslated for i := 0; i < 3; i++ { spID := 60000 + i _, err := repo.Upsert(ctx, &Recipe{ Source: "spoonacular", SpoonacularID: &spID, Title: "Untranslated " + string(rune('A'+i)), Difficulty: &diff, Ingredients: json.RawMessage(`[]`), Steps: json.RawMessage(`[]`), Tags: json.RawMessage(`[]`), }) if err != nil { t.Fatalf("upsert: %v", err) } } // Insert translated spID := 60100 translated, err := repo.Upsert(ctx, &Recipe{ Source: "spoonacular", SpoonacularID: &spID, Title: "Translated Recipe", Difficulty: &diff, Ingredients: json.RawMessage(`[]`), Steps: json.RawMessage(`[]`), Tags: json.RawMessage(`[]`), }) if err != nil { t.Fatalf("upsert translated: %v", err) } titleRu := "Переведённый рецепт" if err := repo.UpdateTranslation(ctx, translated.ID, &titleRu, nil, translated.Steps); err != nil { t.Fatalf("update translation: %v", err) } untranslated, err := repo.ListUntranslated(ctx, 10, 0) if err != nil { t.Fatalf("list untranslated: %v", err) } for _, r := range untranslated { if r.Title == "Translated Recipe" { t.Error("translated recipe should not appear in ListUntranslated") } } if len(untranslated) < 3 { t.Errorf("expected at least 3 untranslated, got %d", len(untranslated)) } } func TestRecipeRepository_GIN_Tags(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() id := 70001 diff := "easy" _, err := repo.Upsert(ctx, &Recipe{ Source: "spoonacular", SpoonacularID: &id, Title: "Veggie Bowl", Difficulty: &diff, Ingredients: json.RawMessage(`[]`), Steps: json.RawMessage(`[]`), Tags: json.RawMessage(`["vegetarian","gluten-free"]`), }) if err != nil { t.Fatalf("upsert: %v", err) } // GIN index query: tags @> '["vegetarian"]' var count int row := pool.QueryRow(ctx, `SELECT count(*) FROM recipes WHERE tags @> '["vegetarian"]'::jsonb AND spoonacular_id = $1`, id) if err := row.Scan(&count); err != nil { t.Fatalf("query: %v", err) } if count != 1 { t.Errorf("expected 1 vegetarian recipe, got %d", count) } }