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( "Servings4", "Servings2147483648", ) 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