Files
Meal-Planner/backend/tests/test_recipe_catalog_audit.py
admin 2695ba0232
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s
feat(meals): enforce three lower-calorie dinners with media
2026-09-01 10:11:36 -07:00

401 lines
14 KiB
Python

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