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

This commit is contained in:
2026-09-01 10:11:36 -07:00
parent 7838c49721
commit 2695ba0232
85 changed files with 8103 additions and 795 deletions
+384
View File
@@ -0,0 +1,384 @@
from __future__ import annotations
import copy
import itertools
import json
import os
import threading
from pathlib import Path
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("ADMIN_EMAIL", "reviewer@example.com")
from app.models import Ingredient, Recipe, RecipeImportStaging, RecipeImportStatus
from app.services.wikibooks_import import WikibooksImportService
from app.services.recipe_import_review import (
RecipeImportConflictError,
approve_recipe_import,
)
pytestmark = pytest.mark.requires_postgres
FIXTURES = Path(__file__).parent / "fixtures" / "wikibooks"
ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"}
_IDENTITIES = itertools.count(1)
def _stage(db, fixture: str = "apple_glazed_pork_chops_parse.json"):
payload = json.loads((FIXTURES / fixture).read_text(encoding="utf-8"))["parse"]
identity = next(_IDENTITIES) * 1_000_000
payload["pageid"] += identity
payload["revid"] += identity
staged = WikibooksImportService("MealPlanner admin API tests").stage_parse_payload(
db, payload
)
db.commit()
return staged, payload
@pytest.fixture
def api_client():
from fastapi.testclient import TestClient
from app.main import app
with TestClient(app) as test_client:
yield test_client
def test_recipe_import_routes_require_admin(api_client):
response = api_client.get("/api/admin/recipe-imports")
assert response.status_code == 401
def test_list_filters_and_detail_exposes_provenance(api_client, db_session):
pending, _ = _stage(db_session)
pending.source = "wikibooks-list-test"
db_session.commit()
quarantined, _ = _stage(db_session, "apple_glaze_missing_apple_parse.json")
response = api_client.get(
"/api/admin/recipe-imports?status=pending&source=wikibooks-list-test&limit=1",
headers=ADMIN_HEADERS,
)
assert response.status_code == 200, response.text
body = response.json()
assert body["total"] == 1
assert body["has_next"] is False
assert body["items"][0]["id"] == str(pending.id)
assert body["items"][0]["name"] == "Apple Glazed Pork Chops"
response = api_client.get(
f"/api/admin/recipe-imports/{quarantined.id}", headers=ADMIN_HEADERS
)
assert response.status_code == 200, response.text
detail = response.json()
assert detail["status"] == "quarantined"
assert detail["license_name"] == "CC BY-SA 4.0"
assert detail["raw_payload"]["revid"] == quarantined.raw_payload["revid"]
assert detail["validation_report"]["valid"] is False
def test_approve_publishes_atomically_and_is_idempotent(api_client, db_session):
staged, _ = _stage(db_session)
staged_id = staged.id
external_id = staged.external_id
response = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert response.status_code == 201, response.text
approved = response.json()
assert approved["status"] == "approved"
assert approved["reviewed_by"] == "admin"
assert approved["recipe_id"] is not None
db_session.expire_all()
recipe = db_session.get(Recipe, approved["recipe_id"])
assert recipe is not None
assert recipe.family_profile_id is None
assert recipe.image_url is None
assert recipe.external_source == "wikibooks"
assert recipe.external_id == external_id
assert recipe.cook_time_minutes == 30
assert len(recipe.ingredients) == 4
ingredient_ids = [item["ingredient_id"] for item in recipe.ingredients]
assert (
db_session.query(Ingredient)
.filter(Ingredient.id.in_(ingredient_ids))
.count()
== 4
)
repeated = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert repeated.status_code == 200, repeated.text
assert repeated.json()["recipe_id"] == approved["recipe_id"]
assert db_session.query(Recipe).filter(Recipe.id == recipe.id).count() == 1
def test_approved_import_can_republish_after_recipe_deletion(api_client, db_session):
staged, _ = _stage(db_session)
first = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/approve", headers=ADMIN_HEADERS
)
assert first.status_code == 201, first.text
first_recipe_id = first.json()["recipe_id"]
recipe = db_session.get(Recipe, first_recipe_id)
db_session.delete(recipe)
db_session.commit()
repeated = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/approve", headers=ADMIN_HEADERS
)
assert repeated.status_code == 201, repeated.text
assert repeated.json()["recipe_id"] != first_recipe_id
assert repeated.json()["status"] == "approved"
def test_approve_rejects_quarantined_candidate(api_client, db_session):
staged, _ = _stage(db_session, "apple_glaze_missing_apple_parse.json")
staged_id = staged.id
external_id = staged.external_id
response = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert response.status_code == 409, response.text
assert (
db_session.query(Recipe)
.filter(Recipe.external_source == "wikibooks", Recipe.external_id == external_id)
.count()
== 0
)
db_session.expire_all()
unchanged = db_session.get(RecipeImportStaging, staged_id)
assert unchanged.status == RecipeImportStatus.QUARANTINED
assert unchanged.reviewed_at is None
def test_reject_is_idempotent_and_blocks_later_approval(api_client, db_session):
staged, _ = _stage(db_session)
staged_id = staged.id
external_id = staged.external_id
request = {"reason": "Instructions need editorial review."}
response = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/reject",
headers=ADMIN_HEADERS,
json=request,
)
assert response.status_code == 200, response.text
rejected = response.json()
assert rejected["status"] == "rejected"
assert rejected["rejection_reason"] == request["reason"]
assert rejected["reviewed_by"] == "admin"
repeated = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/reject",
headers=ADMIN_HEADERS,
json=request,
)
assert repeated.status_code == 200, repeated.text
approve = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert approve.status_code == 409, approve.text
assert (
db_session.query(Recipe)
.filter(Recipe.external_source == "wikibooks", Recipe.external_id == external_id)
.count()
== 0
)
def test_reject_accepts_quarantined_candidate(api_client, db_session):
staged, _ = _stage(db_session, "apple_glaze_missing_apple_parse.json")
response = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/reject",
headers=ADMIN_HEADERS,
json={"reason": "Title does not match the ingredient list."},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "rejected"
def test_new_source_revision_conflicts_with_published_page(api_client, db_session):
first, payload = _stage(db_session)
first_id = first.id
second_payload = copy.deepcopy(payload)
second_payload["revid"] += 1
second = WikibooksImportService(
"MealPlanner admin API tests"
).stage_parse_payload(db_session, second_payload)
db_session.commit()
second_id = second.id
first_response = api_client.post(
f"/api/admin/recipe-imports/{first_id}/approve", headers=ADMIN_HEADERS
)
assert first_response.status_code == 201, first_response.text
second_response = api_client.post(
f"/api/admin/recipe-imports/{second_id}/approve", headers=ADMIN_HEADERS
)
assert second_response.status_code == 409, second_response.text
db_session.expire_all()
assert (
db_session.get(RecipeImportStaging, second_id).status
== RecipeImportStatus.PENDING
)
assert (
db_session.query(Recipe)
.filter(Recipe.external_source == "wikibooks", Recipe.external_id == first.external_id)
.count()
== 1
)
def test_checksum_conflict_rolls_back_review_state(api_client, db_session):
staged, _ = _stage(db_session)
staged_id = staged.id
external_id = staged.external_id
staged.payload_sha256 = "0" * 64
db_session.commit()
response = api_client.post(
f"/api/admin/recipe-imports/{staged_id}/approve", headers=ADMIN_HEADERS
)
assert response.status_code == 409, response.text
assert (
db_session.query(Recipe)
.filter(Recipe.external_source == "wikibooks", Recipe.external_id == external_id)
.count()
== 0
)
db_session.expire_all()
unchanged = db_session.get(RecipeImportStaging, staged_id)
assert unchanged.status == RecipeImportStatus.PENDING
assert unchanged.reviewed_at is None
assert unchanged.recipe_id is None
def test_oversized_numeric_fields_are_quarantined_before_approval(
api_client, db_session
):
payload = json.loads(
(FIXTURES / "apple_glazed_pork_chops_parse.json").read_text(
encoding="utf-8"
)
)["parse"]
identity = next(_IDENTITIES) * 1_000_000
payload["pageid"] += identity
payload["revid"] += identity
payload["text"] = payload["text"].replace(
"<th>Servings</th><td>4</td>",
"<th>Servings</th><td>2147483648</td>",
)
staged = WikibooksImportService(
"MealPlanner admin API tests"
).stage_parse_payload(db_session, payload)
db_session.commit()
assert staged.status == RecipeImportStatus.QUARANTINED
assert any(
error["code"] == "servings_out_of_range"
for error in staged.validation_report["errors"]
)
response = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/approve", headers=ADMIN_HEADERS
)
assert response.status_code == 409, response.text
def test_concurrent_revision_approval_publishes_only_one_recipe(db_session):
first, payload = _stage(db_session)
second_payload = copy.deepcopy(payload)
second_payload["revid"] += 1
second = WikibooksImportService(
"MealPlanner admin API tests"
).stage_parse_payload(db_session, second_payload)
db_session.commit()
engine = db_session.get_bind()
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
barrier = threading.Barrier(2)
outcomes: list[str] = []
def approve(import_id):
session = Session()
try:
barrier.wait(timeout=5)
approve_recipe_import(session, import_id, "concurrent-reviewer")
outcomes.append("approved")
except (IntegrityError, RecipeImportConflictError):
session.rollback()
outcomes.append("conflict")
finally:
session.close()
threads = [
threading.Thread(target=approve, args=(first.id,)),
threading.Thread(target=approve, args=(second.id,)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
assert not thread.is_alive()
assert sorted(outcomes) == ["approved", "conflict"]
db_session.expire_all()
assert (
db_session.query(Recipe)
.filter(
Recipe.external_source == "wikibooks",
Recipe.external_id == first.external_id,
)
.count()
== 1
)
def test_invalid_token_cannot_mutate_import(api_client, db_session):
staged, _ = _stage(db_session)
response = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/approve",
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401
db_session.expire_all()
assert (
db_session.get(RecipeImportStaging, staged.id).status
== RecipeImportStatus.PENDING
)
@pytest.mark.parametrize(
"url",
[
"/api/admin/recipe-imports?status=unknown",
"/api/admin/recipe-imports?limit=0",
"/api/admin/recipe-imports?offset=-1",
],
)
def test_list_rejects_invalid_filters(api_client, url):
response = api_client.get(url, headers=ADMIN_HEADERS)
assert response.status_code == 422
def test_reject_requires_nonblank_reason(api_client, db_session):
staged, _ = _stage(db_session)
response = api_client.post(
f"/api/admin/recipe-imports/{staged.id}/reject",
headers=ADMIN_HEADERS,
json={"reason": " "},
)
assert response.status_code == 422