Public Access
feat(meals): enforce three lower-calorie dinners with media
This commit is contained in:
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 "<script>" 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user