Public Access
385 lines
12 KiB
Python
385 lines
12 KiB
Python
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
|