feat(meals): enforce three lower-calorie dinners with media
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s

This commit is contained in:
2026-09-01 10:11:36 -07:00
parent 7838c49721
commit 2695ba0232
85 changed files with 8103 additions and 795 deletions
@@ -0,0 +1,14 @@
{
"parse": {
"title": "Cookbook:Pan-Seared Pork Chops with Apple Glaze",
"pageid": 902,
"revid": 5002,
"displaytitle": "Pan-Seared Pork Chops with Apple Glaze",
"wikitext": "Recipe text",
"sections": [
{"line": "Ingredients"},
{"line": "Directions"}
],
"text": "<div class=\"mw-parser-output\"><table class=\"infobox\"><tr><th>Servings</th><td>4</td></tr><tr><th>Cook time</th><td>20 minutes</td></tr></table><p>A deliberately incomplete apple glaze recipe.</p><h2><span id=\"Ingredients\">Ingredients</span></h2><ul><li>2 pork chops</li><li>1 tbsp butter</li><li>2 cloves garlic</li><li>1 cup broth</li></ul><h2><span id=\"Directions\">Directions</span></h2><ol><li>Brown the pork chops.</li><li>Simmer with butter, garlic, and broth.</li></ol></div>"
}
}
@@ -0,0 +1,14 @@
{
"parse": {
"title": "Cookbook:Apple Glazed Pork Chops",
"pageid": 901,
"revid": 5001,
"displaytitle": "Apple Glazed Pork Chops",
"wikitext": "Recipe text",
"sections": [
{"line": "Ingredients"},
{"line": "Procedure"}
],
"text": "<div class=\"mw-parser-output\"><table class=\"infobox\"><tr><th>Servings</th><td>4</td></tr><tr><th>Prep time</th><td>15 minutes</td></tr><tr><th>Cook time</th><td>20 minutes</td></tr><tr><th>Baking time</th><td>10 minutes</td></tr></table><p>Sweet apples balance savory pork chops.</p><h2><span id=\"Ingredients\">Ingredients</span></h2><ul><li>1 1/2 cups (360 ml) apple cider</li><li>½ tbsp butter</li></ul><h3>Seasoning</h3><ul><li>1¼ tsp salt</li><li>2 pork chops</li></ul><h2><span id=\"Procedure\">Procedure</span></h2><ol><li>Brown the pork chops in butter.</li></ol><h3>Finish</h3><ol><li>Add cider and cook until glazed.</li></ol><h2><span id=\"Notes\">Notes</span></h2><p>Do not import this note.</p></div>"
}
}
File diff suppressed because one or more lines are too long
+16
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import os
import pytest
from starlette.requests import Request
# Configure auth secrets BEFORE app import. conftest.py runs first and sets
@@ -39,6 +40,21 @@ def _reload_settings(monkeypatch):
# ---------------------------------------------------------------------------
# Admin bearer token
# ---------------------------------------------------------------------------
def test_admin_principal_defaults_when_email_is_unset(monkeypatch):
from app import security
monkeypatch.setattr(security.settings, "ADMIN_TOKEN", "test-admin-token")
monkeypatch.setattr(security.settings, "ADMIN_EMAIL", None)
request = Request(
{
"type": "http",
"headers": [(b"authorization", b"Bearer test-admin-token")],
}
)
assert security.require_admin(request) == "admin"
@pytest.mark.requires_postgres
def test_admin_requires_token(client):
"""No bearer → 401. Valid bearer → not 401 (handler runs)."""
+42
View File
@@ -0,0 +1,42 @@
import base64
from types import SimpleNamespace
from unittest.mock import Mock
from uuid import UUID
from app.services import image_generation
def test_generate_image_persists_local_url_and_openai_provenance(tmp_path, monkeypatch):
recipe = SimpleNamespace(
id=UUID("12345678-1234-5678-1234-567812345678"),
name="Spiced Chickpea Bowl",
ingredients=[{"name": "chickpeas"}],
cuisine_tags=["Mediterranean"],
image_url=None,
image_source=None,
)
db = Mock()
image_bytes = b"mock-png-bytes"
response = Mock(status_code=200)
response.json.return_value = {
"data": [{"b64_json": base64.b64encode(image_bytes).decode()}]
}
post = Mock(return_value=response)
monkeypatch.setattr(image_generation.settings, "AI_IMAGE_ENABLED", True)
monkeypatch.setattr(image_generation, "_openai_api_key", lambda: "test-key")
monkeypatch.setattr(image_generation, "STATIC_IMAGES_DIR", tmp_path)
monkeypatch.setattr(image_generation.httpx, "post", post)
monkeypatch.setenv("AI_IMAGE_MODEL", "gpt-image-1")
image_url = image_generation.generate_image_for_recipe(recipe, db=db)
assert image_url is not None
assert image_url.startswith("/static/images/")
assert (tmp_path / image_url.removeprefix("/static/images/")).read_bytes() == image_bytes
assert recipe.image_url == image_url
db.add.assert_called_once_with(recipe)
db.commit.assert_called_once_with()
assert recipe.image_source
assert "openai" in recipe.image_source.lower()
assert "gpt-image-1" in recipe.image_source.lower()
+39
View File
@@ -0,0 +1,39 @@
import uuid
from app.api.llm_plan import _validate_picks
def test_validate_picks_accepts_only_dinners():
recipe_id = str(uuid.uuid4())
picks = _validate_picks(
[
{"day_of_week": 1, "meal_type": "breakfast", "recipe_id": recipe_id},
{"day_of_week": 1, "meal_type": "lunch", "recipe_id": recipe_id},
{"day_of_week": 1, "meal_type": "dinner", "recipe_id": recipe_id},
],
{recipe_id},
)
assert [(pick.day_of_week, pick.meal_type) for pick in picks] == [(1, "dinner")]
def test_validate_picks_limits_schedule_to_three_unique_dinners():
recipe_ids = [str(uuid.uuid4()) for _ in range(5)]
picks = _validate_picks(
[
{"day_of_week": 1, "meal_type": "dinner", "recipe_id": recipe_ids[0]},
{"day_of_week": 2, "meal_type": "dinner", "recipe_id": recipe_ids[1]},
{"day_of_week": 3, "meal_type": "dinner", "recipe_id": recipe_ids[2]},
{"day_of_week": 3, "meal_type": "dinner", "recipe_id": recipe_ids[3]},
{"day_of_week": 5, "meal_type": "dinner", "recipe_id": recipe_ids[4]},
],
set(recipe_ids),
)
assert [(pick.day_of_week, pick.recipe_id) for pick in picks] == [
(1, recipe_ids[0]),
(3, recipe_ids[2]),
(5, recipe_ids[4]),
]
@@ -0,0 +1,90 @@
"""Direct CLI coverage for the bundled starter recipe loader."""
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import pytest
from app.services.starter_recipe_catalog import StarterRecipeCatalogError
SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "load_starter_recipes.py"
class FakeSession:
def __init__(self):
self.rollback_count = 0
self.close_count = 0
def rollback(self):
self.rollback_count += 1
def close(self):
self.close_count += 1
@pytest.fixture()
def loader_cli():
spec = importlib.util.spec_from_file_location("load_starter_recipes", SCRIPT_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_cli_forwards_update_existing_and_writes_json(loader_cli, monkeypatch, capsys):
db = FakeSession()
calls = []
summary = {"catalog_version": 2, "updated_recipes": 50}
monkeypatch.setattr(loader_cli, "SessionLocal", lambda: db)
monkeypatch.setattr(
loader_cli,
"load_bundled_starter_catalog",
lambda session, **kwargs: calls.append((session, kwargs)) or summary,
)
assert loader_cli.main(["--update-existing"]) == 0
assert calls == [(db, {"dry_run": False, "update_existing": True})]
assert json.loads(capsys.readouterr().out) == summary
assert db.rollback_count == 0
assert db.close_count == 1
def test_cli_reports_catalog_errors_as_json_and_closes_rolled_back_session(
loader_cli, monkeypatch, capsys
):
db = FakeSession()
monkeypatch.setattr(loader_cli, "SessionLocal", lambda: db)
def fail_catalog_load(session, **kwargs):
raise StarterRecipeCatalogError("catalog validation failed")
monkeypatch.setattr(loader_cli, "load_bundled_starter_catalog", fail_catalog_load)
assert loader_cli.main(["--dry-run"]) == 1
assert json.loads(capsys.readouterr().out) == {
"dry_run": True,
"error": "catalog validation failed",
}
assert db.rollback_count == 1
assert db.close_count == 1
def test_cli_rolls_back_and_closes_session_for_database_failures(loader_cli, monkeypatch):
db = FakeSession()
monkeypatch.setattr(loader_cli, "SessionLocal", lambda: db)
def fail_catalog_load(session, **kwargs):
raise RuntimeError("database write failed")
monkeypatch.setattr(loader_cli, "load_bundled_starter_catalog", fail_catalog_load)
with pytest.raises(RuntimeError, match="database write failed"):
loader_cli.main([])
assert db.rollback_count == 1
assert db.close_count == 1
+87 -1
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import uuid4
@@ -70,6 +70,92 @@ def test_generate_endpoint_requires_admin_token(client, family_with_groceries):
assert r.status_code == 401
def test_public_generate_uses_session_family_and_three_dinner_schedule(
client, family_with_groceries
):
from app.security import SESSION_COOKIE, issue_session
client.cookies.set(
SESSION_COOKIE,
issue_session(str(family_with_groceries.id)),
)
r = client.post(
"/api/meal-plans/generate",
json={"week_start_date": "2026-06-01"},
)
assert r.status_code == 201, r.text
data = r.json()
assert [item["day_of_week"] for item in data["items"]] == [1, 3, 5]
def test_public_generate_requires_session(client):
r = client.post(
"/api/meal-plans/generate",
json={"week_start_date": "2026-06-08"},
)
assert r.status_code == 401
def test_public_generate_preserves_populated_plan(client, db, family_with_groceries):
from app.models import MealPlan, MealPlanItem
from app.security import SESSION_COOKIE, issue_session
client.cookies.set(
SESSION_COOKIE,
issue_session(str(family_with_groceries.id)),
)
payload = {"week_start_date": "2026-06-15"}
first = client.post("/api/meal-plans/generate", json=payload)
assert first.status_code == 201, first.text
plan_id = first.json()["meal_plan_id"]
item_ids = {
item_id
for (item_id,) in db.query(MealPlanItem.id).filter(
MealPlanItem.meal_plan_id == plan_id
).all()
}
second = client.post("/api/meal-plans/generate", json=payload)
assert second.status_code == 409
assert db.query(MealPlan).filter(MealPlan.id == plan_id).first() is not None
assert {
item_id
for (item_id,) in db.query(MealPlanItem.id).filter(
MealPlanItem.meal_plan_id == plan_id
).all()
} == item_ids
def test_public_generate_replaces_only_empty_plan(client, db, family_with_groceries):
from app.models import MealPlan
from app.security import SESSION_COOKIE, issue_session
empty_plan = MealPlan(
family_profile_id=family_with_groceries.id,
week_start_date=date(2026, 6, 22),
status="draft",
)
db.add(empty_plan)
db.flush()
empty_plan_id = empty_plan.id
client.cookies.set(
SESSION_COOKIE,
issue_session(str(family_with_groceries.id)),
)
response = client.post(
"/api/meal-plans/generate",
json={"week_start_date": "2026-06-22"},
)
assert response.status_code == 201, response.text
assert response.json()["meal_plan_id"] != str(empty_plan_id)
assert db.query(MealPlan).filter(MealPlan.id == empty_plan_id).first() is None
def test_generate_endpoint_returns_404_for_unknown_family(client):
body = {
"family_profile_id": str(uuid4()),
+246
View File
@@ -11,6 +11,37 @@ pytestmark = pytest.mark.requires_postgres
WEEK = date(2026, 5, 8) # a Friday
def _add_quarantined_item(db, meal_plan, *, name, approval_status, day_of_week):
from app.models import (
MealPlanItem,
MealType,
Recipe,
RecipeCatalogStatus,
)
recipe = Recipe(
id=uuid4(),
name=name,
servings=4,
ingredients=[{"name": "legacy ingredient", "qty": "1", "unit": "cup"}],
instructions=["Do not include this legacy recipe."],
catalog_status=RecipeCatalogStatus.QUARANTINED,
)
db.add(recipe)
db.flush()
item = MealPlanItem(
id=uuid4(),
meal_plan_id=meal_plan.id,
recipe_id=recipe.id,
day_of_week=day_of_week,
meal_type=MealType.DINNER,
approval_status=approval_status,
)
db.add(item)
db.flush()
return item
@pytest.fixture()
def family(db):
from app.models import FamilyProfile
@@ -339,6 +370,38 @@ def test_step_email_escapes_recipe_name(
assert "&lt;script&gt;" in sent[0]["html"]
def test_step_email_suppresses_quarantined_recipes(
db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch
):
from app.models import MealPlanItemStatus
_add_quarantined_item(
db,
meal_plan,
name="Legacy Mango Pork",
approval_status=MealPlanItemStatus.pending,
day_of_week=6,
)
sent = []
class FakeBackend:
def send(self, **kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"app.services.orchestrator.steps.get_email_backend",
lambda: FakeBackend(),
)
from app.services.orchestrator.steps import step_email
step_email(weekly_run_generated, db)
assert len(sent) == 1
assert "Test Pasta" in sent[0]["html"]
assert "Legacy Mango Pork" not in sent[0]["html"]
assert "legacy ingredient" not in sent[0]["html"]
# ── step_reminder ──────────────────────────────────────────────────────────
@@ -447,6 +510,37 @@ def test_step_reminder_no_sends_when_all_voted(
assert weekly_run_emailed.reminded_at is not None
def test_step_reminder_suppresses_quarantined_recipes(
db, weekly_run_emailed, meal_plan, pending_item, member, monkeypatch
):
from app.models import MealPlanItemStatus
_add_quarantined_item(
db,
meal_plan,
name="Legacy Mango Pork",
approval_status=MealPlanItemStatus.pending,
day_of_week=6,
)
sent = []
class FakeBackend:
def send(self, **kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"app.services.orchestrator.steps.get_email_backend",
lambda: FakeBackend(),
)
from app.services.orchestrator.steps import step_reminder
step_reminder(weekly_run_emailed, db)
assert len(sent) == 1
assert "Test Pasta" in sent[0]["html"]
assert "Legacy Mango Pork" not in sent[0]["html"]
# ---------------------------------------------------------------------------
# step_deadline tests
# ---------------------------------------------------------------------------
@@ -573,6 +667,158 @@ def test_step_finalize_no_approved_sends_empty_message(db, weekly_run_deadline_p
assert "No meals were approved" in sent[0]["html"]
def test_step_finalize_suppresses_quarantined_recipes(
db, weekly_run_deadline_passed, meal_plan, approved_item, member, monkeypatch
):
from app.models import MealPlanItemStatus
_add_quarantined_item(
db,
meal_plan,
name="Legacy Mango Pork",
approval_status=MealPlanItemStatus.approved,
day_of_week=7,
)
sent = []
class FakeBackend:
def send(self, **kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"app.services.orchestrator.steps.get_email_backend",
lambda: FakeBackend(),
)
from app.services.orchestrator.steps import step_finalize
step_finalize(weekly_run_deadline_passed, db)
assert len(sent) == 1
assert "Approved Chicken" in sent[0]["html"]
assert "Legacy Mango Pork" not in sent[0]["html"]
assert "legacy ingredient" not in sent[0]["html"]
def test_step_finalize_stages_candidates_without_publishing(
db, weekly_run_deadline_passed, meal_plan, member, monkeypatch
):
from types import SimpleNamespace
from app.models import Recipe, RecipeImportStaging, RecipeImportStatus
class FakeBackend:
def send(self, **kwargs):
pass
analysis = SimpleNamespace(discovery_queries=["apple dinner"], confidence=0.9)
analysis.to_dict = lambda: {
"discovery_queries": analysis.discovery_queries,
"confidence": analysis.confidence,
}
monkeypatch.setattr(
"app.services.orchestrator.steps.get_email_backend", lambda: FakeBackend()
)
monkeypatch.setattr(
"app.services.orchestrator.steps.FeedbackAnalyzer.analyze",
lambda self, database, family_id: analysis,
)
external_id = str(uuid4())
def stage(service, database, limit, queries):
assert queries == ["apple dinner"]
row = RecipeImportStaging(
source="wikibooks",
external_id=external_id,
source_url="https://en.wikibooks.org/wiki/Cookbook:Finalize_Test",
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": "Cookbook:Finalize Test"},
payload_sha256="a" * 64,
normalized_recipe={"name": "Finalize Test"},
validation_report={"valid": True, "errors": [], "warnings": []},
status=RecipeImportStatus.PENDING,
)
database.add(row)
database.flush()
return [row]
monkeypatch.setattr(
"app.services.orchestrator.steps.WikibooksImportService.stage_discovered_pages",
stage,
)
before = db.query(Recipe).count()
from app.services.orchestrator.steps import step_finalize
step_finalize(weekly_run_deadline_passed, db)
assert weekly_run_deadline_passed.status == "completed"
assert (
db.query(RecipeImportStaging).filter_by(external_id=external_id).count() == 1
)
assert db.query(Recipe).count() == before
def test_step_finalize_rolls_back_partial_staging_failure(
db, weekly_run_deadline_passed, meal_plan, member, monkeypatch
):
from types import SimpleNamespace
from app.models import RecipeImportStaging, RecipeImportStatus
class FakeBackend:
def send(self, **kwargs):
pass
analysis = SimpleNamespace(discovery_queries=["apple dinner"], confidence=0.9)
analysis.to_dict = lambda: {
"discovery_queries": analysis.discovery_queries,
"confidence": analysis.confidence,
}
monkeypatch.setattr(
"app.services.orchestrator.steps.get_email_backend", lambda: FakeBackend()
)
monkeypatch.setattr(
"app.services.orchestrator.steps.FeedbackAnalyzer.analyze",
lambda self, database, family_id: analysis,
)
external_id = str(uuid4())
def stage_then_fail(service, database, limit, queries):
assert queries == ["apple dinner"]
database.add(
RecipeImportStaging(
source="wikibooks",
external_id=external_id,
source_url="https://en.wikibooks.org/wiki/Cookbook:Partial_Test",
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": "Cookbook:Partial Test"},
payload_sha256="b" * 64,
normalized_recipe={"name": "Partial Test"},
validation_report={"valid": True, "errors": [], "warnings": []},
status=RecipeImportStatus.PENDING,
)
)
database.flush()
raise RuntimeError("upstream failed after first page")
monkeypatch.setattr(
"app.services.orchestrator.steps.WikibooksImportService.stage_discovered_pages",
stage_then_fail,
)
from app.services.orchestrator.steps import step_finalize
step_finalize(weekly_run_deadline_passed, db)
assert weekly_run_deadline_passed.status == "completed"
assert (
db.query(RecipeImportStaging).filter_by(external_id=external_id).count() == 0
)
# ---------------------------------------------------------------------------
# runner tests
# ---------------------------------------------------------------------------
+90
View File
@@ -87,3 +87,93 @@ def test_generate_meal_plan_against_seeded_data():
finally:
cleanup.close()
work.close()
def test_generate_meal_plan_uses_three_active_calorie_eligible_recipes(db):
from app.models import (
FamilyProfile,
MealPlanItem,
MealType,
Recipe,
RecipeCatalogStatus,
)
from app.services.planner.generate import generate_meal_plan
db.query(Recipe).update(
{Recipe.catalog_status: RecipeCatalogStatus.QUARANTINED},
synchronize_session=False,
)
family = FamilyProfile(
id=uuid4(),
name="Three Dinner Family",
household_size=2,
adult_count=2,
child_count=0,
calorie_target=400,
planner_config={"set_size": 7},
)
db.add(family)
eligible = [
Recipe(
id=uuid4(),
name=f"Lower Calorie Dinner {index}",
servings=4,
calories_per_serving=calories,
prep_time_minutes=10,
cook_time_minutes=20,
ingredients=[],
instructions=["Cook."],
catalog_status=RecipeCatalogStatus.ACTIVE,
)
for index, calories in enumerate((360, 400, 440), start=1)
]
high_calorie = Recipe(
id=uuid4(),
name="Old High Calorie Dinner",
servings=4,
calories_per_serving=700,
prep_time_minutes=10,
cook_time_minutes=20,
ingredients=[],
instructions=["Cook."],
catalog_status=RecipeCatalogStatus.ACTIVE,
)
quarantined = Recipe(
id=uuid4(),
name="Quarantined Legacy Dinner",
servings=4,
calories_per_serving=400,
prep_time_minutes=10,
cook_time_minutes=20,
ingredients=[],
instructions=["Cook."],
catalog_status=RecipeCatalogStatus.QUARANTINED,
)
db.add_all([*eligible, high_calorie, quarantined])
db.flush()
result = generate_meal_plan(
db,
family_id=family.id,
week_start_date=date(2026, 8, 31),
today=date(2026, 8, 31),
)
items = (
db.query(MealPlanItem)
.filter(MealPlanItem.meal_plan_id == result.meal_plan_id)
.order_by(MealPlanItem.day_of_week)
.all()
)
assert {selected.recipe_id for selected in result.selected} == {
recipe.id for recipe in eligible
}
assert [(item.day_of_week, item.meal_type) for item in items] == [
(1, MealType.DINNER),
(3, MealType.DINNER),
(5, MealType.DINNER),
]
assert high_calorie.id not in {item.recipe_id for item in items}
assert quarantined.id not in {item.recipe_id for item in items}
assert result.rejected_summary["calories"] == 1
+400
View File
@@ -0,0 +1,400 @@
"""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
+384
View File
@@ -0,0 +1,384 @@
from __future__ import annotations
import copy
import itertools
import json
import os
import threading
from pathlib import Path
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("ADMIN_EMAIL", "reviewer@example.com")
from app.models import Ingredient, Recipe, RecipeImportStaging, RecipeImportStatus
from app.services.wikibooks_import import WikibooksImportService
from app.services.recipe_import_review import (
RecipeImportConflictError,
approve_recipe_import,
)
pytestmark = pytest.mark.requires_postgres
FIXTURES = Path(__file__).parent / "fixtures" / "wikibooks"
ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"}
_IDENTITIES = itertools.count(1)
def _stage(db, fixture: str = "apple_glazed_pork_chops_parse.json"):
payload = json.loads((FIXTURES / fixture).read_text(encoding="utf-8"))["parse"]
identity = next(_IDENTITIES) * 1_000_000
payload["pageid"] += identity
payload["revid"] += identity
staged = WikibooksImportService("MealPlanner admin API tests").stage_parse_payload(
db, payload
)
db.commit()
return staged, payload
@pytest.fixture
def api_client():
from fastapi.testclient import TestClient
from app.main import app
with TestClient(app) as test_client:
yield test_client
def test_recipe_import_routes_require_admin(api_client):
response = api_client.get("/api/admin/recipe-imports")
assert response.status_code == 401
def test_list_filters_and_detail_exposes_provenance(api_client, db_session):
pending, _ = _stage(db_session)
pending.source = "wikibooks-list-test"
db_session.commit()
quarantined, _ = _stage(db_session, "apple_glaze_missing_apple_parse.json")
response = api_client.get(
"/api/admin/recipe-imports?status=pending&source=wikibooks-list-test&limit=1",
headers=ADMIN_HEADERS,
)
assert response.status_code == 200, response.text
body = response.json()
assert body["total"] == 1
assert body["has_next"] is False
assert body["items"][0]["id"] == str(pending.id)
assert body["items"][0]["name"] == "Apple Glazed Pork Chops"
response = api_client.get(
f"/api/admin/recipe-imports/{quarantined.id}", headers=ADMIN_HEADERS
)
assert response.status_code == 200, response.text
detail = response.json()
assert detail["status"] == "quarantined"
assert detail["license_name"] == "CC BY-SA 4.0"
assert detail["raw_payload"]["revid"] == quarantined.raw_payload["revid"]
assert detail["validation_report"]["valid"] is False
def test_approve_publishes_atomically_and_is_idempotent(api_client, db_session):
staged, _ = _stage(db_session)
staged_id = staged.id
external_id = staged.external_id
response = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert response.status_code == 201, response.text
approved = response.json()
assert approved["status"] == "approved"
assert approved["reviewed_by"] == "admin"
assert approved["recipe_id"] is not None
db_session.expire_all()
recipe = db_session.get(Recipe, approved["recipe_id"])
assert recipe is not None
assert recipe.family_profile_id is None
assert recipe.image_url is None
assert recipe.external_source == "wikibooks"
assert recipe.external_id == external_id
assert recipe.cook_time_minutes == 30
assert len(recipe.ingredients) == 4
ingredient_ids = [item["ingredient_id"] for item in recipe.ingredients]
assert (
db_session.query(Ingredient)
.filter(Ingredient.id.in_(ingredient_ids))
.count()
== 4
)
repeated = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert repeated.status_code == 200, repeated.text
assert repeated.json()["recipe_id"] == approved["recipe_id"]
assert db_session.query(Recipe).filter(Recipe.id == recipe.id).count() == 1
def test_approved_import_can_republish_after_recipe_deletion(api_client, db_session):
staged, _ = _stage(db_session)
first = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/approve", headers=ADMIN_HEADERS
)
assert first.status_code == 201, first.text
first_recipe_id = first.json()["recipe_id"]
recipe = db_session.get(Recipe, first_recipe_id)
db_session.delete(recipe)
db_session.commit()
repeated = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/approve", headers=ADMIN_HEADERS
)
assert repeated.status_code == 201, repeated.text
assert repeated.json()["recipe_id"] != first_recipe_id
assert repeated.json()["status"] == "approved"
def test_approve_rejects_quarantined_candidate(api_client, db_session):
staged, _ = _stage(db_session, "apple_glaze_missing_apple_parse.json")
staged_id = staged.id
external_id = staged.external_id
response = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert response.status_code == 409, response.text
assert (
db_session.query(Recipe)
.filter(Recipe.external_source == "wikibooks", Recipe.external_id == external_id)
.count()
== 0
)
db_session.expire_all()
unchanged = db_session.get(RecipeImportStaging, staged_id)
assert unchanged.status == RecipeImportStatus.QUARANTINED
assert unchanged.reviewed_at is None
def test_reject_is_idempotent_and_blocks_later_approval(api_client, db_session):
staged, _ = _stage(db_session)
staged_id = staged.id
external_id = staged.external_id
request = {"reason": "Instructions need editorial review."}
response = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/reject",
headers=ADMIN_HEADERS,
json=request,
)
assert response.status_code == 200, response.text
rejected = response.json()
assert rejected["status"] == "rejected"
assert rejected["rejection_reason"] == request["reason"]
assert rejected["reviewed_by"] == "admin"
repeated = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/reject",
headers=ADMIN_HEADERS,
json=request,
)
assert repeated.status_code == 200, repeated.text
approve = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert approve.status_code == 409, approve.text
assert (
db_session.query(Recipe)
.filter(Recipe.external_source == "wikibooks", Recipe.external_id == external_id)
.count()
== 0
)
def test_reject_accepts_quarantined_candidate(api_client, db_session):
staged, _ = _stage(db_session, "apple_glaze_missing_apple_parse.json")
response = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/reject",
headers=ADMIN_HEADERS,
json={"reason": "Title does not match the ingredient list."},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "rejected"
def test_new_source_revision_conflicts_with_published_page(api_client, db_session):
first, payload = _stage(db_session)
first_id = first.id
second_payload = copy.deepcopy(payload)
second_payload["revid"] += 1
second = WikibooksImportService(
"MealPlanner admin API tests"
).stage_parse_payload(db_session, second_payload)
db_session.commit()
second_id = second.id
first_response = api_client.post(
f"/api/admin/recipe-imports/{first_id}/approve", headers=ADMIN_HEADERS
)
assert first_response.status_code == 201, first_response.text
second_response = api_client.post(
f"/api/admin/recipe-imports/{second_id}/approve", headers=ADMIN_HEADERS
)
assert second_response.status_code == 409, second_response.text
db_session.expire_all()
assert (
db_session.get(RecipeImportStaging, second_id).status
== RecipeImportStatus.PENDING
)
assert (
db_session.query(Recipe)
.filter(Recipe.external_source == "wikibooks", Recipe.external_id == first.external_id)
.count()
== 1
)
def test_checksum_conflict_rolls_back_review_state(api_client, db_session):
staged, _ = _stage(db_session)
staged_id = staged.id
external_id = staged.external_id
staged.payload_sha256 = "0" * 64
db_session.commit()
response = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert response.status_code == 409, response.text
assert (
db_session.query(Recipe)
.filter(Recipe.external_source == "wikibooks", Recipe.external_id == external_id)
.count()
== 0
)
db_session.expire_all()
unchanged = db_session.get(RecipeImportStaging, staged_id)
assert unchanged.status == RecipeImportStatus.PENDING
assert unchanged.reviewed_at is None
assert unchanged.recipe_id is None
def test_oversized_numeric_fields_are_quarantined_before_approval(
api_client, db_session
):
payload = json.loads(
(FIXTURES / "apple_glazed_pork_chops_parse.json").read_text(
encoding="utf-8"
)
)["parse"]
identity = next(_IDENTITIES) * 1_000_000
payload["pageid"] += identity
payload["revid"] += identity
payload["text"] = payload["text"].replace(
"<th>Servings</th><td>4</td>",
"<th>Servings</th><td>2147483648</td>",
)
staged = WikibooksImportService(
"MealPlanner admin API tests"
).stage_parse_payload(db_session, payload)
db_session.commit()
assert staged.status == RecipeImportStatus.QUARANTINED
assert any(
error["code"] == "servings_out_of_range"
for error in staged.validation_report["errors"]
)
response = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/approve", headers=ADMIN_HEADERS
)
assert response.status_code == 409, response.text
def test_concurrent_revision_approval_publishes_only_one_recipe(db_session):
first, payload = _stage(db_session)
second_payload = copy.deepcopy(payload)
second_payload["revid"] += 1
second = WikibooksImportService(
"MealPlanner admin API tests"
).stage_parse_payload(db_session, second_payload)
db_session.commit()
engine = db_session.get_bind()
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
barrier = threading.Barrier(2)
outcomes: list[str] = []
def approve(import_id):
session = Session()
try:
barrier.wait(timeout=5)
approve_recipe_import(session, import_id, "concurrent-reviewer")
outcomes.append("approved")
except (IntegrityError, RecipeImportConflictError):
session.rollback()
outcomes.append("conflict")
finally:
session.close()
threads = [
threading.Thread(target=approve, args=(first.id,)),
threading.Thread(target=approve, args=(second.id,)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
assert not thread.is_alive()
assert sorted(outcomes) == ["approved", "conflict"]
db_session.expire_all()
assert (
db_session.query(Recipe)
.filter(
Recipe.external_source == "wikibooks",
Recipe.external_id == first.external_id,
)
.count()
== 1
)
def test_invalid_token_cannot_mutate_import(api_client, db_session):
staged, _ = _stage(db_session)
response = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/approve",
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401
db_session.expire_all()
assert (
db_session.get(RecipeImportStaging, staged.id).status
== RecipeImportStatus.PENDING
)
@pytest.mark.parametrize(
"url",
[
"/api/admin/recipe-imports?status=unknown",
"/api/admin/recipe-imports?limit=0",
"/api/admin/recipe-imports?offset=-1",
],
)
def test_list_rejects_invalid_filters(api_client, url):
response = api_client.get(url, headers=ADMIN_HEADERS)
assert response.status_code == 422
def test_reject_requires_nonblank_reason(api_client, db_session):
staged, _ = _stage(db_session)
response = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/reject",
headers=ADMIN_HEADERS,
json={"reason": " "},
)
assert response.status_code == 422
+184
View File
@@ -0,0 +1,184 @@
"""Focused persistence tests for global recipe import staging."""
from __future__ import annotations
import importlib.util
import os
import pathlib
import subprocess
from urllib.parse import urlparse
import pytest
from sqlalchemy import CheckConstraint, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import DBAPIError, IntegrityError
from app.models import RecipeImportStaging, RecipeImportStatus
BACKEND_ROOT = pathlib.Path(__file__).resolve().parent.parent
MIGRATION_PATH = BACKEND_ROOT / "alembic" / "versions" / "0017_recipe_import_staging.py"
def _staged_recipe(**overrides):
payload = {
"source": "wikibooks",
"revision": "12345",
"title": "Tomato Soup",
}
values = {
"source": "wikibooks",
"external_id": "Cookbook:Tomato_Soup",
"source_url": "https://en.wikibooks.org/wiki/Cookbook:Tomato_Soup",
"source_revision": "12345",
"license_name": "CC BY-SA 4.0",
"license_url": "https://creativecommons.org/licenses/by-sa/4.0/",
"attribution": "Wikibooks contributors",
"raw_payload": payload,
"payload_sha256": "a" * 64,
"normalized_recipe": {"name": "Tomato Soup"},
"validation_report": {"valid": True, "errors": []},
}
values.update(overrides)
return RecipeImportStaging(**values)
def test_recipe_import_staging_model_metadata():
table = RecipeImportStaging.__table__
assert "family_profile_id" not in table.c
assert set(table.c.keys()) == {
"id",
"source",
"external_id",
"source_url",
"source_revision",
"license_name",
"license_url",
"attribution",
"raw_payload",
"payload_sha256",
"normalized_recipe",
"validation_report",
"status",
"rejection_reason",
"recipe_id",
"acquired_at",
"reviewed_at",
"reviewed_by",
}
assert isinstance(table.c.raw_payload.type, JSONB)
assert isinstance(table.c.normalized_recipe.type, JSONB)
assert isinstance(table.c.validation_report.type, JSONB)
assert table.c.status.type.enums == [
"pending",
"quarantined",
"approved",
"rejected",
]
assert table.c.status.default.arg is RecipeImportStatus.PENDING
assert table.c.status.server_default.arg == "pending"
assert table.c.acquired_at.nullable is False
assert table.c.recipe_id.nullable is True
foreign_key = next(iter(table.c.recipe_id.foreign_keys))
assert foreign_key.target_fullname == "recipe.id"
assert foreign_key.ondelete == "SET NULL"
unique_constraints = {
tuple(column.name for column in constraint.columns)
for constraint in table.constraints
if isinstance(constraint, UniqueConstraint)
}
assert ("source", "external_id", "source_revision") in unique_constraints
check_constraints = {
constraint.name: str(constraint.sqltext)
for constraint in table.constraints
if isinstance(constraint, CheckConstraint)
}
assert "payload_sha256" in check_constraints[
"ck_recipe_import_staging_payload_sha256"
]
assert "reviewed_at" in check_constraints[
"ck_recipe_import_staging_review_metadata"
]
index = next(
index
for index in table.indexes
if index.name == "ix_recipe_import_staging_status_acquired_at"
)
assert [column.name for column in index.columns] == ["status", "acquired_at"]
def test_recipe_import_staging_migration_follows_0016():
spec = importlib.util.spec_from_file_location("recipe_import_staging", MIGRATION_PATH)
assert spec and spec.loader
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
assert migration.revision == "0017"
assert migration.down_revision == "0016"
@pytest.mark.requires_postgres
def test_recipe_import_staging_alembic_round_trip_from_0016():
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_0016 = alembic("upgrade", "0016")
assert at_0016.returncode == 0, at_0016.stderr
at_0017 = alembic("upgrade", "0017")
assert at_0017.returncode == 0, at_0017.stderr
back_to_0016 = alembic("downgrade", "0016")
assert back_to_0016.returncode == 0, back_to_0016.stderr
back_to_0017 = alembic("upgrade", "0017")
assert back_to_0017.returncode == 0, back_to_0017.stderr
finally:
# Never cross irreversible 0015; leave the dedicated test database at head.
restore_head = alembic("upgrade", "head")
assert restore_head.returncode == 0, restore_head.stderr
@pytest.mark.requires_postgres
def test_recipe_import_staging_versions_revisions_and_preserves_raw_payload(db):
staged = _staged_recipe()
revised = _staged_recipe(source_revision="12346")
db.add_all([staged, revised])
db.flush()
assert staged.status is RecipeImportStatus.PENDING
assert staged.acquired_at is not None
with db.begin_nested():
db.add(_staged_recipe())
with pytest.raises(IntegrityError):
db.flush()
with db.begin_nested():
staged.raw_payload = {"source": "wikibooks", "revision": "tampered"}
with pytest.raises(DBAPIError, match="raw_payload is immutable"):
db.flush()
db.refresh(staged)
assert staged.raw_payload["revision"] == "12345"
+23 -1
View File
@@ -3,7 +3,7 @@ from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.schemas.recipe import RecipeCreate, RecipeIngredientRef, ResolveIngredientRequest
from app.schemas.recipe import RecipeCreate, RecipeIngredientRef, RecipeRead, ResolveIngredientRequest
def test_recipe_create_requires_canonical_ingredient_ids() -> None:
@@ -36,6 +36,28 @@ def test_recipe_create_rejects_empty_ingredients() -> None:
)
def test_recipe_read_allows_nullable_discovered_recipe_fields() -> None:
iid = uuid4()
payload = RecipeRead(
id=uuid4(),
name="Imported Recipe With Sparse Metadata",
servings=4,
prep_time_minutes=None,
cook_time_minutes=None,
cuisine_tags=[],
dietary_tags=[],
protein_type=None,
ingredients=[RecipeIngredientRef(ingredient_id=iid, qty=1.0, unit="ea")],
instructions=["Cook until done"],
side_dishes=None,
)
assert payload.prep_time_minutes is None
assert payload.cook_time_minutes is None
assert payload.protein_type is None
def test_resolve_ingredient_request_round_trip() -> None:
req = ResolveIngredientRequest(text="1 lb chicken thighs")
assert req.text == "1 lb chicken thighs"
@@ -0,0 +1,75 @@
"""Regression tests for retired direct third-party acquisition endpoints."""
from types import SimpleNamespace
from uuid import uuid4
import pytest
pytestmark = pytest.mark.requires_postgres
def _login(client):
response = client.post("/api/auth/login", json={"password": "test-family-password"})
assert response.status_code == 204, response.text
def test_direct_recipe_search_and_import_are_gone(client):
_login(client)
search = client.get("/api/recipes/search", params={"q": "apple", "limit": 5})
assert search.status_code == 410
assert "staged and approved" in search.json()["detail"]
imported = client.post(
"/api/recipes/import",
json={"external_id": "123", "external_source": "spoonacular"},
)
assert imported.status_code == 410
assert "staged and approved" in imported.json()["detail"]
def test_admin_discovery_stages_without_publishing(client, db, monkeypatch):
from app.models import FamilyProfile, Recipe, RecipeImportStatus
family = FamilyProfile(
name=f"Staging Family {uuid4()}",
household_size=2,
adult_count=2,
child_count=0,
)
db.add(family)
db.flush()
analysis = SimpleNamespace(
discovery_queries=["apple dinner"],
confidence=0.9,
)
analysis.to_dict = lambda: {
"discovery_queries": analysis.discovery_queries,
"confidence": analysis.confidence,
}
monkeypatch.setattr(
"app.api.admin.FeedbackAnalyzer.analyze",
lambda self, database, family_id: analysis,
)
staged = [
SimpleNamespace(status=RecipeImportStatus.PENDING),
SimpleNamespace(status=RecipeImportStatus.QUARANTINED),
]
monkeypatch.setattr(
"app.api.admin.WikibooksImportService.stage_discovered_pages",
lambda self, database, limit, queries: staged,
)
before = db.query(Recipe).count()
response = client.post(
"/api/admin/trigger-discovery",
params={"family_profile_id": str(family.id), "limit": 2},
headers={"Authorization": "Bearer test-admin-token"},
)
assert response.status_code == 200, response.text
assert response.json()["candidates_staged"] == 2
assert response.json()["pending"] == 1
assert response.json()["quarantined"] == 1
assert response.json()["recipes_added"] == 0
assert db.query(Recipe).count() == before
@@ -0,0 +1,606 @@
"""Offline and PostgreSQL coverage for the bundled starter recipe loader."""
from __future__ import annotations
import copy
import threading
from datetime import date
from queue import Empty, Queue
import pytest
from sqlalchemy import event
from sqlalchemy.orm import sessionmaker
from app.models import (
Ingredient,
MealPlan,
MealPlanItem,
MealType,
Recipe,
RecipeCatalogStatus,
)
from app.services.starter_recipe_catalog import (
DEFAULT_CATALOG_PATH,
MAX_CALORIES_PER_SERVING,
STARTER_CATALOG_VERSION,
STARTER_DISCOVERY_REASON,
STARTER_RECIPE_COUNT,
StarterRecipeCatalogError,
load_bundled_starter_catalog,
load_starter_catalog,
read_starter_catalog,
validate_starter_catalog,
)
@pytest.fixture(name="db")
def starter_catalog_db(_engine):
"""Use a commit-aware session and remove only rows created by each loader test."""
if _engine is None:
pytest.skip("Postgres not reachable")
Session = sessionmaker(
bind=_engine,
autocommit=False,
autoflush=False,
expire_on_commit=False,
)
baseline = Session()
try:
recipe_ids = {recipe.id for recipe in baseline.query(Recipe).all()}
ingredient_ids = {ingredient.id for ingredient in baseline.query(Ingredient).all()}
meal_plan_ids = {meal_plan.id for meal_plan in baseline.query(MealPlan).all()}
meal_plan_item_ids = {
meal_plan_item.id for meal_plan_item in baseline.query(MealPlanItem).all()
}
finally:
baseline.rollback()
baseline.close()
session = Session()
try:
yield session
finally:
session.rollback()
session.close()
cleanup = Session()
try:
for meal_plan_item in cleanup.query(MealPlanItem).all():
if meal_plan_item.id not in meal_plan_item_ids:
cleanup.delete(meal_plan_item)
cleanup.flush()
for meal_plan in cleanup.query(MealPlan).all():
if meal_plan.id not in meal_plan_ids:
cleanup.delete(meal_plan)
cleanup.flush()
for recipe in cleanup.query(Recipe).all():
if recipe.id not in recipe_ids:
cleanup.delete(recipe)
cleanup.flush()
for ingredient in cleanup.query(Ingredient).all():
if ingredient.id not in ingredient_ids:
cleanup.delete(ingredient)
cleanup.commit()
finally:
cleanup.rollback()
cleanup.close()
def _catalog() -> dict[str, object]:
return dict(read_starter_catalog(DEFAULT_CATALOG_PATH))
def _catalog_recipe(catalog: dict[str, object], name: str) -> dict[str, object]:
return next(
recipe for recipe in catalog["recipes"] if recipe["name"] == name # type: ignore[index]
)
def _prior_catalog() -> dict[str, object]:
catalog = copy.deepcopy(_catalog())
for recipe in catalog["recipes"]: # type: ignore[index]
recipe["description"] = "A prior bundled formulation before the version two update."
recipe["calories_per_serving"] = (
recipe["calories_per_serving"] - 1
if recipe["calories_per_serving"] == MAX_CALORIES_PER_SERVING
else recipe["calories_per_serving"] + 1
)
recipe["ingredients"][0]["quantity"] += 1
recipe["instructions"][0] = (
"Prepare the prior bundled formulation with measured ingredients and careful cooking."
)
return catalog
def _ingredient_values(db, recipe: Recipe) -> list[tuple[str, object, object]]:
ingredients = {
str(ingredient.id): ingredient.name_lower
for ingredient in db.query(Ingredient)
.filter(Ingredient.id.in_([item["ingredient_id"] for item in recipe.ingredients]))
.all()
}
return [
(ingredients[item["ingredient_id"]], item["qty"], item["unit"])
for item in recipe.ingredients
]
def _unrelated_recipes() -> tuple[Recipe, Recipe]:
approved = Recipe(
name="Rigatoni Pasta with Ricotta in Tomato Cream Sauce",
description="An already approved Wikibooks provenance row.",
servings=4,
ingredients=[],
instructions=[
"Keep the approved provenance recipe unchanged during catalog loading."
],
is_manually_added=False,
external_source="wikibooks",
external_id="approved-rigatoni-provenance-row",
source_url="https://en.wikibooks.org/wiki/Cookbook:Rigatoni",
discovery_reason="Approved import from revision 1",
catalog_status=RecipeCatalogStatus.ACTIVE,
catalog_findings=[],
)
legacy = Recipe(
name="Legacy quarantined recipe remains unchanged",
description="A historical row outside the bundled catalog.",
servings=2,
ingredients=[],
instructions=[
"Keep historical links to this quarantined recipe available without changes."
],
is_manually_added=False,
catalog_status=RecipeCatalogStatus.QUARANTINED,
catalog_findings=[{"code": "legacy", "severity": "error"}],
)
return approved, legacy
def test_default_catalog_path_is_validated_as_the_complete_catalog():
assert DEFAULT_CATALOG_PATH.is_file()
catalog = _catalog()
recipes = validate_starter_catalog(catalog)
assert catalog["version"] == STARTER_CATALOG_VERSION
assert len(recipes) == STARTER_RECIPE_COUNT
assert len({recipe["name"] for recipe in recipes}) == STARTER_RECIPE_COUNT
assert max(recipe["calories_per_serving"] for recipe in recipes) <= MAX_CALORIES_PER_SERVING
@pytest.mark.parametrize("version", [1, 3])
def test_validation_rejects_catalog_versions_other_than_two(version):
catalog = copy.deepcopy(_catalog())
catalog["version"] = version
with pytest.raises(StarterRecipeCatalogError, match="Catalog version must be 2"):
validate_starter_catalog(catalog)
def test_validation_rejects_calories_per_serving_over_limit():
catalog = copy.deepcopy(_catalog())
catalog["recipes"][0]["calories_per_serving"] = MAX_CALORIES_PER_SERVING + 1 # type: ignore[index]
with pytest.raises(
StarterRecipeCatalogError,
match="calories_per_serving must be an integer between 1 and 500",
):
validate_starter_catalog(catalog)
def test_validation_rejects_partial_catalog_before_database_access():
catalog = copy.deepcopy(_catalog())
catalog["recipes"] = catalog["recipes"][:-1] # type: ignore[index]
class NoDatabase:
def __getattr__(self, name):
raise AssertionError(f"Database access attempted through {name}")
with pytest.raises(StarterRecipeCatalogError, match="exactly 50"):
load_starter_catalog(NoDatabase(), catalog) # type: ignore[arg-type]
def test_nonstandard_json_number_is_a_catalog_error(tmp_path):
invalid_catalog = tmp_path / "invalid_catalog.json"
invalid_catalog.write_text('{"version": NaN, "recipes": []}', encoding="utf-8")
with pytest.raises(StarterRecipeCatalogError, match="not valid JSON"):
read_starter_catalog(invalid_catalog)
@pytest.mark.requires_postgres
def test_dry_run_validates_actual_catalog_without_writes(db):
before = (
db.query(Recipe).count(),
db.query(Ingredient).count(),
db.query(Recipe).filter(Recipe.discovery_reason == STARTER_DISCOVERY_REASON).count(),
)
db.rollback()
summary = load_bundled_starter_catalog(db, dry_run=True)
assert summary["dry_run"] is True
assert summary["validated_recipes"] == STARTER_RECIPE_COUNT
assert summary["created_recipes"] == STARTER_RECIPE_COUNT
after = (
db.query(Recipe).count(),
db.query(Ingredient).count(),
db.query(Recipe).filter(Recipe.discovery_reason == STARTER_DISCOVERY_REASON).count(),
)
assert after == before
@pytest.mark.requires_postgres
def test_loader_uses_actual_catalog_and_preserves_unrelated_rows(db):
catalog = _catalog()
approved, legacy = _unrelated_recipes()
db.add_all([approved, legacy])
db.commit()
approved_before = {
"name": approved.name,
"external_source": approved.external_source,
"external_id": approved.external_id,
"source_url": approved.source_url,
"discovery_reason": approved.discovery_reason,
"catalog_status": approved.catalog_status,
}
legacy_before = {
"name": legacy.name,
"catalog_status": legacy.catalog_status,
"catalog_findings": legacy.catalog_findings,
}
db.rollback()
first = load_bundled_starter_catalog(db)
assert first["created_recipes"] == STARTER_RECIPE_COUNT
assert (
db.query(Recipe)
.filter(Recipe.discovery_reason == STARTER_DISCOVERY_REASON)
.count()
== STARTER_RECIPE_COUNT
)
loaded = (
db.query(Recipe)
.filter(Recipe.name == catalog["recipes"][0]["name"]) # type: ignore[index]
.one()
)
expected = _catalog_recipe(catalog, loaded.name)
assert _ingredient_values(db, loaded) == [
(" ".join(item["name"].split()).casefold(), item["quantity"], item["unit"])
for item in expected["ingredients"]
]
approved_after = db.get(Recipe, approved.id)
legacy_after = db.get(Recipe, legacy.id)
assert {
"name": approved_after.name,
"external_source": approved_after.external_source,
"external_id": approved_after.external_id,
"source_url": approved_after.source_url,
"discovery_reason": approved_after.discovery_reason,
"catalog_status": approved_after.catalog_status,
} == approved_before
assert {
"name": legacy_after.name,
"catalog_status": legacy_after.catalog_status,
"catalog_findings": legacy_after.catalog_findings,
} == legacy_before
db.rollback()
second = load_bundled_starter_catalog(db)
assert second["created_recipes"] == 0
assert second["existing_recipes"] == STARTER_RECIPE_COUNT
@pytest.mark.requires_postgres
@pytest.mark.parametrize("field", ["description", "instructions", "ingredients"])
def test_loader_rejects_drifted_bundled_content(db, field):
catalog = _catalog()
load_bundled_starter_catalog(db)
drifted = (
db.query(Recipe)
.filter(Recipe.name == catalog["recipes"][0]["name"]) # type: ignore[index]
.one()
)
if field == "description":
drifted.description = "This description was changed after the initial catalog load."
elif field == "instructions":
drifted.instructions = ["This instruction was changed after the initial catalog load."]
else:
ingredients = copy.deepcopy(drifted.ingredients)
ingredients[0]["qty"] += 1
drifted.ingredients = ingredients
db.commit()
with pytest.raises(StarterRecipeCatalogError, match="content has drifted"):
load_bundled_starter_catalog(db)
@pytest.mark.requires_postgres
def test_update_existing_preserves_recipe_and_meal_plan_item_ids(db):
catalog = _catalog()
load_starter_catalog(db, _prior_catalog())
target = (
db.query(Recipe)
.filter(Recipe.name == catalog["recipes"][0]["name"]) # type: ignore[index]
.one()
)
recipe_id = target.id
prior_ingredients = copy.deepcopy(target.ingredients)
meal_plan = MealPlan(week_start_date=date(2026, 1, 5))
db.add(meal_plan)
db.flush()
meal_plan_item = MealPlanItem(
meal_plan_id=meal_plan.id,
recipe_id=recipe_id,
day_of_week=1,
meal_type=MealType.DINNER,
)
db.add(meal_plan_item)
db.commit()
with pytest.raises(StarterRecipeCatalogError, match="content has drifted"):
load_bundled_starter_catalog(db)
summary = load_bundled_starter_catalog(db, update_existing=True)
assert summary["created_recipes"] == 0
assert summary["updated_recipes"] == STARTER_RECIPE_COUNT
db.expire_all()
updated = db.get(Recipe, recipe_id)
expected = _catalog_recipe(catalog, updated.name)
assert updated.id == recipe_id
assert db.get(MealPlanItem, meal_plan_item.id).recipe_id == recipe_id
assert updated.description == expected["description"]
assert updated.calories_per_serving == expected["calories_per_serving"]
assert updated.instructions == expected["instructions"]
assert updated.ingredients != prior_ingredients
assert _ingredient_values(db, updated) == [
(" ".join(item["name"].split()).casefold(), item["quantity"], item["unit"])
for item in expected["ingredients"]
]
db.rollback()
assert load_bundled_starter_catalog(db, update_existing=True)["updated_recipes"] == 0
@pytest.mark.requires_postgres
def test_update_existing_dry_run_reports_updates_without_writes(db):
catalog = _catalog()
load_starter_catalog(db, _prior_catalog())
target = (
db.query(Recipe)
.filter(Recipe.name == catalog["recipes"][0]["name"]) # type: ignore[index]
.one()
)
recipe_id = target.id
before = (
target.description,
target.calories_per_serving,
copy.deepcopy(target.ingredients),
list(target.instructions),
)
db.rollback()
summary = load_bundled_starter_catalog(db, dry_run=True, update_existing=True)
assert summary["dry_run"] is True
assert summary["created_recipes"] == 0
assert summary["updated_recipes"] == STARTER_RECIPE_COUNT
db.expire_all()
unchanged = db.get(Recipe, recipe_id)
assert (
unchanged.description,
unchanged.calories_per_serving,
unchanged.ingredients,
unchanged.instructions,
) == before
@pytest.mark.requires_postgres
def test_update_existing_rejects_an_incomplete_marker_owned_catalog(db):
catalog = _catalog()
load_starter_catalog(db, _prior_catalog())
missing = (
db.query(Recipe)
.filter(Recipe.name == catalog["recipes"][0]["name"]) # type: ignore[index]
.one()
)
missing.discovery_reason = None
db.commit()
with pytest.raises(StarterRecipeCatalogError, match="exactly all 50"):
load_bundled_starter_catalog(db, update_existing=True)
@pytest.mark.requires_postgres
def test_update_existing_rolls_back_all_content_changes_on_flush_failure(db):
catalog = _catalog()
load_starter_catalog(db, _prior_catalog())
target = (
db.query(Recipe)
.filter(Recipe.name == catalog["recipes"][0]["name"]) # type: ignore[index]
.one()
)
recipe_id = target.id
before = (
target.description,
target.calories_per_serving,
copy.deepcopy(target.ingredients),
list(target.instructions),
)
db.rollback()
def fail_recipe_update(mapper, connection, target):
raise RuntimeError("forced recipe update failure")
event.listen(Recipe, "before_update", fail_recipe_update)
try:
with pytest.raises(RuntimeError, match="forced recipe update failure"):
load_bundled_starter_catalog(db, update_existing=True)
finally:
event.remove(Recipe, "before_update", fail_recipe_update)
db.expire_all()
unchanged = db.get(Recipe, recipe_id)
assert (
unchanged.description,
unchanged.calories_per_serving,
unchanged.ingredients,
unchanged.instructions,
) == before
@pytest.mark.requires_postgres
def test_loader_rolls_back_all_catalog_writes_if_recipe_flush_fails(db):
before = (
db.query(Recipe).count(),
db.query(Ingredient).count(),
db.query(Recipe).filter(Recipe.discovery_reason == STARTER_DISCOVERY_REASON).count(),
)
db.rollback()
def fail_recipe_flush(mapper, connection, target):
raise RuntimeError("forced recipe flush failure")
event.listen(Recipe, "before_insert", fail_recipe_flush)
try:
with pytest.raises(RuntimeError, match="forced recipe flush failure"):
load_bundled_starter_catalog(db)
finally:
event.remove(Recipe, "before_insert", fail_recipe_flush)
after = (
db.query(Recipe).count(),
db.query(Ingredient).count(),
db.query(Recipe).filter(Recipe.discovery_reason == STARTER_DISCOVERY_REASON).count(),
)
assert after == before
@pytest.mark.requires_postgres
def test_concurrent_sessions_create_one_bundled_catalog(db):
engine = db.get_bind().engine
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
baseline = Session()
try:
initial_ingredient_ids = {ingredient.id for ingredient in baseline.query(Ingredient).all()}
assert (
baseline.query(Recipe)
.filter(Recipe.discovery_reason == STARTER_DISCOVERY_REASON)
.count()
== 0
)
finally:
baseline.rollback()
baseline.close()
barrier = threading.Barrier(2)
results: Queue[dict[str, object]] = Queue()
errors: Queue[BaseException] = Queue()
def load_in_session() -> None:
session = Session()
try:
barrier.wait(timeout=5)
results.put(load_bundled_starter_catalog(session))
except BaseException as exc:
session.rollback()
errors.put(exc)
finally:
session.close()
threads = [threading.Thread(target=load_in_session) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=20)
try:
assert all(not thread.is_alive() for thread in threads)
if not errors.empty():
raise errors.get_nowait()
try:
summaries = [results.get_nowait(), results.get_nowait()]
except Empty as exc:
raise AssertionError("concurrent catalog load did not return two summaries") from exc
assert sorted(summary["created_recipes"] for summary in summaries) == [0, 50]
verifier = Session()
try:
bundled = (
verifier.query(Recipe)
.filter(Recipe.discovery_reason == STARTER_DISCOVERY_REASON)
.all()
)
assert len(bundled) == STARTER_RECIPE_COUNT
assert len({recipe.name for recipe in bundled}) == STARTER_RECIPE_COUNT
finally:
verifier.rollback()
verifier.close()
finally:
cleanup = Session()
try:
bundled = (
cleanup.query(Recipe)
.filter(Recipe.discovery_reason == STARTER_DISCOVERY_REASON)
.all()
)
for recipe in bundled:
cleanup.delete(recipe)
cleanup.flush()
for ingredient in cleanup.query(Ingredient).all():
if ingredient.id not in initial_ingredient_ids:
cleanup.delete(ingredient)
cleanup.commit()
finally:
cleanup.rollback()
cleanup.close()
@pytest.mark.requires_postgres
def test_concurrent_update_existing_calls_serialize(db):
catalog = _catalog()
load_starter_catalog(db, _prior_catalog())
engine = db.get_bind().engine
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
barrier = threading.Barrier(2)
results: Queue[dict[str, object]] = Queue()
errors: Queue[BaseException] = Queue()
def update_in_session() -> None:
session = Session()
try:
barrier.wait(timeout=5)
results.put(load_bundled_starter_catalog(session, update_existing=True))
except BaseException as exc:
session.rollback()
errors.put(exc)
finally:
session.close()
threads = [threading.Thread(target=update_in_session) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=20)
assert all(not thread.is_alive() for thread in threads)
if not errors.empty():
raise errors.get_nowait()
try:
summaries = [results.get_nowait(), results.get_nowait()]
except Empty as exc:
raise AssertionError("concurrent catalog update did not return two summaries") from exc
assert sorted(summary["updated_recipes"] for summary in summaries) == [0, 50]
verifier = Session()
try:
target = (
verifier.query(Recipe)
.filter(Recipe.name == catalog["recipes"][0]["name"]) # type: ignore[index]
.one()
)
expected = _catalog_recipe(catalog, target.name)
assert target.description == expected["description"]
assert target.calories_per_serving == expected["calories_per_serving"]
finally:
verifier.rollback()
verifier.close()
+729
View File
@@ -0,0 +1,729 @@
"""Offline tests for the bounded Wikibooks recipe staging service."""
from __future__ import annotations
import copy
import json
import math
import threading
import uuid
from contextlib import nullcontext
from datetime import datetime, timezone
from pathlib import Path
from queue import Empty, Queue
import pytest
from sqlalchemy import text
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import sessionmaker
from app.models import RecipeImportStaging, RecipeImportStatus
from app.services.wikibooks_import import (
CC_BY_SA_4_LICENSE_URL,
WIKIBOOKS_API_URL,
WikibooksImportError,
WikibooksImportService,
WikibooksResponseError,
_normalize_ingredient,
canonical_json_sha256,
)
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "wikibooks"
class FakeResponse:
def __init__(self, payload, *, status_code=200, headers=None):
self.status_code = status_code
self.headers = {"Content-Length": str(len(json.dumps(payload).encode("utf-8")))}
self.headers.update(headers or {})
self._body = json.dumps(payload).encode("utf-8")
self.closed = False
def iter_content(self, chunk_size):
for offset in range(0, len(self._body), chunk_size):
yield self._body[offset : offset + chunk_size]
def close(self):
self.closed = True
class FakeSession:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def get(self, url, **kwargs):
self.calls.append((url, kwargs))
return self.responses.pop(0)
class FakeStageResult:
def __init__(self, value):
self.value = value
def scalar_one(self):
return self.value
def scalars(self):
return self
def all(self):
return [] if self.value is None else [self.value]
class FakeStageDb:
def __init__(self):
self.winner = object()
self.statements = []
self.no_autoflush = nullcontext()
def execute(self, statement):
self.statements.append(statement)
return FakeStageResult(self.winner)
def flush(self):
pass
def _parse_fixture(name: str):
return json.loads((FIXTURE_DIR / name).read_text())["parse"]
def test_stage_discovered_pages_stages_each_title(monkeypatch):
service = WikibooksImportService("MealPlanner recipe test")
titles = ["Cookbook:Apple Pie I", "Cookbook:Banana Bread"]
staged = [object(), object()]
monkeypatch.setattr(service, "discover_recipe_titles", lambda limit: titles[:limit])
monkeypatch.setattr(
service,
"stage_page",
lambda db, title: staged[titles.index(title)],
)
assert service.stage_discovered_pages(object(), limit=2) == staged
def test_stage_discovered_pages_uses_feedback_queries(monkeypatch):
service = WikibooksImportService("MealPlanner recipe test")
titles = ["Cookbook:Apple Pie I"]
staged = [object()]
captured = {}
monkeypatch.setattr(
service,
"search_recipe_titles",
lambda queries, limit: captured.update(queries=queries, limit=limit) or titles,
)
monkeypatch.setattr(service, "stage_page", lambda db, title: staged[0])
assert service.stage_discovered_pages(
object(), limit=1, queries=["apple dinner"]
) == staged
assert captured == {"queries": ["apple dinner"], "limit": 1}
def _service(session=None, **kwargs):
return WikibooksImportService(
"MealPlanner-Wikibooks-Import/1.0 (tests@example.invalid)",
session=session,
min_request_interval=0,
**kwargs,
)
def test_discovery_uses_fixed_params_and_bounded_continuation():
session = FakeSession(
[
FakeResponse(
{
"continue": {"cmcontinue": "page|2", "continue": "-||"},
"query": {
"categorymembers": [
{"pageid": 1, "title": "Cookbook:First"}
]
},
}
),
FakeResponse(
{
"query": {
"categorymembers": [
{"pageid": 2, "title": "Cookbook:Second"}
]
}
}
),
]
)
assert _service(session).discover_recipe_titles(2) == ["Cookbook:First", "Cookbook:Second"]
first_url, first_kwargs = session.calls[0]
assert first_url == WIKIBOOKS_API_URL
assert first_kwargs["params"] == {
"action": "query",
"list": "categorymembers",
"cmtitle": "Category:Recipes",
"cmtype": "page",
"cmlimit": 2,
"format": "json",
"formatversion": 2,
"maxlag": 5,
}
assert first_kwargs["headers"]["User-Agent"].startswith("MealPlanner-Wikibooks-Import/")
assert first_kwargs["stream"] is True
assert first_kwargs["allow_redirects"] is False
assert session.calls[1][1]["params"]["cmcontinue"] == "page|2"
assert session.calls[1][1]["params"]["cmlimit"] == 1
def test_feedback_search_uses_cookbook_namespace_and_deduplicates():
session = FakeSession(
[
FakeResponse(
{
"query": {
"search": [
{"pageid": 1, "title": "Cookbook:Apple Pie I"},
{"pageid": 2, "title": "Cookbook:Apple Sauce"},
]
}
}
),
FakeResponse(
{
"query": {
"search": [
{"pageid": 1, "title": "Cookbook:Apple Pie I"},
{"pageid": 3, "title": "Cookbook:Pork Chops"},
]
}
}
),
]
)
assert _service(session).search_recipe_titles(
[" apple dinner ", "pork dinner"], 3
) == [
"Cookbook:Apple Pie I",
"Cookbook:Apple Sauce",
"Cookbook:Pork Chops",
]
assert session.calls[0][1]["params"] == {
"action": "query",
"list": "search",
"srsearch": "apple dinner",
"srnamespace": 102,
"srlimit": 3,
"format": "json",
"formatversion": 2,
"maxlag": 5,
}
assert session.calls[1][1]["params"]["srsearch"] == "pork dinner"
assert session.calls[1][1]["params"]["srlimit"] == 1
def test_fetch_retries_retry_after_and_mediawiki_maxlag():
parse_payload = _parse_fixture("apple_glazed_pork_chops_parse.json")
session = FakeSession(
[
FakeResponse({"error": "busy"}, status_code=429, headers={"Retry-After": "3"}),
FakeResponse({"error": {"code": "maxlag"}}),
FakeResponse({"parse": parse_payload}),
]
)
sleeps = []
assert _service(session, backoff_seconds=0.25, sleep=sleeps.append).fetch_page(
"Cookbook:Apple Glazed Pork Chops"
) == parse_payload
assert sleeps == [3.0, 0.5]
assert len(session.calls) == 3
assert session.calls[-1][1]["params"] == {
"action": "parse",
"page": "Cookbook:Apple Glazed Pork Chops",
"redirects": 1,
"prop": "text|wikitext|sections|revid|displaytitle",
"format": "json",
"formatversion": 2,
"maxlag": 5,
}
def test_fetch_rejects_redirect_responses():
response = FakeResponse({}, status_code=302)
session = FakeSession([response])
with pytest.raises(WikibooksImportError, match="redirect HTTP 302 rejected"):
_service(session).fetch_page("Cookbook:Apple Glazed Pork Chops")
assert session.calls[0][1]["allow_redirects"] is False
assert response.closed is True
def test_retry_delays_are_capped():
parse_payload = _parse_fixture("apple_glazed_pork_chops_parse.json")
retry_after_sleeps = []
retry_after_session = FakeSession(
[
FakeResponse({"error": "busy"}, status_code=429, headers={"Retry-After": "300"}),
FakeResponse({"parse": parse_payload}),
]
)
_service(
retry_after_session,
max_attempts=2,
max_retry_delay=2,
sleep=retry_after_sleeps.append,
).fetch_page("Cookbook:Apple Glazed Pork Chops")
assert retry_after_sleeps == [2.0]
backoff_sleeps = []
backoff_session = FakeSession(
[
FakeResponse({"error": {"code": "maxlag"}}),
FakeResponse({"parse": parse_payload}),
]
)
_service(
backoff_session,
backoff_seconds=10,
max_retry_delay=2,
sleep=backoff_sleeps.append,
).fetch_page("Cookbook:Apple Glazed Pork Chops")
assert backoff_sleeps == [2.0]
@pytest.mark.parametrize(
"kwargs",
[
{"timeout": None},
{"timeout": (0, 1)},
{"timeout": (1, math.inf)},
{"max_attempts": 0},
{"max_attempts": 1.5},
{"backoff_seconds": math.nan},
{"max_retry_delay": 0},
],
)
def test_constructor_rejects_invalid_timeout_and_retry_configuration(kwargs):
with pytest.raises(ValueError):
_service(**kwargs)
def test_payload_limit_boundary_and_malformed_parse_shape():
parse_payload = _parse_fixture("apple_glazed_pork_chops_parse.json")
response_payload = {"parse": parse_payload}
size = len(json.dumps(response_payload).encode("utf-8"))
assert _service(
FakeSession([FakeResponse(response_payload)]), max_payload_bytes=size
).fetch_page("Cookbook:Apple Glazed Pork Chops") == parse_payload
with pytest.raises(WikibooksResponseError, match="payload exceeds"):
_service(
FakeSession([FakeResponse(response_payload)]), max_payload_bytes=size - 1
).fetch_page("Cookbook:Apple Glazed Pork Chops")
malformed = copy.deepcopy(parse_payload)
malformed["sections"] = [{}]
with pytest.raises(WikibooksResponseError, match="sections item line"):
_service(FakeSession([FakeResponse({"parse": malformed})])).fetch_page(
"Cookbook:Apple Glazed Pork Chops"
)
def test_normalization_extracts_text_sections_times_and_fractions():
candidate = _service().normalize_page(_parse_fixture("apple_glazed_pork_chops_parse.json"))
assert candidate["name"] == "Apple Glazed Pork Chops"
assert candidate["description"] == "Sweet apples balance savory pork chops."
assert candidate["servings"] == 4
assert candidate["prep_time_minutes"] == 15
assert candidate["cook_time_minutes"] == 20
assert candidate["baking_time_minutes"] == 10
assert [item["quantity"] for item in candidate["ingredients"]] == [1.5, 0.5, 1.25, 2.0]
assert [item["quantity_text"] for item in candidate["ingredients"]] == ["1 1/2", "½", "1¼", "2"]
assert candidate["ingredients"][0] == {
"quantity": 1.5,
"quantity_text": "1 1/2",
"unit": "cups",
"name": "apple cider",
"source_text": "1 1/2 cups (360 ml) apple cider",
}
assert candidate["instructions"] == [
"Brown the pork chops in butter.",
"Add cider and cook until glazed.",
]
assert _service().validate_recipe(candidate) == {"valid": True, "errors": [], "warnings": []}
def test_validation_rejects_invalid_servings_and_ingredient_quantities():
valid_candidate = _service().normalize_page(_parse_fixture("apple_glazed_pork_chops_parse.json"))
for servings in (None, 0, -1):
invalid_servings = copy.deepcopy(valid_candidate)
invalid_servings["servings"] = servings
assert any(
error["code"] == "invalid_servings"
for error in _service().validate_recipe(invalid_servings)["errors"]
)
for quantity in (None, 0, -1):
invalid_quantity = copy.deepcopy(valid_candidate)
invalid_quantity["ingredients"][0]["quantity"] = quantity
assert any(
error["code"] == "invalid_ingredient_quantity"
for error in _service().validate_recipe(invalid_quantity)["errors"]
)
def test_validation_reports_apple_title_mismatch():
bad_candidate = _service().normalize_page(
_parse_fixture("apple_glaze_missing_apple_parse.json")
)
report = _service().validate_recipe(bad_candidate)
mismatch = next(error for error in report["errors"] if error["code"] == "title_ingredient_mismatch")
assert mismatch["missing_tokens"] == ["apple"]
assert report["valid"] is False
def test_validation_quarantines_database_integer_overflow():
candidate = _service().normalize_page(
_parse_fixture("apple_glazed_pork_chops_parse.json")
)
candidate["servings"] = 2_147_483_648
candidate["prep_time_minutes"] = 2_147_483_648
candidate["cook_time_minutes"] = 2_147_483_640
candidate["baking_time_minutes"] = 10
report = _service().validate_recipe(candidate)
assert report["valid"] is False
assert {error["code"] for error in report["errors"]} >= {
"servings_out_of_range",
"time_out_of_range",
"combined_cook_time_out_of_range",
}
def test_validation_accepts_generic_pie_form_while_rejecting_missing_apple():
candidate = _service().normalize_page(_parse_fixture("apple_glazed_pork_chops_parse.json"))
apple_pie = copy.deepcopy(candidate)
apple_pie["name"] = "Apple Pie"
apple_pie["ingredients"][0]["name"] = "apples"
assert not any(
error["code"] == "title_ingredient_mismatch"
for error in _service().validate_recipe(apple_pie)["errors"]
)
missing_apple = _service().normalize_page(
_parse_fixture("apple_glaze_missing_apple_parse.json")
)
assert any(
error["code"] == "title_ingredient_mismatch"
for error in _service().validate_recipe(missing_apple)["errors"]
)
@pytest.mark.parametrize(
("title", "ingredient_name"),
[
("Fried Apple Pies", "apples"),
("Classic American Apple Pie", "apples"),
("20-Minute Beef Stroganoff", "beef"),
("Apple Pie II", "apples"),
("8 Desserts in 1 Pan", "apples"),
("Classic American Salad Dressing Out", "apples"),
("Chocolate Chip Cookies I", "chocolate chips"),
],
)
def test_validation_ignores_generic_title_descriptors_and_variants(title, ingredient_name):
candidate = _service().normalize_page(_parse_fixture("apple_glazed_pork_chops_parse.json"))
candidate["name"] = title
candidate["ingredients"][0]["name"] = ingredient_name
report = _service().validate_recipe(candidate)
assert report["valid"] is True
assert not any(error["code"] == "title_ingredient_mismatch" for error in report["errors"])
def test_live_apple_pie_fixture_preserves_missing_quantity_failures():
payload = json.loads((FIXTURE_DIR / "apple_pie_i_live_parse.json").read_text())
candidate = _service().normalize_page(payload)
report = _service().validate_recipe(candidate)
assert candidate["name"] == "Apple Pie I"
assert sum(
error["code"] == "invalid_ingredient_quantity" for error in report["errors"]
) == 4
assert not any(
error["code"] == "title_ingredient_mismatch" for error in report["errors"]
)
assert report["valid"] is False
@pytest.mark.parametrize("source_text", ["1-2 cups apples", "1–2 cups apples", "1 — 2 cups apples"])
def test_validation_accepts_plural_title_matches_and_quarantines_quantity_ranges(source_text):
candidate = _service().normalize_page(_parse_fixture("apple_glazed_pork_chops_parse.json"))
banana_bread = copy.deepcopy(candidate)
banana_bread["name"] = "Banana Bread"
banana_bread["ingredients"][0]["name"] = "bananas"
assert not any(
error["code"] == "title_ingredient_mismatch"
for error in _service().validate_recipe(banana_bread)["errors"]
)
ranged_ingredient = _normalize_ingredient(source_text)
assert ranged_ingredient["quantity"] is None
assert ranged_ingredient["quantity_text"] is None
ranged_candidate = copy.deepcopy(candidate)
ranged_candidate["ingredients"][0] = ranged_ingredient
assert any(
error["code"] == "invalid_ingredient_quantity"
for error in _service().validate_recipe(ranged_candidate)["errors"]
)
@pytest.mark.parametrize("source_text", ["1/0 cup apples", "1/ cups apples", "1 1/0 cups apples"])
def test_validation_quarantines_invalid_fraction_prefixes_and_nonfinite_quantities(source_text):
candidate = _service().normalize_page(_parse_fixture("apple_glazed_pork_chops_parse.json"))
invalid_fraction = _normalize_ingredient(source_text)
assert invalid_fraction["quantity"] is None
assert invalid_fraction["quantity_text"] is None
invalid_candidate = copy.deepcopy(candidate)
invalid_candidate["ingredients"][0] = invalid_fraction
assert any(
error["code"] == "invalid_ingredient_quantity"
for error in _service().validate_recipe(invalid_candidate)["errors"]
)
for value in (math.nan, math.inf, -math.inf):
nonfinite_candidate = copy.deepcopy(candidate)
nonfinite_candidate["ingredients"][0]["quantity"] = value
assert any(
error["code"] == "invalid_ingredient_quantity"
for error in _service().validate_recipe(nonfinite_candidate)["errors"]
)
def test_staging_uses_atomic_insert_and_owns_payload_snapshot():
parse_payload = _parse_fixture("apple_glazed_pork_chops_parse.json")
original_text = parse_payload["text"]
db = FakeStageDb()
assert _service().stage_parse_payload(db, parse_payload) is db.winner
parse_payload["text"] = "mutated after staging"
parse_payload["sections"][0]["line"] = "mutated"
compiled = db.statements[0].compile(dialect=postgresql.dialect())
assert compiled.params["raw_payload"]["text"] == original_text
assert compiled.params["raw_payload"]["sections"][0]["line"] == "Ingredients"
assert "ON CONFLICT ON CONSTRAINT uq_recipe_import_staging_source_revision DO NOTHING" in str(
compiled
)
assert "RETURNING recipe_import_staging.id" in str(compiled)
assert len(db.statements) == 2
@pytest.mark.requires_postgres
def test_staging_checksums_deduplicates_and_quarantines_invalid_revisions(db, monkeypatch):
valid_parse = _parse_fixture("apple_glazed_pork_chops_parse.json")
invalid_parse = _parse_fixture("apple_glaze_missing_apple_parse.json")
service = _service()
monkeypatch.setattr(service, "fetch_page", lambda title: copy.deepcopy(valid_parse))
staged = service.stage_page(db, "Cookbook:Apple Glazed Pork Chops")
same_revision = service.stage_page(db, "Cookbook:Apple Glazed Pork Chops")
assert staged is same_revision
assert staged.status is RecipeImportStatus.PENDING
assert staged.raw_payload == valid_parse
assert staged.payload_sha256 == canonical_json_sha256(valid_parse)
assert staged.source_url == "https://en.wikibooks.org/wiki/Cookbook:Apple_Glazed_Pork_Chops"
assert staged.license_url == CC_BY_SA_4_LICENSE_URL
assert "Wikibooks contributors" in staged.attribution
assert "action=history" in staged.attribution
assert "normalized_recipe" not in staged.raw_payload
monkeypatch.setattr(service, "fetch_page", lambda title: copy.deepcopy(invalid_parse))
quarantined = service.stage_page(db, "Cookbook:Pan-Seared Pork Chops with Apple Glaze")
assert quarantined.status is RecipeImportStatus.QUARANTINED
assert any(
error["code"] == "title_ingredient_mismatch"
for error in quarantined.validation_report["errors"]
)
assert db.query(RecipeImportStaging).count() == 2
@pytest.mark.requires_postgres
def test_revalidation_updates_only_unreviewed_candidates_without_changing_raw_payload(db):
service = _service()
parse_payload = _parse_fixture("apple_glazed_pork_chops_parse.json")
parse_payload["pageid"] = 2_000_000_001
parse_payload["revid"] = 7_001
staged = service.stage_parse_payload(db, parse_payload)
raw_payload = copy.deepcopy(staged.raw_payload)
payload_sha256 = staged.payload_sha256
staged.status = RecipeImportStatus.QUARANTINED
staged.validation_report = {
"valid": False,
"errors": [{"code": "obsolete_validator_error"}],
"warnings": [],
}
staged.rejection_reason = "obsolete_validator_error"
tampered_checksum_payload = copy.deepcopy(parse_payload)
tampered_checksum_payload["pageid"] = 2_000_000_002
tampered_checksum_payload["revid"] = 7_002
tampered_checksum = service.stage_parse_payload(db, tampered_checksum_payload)
tampered_checksum.payload_sha256 = "0" * 64
reviewed_payload = copy.deepcopy(parse_payload)
reviewed_payload["pageid"] = 2_000_000_003
reviewed_payload["revid"] = 7_003
reviewed = service.stage_parse_payload(db, reviewed_payload)
reviewed.status = RecipeImportStatus.REJECTED
reviewed.reviewed_at = datetime.now(timezone.utc)
reviewed.reviewed_by = "reviewer@example.com"
reviewed.rejection_reason = "manual rejection"
approved_payload = copy.deepcopy(parse_payload)
approved_payload["pageid"] = 2_000_000_004
approved_payload["revid"] = 7_004
approved = service.stage_parse_payload(db, approved_payload)
approved.status = RecipeImportStatus.APPROVED
approved.reviewed_at = datetime.now(timezone.utc)
approved.reviewed_by = "reviewer@example.com"
reviewed_quarantine_payload = copy.deepcopy(parse_payload)
reviewed_quarantine_payload["pageid"] = 2_000_000_005
reviewed_quarantine_payload["revid"] = 7_005
reviewed_quarantine = service.stage_parse_payload(db, reviewed_quarantine_payload)
reviewed_quarantine.status = RecipeImportStatus.QUARANTINED
reviewed_quarantine.reviewed_at = datetime.now(timezone.utc)
reviewed_quarantine.reviewed_by = "reviewer@example.com"
reviewed_quarantine.rejection_reason = "manual quarantine"
db.flush()
result = service.revalidate_staged_imports(db, limit=10)
assert result == {"processed": 2, "pending": 1, "quarantined": 1}
assert staged.status is RecipeImportStatus.PENDING
assert staged.validation_report["valid"] is True
assert staged.rejection_reason is None
assert staged.raw_payload == raw_payload
assert staged.payload_sha256 == payload_sha256
assert tampered_checksum.status is RecipeImportStatus.QUARANTINED
assert tampered_checksum.validation_report["errors"] == [
{
"code": "payload_checksum_mismatch",
"field": "raw_payload",
"message": "Raw payload checksum does not match immutable provenance.",
}
]
assert reviewed.status is RecipeImportStatus.REJECTED
assert reviewed.rejection_reason == "manual rejection"
assert approved.status is RecipeImportStatus.APPROVED
assert reviewed_quarantine.status is RecipeImportStatus.QUARANTINED
assert reviewed_quarantine.rejection_reason == "manual quarantine"
def test_revalidation_uses_skip_locked():
db = FakeStageDb()
db.winner = None
assert _service().revalidate_staged_imports(db, limit=10) == {
"processed": 0,
"pending": 0,
"quarantined": 0,
}
compiled = db.statements[0].compile(dialect=postgresql.dialect())
assert "FOR UPDATE SKIP LOCKED" in str(compiled)
@pytest.mark.requires_postgres
def test_staging_deduplicates_two_concurrent_sessions(db):
engine = db.get_bind().engine
Session = sessionmaker(bind=engine, autoflush=False)
parse_payload = _parse_fixture("apple_glazed_pork_chops_parse.json")
external_id = str(uuid.uuid4().int % 2_000_000_000 + 1)
parse_payload["pageid"] = int(external_id)
parse_payload["revid"] = 6001
identity = {
"source": "wikibooks",
"external_id": external_id,
"source_revision": "6001",
}
start = threading.Barrier(2)
release_winner = threading.Event()
results = Queue()
errors = Queue()
service = _service()
def stage_in_session():
session = Session()
try:
session.execute(text("SET LOCAL lock_timeout = '3s'"))
session.execute(text("SET LOCAL statement_timeout = '10s'"))
start.wait(timeout=5)
staged = service.stage_parse_payload(session, copy.deepcopy(parse_payload))
results.put(staged.id)
if not release_winner.wait(timeout=5):
raise TimeoutError("winner transaction was not released")
session.commit()
except Exception as exc: # Propagate worker failures to the test thread.
session.rollback()
errors.put(exc)
finally:
session.rollback()
session.close()
threads = [threading.Thread(target=stage_in_session) for _ in range(2)]
for thread in threads:
thread.start()
def next_result(timeout: float):
try:
return results.get(timeout=timeout)
except Empty:
if not errors.empty():
raise errors.get()
pytest.fail("concurrent staging did not complete before timeout")
try:
first_id = next_result(5)
release_winner.set()
second_id = next_result(10)
for thread in threads:
thread.join(timeout=5)
assert not thread.is_alive(), "concurrent staging thread did not exit"
if not errors.empty():
raise errors.get()
verifier = Session()
try:
persisted = verifier.query(RecipeImportStaging).filter_by(**identity).all()
assert first_id == second_id == persisted[0].id
assert len(persisted) == 1
finally:
verifier.rollback()
verifier.close()
finally:
release_winner.set()
for thread in threads:
thread.join(timeout=5)
cleanup = Session()
try:
cleanup.query(RecipeImportStaging).filter_by(**identity).delete(
synchronize_session=False
)
cleanup.commit()
finally:
cleanup.rollback()
cleanup.close()