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