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( "