"""Catalog quarantine migration, audit, and visibility tests.""" from __future__ import annotations import importlib.util import os import subprocess from datetime import date, datetime, timezone from pathlib import Path from urllib.parse import urlparse from uuid import uuid4 import pytest from app.models import ( Ingredient, MealPlan, Recipe, RecipeCatalogStatus, RecipeImportStaging, RecipeImportStatus, ) from app.services.recipe_catalog_audit import audit_recipe_catalog pytestmark = pytest.mark.requires_postgres BACKEND_ROOT = Path(__file__).resolve().parent.parent MIGRATION_PATH = BACKEND_ROOT / "alembic" / "versions" / "0018_recipe_catalog_quarantine.py" def _ingredient(db, name: str) -> Ingredient: row = Ingredient(name=name, name_lower=name.casefold(), aliases=[]) db.add(row) db.flush() return row def _recipe(name: str, ingredients: list[Ingredient], **overrides) -> Recipe: values = { "name": name, "description": "A complete test recipe.", "prep_time_minutes": 10, "cook_time_minutes": 20, "servings": 4, "ingredients": [ {"ingredient_id": str(item.id), "qty": 1.0, "unit": "cup"} for item in ingredients ], "instructions": ["Prepare the ingredients.", "Cook until done."], "is_manually_added": True, } values.update(overrides) return Recipe(**values) def _approved_link(recipe: Recipe, **overrides) -> RecipeImportStaging: values = { "source": "wikibooks", "external_id": recipe.external_id, "source_url": recipe.source_url, "source_revision": str(uuid4()), "license_name": "CC BY-SA 4.0", "license_url": "https://creativecommons.org/licenses/by-sa/4.0/", "attribution": "Wikibooks contributors", "raw_payload": {"title": recipe.name}, "payload_sha256": "a" * 64, "normalized_recipe": {"name": recipe.name}, "validation_report": {"valid": True, "errors": [], "warnings": []}, "status": RecipeImportStatus.APPROVED, "recipe_id": recipe.id, "reviewed_at": datetime(2026, 8, 14, tzinfo=timezone.utc), "reviewed_by": "test-admin", } values.update(overrides) return RecipeImportStaging(**values) def test_recipe_catalog_model_and_migration_chain(): table = Recipe.__table__ assert table.c.catalog_status.type.enums == ["active", "quarantined"] assert table.c.catalog_status.default.arg is RecipeCatalogStatus.ACTIVE assert table.c.catalog_status.server_default.arg == "active" assert table.c.catalog_findings.nullable is False spec = importlib.util.spec_from_file_location("catalog_quarantine", MIGRATION_PATH) assert spec and spec.loader migration = importlib.util.module_from_spec(spec) spec.loader.exec_module(migration) assert migration.revision == "0018" assert migration.down_revision == "0017" def test_recipe_catalog_alembic_round_trip_from_0017(): dsn = os.environ.get("TEST_DATABASE_URL") if not dsn: pytest.skip("TEST_DATABASE_URL is required to avoid modifying a non-test database") database_name = urlparse(dsn).path.rsplit("/", 1)[-1] if not database_name.endswith("_test"): pytest.skip("TEST_DATABASE_URL must target a database ending in '_test'") env = os.environ.copy() env["DATABASE_URL"] = dsn def alembic(*args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["alembic", *args], cwd=str(BACKEND_ROOT), env=env, capture_output=True, text=True, ) try: at_head = alembic("upgrade", "head") assert at_head.returncode == 0, at_head.stderr at_0017 = alembic("downgrade", "0017") assert at_0017.returncode == 0, at_0017.stderr at_0018 = alembic("upgrade", "0018") assert at_0018.returncode == 0, at_0018.stderr back_to_0017 = alembic("downgrade", "0017") assert back_to_0017.returncode == 0, back_to_0017.stderr back_to_0018 = alembic("upgrade", "0018") assert back_to_0018.returncode == 0, back_to_0018.stderr finally: restore_head = alembic("upgrade", "head") assert restore_head.returncode == 0, restore_head.stderr def test_audit_reports_without_mutation_and_applies_quarantine(db): pork = _ingredient(db, f"Audit Pork {uuid4()}") salt = _ingredient(db, f"Audit Salt {uuid4()}") manual = _recipe("Audit Pork Salt", [pork, salt]) unsafe = _recipe( "Audit Apple Pork Glaze", [pork, salt], is_manually_added=False, external_source="spoonacular", external_id=str(uuid4()), source_url="https://example.invalid/source", ) db.add_all([manual, unsafe]) db.flush() report = audit_recipe_catalog( db, apply=False, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) records = {row["id"]: row for row in report["recipes"]} assert records[str(manual.id)]["catalog_status"] == "active" assert records[str(unsafe.id)]["catalog_status"] == "quarantined" unsafe_codes = {item["code"] for item in records[str(unsafe.id)]["findings"]} assert "unlicensed_external_source" in unsafe_codes assert "title_ingredient_mismatch" in unsafe_codes assert unsafe.catalog_status is RecipeCatalogStatus.ACTIVE audit_recipe_catalog( db, apply=True, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) assert manual.catalog_status is RecipeCatalogStatus.ACTIVE assert unsafe.catalog_status is RecipeCatalogStatus.QUARANTINED assert unsafe.catalog_reviewed_at is not None assert unsafe.catalog_findings def test_audit_requires_exact_approved_wikibooks_provenance(db): first = _ingredient(db, f"Provenance First {uuid4()}") second = _ingredient(db, f"Provenance Second {uuid4()}") recipe = _recipe( "Provenance First Second", [first, second], is_manually_added=False, external_source="wikibooks", external_id=str(uuid4()), source_url="https://en.wikibooks.org/wiki/Cookbook:Provenance_Test", ) db.add(recipe) db.flush() db.add(_approved_link(recipe, external_id="unrelated-page")) db.flush() report = audit_recipe_catalog( db, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) record = next(row for row in report["recipes"] if row["id"] == str(recipe.id)) assert "missing_import_provenance" in { finding["code"] for finding in record["findings"] } db.add(_approved_link(recipe)) db.flush() report = audit_recipe_catalog( db, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) record = next(row for row in report["recipes"] if row["id"] == str(recipe.id)) assert "missing_import_provenance" not in { finding["code"] for finding in record["findings"] } def test_audit_preserves_operator_quarantine_for_valid_wikibooks_recipe(db): first = _ingredient(db, f"Operator First {uuid4()}") second = _ingredient(db, f"Operator Second {uuid4()}") operator_finding = { "code": "operator_quarantine", "severity": "error", "field": "catalog_status", "message": "Quarantined by an operator.", } recipe = _recipe( "Operator First Second", [first, second], is_manually_added=False, external_source="wikibooks", external_id=str(uuid4()), source_url="https://en.wikibooks.org/wiki/Cookbook:Operator_Test", catalog_findings=[operator_finding], ) db.add(recipe) db.flush() db.add(_approved_link(recipe)) db.flush() report = audit_recipe_catalog( db, apply=False, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) record = next(row for row in report["recipes"] if row["id"] == str(recipe.id)) assert record["catalog_status"] == "quarantined" assert operator_finding in record["findings"] applied_report = audit_recipe_catalog( db, apply=True, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) applied_record = next( row for row in applied_report["recipes"] if row["id"] == str(recipe.id) ) assert applied_record["catalog_status"] == "quarantined" assert operator_finding in applied_record["findings"] assert recipe.catalog_status is RecipeCatalogStatus.QUARANTINED assert operator_finding in recipe.catalog_findings @pytest.mark.parametrize( "prior_findings", [ 1, {"code": "operator_quarantine", "severity": "error"}, None, ], ids=["truthy-scalar", "truthy-object", "null"], ) def test_audit_discards_malformed_prior_catalog_findings(db, prior_findings): first = _ingredient(db, f"Malformed First {uuid4()}") second = _ingredient(db, f"Malformed Second {uuid4()}") recipe = _recipe( "Malformed First Second", [first, second], catalog_findings=prior_findings, ) db.add(recipe) db.flush() report = audit_recipe_catalog( db, apply=True, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) record = next(row for row in report["recipes"] if row["id"] == str(recipe.id)) assert record["catalog_status"] == "active" assert record["findings"] == [] assert recipe.catalog_findings == [] def test_audit_discards_stale_normal_findings(db): first = _ingredient(db, f"Stale First {uuid4()}") second = _ingredient(db, f"Stale Second {uuid4()}") stale_finding = { "code": "obsolete_validation_error", "severity": "error", "field": "name", "message": "No longer applies.", } recipe = _recipe( "Stale First Second", [first, second], catalog_findings=[stale_finding], ) db.add(recipe) db.flush() report = audit_recipe_catalog( db, apply=False, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) record = next(row for row in report["recipes"] if row["id"] == str(recipe.id)) assert record["catalog_status"] == "active" assert record["findings"] == [] assert recipe.catalog_findings == [stale_finding] audit_recipe_catalog( db, apply=True, reviewed_at=datetime(2026, 8, 14, tzinfo=timezone.utc) ) assert recipe.catalog_findings == [] def test_public_catalog_hides_quarantined_recipes(client, db): first = _ingredient(db, f"Visible First {uuid4()}") second = _ingredient(db, f"Visible Second {uuid4()}") active = _recipe("Visible First Second", [first, second]) quarantined = _recipe( "Hidden First Second", [first, second], catalog_status=RecipeCatalogStatus.QUARANTINED, catalog_findings=[{"code": "test_quarantine", "severity": "error"}], ) db.add_all([active, quarantined]) db.flush() response = client.get("/api/recipes") assert response.status_code == 200 ids = {row["id"] for row in response.json()} assert str(active.id) in ids assert str(quarantined.id) not in ids assert client.get(f"/api/recipes/{quarantined.id}").status_code == 404 def test_meal_plan_creation_rejects_quarantined_recipe(client, db): first = _ingredient(db, f"Plan First {uuid4()}") second = _ingredient(db, f"Plan Second {uuid4()}") quarantined = _recipe( "Plan First Second", [first, second], catalog_status=RecipeCatalogStatus.QUARANTINED, ) db.add(quarantined) db.flush() login = client.post("/api/auth/login", json={"password": "test-family-password"}) assert login.status_code == 204, login.text response = client.post( "/api/meals", json={ "week_start_date": "2031-01-03", "items": [ { "recipe_id": str(quarantined.id), "day_of_week": 1, "meal_type": "dinner", } ], }, ) assert response.status_code == 400 assert response.json()["detail"] == "Meal plan contains unavailable recipes" def test_generate_single_item_reports_empty_active_catalog(client, db): db.query(Recipe).update( {Recipe.catalog_status: RecipeCatalogStatus.QUARANTINED}, synchronize_session=False, ) plan = MealPlan(week_start_date=date(2031, 1, 13)) db.add(plan) db.flush() login = client.post("/api/auth/login", json={"password": "test-family-password"}) assert login.status_code == 204, login.text response = client.post( f"/api/meals/{plan.id}/generate-item", params={"day_of_week": 4, "meal_type": "dinner"}, ) assert response.status_code == 409 assert response.json()["detail"] == ( "No active recipes are available. Approve a staged recipe before generating meals." ) def test_llm_library_includes_active_global_and_excludes_quarantined(db): from app.models import FamilyProfile from app.api.llm_plan import _serialize_library family = FamilyProfile( name=f"Audit Family {uuid4()}", household_size=2, adult_count=2, child_count=0, ) first = _ingredient(db, f"Library First {uuid4()}") second = _ingredient(db, f"Library Second {uuid4()}") active = _recipe("Library First Second", [first, second], family_profile_id=None) quarantined = _recipe( "Quarantined First Second", [first, second], family_profile_id=None, catalog_status=RecipeCatalogStatus.QUARANTINED, ) db.add_all([family, active, quarantined]) db.flush() ids = {row["id"] for row in _serialize_library(db, family.id)} assert str(active.id) in ids assert str(quarantined.id) not in ids