diff options
| -rw-r--r-- | jb/flow/assignment_tasks.py | 90 | ||||
| -rw-r--r-- | jb/flow/events.py | 4 | ||||
| -rw-r--r-- | jb/managers/hit.py | 3 | ||||
| -rw-r--r-- | tests/__init__.py | 2 | ||||
| -rw-r--r-- | tests/amt/test_models.py | 47 | ||||
| -rw-r--r-- | tests/conftest.py | 508 | ||||
| -rw-r--r-- | tests/fixtures/__init__.py (renamed from tests/amt/__init__.py) | 0 | ||||
| -rw-r--r-- | tests/fixtures/amt.py | 263 | ||||
| -rw-r--r-- | tests/fixtures/flow.py | 296 | ||||
| -rw-r--r-- | tests/fixtures/http.py (renamed from tests/http/conftest.py) | 0 | ||||
| -rw-r--r-- | tests/fixtures/managers.py | 77 | ||||
| -rw-r--r-- | tests/fixtures/models.py | 279 | ||||
| -rw-r--r-- | tests/flow/test_tasks.py | 511 | ||||
| -rw-r--r-- | tests/http/test_notifications.py | 11 | ||||
| -rw-r--r-- | tests/http/test_report.py | 0 | ||||
| -rw-r--r-- | tests/managers/amt.py | 22 | ||||
| -rw-r--r-- | tests/managers/hit.py | 25 | ||||
| -rw-r--r-- | tests/managers/test_amt.py | 36 | ||||
| -rw-r--r-- | tests/managers/test_hit.py | 52 | ||||
| -rw-r--r-- | tests/models/__init__.py (renamed from tests/amt/conftest.py) | 0 | ||||
| -rw-r--r-- | tests/models/test_assignment.py | 41 | ||||
| -rw-r--r-- | tests/models/test_event.py | 13 | ||||
| -rw-r--r-- | tests/models/test_hit.py | 10 | ||||
| -rw-r--r-- | tests_sandbox/test_flow.py | 43 |
24 files changed, 1333 insertions, 1000 deletions
diff --git a/jb/flow/assignment_tasks.py b/jb/flow/assignment_tasks.py index bb0877d..345d629 100644 --- a/jb/flow/assignment_tasks.py +++ b/jb/flow/assignment_tasks.py @@ -7,7 +7,6 @@ from generalresearchutils.models.thl.definitions import PayoutStatus, StatusCode from generalresearchutils.models.thl.wallet.cashout_method import CashoutRequestInfo from generalresearchutils.currency import USDCent -from jb.decorators import AM, HM, BM from jb.flow.monitoring import emit_error_event, emit_assignment_event, emit_bonus_event from jb.managers.amt import ( AMTManager, @@ -29,10 +28,15 @@ from jb.managers.thl import ( from jb.models.assignment import Assignment from jb.models.definitions import AssignmentStatus from jb.models.event import MTurkEvent +from jb.managers.assignment import AssignmentManager +from jb.managers.hit import HitManager +from jb.managers.bonus import BonusManager from jb.config import settings -def process_assignment_submitted(event: MTurkEvent) -> None: +def process_assignment_submitted( + am: AssignmentManager, hm: HitManager, bm: BonusManager, event: MTurkEvent +) -> None: """ Called either directly or from the SNS Notification that a HIT was submitted @@ -64,7 +68,7 @@ def process_assignment_submitted(event: MTurkEvent) -> None: return None # Even if the assignment doesn't exist, the hit must ... - hit = HM.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) + hit = hm.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) # # Step 2: Attempt to get the Assignment out of the DB @@ -72,7 +76,7 @@ def process_assignment_submitted(event: MTurkEvent) -> None: # Now, we need to confirm it is something that we have in the db. If not, # that means either something broke, or some funny business is happening # (maybe a baddie is submitting an assignment without doing any work). - stub = AM.get_stub_if_exists(amt_assignment_id=assignment.amt_assignment_id) + stub = am.get_stub_if_exists(amt_assignment_id=assignment.amt_assignment_id) if stub is None: # When they visited the "work" page, it should have created an # AssignmentStub in the db. If that doesn't exist, something bad @@ -83,11 +87,13 @@ def process_assignment_submitted(event: MTurkEvent) -> None: amt_hit_type_id=event.amt_hit_type_id, ) reject_assignment( + am=am, + hm=hm, amt_assignment_id=assignment.amt_assignment_id, msg=REJECT_MESSAGE_UNKNOWN_ASSIGNMENT, amt_hit_type_id=hit.amt_hit_type_id, ) - review_hit(assignment) + review_hit(hm=hm, assignment=assignment) return None assert assignment.amt_assignment_id == event.amt_assignment_id @@ -99,7 +105,7 @@ def process_assignment_submitted(event: MTurkEvent) -> None: # We don't have a TSID associated with the assignment until we the # assignment is submitted. - AM.update_answer(assignment=assignment) + am.update_answer(assignment=assignment) # check if the user is blocked by thl if get_user_blocked_or_not_exists(amt_worker_id=amt_worker_id): @@ -111,33 +117,35 @@ def process_assignment_submitted(event: MTurkEvent) -> None: amt_hit_type_id=event.amt_hit_type_id, ) reject_assignment( + am=am, + hm=hm, amt_assignment_id=amt_assignment_id, msg=REJECT_MESSAGE_BADDIE, amt_hit_type_id=hit.amt_hit_type_id, ) - review_hit(assignment) + review_hit(hm=hm, assignment=assignment) return None if assignment.tsid is None: - assignment = handle_assignment_w_no_work(assignment) + assignment = handle_assignment_w_no_work(am=am, hm=hm, assignment=assignment) else: # We need to validate the work exists on thl, and if so, approve - assignment = handle_assignment_w_work(assignment) + assignment = handle_assignment_w_work(am=am, hm=hm, assignment=assignment) # # Step 4: Tell Amazon we've reviewed the HIT, and update the DB # - review_hit(assignment) + review_hit(hm=hm, assignment=assignment) if ( assignment.tsid and assignment.status == AssignmentStatus.Approved and assignment.requester_feedback != NO_WORK_APPROVAL_MESSAGE ): - return issue_worker_payment(assignment) + return issue_worker_payment(hm=hm, bm=bm, assignment=assignment) -def review_hit(assignment: Assignment) -> None: +def review_hit(hm: HitManager, assignment: Assignment) -> None: # Reviewable to Reviewing AMTManager.update_hit_review_status(amt_hit_id=assignment.amt_hit_id, revert=False) hit, _ = AMTManager.get_hit_if_exists(amt_hit_id=assignment.amt_hit_id) @@ -149,12 +157,14 @@ def review_hit(assignment: Assignment) -> None: return None # Update the db - HM.update_hit(hit) + hm.update_hit(hit) return None -def handle_assignment_w_no_work(assignment: Assignment) -> Assignment: +def handle_assignment_w_no_work( + am: AssignmentManager, hm: HitManager, assignment: Assignment +) -> Assignment: """ Called when an assignment is submitted without a wall event. Not entirely clear why this happens. I think they accept a HIT, get no work @@ -167,18 +177,20 @@ def handle_assignment_w_no_work(assignment: Assignment) -> Assignment: ) amt_worker_id = assignment.amt_worker_id amt_assignment_id = assignment.amt_assignment_id - hit = HM.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) + hit = hm.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) emit_error_event( event_type="assignment_submitted_no_work", amt_hit_type_id=hit.amt_hit_type_id, ) # They get 3 chances per week. If exceeded: Reject! - if (AM.missing_tsid_count(amt_worker_id=amt_worker_id) >= 3) or ( - AM.rejected_count(amt_worker_id=amt_worker_id) >= 3 + if (am.missing_tsid_count(amt_worker_id=amt_worker_id) >= 3) or ( + am.rejected_count(amt_worker_id=amt_worker_id) >= 3 or get_user_blocked(amt_worker_id=amt_worker_id) ): assignment = reject_assignment( + am=am, + hm=hm, amt_assignment_id=amt_assignment_id, msg=REJECT_MESSAGE_NO_WORK, amt_hit_type_id=hit.amt_hit_type_id, @@ -189,6 +201,8 @@ def handle_assignment_w_no_work(assignment: Assignment) -> Assignment: # Approve with a message explaining they shouldn't do it. assignment = approve_assignment( + am=am, + hm=hm, amt_assignment_id=amt_assignment_id, msg=NO_WORK_APPROVAL_MESSAGE, amt_hit_type_id=hit.amt_hit_type_id, @@ -198,7 +212,11 @@ def handle_assignment_w_no_work(assignment: Assignment) -> Assignment: def reject_assignment( - amt_assignment_id: str, msg: str, amt_hit_type_id: str + am: AssignmentManager, + hm: HitManager, + amt_assignment_id: str, + msg: str, + amt_hit_type_id: str, ) -> Assignment: # Reject in AMT, update db @@ -220,16 +238,16 @@ def reject_assignment( # And update the db. The assignment may not actually exist in the db (if # a baddie intercepted it and is trying to game us). So, we might # need to create as assignment first ... - stub = AM.get_stub_if_exists(amt_assignment_id=assignment.amt_assignment_id) + stub = am.get_stub_if_exists(amt_assignment_id=assignment.amt_assignment_id) if stub is None: logging.warning( f"Rejected assignment doesn't exist in DB. Creating ... : {amt_assignment_id}" ) # Even if the assignment doesn't exist, the hit must ... - hit = HM.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) + hit = hm.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) assignment.hit_id = hit.id - AM.create(assignment=assignment) - AM.reject(assignment=assignment) + am.create(assignment=assignment) + am.reject(assignment=assignment) emit_assignment_event( status=AssignmentStatus.Rejected, amt_hit_type_id=amt_hit_type_id, reason=msg ) @@ -238,7 +256,11 @@ def reject_assignment( def approve_assignment( - amt_assignment_id: str, msg: str, amt_hit_type_id: str + am: AssignmentManager, + hm: HitManager, + amt_assignment_id: str, + msg: str, + amt_hit_type_id: str, ) -> Assignment: # Approve in AMT, update db @@ -257,7 +279,7 @@ def approve_assignment( assignment = AMTManager.get_assignment(amt_assignment_id=amt_assignment_id) assert assignment.status == AssignmentStatus.Approved # And update the db - AM.approve(assignment=assignment) + am.approve(assignment=assignment) emit_assignment_event( status=AssignmentStatus.Approved, amt_hit_type_id=amt_hit_type_id, reason=msg ) @@ -265,7 +287,9 @@ def approve_assignment( return assignment -def handle_assignment_w_work(assignment: Assignment) -> Assignment: +def handle_assignment_w_work( + am: AssignmentManager, hm: HitManager, assignment: Assignment +) -> Assignment: """ Called when an assignment is submitted with a tsid. - Check the tsid (thl status endpoint). Make sure it is finished, and @@ -280,7 +304,7 @@ def handle_assignment_w_work(assignment: Assignment) -> Assignment: tsid is not None ), "Assignment must have a tsid to be handled in handle_assignment_w_work" - hit = HM.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) + hit = hm.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) tsr = get_task_status(tsid=tsid) if ( @@ -308,6 +332,8 @@ def handle_assignment_w_work(assignment: Assignment) -> Assignment: amt_hit_type_id=hit.amt_hit_type_id, ) assignment = reject_assignment( + am=am, + hm=hm, amt_assignment_id=amt_assignment_id, msg=REJECT_MESSAGE_BADDIE, amt_hit_type_id=hit.amt_hit_type_id, @@ -332,6 +358,8 @@ def handle_assignment_w_work(assignment: Assignment) -> Assignment: amt_hit_type_id=hit.amt_hit_type_id, ) assignment = reject_assignment( + am=am, + hm=hm, amt_assignment_id=amt_assignment_id, msg=REJECT_MESSAGE_BADDIE, amt_hit_type_id=hit.amt_hit_type_id, @@ -342,6 +370,8 @@ def handle_assignment_w_work(assignment: Assignment) -> Assignment: # We've approved the HIT payment, now update the db to reflect this, and approve the assignment assignment = approve_assignment( + am=am, + hm=hm, amt_assignment_id=amt_assignment_id, msg=APPROVAL_MESSAGE, amt_hit_type_id=hit.amt_hit_type_id, @@ -398,7 +428,9 @@ def submit_and_approve_amt_bonus_request( return req -def issue_worker_payment(assignment: Assignment) -> None: +def issue_worker_payment( + hm: HitManager, bm: BonusManager, assignment: Assignment +) -> None: # For now, since we have no "I want my bonus" request/button. A user's # balance will be sent out anytime they get an approved assignment. We # don't need the task status, the tsid, nor the amount / user_payout @@ -406,7 +438,7 @@ def issue_worker_payment(assignment: Assignment) -> None: # We just get the wallet balance and submit a cashout request if >0 # then approve it, send the amt bonus, then complete it amt_assignment_id = assignment.amt_assignment_id - hit = HM.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) + hit = hm.get_from_amt_id(amt_hit_id=assignment.amt_hit_id) wallet_balance = get_wallet_balance(amt_worker_id=assignment.amt_worker_id) amount = round_payment(amount=wallet_balance) if not amount: @@ -450,7 +482,7 @@ def issue_worker_payment(assignment: Assignment) -> None: return None # Create in DB - BM.create(bonus=bonus) + bm.create(bonus=bonus) emit_bonus_event(amount=amount, amt_hit_type_id=hit.amt_hit_type_id) # Complete cashout diff --git a/jb/flow/events.py b/jb/flow/events.py index 7b7bd32..5252fd0 100644 --- a/jb/flow/events.py +++ b/jb/flow/events.py @@ -96,7 +96,9 @@ def process_mturk_events_chunk(executor: Executor) -> Optional[int]: def process_assignment_submitted_event(event: MTurkEvent, msg_id: str): - process_assignment_submitted(event) + from jb.decorators import AM, HM, BM + + process_assignment_submitted(am=AM, hm=HM, bm=BM, event=event) REDIS.xackdel(JB_EVENTS_STREAM, CONSUMER_GROUP, msg_id) diff --git a/jb/managers/hit.py b/jb/managers/hit.py index ce8ffa5..7d60b63 100644 --- a/jb/managers/hit.py +++ b/jb/managers/hit.py @@ -69,7 +69,9 @@ class HitQuestionManager(PostgresManager): class HitTypeManager(PostgresManager): def create(self, hit_type: HitType) -> None: + assert hit_type.id is None assert hit_type.amt_hit_type_id is not None + data = hit_type.to_postgres() query = sql.SQL( """ @@ -162,6 +164,7 @@ class HitTypeManager(PostgresManager): c.execute(query, data) conn.commit() assert c.rowcount == 1, c.rowcount + return None diff --git a/tests/__init__.py b/tests/__init__.py index f37e785..e60faf0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,6 +2,6 @@ import random import string -def generate_amt_id(length=30) -> str: +def generate_amt_id(length: int = 30) -> str: chars = string.ascii_uppercase + string.digits return "".join(random.choices(chars, k=length)) diff --git a/tests/amt/test_models.py b/tests/amt/test_models.py deleted file mode 100644 index cecd948..0000000 --- a/tests/amt/test_models.py +++ /dev/null @@ -1,47 +0,0 @@ -import copy - -import pytest - -from jb.models.assignment import Assignment - - -@pytest.fixture -def get_assignment_response_bad_tsid( - get_assignment_response, amt_worker_id, amt_assignment_id -): - res = copy.deepcopy(get_assignment_response) - res["Assignment"]["Answer"] = ( - '<?xml version="1.0" encoding="UTF-8"?>\n' - '<QuestionFormAnswers xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2005-10-01/QuestionFormAnswers.xsd">\n ' - "<Answer>\n <QuestionIdentifier>amt_worker_id</QuestionIdentifier>\n " - f" <FreeText>{amt_worker_id}</FreeText>\n </Answer>\n <Answer>\n " - " <QuestionIdentifier>amt_assignment_id</QuestionIdentifier>\n " - f" <FreeText>{amt_assignment_id}</FreeText>\n </Answer>\n " - f" <Answer>\n <QuestionIdentifier>tsid</QuestionIdentifier>\n abc123 <FreeText></FreeText>\n </Answer>\n" - f"</QuestionFormAnswers>" - ) - return res - - -class TestAssignment: - - @pytest.mark.anyio - def test_get_assignment(get_assignment_response): - assignment = Assignment.from_amt_get_assignment( - get_assignment_response["Assignment"] - ) - assert assignment.tsid is not None - - @pytest.mark.anyio - def test_get_assignment_no_tsid(get_assignment_response_no_tsid): - assignment = Assignment.from_amt_get_assignment( - get_assignment_response_no_tsid["Assignment"] - ) - assert assignment.tsid is None - - @pytest.mark.anyio - def test_get_assignment_bad_tsid(get_assignment_response_bad_tsid): - assignment = Assignment.from_amt_get_assignment( - get_assignment_response_bad_tsid["Assignment"] - ) - assert assignment.tsid is None diff --git a/tests/conftest.py b/tests/conftest.py index a33b149..5138f49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,38 +1,36 @@ -import copy -from datetime import datetime, timezone, timedelta import os -from typing import Optional, TYPE_CHECKING, Callable, Dict, Any +from typing import TYPE_CHECKING from uuid import uuid4 from dotenv import load_dotenv import pytest -from dateutil.tz import tzlocal -from mypy_boto3_mturk.type_defs import ( - GetHITResponseTypeDef, - CreateHITTypeResponseTypeDef, - CreateHITWithHITTypeResponseTypeDef, - GetAssignmentResponseTypeDef, -) -from jb.managers import Permission from generalresearchutils.pg_helper import PostgresConfig -from jb.managers.amt import AMTManager, APPROVAL_MESSAGE, NO_WORK_APPROVAL_MESSAGE -from jb.models.assignment import AssignmentStub, Assignment -from generalresearchutils.currency import USDCent -from jb.models.definitions import HitStatus, HitReviewStatus, AssignmentStatus -from jb.models.hit import HitType, HitQuestion, Hit from tests import generate_amt_id from _pytest.config import Config +from jb.decorators import CLIENT_CONFIG +from mypy_boto3_mturk import MTurkClient if TYPE_CHECKING: from jb.settings import Settings - from jb.managers.hit import HitQuestionManager, HitTypeManager, HitManager - from jb.managers.assignment import AssignmentManager - from jb.managers.bonus import BonusManager + + +pytest_plugins = [ + "tests.fixtures.amt", + "tests.fixtures.flow", + "tests.fixtures.http", + "tests.fixtures.managers", + "tests.fixtures.models", +] # --- IDs and Identifiers --- @pytest.fixture +def amt_hit_id() -> str: + return generate_amt_id() + + +@pytest.fixture def amt_hit_type_id() -> str: return generate_amt_id() @@ -121,466 +119,16 @@ def pg_config(settings: "Settings") -> PostgresConfig: ) -# --- Managers --- - - -@pytest.fixture(scope="session") -def hqm(pg_config: PostgresConfig) -> "HitQuestionManager": - assert ( - pg_config.dsn.path and "/unittest-" in pg_config.dsn.path - ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" - - from jb.managers.hit import HitQuestionManager - - return HitQuestionManager( - pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] - ) - - +# --- Connectors --- @pytest.fixture(scope="session") -def htm(pg_config: PostgresConfig) -> "HitTypeManager": - assert ( - pg_config.dsn.path and "/unittest-" in pg_config.dsn.path - ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" - - from jb.managers.hit import HitTypeManager - - return HitTypeManager( - pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] - ) - - -@pytest.fixture(scope="session") -def hm(pg_config: PostgresConfig) -> "HitManager": - assert ( - pg_config.dsn.path and "/unittest-" in pg_config.dsn.path - ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" - - from jb.managers.hit import HitManager - - return HitManager( - pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] - ) - - -@pytest.fixture(scope="session") -def am(pg_config: PostgresConfig) -> "AssignmentManager": - assert ( - pg_config.dsn.path and "/unittest-" in pg_config.dsn.path - ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" - - from jb.managers.assignment import AssignmentManager - - return AssignmentManager( - pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] - ) - - -@pytest.fixture(scope="session") -def bm(pg_config: PostgresConfig) -> "BonusManager": - assert ( - pg_config.dsn.path and "/unittest-" in pg_config.dsn.path - ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" - - from jb.managers.bonus import BonusManager - - return BonusManager( - pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] - ) - - -# --- Question --- - - -@pytest.fixture -def question() -> HitQuestion: - return HitQuestion(url="https://jamesbillings67.com/work/", height=1200) - - -@pytest.fixture -def question_record(hqm: "HitQuestionManager", question: HitQuestion) -> HitQuestion: - return hqm.get_or_create(question) - - -# --- HITType --- - - -@pytest.fixture -def hit_type() -> HitType: - return HitType( - title="Awesome Surveys!", - description="Give us your opinion", - reward=USDCent(5), - keywords="market,research,amazing", - min_active=10, - ) - - -@pytest.fixture -def hit_type_record(htm: "HitTypeManager", hit_type: HitType) -> HitType: - hit_type.amt_hit_type_id = generate_amt_id() - - return htm.get_or_create(hit_type) - - -@pytest.fixture -def hit_type_with_amt_id(htm: "HitTypeManager", hit_type: HitType) -> HitType: - # This is a real hit type I've previously registered with amt (sandbox). - # It will always exist - hit_type.amt_hit_type_id = "3217B3DC4P5YW9DRV9R3X8O56V041J" - - # Get or create our db - htm.get_or_create(hit_type) - # this call adds the pk int id ---^ - - return hit_type - - -# --- HIT --- - - -@pytest.fixture -def amt_hit_id() -> str: - return generate_amt_id() - - -@pytest.fixture -def hit( - amt_hit_id: str, amt_hit_type_id: str, amt_group_id: str, question: HitQuestion -) -> Hit: - now = datetime.now(tz=timezone.utc) - - return Hit.model_validate( - dict( - amt_hit_id=amt_hit_id, - amt_hit_type_id=amt_hit_type_id, - amt_group_id=amt_group_id, - status=HitStatus.Assignable, - review_status=HitReviewStatus.NotReviewed, - creation_time=now, - expiration=now + timedelta(days=3), - hit_question_xml=question.xml, - qualification_requirements=[], - max_assignments=1, - assignment_pending_count=0, - assignment_available_count=1, - assignment_completed_count=0, - description="Description", - keywords="Keywords", - reward=USDCent(5), - title="Title", - question_id=question.id, - hit_type_id=None, - ) +def amt_client(settings: "Settings") -> MTurkClient: + import boto3 + + return boto3.client( + service_name="mturk", + region_name="us-east-1", + endpoint_url=str(settings.amt_endpoint), + aws_access_key_id=settings.amt_access_id, + aws_secret_access_key=settings.amt_secret_key, + config=CLIENT_CONFIG, ) - - -@pytest.fixture -def hit_record( - hm: "HitManager", - question_record: HitQuestion, - hit_type_record: HitType, - hit: Hit, - amt_hit_id: str, -) -> Hit: - """ - Returns a hit that exists in our db, but does not in amazon (the amt ids - are random). The mtwerk_hittype and mtwerk_question records will also - exist (in the db) - """ - - hit.hit_type_id = hit_type_record.id - hit.amt_hit_id = amt_hit_id - hit.question_id = question_record.id - - hm.create(hit) - return hit - - -@pytest.fixture -def hit_in_amt( - hm: "HitManager", question_record: HitQuestion, hit_type_with_amt_id: HitType -) -> Hit: - # Actually create a new HIT in amt (sandbox) - hit = AMTManager.create_hit_with_hit_type( - hit_type=hit_type_with_amt_id, question=question_record - ) - # Create it in the DB - hm.create(hit) - return hit - - -# --- Assignment --- - - -@pytest.fixture -def assignment_stub(hit: Hit, amt_assignment_id: str, amt_worker_id: str): - now = datetime.now(tz=timezone.utc) - return AssignmentStub( - amt_assignment_id=amt_assignment_id, - amt_hit_id=hit.amt_hit_id, - amt_worker_id=amt_worker_id, - status=AssignmentStatus.Submitted, - modified_at=now, - created_at=now, - ) - - -@pytest.fixture -def assignment_stub_record( - am: "AssignmentManager", hit_record: Hit, assignment_stub: AssignmentStub -) -> AssignmentStub: - """ - Returns an AssignmentStub that exists in our db, but does not in - amazon (the amt ids are random). The mtwerk_hit, mtwerk_hittype, and - mtwerk_question records will also exist (in the db) - """ - assignment_stub.hit_id = hit_record.id - am.create_stub(stub=assignment_stub) - return assignment_stub - - -@pytest.fixture -def assignment_factory(hit: Hit): - - def inner(amt_worker_id: str = None) -> Assignment: - now = datetime.now(tz=timezone.utc) - amt_assignment_id = generate_amt_id() - amt_worker_id = amt_worker_id or generate_amt_id() - - return Assignment( - amt_assignment_id=amt_assignment_id, - amt_hit_id=hit.amt_hit_id, - amt_worker_id=amt_worker_id, - status=AssignmentStatus.Submitted, - modified_at=now, - created_at=now, - accept_time=now, - auto_approval_time=now, - submit_time=now, - ) - - return inner - - -@pytest.fixture -def assignment_record_factory( - am: "AssignmentManager", assignment_factory: Callable[..., Assignment] -): - - def inner(hit_id: int, amt_worker_id: Optional[str] = None): - a = assignment_factory(amt_worker_id=amt_worker_id) - a.hit_id = hit_id - am.create_stub(a) - am.update_answer(a) - return a - - return inner - - -# --- Response --- - - -@pytest.fixture -def amt_response_metadata() -> Dict[str, Any]: - req_id = str(uuid4()) - return { - "RequestId": req_id, - "HTTPStatusCode": 200, - "HTTPHeaders": { - "x-amzn-requestid": req_id, - "content-type": "application/x-amz-json-1.1", - "content-length": "46", - "date": "Wed, 15 Oct 2025 02:16:16 GMT", - }, - "RetryAttempts": 0, - } - - -@pytest.fixture -def create_hit_type_response( - amt_hit_type_id: str, amt_response_metadata: Dict[str, Any] -) -> CreateHITTypeResponseTypeDef: - return { - "HITTypeId": amt_hit_type_id, - "ResponseMetadata": amt_response_metadata, - } - - -@pytest.fixture -def create_hit_with_hit_type_response( - amt_hit_type_id: str, amt_hit_id: str, amt_response_metadata -) -> CreateHITWithHITTypeResponseTypeDef: - amt_group_id = generate_amt_id(length=30) - return { - "HIT": { - "HITId": amt_hit_id, - "HITTypeId": amt_hit_type_id, - "HITGroupId": amt_group_id, - "CreationTime": datetime(2025, 10, 14, 20, 22, tzinfo=tzlocal()), - "Title": "Test", - "Description": "test", - "Question": '<?xml version="1.0" encoding="UTF-8"?>\n<ExternalQuestion xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2006-07-14/ExternalQuestion.xsd">\n <ExternalURL>https://jamesbillings67.com/work/</ExternalURL>\n <FrameHeight>1200</FrameHeight>\n </ExternalQuestion>', - "HITStatus": "Assignable", - "MaxAssignments": 1, - "Reward": "0.05", - "AutoApprovalDelayInSeconds": 2_592_000, - "Expiration": datetime(2025, 10, 14, 20, 24, 3, tzinfo=tzlocal()), - "AssignmentDurationInSeconds": 123, - "QualificationRequirements": [], - "HITReviewStatus": "NotReviewed", - "NumberOfAssignmentsPending": 0, - "NumberOfAssignmentsAvailable": 1, - "NumberOfAssignmentsCompleted": 0, - }, - "ResponseMetadata": amt_response_metadata, - } - - -@pytest.fixture -def get_hit_response( - amt_hit_type_id: str, amt_hit_id: str, amt_response_metadata -) -> GetHITResponseTypeDef: - amt_group_id = generate_amt_id(length=30) - return { - "HIT": { - "HITId": amt_hit_id, - "HITTypeId": amt_hit_type_id, - "HITGroupId": amt_group_id, - "CreationTime": datetime(2025, 10, 13, 23, 0, 3, tzinfo=tzlocal()), - "Title": "Awesome Surveys!", - "Description": "Give us your opinion", - "Question": '<?xml version="1.0" encoding="UTF-8"?>\n<ExternalQuestion xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2006-07-14/ExternalQuestion.xsd">\n <ExternalURL>https://jamesbillings67.com/work/</ExternalURL>\n <FrameHeight>1200</FrameHeight>\n </ExternalQuestion>', - "Keywords": "market,research,amazing", - "HITStatus": "Assignable", - "MaxAssignments": 1, - "Reward": "0.05", - "AutoApprovalDelayInSeconds": 604_800, - "Expiration": datetime(2025, 10, 27, 23, 0, 3, tzinfo=tzlocal()), - "AssignmentDurationInSeconds": 5_400, - "QualificationRequirements": [], - "HITReviewStatus": "NotReviewed", - "NumberOfAssignmentsPending": 0, - "NumberOfAssignmentsAvailable": 1, - "NumberOfAssignmentsCompleted": 0, - }, - "ResponseMetadata": amt_response_metadata, - } - - -@pytest.fixture -def get_hit_response_reviewing(get_hit_response): - res = copy.deepcopy(get_hit_response) - res["HIT"]["NumberOfAssignmentsAvailable"] = 0 - res["HIT"]["NumberOfAssignmentsCompleted"] = 1 - res["HIT"]["HITStatus"] = "Reviewing" - return res - - -@pytest.fixture -def get_assignment_response( - amt_hit_id: str, - amt_assignment_id: str, - amt_worker_id: str, - get_hit_response, - amt_response_metadata, - tsid: str, -) -> GetAssignmentResponseTypeDef: - hit_response = get_hit_response["HIT"] - local_now = datetime.now(tz=tzlocal()) - return { - "Assignment": { - "AssignmentId": amt_assignment_id, - "WorkerId": amt_worker_id, - "HITId": amt_hit_id, - "AssignmentStatus": "Submitted", - "AutoApprovalTime": local_now + timedelta(days=7), - "AcceptTime": local_now - timedelta(minutes=10), - "SubmitTime": local_now, - "Deadline": local_now + timedelta(minutes=90), - "Answer": '<?xml version="1.0" encoding="UTF-8"?>\n' - '<QuestionFormAnswers xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2005-10-01/QuestionFormAnswers.xsd">\n ' - "<Answer>\n <QuestionIdentifier>amt_worker_id</QuestionIdentifier>\n " - f" <FreeText>{amt_worker_id}</FreeText>\n </Answer>\n <Answer>\n " - " <QuestionIdentifier>amt_assignment_id</QuestionIdentifier>\n " - f" <FreeText>{amt_assignment_id}</FreeText>\n </Answer>\n <Answer>\n " - f" <QuestionIdentifier>tsid</QuestionIdentifier>\n <FreeText>{tsid}</FreeText>\n " - " </Answer>\n</QuestionFormAnswers>", - "RequesterFeedback": "Good work", - }, - "HIT": hit_response, - "ResponseMetadata": amt_response_metadata, - } - - -@pytest.fixture -def get_assignment_response_no_tsid( - get_assignment_response, amt_worker_id: str, amt_assignment_id: str -): - res = copy.deepcopy(get_assignment_response) - res["Assignment"]["Answer"] = ( - '<?xml version="1.0" encoding="UTF-8"?>\n' - '<QuestionFormAnswers xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2005-10-01/QuestionFormAnswers.xsd">\n ' - "<Answer>\n <QuestionIdentifier>amt_worker_id</QuestionIdentifier>\n " - f" <FreeText>{amt_worker_id}</FreeText>\n </Answer>\n <Answer>\n " - " <QuestionIdentifier>amt_assignment_id</QuestionIdentifier>\n " - f" <FreeText>{amt_assignment_id}</FreeText>\n </Answer>\n " - # f"<Answer>\n <QuestionIdentifier>tsid</QuestionIdentifier>\n <FreeText> {tsid}</FreeText>\n </Answer>\n" - f"</QuestionFormAnswers>" - ) - return res - - -@pytest.fixture -def get_assignment_response_approved( - get_assignment_response: GetAssignmentResponseTypeDef, -): - def inner(feedback: str = APPROVAL_MESSAGE) -> GetAssignmentResponseTypeDef: - res = copy.deepcopy(get_assignment_response) - res["Assignment"]["AssignmentStatus"] = "Approved" - res["Assignment"]["RequesterFeedback"] = feedback - res["Assignment"]["ApprovalTime"] = res["Assignment"]["SubmitTime"] - return res - - return inner - - -@pytest.fixture -def get_assignment_response_rejected( - get_assignment_response: GetAssignmentResponseTypeDef, -): - - def inner(reject_reason: str = "reject reason") -> GetAssignmentResponseTypeDef: - res = copy.deepcopy(get_assignment_response) - res["Assignment"]["AssignmentStatus"] = "Rejected" - res["Assignment"]["RequesterFeedback"] = reject_reason - res["Assignment"]["RejectionTime"] = res["Assignment"]["SubmitTime"] - return res - - return inner - - -@pytest.fixture -def get_assignment_response_rejected_no_tsid( - get_assignment_response_no_tsid: GetAssignmentResponseTypeDef, -): - - def inner(reject_reason: str = "reject reason") -> GetAssignmentResponseTypeDef: - res = copy.deepcopy(get_assignment_response_no_tsid) - res["Assignment"]["AssignmentStatus"] = "Rejected" - res["Assignment"]["RequesterFeedback"] = reject_reason - res["Assignment"]["RejectionTime"] = res["Assignment"]["SubmitTime"] - return res - - return inner - - -@pytest.fixture -def get_assignment_response_approved_no_tsid( - get_assignment_response_no_tsid: GetAssignmentResponseTypeDef, -): - res = copy.deepcopy(get_assignment_response_no_tsid) - res["Assignment"]["AssignmentStatus"] = "Approved" - res["Assignment"]["RequesterFeedback"] = NO_WORK_APPROVAL_MESSAGE - res["Assignment"]["ApprovalTime"] = res["Assignment"]["SubmitTime"] - return res diff --git a/tests/amt/__init__.py b/tests/fixtures/__init__.py index e69de29..e69de29 100644 --- a/tests/amt/__init__.py +++ b/tests/fixtures/__init__.py diff --git a/tests/fixtures/amt.py b/tests/fixtures/amt.py new file mode 100644 index 0000000..65df125 --- /dev/null +++ b/tests/fixtures/amt.py @@ -0,0 +1,263 @@ +import pytest +import copy + +from datetime import datetime, timedelta +from typing import Callable +from uuid import uuid4 +from dateutil.tz import tzlocal +from mypy_boto3_mturk.type_defs import ( + GetHITResponseTypeDef, + CreateHITTypeResponseTypeDef, + ResponseMetadataTypeDef, + CreateHITWithHITTypeResponseTypeDef, + GetAssignmentResponseTypeDef, +) +from jb.managers.amt import APPROVAL_MESSAGE, NO_WORK_APPROVAL_MESSAGE +from tests import generate_amt_id + +# --- Generic Boto --- + + +@pytest.fixture +def amt_response_metadata() -> ResponseMetadataTypeDef: + req_id = str(uuid4()) + return { + "RequestId": req_id, + "HTTPStatusCode": 200, + "HTTPHeaders": { + "x-amzn-requestid": req_id, + "content-type": "application/x-amz-json-1.1", + "content-length": "46", + "date": "Wed, 15 Oct 2025 02:16:16 GMT", + }, + "RetryAttempts": 0, + } + + +# --- Assignment --- + + +@pytest.fixture +def assignment_response( + amt_hit_id: str, + amt_assignment_id: str, + amt_worker_id: str, + hit_response: GetHITResponseTypeDef, + amt_response_metadata: ResponseMetadataTypeDef, + tsid: str, +) -> GetAssignmentResponseTypeDef: + + hit_response = hit_response["HIT"] + local_now = datetime.now(tz=tzlocal()) + + return { + "Assignment": { + "AssignmentId": amt_assignment_id, + "WorkerId": amt_worker_id, + "HITId": amt_hit_id, + "AssignmentStatus": "Submitted", + "AutoApprovalTime": local_now + timedelta(days=7), + "AcceptTime": local_now - timedelta(minutes=10), + "SubmitTime": local_now, + "Deadline": local_now + timedelta(minutes=90), + "Answer": '<?xml version="1.0" encoding="UTF-8"?>\n' + '<QuestionFormAnswers xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2005-10-01/QuestionFormAnswers.xsd">\n ' + "<Answer>\n <QuestionIdentifier>amt_worker_id</QuestionIdentifier>\n " + f" <FreeText>{amt_worker_id}</FreeText>\n </Answer>\n <Answer>\n " + " <QuestionIdentifier>amt_assignment_id</QuestionIdentifier>\n " + f" <FreeText>{amt_assignment_id}</FreeText>\n </Answer>\n <Answer>\n " + f" <QuestionIdentifier>tsid</QuestionIdentifier>\n <FreeText>{tsid}</FreeText>\n " + " </Answer>\n</QuestionFormAnswers>", + "RequesterFeedback": "Good work", + }, + "HIT": hit_response, + "ResponseMetadata": amt_response_metadata, + } + + +@pytest.fixture +def assignment_response_bad_tsid( + assignment_response: GetAssignmentResponseTypeDef, + amt_worker_id: str, + amt_assignment_id: str, +) -> GetAssignmentResponseTypeDef: + res = copy.deepcopy(assignment_response) + res["Assignment"]["Answer"] = ( + '<?xml version="1.0" encoding="UTF-8"?>\n' + '<QuestionFormAnswers xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2005-10-01/QuestionFormAnswers.xsd">\n ' + "<Answer>\n <QuestionIdentifier>amt_worker_id</QuestionIdentifier>\n " + f" <FreeText>{amt_worker_id}</FreeText>\n </Answer>\n <Answer>\n " + " <QuestionIdentifier>amt_assignment_id</QuestionIdentifier>\n " + f" <FreeText>{amt_assignment_id}</FreeText>\n </Answer>\n " + f" <Answer>\n <QuestionIdentifier>tsid</QuestionIdentifier>\n abc123 <FreeText></FreeText>\n </Answer>\n" + f"</QuestionFormAnswers>" + ) + return res + + +@pytest.fixture +def assignment_response_no_tsid( + assignment_response: GetAssignmentResponseTypeDef, + amt_worker_id: str, + amt_assignment_id: str, +) -> GetAssignmentResponseTypeDef: + res = copy.deepcopy(assignment_response) + res["Assignment"]["Answer"] = ( + '<?xml version="1.0" encoding="UTF-8"?>\n' + '<QuestionFormAnswers xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2005-10-01/QuestionFormAnswers.xsd">\n ' + "<Answer>\n <QuestionIdentifier>amt_worker_id</QuestionIdentifier>\n " + f" <FreeText>{amt_worker_id}</FreeText>\n </Answer>\n <Answer>\n " + " <QuestionIdentifier>amt_assignment_id</QuestionIdentifier>\n " + f" <FreeText>{amt_assignment_id}</FreeText>\n </Answer>\n " + # f"<Answer>\n <QuestionIdentifier>tsid</QuestionIdentifier>\n <FreeText> {tsid}</FreeText>\n </Answer>\n" + f"</QuestionFormAnswers>" + ) + return res + + +@pytest.fixture +def assignment_response_approved_no_tsid( + assignment_response_no_tsid: GetAssignmentResponseTypeDef, +) -> GetAssignmentResponseTypeDef: + res = copy.deepcopy(assignment_response_no_tsid) + res["Assignment"]["AssignmentStatus"] = "Approved" + res["Assignment"]["RequesterFeedback"] = NO_WORK_APPROVAL_MESSAGE + res["Assignment"]["ApprovalTime"] = res["Assignment"]["SubmitTime"] + return res + + +@pytest.fixture +def assignment_response_factory_approved( + assignment_response: GetAssignmentResponseTypeDef, +) -> Callable[[str], GetAssignmentResponseTypeDef]: + + def inner(feedback: str = APPROVAL_MESSAGE) -> GetAssignmentResponseTypeDef: + res = copy.deepcopy(assignment_response) + res["Assignment"]["AssignmentStatus"] = "Approved" + res["Assignment"]["RequesterFeedback"] = feedback + res["Assignment"]["ApprovalTime"] = res["Assignment"]["SubmitTime"] + return res + + return inner + + +@pytest.fixture +def assignment_response_factory_rejected( + assignment_response: GetAssignmentResponseTypeDef, +) -> Callable[[str], GetAssignmentResponseTypeDef]: + + def inner(reject_reason: str = "reject reason") -> GetAssignmentResponseTypeDef: + res = copy.deepcopy(assignment_response) + res["Assignment"]["AssignmentStatus"] = "Rejected" + res["Assignment"]["RequesterFeedback"] = reject_reason + res["Assignment"]["RejectionTime"] = res["Assignment"]["SubmitTime"] + return res + + return inner + + +@pytest.fixture +def assignment_response_factory_rejected_no_tsid( + assignment_response_no_tsid: GetAssignmentResponseTypeDef, +) -> Callable[[str], GetAssignmentResponseTypeDef]: + + def inner(reject_reason: str = "reject reason") -> GetAssignmentResponseTypeDef: + res = copy.deepcopy(assignment_response_no_tsid) + res["Assignment"]["AssignmentStatus"] = "Rejected" + res["Assignment"]["RequesterFeedback"] = reject_reason + res["Assignment"]["RejectionTime"] = res["Assignment"]["SubmitTime"] + return res + + return inner + + +# --- HITType --- + + +@pytest.fixture +def create_hit_type_response( + amt_hit_type_id: str, amt_response_metadata: ResponseMetadataTypeDef +) -> CreateHITTypeResponseTypeDef: + return { + "HITTypeId": amt_hit_type_id, + "ResponseMetadata": amt_response_metadata, + } + + +# --- HIT --- + + +@pytest.fixture +def hit_response_with_hit_type( + amt_hit_type_id: str, + amt_hit_id: str, + amt_response_metadata: ResponseMetadataTypeDef, +) -> CreateHITWithHITTypeResponseTypeDef: + amt_group_id = generate_amt_id(length=30) + return { + "HIT": { + "HITId": amt_hit_id, + "HITTypeId": amt_hit_type_id, + "HITGroupId": amt_group_id, + "CreationTime": datetime(2025, 10, 14, 20, 22, tzinfo=tzlocal()), + "Title": "Test", + "Description": "test", + "Question": '<?xml version="1.0" encoding="UTF-8"?>\n<ExternalQuestion xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2006-07-14/ExternalQuestion.xsd">\n <ExternalURL>https://jamesbillings67.com/work/</ExternalURL>\n <FrameHeight>1200</FrameHeight>\n </ExternalQuestion>', + "HITStatus": "Assignable", + "MaxAssignments": 1, + "Reward": "0.05", + "AutoApprovalDelayInSeconds": 2_592_000, + "Expiration": datetime(2025, 10, 14, 20, 24, 3, tzinfo=tzlocal()), + "AssignmentDurationInSeconds": 123, + "QualificationRequirements": [], + "HITReviewStatus": "NotReviewed", + "NumberOfAssignmentsPending": 0, + "NumberOfAssignmentsAvailable": 1, + "NumberOfAssignmentsCompleted": 0, + }, + "ResponseMetadata": amt_response_metadata, + } + + +@pytest.fixture +def hit_response( + amt_hit_type_id: str, + amt_hit_id: str, + amt_response_metadata: ResponseMetadataTypeDef, +) -> GetHITResponseTypeDef: + amt_group_id = generate_amt_id(length=30) + return { + "HIT": { + "HITId": amt_hit_id, + "HITTypeId": amt_hit_type_id, + "HITGroupId": amt_group_id, + "CreationTime": datetime(2025, 10, 13, 23, 0, 3, tzinfo=tzlocal()), + "Title": "Awesome Surveys!", + "Description": "Give us your opinion", + "Question": '<?xml version="1.0" encoding="UTF-8"?>\n<ExternalQuestion xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2006-07-14/ExternalQuestion.xsd">\n <ExternalURL>https://jamesbillings67.com/work/</ExternalURL>\n <FrameHeight>1200</FrameHeight>\n </ExternalQuestion>', + "Keywords": "market,research,amazing", + "HITStatus": "Assignable", + "MaxAssignments": 1, + "Reward": "0.05", + "AutoApprovalDelayInSeconds": 604_800, + "Expiration": datetime(2025, 10, 27, 23, 0, 3, tzinfo=tzlocal()), + "AssignmentDurationInSeconds": 5_400, + "QualificationRequirements": [], + "HITReviewStatus": "NotReviewed", + "NumberOfAssignmentsPending": 0, + "NumberOfAssignmentsAvailable": 1, + "NumberOfAssignmentsCompleted": 0, + }, + "ResponseMetadata": amt_response_metadata, + } + + +@pytest.fixture +def hit_response_reviewing( + hit_response: GetHITResponseTypeDef, +) -> GetHITResponseTypeDef: + res = copy.deepcopy(hit_response) + res["HIT"]["NumberOfAssignmentsAvailable"] = 0 + res["HIT"]["NumberOfAssignmentsCompleted"] = 1 + res["HIT"]["HITStatus"] = "Reviewing" + return res diff --git a/tests/fixtures/flow.py b/tests/fixtures/flow.py new file mode 100644 index 0000000..633e05d --- /dev/null +++ b/tests/fixtures/flow.py @@ -0,0 +1,296 @@ +from datetime import timezone, datetime +from typing import Dict, Callable, Any, Optional +from uuid import uuid4 + +import pytest +import requests +from generalresearchutils.models.thl.payout import UserPayoutEvent +from generalresearchutils.models.thl.wallet import PayoutType +from generalresearchutils.models.thl.wallet.cashout_method import ( + CashoutRequestResponse, + CashoutRequestInfo, +) +from mypy_boto3_mturk.type_defs import ( + GetHITResponseTypeDef, + GetAssignmentResponseTypeDef, +) + +from jb.config import settings +from jb.managers.amt import ( + APPROVAL_MESSAGE, + BONUS_MESSAGE, +) +from generalresearchutils.currency import USDCent +from generalresearchutils.models.thl.definitions import PayoutStatus + + +@pytest.fixture +def approved_assignment_stubs( + get_assignment_response: GetAssignmentResponseTypeDef, + get_assignment_response_approved: Callable[[str], GetAssignmentResponseTypeDef], + amt_assignment_id: str, + amt_hit_id: str, + get_hit_response_reviewing: GetHITResponseTypeDef, +) -> Callable[..., list[Dict[str, Any]]]: + + # These are the AMT_CLIENT stubs/mocks that need to be set when running + # process_assignment_submitted() which will result in an approved + # assignment and sent bonus + def _inner( + feedback: str = APPROVAL_MESSAGE, + override_response: Optional[str] = None, + override_approve_response: Optional[str] = None, + ) -> list[Dict[str, Any]]: + + response = override_response or get_assignment_response + approve_response = ( + override_approve_response or get_assignment_response_approved(feedback) + ) + + return [ + { + "operation": "get_assignment", + "response": response, + "expected_params": {"AssignmentId": amt_assignment_id}, + }, + { + "operation": "approve_assignment", + "response": {}, + "expected_params": { + "AssignmentId": amt_assignment_id, + "RequesterFeedback": feedback, + "OverrideRejection": False, + }, + }, + { + "operation": "get_assignment", + "response": approve_response, + "expected_params": {"AssignmentId": amt_assignment_id}, + }, + { + "operation": "update_hit_review_status", + "response": {}, + "expected_params": {"HITId": amt_hit_id, "Revert": False}, + }, + { + "operation": "get_hit", + "response": get_hit_response_reviewing, + "expected_params": {"HITId": amt_hit_id}, + }, + ] + + return _inner + + +@pytest.fixture +def approved_assignment_stubs_w_bonus( + approved_assignment_stubs: Callable[..., list[Dict[str, Any]]], + amt_worker_id: str, + amt_assignment_id: str, + pe_id: str, +) -> list[Dict[str, Any]]: + + now = datetime.now(tz=timezone.utc) + stubs = approved_assignment_stubs().copy() + stubs.append( + { + "operation": "send_bonus", + "response": {}, + "expected_params": { + "WorkerId": amt_worker_id, + "BonusAmount": "0.07", + "AssignmentId": amt_assignment_id, + "Reason": BONUS_MESSAGE, + "UniqueRequestToken": pe_id, + }, + } + ) + stubs.append( + { + "operation": "list_bonus_payments", + "response": { + "BonusPayments": [ + { + "WorkerId": amt_worker_id, + "BonusAmount": "0.07", + "AssignmentId": amt_assignment_id, + "Reason": BONUS_MESSAGE, + "GrantTime": now, + } + ] + }, + "expected_params": {"AssignmentId": amt_assignment_id}, + } + ) + return stubs + + +@pytest.fixture +def rejected_assignment_stubs( + assignment_response: GetAssignmentResponseTypeDef, + assignment_response_factory_rejected: Callable[[str], GetAssignmentResponseTypeDef], + amt_assignment_id: str, + amt_hit_id: str, + hit_response_reviewing: GetHITResponseTypeDef, +) -> Callable[..., list[Dict[str, Any]]]: + + # These are the AMT_CLIENT stubs/mocks that need to be set when running + # process_assignment_submitted() which will result in a rejected + # assignment + def _inner( + reject_reason: str, + override_response: Optional[str] = None, + override_reject_response: Optional[str] = None, + ) -> list[Dict[str, Any]]: + + response = override_response or assignment_response + reject_response = ( + override_reject_response + or assignment_response_factory_rejected(reject_reason) + ) + + return [ + { + "operation": "get_assignment", + "response": response, + "expected_params": {"AssignmentId": amt_assignment_id}, + }, + { + "operation": "reject_assignment", + "response": {}, + "expected_params": { + "AssignmentId": amt_assignment_id, + "RequesterFeedback": reject_reason, + }, + }, + { + "operation": "get_assignment", + "response": reject_response, + "expected_params": {"AssignmentId": amt_assignment_id}, + }, + { + "operation": "update_hit_review_status", + "response": {}, + "expected_params": {"HITId": amt_hit_id, "Revert": False}, + }, + { + "operation": "get_hit", + "response": hit_response_reviewing, + "expected_params": {"HITId": amt_hit_id}, + }, + ] + + return _inner + + +@pytest.fixture +def mock_thl_responses( + monkeypatch: pytest.MonkeyPatch, amt_worker_id: str, tsid: str, pe_id: str +) -> Callable[..., None]: + + original_get = requests.get + original_post = requests.post + + class MockThlCashoutRequestResponse: + def json(self): + return CashoutRequestResponse( + status="success", + cashout=CashoutRequestInfo( + id=pe_id, + description="amt something", + status=PayoutStatus.PENDING, + ), + ).model_dump(mode="json") + + def _inner( + user_blocked: bool = False, + status_finished: bool = True, + status_complete: bool = False, + wallet_redeemable_amount: int = 10, + ): + + def mock_get(url, *args, **kwargs): # type: ignore + profile_url = f"{settings.fsb_host}{settings.product_id}/user/{amt_worker_id}/profile/" + status_url = f"{settings.fsb_host}{settings.product_id}/status/{tsid}/" + cashout_request_url = f"{settings.fsb_host}{settings.product_id}/cashout/" + wallet_url = f"{settings.fsb_host}{settings.product_id}/wallet/" + if url == profile_url: + + class MockThlProfileResponse: + def json(self): + return {"user_profile": {"user": {"blocked": user_blocked}}} + + return MockThlProfileResponse() + + elif url == wallet_url: + + class MockThlWalletResponse: + def json(self) -> Dict[str, Any]: + return { + "wallet": {"redeemable_amount": wallet_redeemable_amount} + } + + return MockThlWalletResponse() + + elif url == status_url: + + class MockThlStatusResponse: + def json(self) -> Dict[str, Any]: + return { + "tsid": tsid, + "product_id": str(settings.product_id), + "product_user_id": amt_worker_id, + "started": "2020-06-02T00:30:35.036398Z", + "finished": ( + "2020-06-02T00:31:35.036398Z" + if status_finished + else None + ), + "status": 3 if status_complete else 2, + "payout": 10 if status_complete else 0, + "user_payout": 10 if status_complete else 0, + "status_code_1": "BUYER_FAIL", + "status_code_2": None, + } + + return MockThlStatusResponse() + + elif url == cashout_request_url: + return MockThlCashoutRequestResponse() + + else: + raise ValueError(f"unhandled call: {url=} {args=} {kwargs=}") + + return original_get(url, *args, **kwargs) + + def mock_post(url, *args, **kwargs): # type: ignore + cashout_request_url = f"{settings.fsb_host}{settings.product_id}/cashout/" + manage_cashout_request_url = f"{settings.fsb_host}{settings.fsb_host_private_route}/thl/manage_cashout/" + + if url == cashout_request_url: + return MockThlCashoutRequestResponse() + + elif url == manage_cashout_request_url: + json = kwargs["json"] + print(json) + payout_id = json["payout_id"] + new_status = json["new_status"] + + class MockThlManageCashoutResponse: + def json(self): + return UserPayoutEvent( + uuid=payout_id, + status=new_status, + amount=USDCent(5), + debit_account_uuid=uuid4().hex, + cashout_method_uuid=uuid4().hex, + payout_type=PayoutType.AMT, + ).model_dump(mode="json") + + return MockThlManageCashoutResponse() + return original_post(url, *args, **kwargs) + + monkeypatch.setattr(requests, "get", mock_get) + monkeypatch.setattr(requests, "post", mock_post) + + return _inner diff --git a/tests/http/conftest.py b/tests/fixtures/http.py index 4f11fde..4f11fde 100644 --- a/tests/http/conftest.py +++ b/tests/fixtures/http.py diff --git a/tests/fixtures/managers.py b/tests/fixtures/managers.py new file mode 100644 index 0000000..6bc2e51 --- /dev/null +++ b/tests/fixtures/managers.py @@ -0,0 +1,77 @@ +from typing import TYPE_CHECKING +import pytest +from jb.managers import Permission +from generalresearchutils.pg_helper import PostgresConfig + +if TYPE_CHECKING: + from jb.managers.hit import HitQuestionManager, HitTypeManager, HitManager + from jb.managers.assignment import AssignmentManager + from jb.managers.bonus import BonusManager + + +# --- Managers --- + + +@pytest.fixture(scope="session") +def hqm(pg_config: PostgresConfig) -> "HitQuestionManager": + assert ( + pg_config.dsn.path and "/unittest-" in pg_config.dsn.path + ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" + + from jb.managers.hit import HitQuestionManager + + return HitQuestionManager( + pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] + ) + + +@pytest.fixture(scope="session") +def htm(pg_config: PostgresConfig) -> "HitTypeManager": + assert ( + pg_config.dsn.path and "/unittest-" in pg_config.dsn.path + ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" + + from jb.managers.hit import HitTypeManager + + return HitTypeManager( + pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] + ) + + +@pytest.fixture(scope="session") +def hm(pg_config: PostgresConfig) -> "HitManager": + assert ( + pg_config.dsn.path and "/unittest-" in pg_config.dsn.path + ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" + + from jb.managers.hit import HitManager + + return HitManager( + pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] + ) + + +@pytest.fixture(scope="session") +def am(pg_config: PostgresConfig) -> "AssignmentManager": + assert ( + pg_config.dsn.path and "/unittest-" in pg_config.dsn.path + ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" + + from jb.managers.assignment import AssignmentManager + + return AssignmentManager( + pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] + ) + + +@pytest.fixture(scope="session") +def bm(pg_config: PostgresConfig) -> "BonusManager": + assert ( + pg_config.dsn.path and "/unittest-" in pg_config.dsn.path + ), "pg_config must point to a unittest database (dsn path must contain '/unittest-')" + + from jb.managers.bonus import BonusManager + + return BonusManager( + pg_config=pg_config, permissions=[Permission.READ, Permission.CREATE] + ) diff --git a/tests/fixtures/models.py b/tests/fixtures/models.py new file mode 100644 index 0000000..03bd8a2 --- /dev/null +++ b/tests/fixtures/models.py @@ -0,0 +1,279 @@ +from datetime import timezone, datetime + +import pytest + +from jb.models.event import MTurkEvent +from generalresearchutils.pg_helper import PostgresConfig + +from datetime import datetime, timezone, timedelta +from typing import Optional, TYPE_CHECKING, Callable, Generator +from jb.managers.amt import AMTManager +from jb.models.assignment import AssignmentStub, Assignment +from generalresearchutils.currency import USDCent +from jb.models.definitions import HitStatus, HitReviewStatus, AssignmentStatus +from jb.models.hit import HitType, HitQuestion, Hit +from tests import generate_amt_id + +if TYPE_CHECKING: + from jb.managers.hit import HitQuestionManager, HitTypeManager, HitManager + from jb.managers.assignment import AssignmentManager + + +# --- MTurk Event --- + + +@pytest.fixture +def mturk_event( + amt_assignment_id: str, amt_hit_id: str, amt_hit_type_id: str +) -> MTurkEvent: + now = datetime.now(tz=timezone.utc) + return MTurkEvent( + event_type="AssignmentSubmitted", + event_timestamp=now, + amt_assignment_id=amt_assignment_id, + amt_hit_type_id=amt_hit_type_id, + amt_hit_id=amt_hit_id, + ) + + +# --- Question --- + + +@pytest.fixture +def question() -> HitQuestion: + return HitQuestion(url="https://jamesbillings67.com/work/", height=1200) + + +@pytest.fixture +def question_record(hqm: "HitQuestionManager", question: HitQuestion) -> HitQuestion: + return hqm.get_or_create(question) + + +# --- HITType --- + + +@pytest.fixture +def hit_type() -> HitType: + return HitType( + title="Awesome Surveys!", + description="Give us your opinion", + reward=USDCent(5), + keywords="market,research,amazing", + min_active=10, + ) + + +@pytest.fixture +def hit_type_record( + pg_config: PostgresConfig, htm: "HitTypeManager", hit_type: HitType +) -> Generator[HitType, None, None]: + + hit_type.amt_hit_type_id = generate_amt_id() + + ht = htm.get_or_create(hit_type) + + yield ht + + with pg_config.make_connection() as conn: + with conn.cursor() as c: + c.execute("DELETE FROM mtwerk_hittype WHERE id=%s", (ht.id,)) + conn.commit() + + +@pytest.fixture +def hit_type_record_with_amt_id( + pg_config: PostgresConfig, htm: "HitTypeManager", hit_type: HitType +) -> Generator[HitType, None, None]: + # This is a real hit type I've previously registered with amt (sandbox). + # It will always exist + hit_type.amt_hit_type_id = "3217B3DC4P5YW9DRV9R3X8O56V041J" + + # Get or create our db + ht = htm.get_or_create(hit_type) + + yield ht + + with pg_config.make_connection() as conn: + with conn.cursor() as c: + c.execute("DELETE FROM mtwerk_hittype WHERE id=%s", (ht.id,)) + conn.commit() + + +# --- HIT --- + + +@pytest.fixture +def hit( + amt_hit_id: str, amt_hit_type_id: str, amt_group_id: str, question: HitQuestion +) -> Hit: + now = datetime.now(tz=timezone.utc) + + return Hit.model_validate( + dict( + amt_hit_id=amt_hit_id, + amt_hit_type_id=amt_hit_type_id, + amt_group_id=amt_group_id, + status=HitStatus.Assignable, + review_status=HitReviewStatus.NotReviewed, + creation_time=now, + expiration=now + timedelta(days=3), + hit_question_xml=question.xml, + qualification_requirements=[], + max_assignments=1, + assignment_pending_count=0, + assignment_available_count=1, + assignment_completed_count=0, + description="Description", + keywords="Keywords", + reward=USDCent(5), + title="Title", + question_id=question.id, + hit_type_id=None, + ) + ) + + +@pytest.fixture +def hit_record( + pg_config: PostgresConfig, + hm: "HitManager", + question_record: HitQuestion, + hit_type_record: HitType, + hit: Hit, + amt_hit_id: str, +) -> Generator[Hit, None, None]: + """ + Returns a hit that exists in our db, but does not in amazon (the amt ids + are random). The mtwerk_hittype and mtwerk_question records will also + exist (in the db) + """ + + hit.hit_type_id = hit_type_record.id + hit.amt_hit_id = amt_hit_id + hit.question_id = question_record.id + + hm.create(hit) + + yield hit + + with pg_config.make_connection() as conn: + with conn.cursor() as c: + c.execute("DELETE FROM mtwerk_hit WHERE id=%s", (hit.id,)) + conn.commit() + + +@pytest.fixture +def hit_in_amt( + hm: "HitManager", question_record: HitQuestion, hit_type_record_with_amt_id: HitType +) -> Hit: + # Actually create a new HIT in amt (sandbox) + hit = AMTManager.create_hit_with_hit_type( + hit_type=hit_type_record_with_amt_id, question=question_record + ) + # Create it in the DB + hm.create(hit) + return hit + + +# --- Assignment --- + + +@pytest.fixture +def assignment_stub( + hit: Hit, amt_assignment_id: str, amt_worker_id: str +) -> AssignmentStub: + now = datetime.now(tz=timezone.utc) + return AssignmentStub( + amt_assignment_id=amt_assignment_id, + amt_hit_id=hit.amt_hit_id, + amt_worker_id=amt_worker_id, + status=AssignmentStatus.Submitted, + modified_at=now, + created_at=now, + ) + + +@pytest.fixture +def assignment_stub_record( + pg_config: PostgresConfig, + am: "AssignmentManager", + hit_record: Hit, + assignment_stub: AssignmentStub, +) -> Generator[AssignmentStub, None, None]: + """ + Returns an AssignmentStub that exists in our db, but does not in + amazon (the amt ids are random). The mtwerk_hit, mtwerk_hittype, and + mtwerk_question records will also exist (in the db) + """ + assignment_stub.hit_id = hit_record.id + am.create_stub(stub=assignment_stub) + + yield assignment_stub + + with pg_config.make_connection() as conn: + with conn.cursor() as c: + c.execute( + "DELETE FROM mtwerk_assignment WHERE id=%s", (assignment_stub.id,) + ) + conn.commit() + + +@pytest.fixture +def assignment(assignment_factory: Callable[..., Assignment]) -> Assignment: + return assignment_factory() + + +@pytest.fixture +def assignment_record( + pg_config: PostgresConfig, + hit_record: Hit, + assignment_record_factory: Callable[..., Assignment], +) -> Generator[Assignment, None, None]: + assignment = assignment_record_factory(hit_id=hit_record.id) + + yield assignment + + with pg_config.make_connection() as conn: + with conn.cursor() as c: + c.execute( + "DELETE FROM mtwerk_assignment WHERE id=%s", (assignment_stub.id,) + ) + conn.commit() + + +@pytest.fixture +def assignment_factory(hit: Hit) -> Callable[[Optional[str]], Assignment]: + + def _inner(amt_worker_id: Optional[str] = None) -> Assignment: + now = datetime.now(tz=timezone.utc) + amt_assignment_id = generate_amt_id() + amt_worker_id = amt_worker_id or generate_amt_id() + + return Assignment( + amt_assignment_id=amt_assignment_id, + amt_hit_id=hit.amt_hit_id, + amt_worker_id=amt_worker_id, + status=AssignmentStatus.Submitted, + modified_at=now, + created_at=now, + accept_time=now, + auto_approval_time=now, + submit_time=now, + ) + + return _inner + + +@pytest.fixture +def assignment_record_factory( + am: "AssignmentManager", assignment_factory: Callable[..., Assignment] +) -> Callable[..., Assignment]: + + def _inner(hit_id: int, amt_worker_id: Optional[str] = None) -> Assignment: + a = assignment_factory(amt_worker_id=amt_worker_id) + a.hit_id = hit_id + am.create_stub(a) + am.update_answer(a) + return a + + return _inner diff --git a/tests/flow/test_tasks.py b/tests/flow/test_tasks.py index b708cf9..82d4912 100644 --- a/tests/flow/test_tasks.py +++ b/tests/flow/test_tasks.py @@ -1,21 +1,10 @@ import logging from contextlib import contextmanager -from datetime import timezone, datetime -from typing import Dict -from uuid import uuid4 +from typing import Callable, Dict, Any import pytest -import requests from botocore.stub import Stubber -from generalresearchutils.models.thl.payout import UserPayoutEvent -from generalresearchutils.models.thl.wallet import PayoutType -from generalresearchutils.models.thl.wallet.cashout_method import ( - CashoutRequestResponse, - CashoutRequestInfo, -) - -from jb.config import settings -from jb.decorators import AMT_CLIENT, AM, BM +from jb.decorators import AMT_CLIENT from jb.flow.assignment_tasks import process_assignment_submitted from jb.managers.amt import ( AMTManager, @@ -23,13 +12,19 @@ from jb.managers.amt import ( REJECT_MESSAGE_BADDIE, REJECT_MESSAGE_UNKNOWN_ASSIGNMENT, REJECT_MESSAGE_NO_WORK, - BONUS_MESSAGE, NO_WORK_APPROVAL_MESSAGE, ) +from mypy_boto3_mturk.type_defs import ( + GetAssignmentResponseTypeDef, +) from generalresearchutils.currency import USDCent +from jb.managers.assignment import AssignmentManager +from jb.managers.bonus import BonusManager +from jb.managers.hit import HitManager from jb.models.definitions import AssignmentStatus -from generalresearchutils.models.thl.definitions import PayoutStatus from jb.models.event import MTurkEvent +from jb.models.hit import Hit +from jb.models.assignment import Assignment, AssignmentStub @contextmanager @@ -45,303 +40,52 @@ def amt_stub_context(responses): yield stub -@pytest.fixture -def approved_assignment_stubs( - get_assignment_response, - get_assignment_response_approved, - amt_assignment_id, - amt_hit_id, - get_hit_response_reviewing, -): - # These are the AMT_CLIENT stubs/mocks that need to be set when running - # process_assignment_submitted() which will result in an approved - # assignment and sent bonus - def _approved_assignment_stubs( - feedback: str = APPROVAL_MESSAGE, - override_response=None, - override_approve_response=None, - ): - response = override_response or get_assignment_response - approve_response = ( - override_approve_response or get_assignment_response_approved(feedback) - ) - return [ - { - "operation": "get_assignment", - "response": response, - "expected_params": {"AssignmentId": amt_assignment_id}, - }, - { - "operation": "approve_assignment", - "response": {}, - "expected_params": { - "AssignmentId": amt_assignment_id, - "RequesterFeedback": feedback, - "OverrideRejection": False, - }, - }, - { - "operation": "get_assignment", - "response": approve_response, - "expected_params": {"AssignmentId": amt_assignment_id}, - }, - { - "operation": "update_hit_review_status", - "response": {}, - "expected_params": {"HITId": amt_hit_id, "Revert": False}, - }, - { - "operation": "get_hit", - "response": get_hit_response_reviewing, - "expected_params": {"HITId": amt_hit_id}, - }, - ] - - return _approved_assignment_stubs - - -@pytest.fixture -def approved_assignment_stubs_w_bonus( - approved_assignment_stubs, amt_worker_id, amt_assignment_id, pe_id -): - now = datetime.now(tz=timezone.utc) - stubs = approved_assignment_stubs().copy() - stubs.append( - { - "operation": "send_bonus", - "response": {}, - "expected_params": { - "WorkerId": amt_worker_id, - "BonusAmount": "0.07", - "AssignmentId": amt_assignment_id, - "Reason": BONUS_MESSAGE, - "UniqueRequestToken": pe_id, - }, - } - ) - stubs.append( - { - "operation": "list_bonus_payments", - "response": { - "BonusPayments": [ - { - "WorkerId": amt_worker_id, - "BonusAmount": "0.07", - "AssignmentId": amt_assignment_id, - "Reason": BONUS_MESSAGE, - "GrantTime": now, - } - ] - }, - "expected_params": {"AssignmentId": amt_assignment_id}, - } - ) - return stubs - - -@pytest.fixture -def rejected_assignment_stubs( - get_assignment_response, - get_assignment_response_rejected, - amt_assignment_id, - amt_hit_id, - get_hit_response_reviewing, -): - # These are the AMT_CLIENT stubs/mocks that need to be set when running - # process_assignment_submitted() which will result in a rejected - # assignment - def _rejected_assignment_stubs( - reject_reason: str, override_response=None, override_reject_response=None - ): - response = override_response or get_assignment_response - reject_response = override_reject_response or get_assignment_response_rejected( - reject_reason - ) - return [ - { - "operation": "get_assignment", - "response": response, - "expected_params": {"AssignmentId": amt_assignment_id}, - }, - { - "operation": "reject_assignment", - "response": {}, - "expected_params": { - "AssignmentId": amt_assignment_id, - "RequesterFeedback": reject_reason, - }, - }, - { - "operation": "get_assignment", - "response": reject_response, - "expected_params": {"AssignmentId": amt_assignment_id}, - }, - { - "operation": "update_hit_review_status", - "response": {}, - "expected_params": {"HITId": amt_hit_id, "Revert": False}, - }, - { - "operation": "get_hit", - "response": get_hit_response_reviewing, - "expected_params": {"HITId": amt_hit_id}, - }, - ] - - return _rejected_assignment_stubs - - -@pytest.fixture -def mock_thl_responses(monkeypatch, amt_worker_id, tsid, pe_id): - original_get = requests.get - original_post = requests.post - - class MockThlCashoutRequestResponse: - def json(self): - return CashoutRequestResponse( - status="success", - cashout=CashoutRequestInfo( - id=pe_id, - description="amt something", - status=PayoutStatus.PENDING, - ), - ).model_dump(mode="json") - - def _apply_mock( - user_blocked: bool = False, - status_finished: bool = True, - status_complete: bool = False, - wallet_redeemable_amount: int = 10, - ): - - def mock_get(url, *args, **kwargs): - profile_url = f"{settings.fsb_host}{settings.product_id}/user/{amt_worker_id}/profile/" - status_url = f"{settings.fsb_host}{settings.product_id}/status/{tsid}/" - cashout_request_url = f"{settings.fsb_host}{settings.product_id}/cashout/" - wallet_url = f"{settings.fsb_host}{settings.product_id}/wallet/" - if url == profile_url: - - class MockThlProfileResponse: - def json(self): - return {"user-profile": {"user": {"blocked": user_blocked}}} - - return MockThlProfileResponse() - - elif url == wallet_url: - - class MockThlWalletResponse: - def json(self): - return { - "wallet": {"redeemable_amount": wallet_redeemable_amount} - } - - return MockThlWalletResponse() - - elif url == status_url: - - class MockThlStatusResponse: - def json(self): - return { - "tsid": tsid, - "product_id": str(settings.product_id), - "product_user_id": amt_worker_id, - "started": "2020-06-02T00:30:35.036398Z", - "finished": ( - "2020-06-02T00:31:35.036398Z" - if status_finished - else None - ), - "status": 3 if status_complete else 2, - "payout": 10 if status_complete else 0, - "user_payout": 10 if status_complete else 0, - "status_code_1": "BUYER_FAIL", - "status_code_2": None, - } - - return MockThlStatusResponse() - elif url == cashout_request_url: - return MockThlCashoutRequestResponse() - else: - raise ValueError(f"unhandled call: {url=} {args=} {kwargs=}") - return original_get(url, *args, **kwargs) - - def mock_post(url, *args, **kwargs): - cashout_request_url = f"{settings.fsb_host}{settings.product_id}/cashout/" - manage_cashout_request_url = f"{settings.fsb_host}{settings.fsb_host_private_route}/thl/manage_cashout/" - - if url == cashout_request_url: - return MockThlCashoutRequestResponse() - - elif url == manage_cashout_request_url: - json = kwargs["json"] - print(json) - payout_id = json["payout_id"] - new_status = json["new_status"] - - class MockThlManageCashoutResponse: - def json(self): - return UserPayoutEvent( - uuid=payout_id, - status=new_status, - amount=USDCent(5), - debit_account_uuid=uuid4().hex, - cashout_method_uuid=uuid4().hex, - payout_type=PayoutType.AMT, - ).model_dump(mode="json") - - return MockThlManageCashoutResponse() - return original_post(url, *args, **kwargs) - - monkeypatch.setattr(requests, "get", mock_get) - monkeypatch.setattr(requests, "post", mock_post) - - return _apply_mock - - -@pytest.fixture -def mturk_event(amt_assignment_id, amt_hit_id, amt_hit_type_id): - now = datetime.now(tz=timezone.utc) - return MTurkEvent( - event_type="AssignmentSubmitted", - event_timestamp=now, - amt_assignment_id=amt_assignment_id, - amt_hit_type_id=amt_hit_type_id, - amt_hit_id=amt_hit_id, - ) - - -def test_fake_get_assignment( - amt_assignment_id, amt_worker_id, get_assignment_response: Dict -): - # Testing just that this boto stubber works (we fake a response using the real boto client) - fake_response = get_assignment_response.copy() - with Stubber(AMT_CLIENT) as stub: - expected_params = {"AssignmentId": amt_assignment_id} - stub.add_response("get_assignment", fake_response, expected_params) +class TestHITTasks: - assignment = AMTManager.get_assignment_if_exists( - amt_assignment_id=amt_assignment_id - ) - assert assignment.amt_assignment_id == amt_assignment_id - assert assignment.amt_worker_id == amt_worker_id + def test_fake_get_assignment( + self, + amt_assignment_id: str, + amt_worker_id: str, + assignment_response: GetAssignmentResponseTypeDef, + ): + # Testing just that this boto stubber works (we fake a response + # using the real boto client) + fake_response = assignment_response.copy() + with Stubber(AMT_CLIENT) as stub: + expected_params = {"AssignmentId": amt_assignment_id} + stub.add_response("get_assignment", fake_response, expected_params) + + assignment = AMTManager.get_assignment_if_exists( + amt_assignment_id=amt_assignment_id + ) + assert assignment is not None + assert assignment.amt_assignment_id == amt_assignment_id + assert assignment.amt_worker_id == amt_worker_id - # Optionally, ensure all queued responses were used: - stub.assert_no_pending_responses() + # Optionally, ensure all queued responses were used: + stub.assert_no_pending_responses() class TestProcessAssignmentSubmitted: def test_no_assignment_in_db( self, - mturk_event, - amt_assignment_id, - get_assignment_response: Dict, - caplog, - hit_record, - rejected_assignment_stubs, + am: AssignmentManager, + hit_record: Hit, + mturk_event: MTurkEvent, + amt_assignment_id: str, + caplog: pytest.LogCaptureFixture, + rejected_assignment_stubs: Callable[..., list[Dict[str, Any]]], ): - # An assignment is submitted. The hit exists in the DB. The amt assignment id is valid, - # but the assignment stub is not in our db. Reject it and write the assignment to the db. + + # These records are auto cleaned up, so we need to explicitly create + # a HIT record in the DB so the process_assignment_submitted task + # doesn't error when we try to process the Request + _ = hit_record + + # An assignment is submitted. The hit exists in the DB. The amt + # assignment id is valid, but the assignment stub is not in our + # db. Reject it and write the assignment to the db. amt_stubs = rejected_assignment_stubs( reject_reason=REJECT_MESSAGE_UNKNOWN_ASSIGNMENT @@ -356,65 +100,73 @@ class TestProcessAssignmentSubmitted: assert f"Rejected assignment: " in caplog.text stub.assert_no_pending_responses() - ass = AM.get(amt_assignment_id=amt_assignment_id) + ass = am.get(amt_assignment_id=amt_assignment_id) assert ass.status == AssignmentStatus.Rejected assert ass.requester_feedback == REJECT_MESSAGE_UNKNOWN_ASSIGNMENT def test_assignment_in_db_user_doesnt_exist( self, - mturk_event, - amt_assignment_id, - assignment_stub_in_db, - caplog, - mock_thl_responses, - rejected_assignment_stubs, + am: AssignmentManager, + hm: HitManager, + bm: BonusManager, + mturk_event: MTurkEvent, + amt_assignment_id: str, + assignment_stub_record: AssignmentStub, + caplog: pytest.LogCaptureFixture, + mock_thl_responses: Callable[..., None], + rejected_assignment_stubs: Callable[..., list[Dict[str, Any]]], ): - # An assignment is submitted. The hit and assignment stub exist in the DB. We think we're going to - # approve the assignment, but the user-profile / check blocked call on THL shows the user doesn't - # exist (same thing would happen if the user does exist and is blocked). So we reject. + # An assignment is submitted. The hit and AssignmentStub exist in the + # DB. We think we're going to approve the Assignment, but the + # user-profile / check blocked call on THL shows the user doesn't + # exist (same thing would happen if the user does exist and is + # blocked). So we reject. - _ = assignment_stub_in_db # we need this to make the assignment stub in the db + # We need this to make the assignment stub in the db + _ = assignment_stub_record amt_stubs = rejected_assignment_stubs(reject_reason=REJECT_MESSAGE_BADDIE) mock_thl_responses(user_blocked=True) with amt_stub_context(amt_stubs) as stub, caplog.at_level(logging.WARNING): - process_assignment_submitted(mturk_event) + process_assignment_submitted(am=am, hm=hm, bm=bm, event=mturk_event) stub.assert_no_pending_responses() assert f"No assignment found in DB: {amt_assignment_id}" not in caplog.text assert f"blocked or not exists" in caplog.text assert f"Rejected assignment: " in caplog.text - ass = AM.get(amt_assignment_id=amt_assignment_id) + ass = am.get(amt_assignment_id=amt_assignment_id) assert ass.status == AssignmentStatus.Rejected assert ass.requester_feedback == REJECT_MESSAGE_BADDIE def test_no_work_w_warning( self, - mturk_event, - amt_assignment_id, - assignment_stub_in_db, - caplog, - mock_thl_responses, - approved_assignment_stubs, - get_assignment_response_approved_no_tsid, - get_assignment_response_no_tsid, + am: AssignmentManager, + mturk_event: MTurkEvent, + amt_assignment_id: str, + assignment_stub_record: AssignmentStub, + caplog: pytest.LogCaptureFixture, + mock_thl_responses: Callable[..., None], + approved_assignment_stubs: Callable[..., list[Dict[str, Any]]], + assignment_response_approved_no_tsid: GetAssignmentResponseTypeDef, + assignment_response_no_tsid: GetAssignmentResponseTypeDef, ): - # An assignment is submitted. The hit and assignment stub exist in the DB. - # The assignment has no tsid. - # We APPROVE this assignment b/c we are very nice and give users a couple - # chances, with an explanation, before rejecting. + # An Assignment is submitted. The hit and AssignmentStub exist in + # the DB. The assignment has no tsid. + # We APPROVE this assignment b/c we are very nice and give users a + # couple chances, with an explanation, before rejecting. - _ = assignment_stub_in_db # we need this to make the assignment stub in the db + # We need this to make the assignment stub in the db + _ = assignment_stub_record - # Simulate that the AMT.get_assignment call returns the assignment, but the answers xml - # has no tsid. + # Simulate that the AMT.get_assignment call returns the assignment, + # but the answers XML has no tsid. amt_stubs = approved_assignment_stubs( feedback=NO_WORK_APPROVAL_MESSAGE, - override_response=get_assignment_response_no_tsid, - override_approve_response=get_assignment_response_approved_no_tsid, + override_response=assignment_response_no_tsid, + override_approve_response=assignment_response_approved_no_tsid, ) mock_thl_responses(user_blocked=False) @@ -427,24 +179,26 @@ class TestProcessAssignmentSubmitted: assert f"Assignment submitted with no tsid" in caplog.text assert f"Approved assignment: " in caplog.text - ass = AM.get(amt_assignment_id=amt_assignment_id) + ass = am.get(amt_assignment_id=amt_assignment_id) assert ass.status == AssignmentStatus.Approved assert ass.requester_feedback == NO_WORK_APPROVAL_MESSAGE - assert AM.missing_tsid_count(amt_worker_id=ass.amt_worker_id) == 1 + assert am.missing_tsid_count(amt_worker_id=ass.amt_worker_id) == 1 def test_no_work_no_warning( self, - mturk_event, - amt_assignment_id, - assignment_stub_in_db, - caplog, - mock_thl_responses, - rejected_assignment_stubs, - get_assignment_response_rejected_no_tsid, - get_assignment_response_no_tsid, - assignment_in_db_factory, - hit_record, - amt_worker_id, + am: AssignmentManager, + mturk_event: MTurkEvent, + amt_assignment_id: str, + assignment_stub_record: AssignmentStub, + caplog: pytest.LogCaptureFixture, + mock_thl_responses: Callable[..., None], + rejected_assignment_stubs: Callable[..., list[Dict[str, Any]]], + assignment_response_rejected_no_tsid: GetAssignmentResponseTypeDef, + assignment_response_no_tsid: GetAssignmentResponseTypeDef, + assignment_factory: Assignment, + assignment_stub_factory: Assignment, + hit_record: Hit, + amt_worker_id: str, ): # An assignment is submitted. The hit and assignment stub exist in the DB. # The assignment has no tsid. @@ -452,20 +206,20 @@ class TestProcessAssignmentSubmitted: # Going to create and submit 3 assignments w no work # (all on the same hit, which we don't do in JB for real, # but doesn't matter here) - a1 = assignment_in_db_factory(hit_id=hit_record.id, amt_worker_id=amt_worker_id) - a2 = assignment_in_db_factory(hit_id=hit_record.id, amt_worker_id=amt_worker_id) - a3 = assignment_in_db_factory(hit_id=hit_record.id, amt_worker_id=amt_worker_id) - assert AM.missing_tsid_count(amt_worker_id=amt_worker_id) == 3 + _a1 = assignment_factory(hit_id=hit_record.id, amt_worker_id=amt_worker_id) + _a2 = assignment_factory(hit_id=hit_record.id, amt_worker_id=amt_worker_id) + _a3 = assignment_factory(hit_id=hit_record.id, amt_worker_id=amt_worker_id) + assert am.missing_tsid_count(amt_worker_id=amt_worker_id) == 3 # So now, we'll reject, b/c they've already gotten 3 warnings - _ = assignment_stub_in_db # we need this to make the assignment stub in the db + _ = assignment_stub_factory # we need this to make the assignment stub in the db # Simulate that the AMT.get_assignment call returns the assignment, but the answers xml # has no tsid. amt_stubs = rejected_assignment_stubs( reject_reason=REJECT_MESSAGE_NO_WORK, - override_response=get_assignment_response_no_tsid, - override_reject_response=get_assignment_response_rejected_no_tsid( + override_response=assignment_response_no_tsid, + override_reject_response=assignment_response_rejected_no_tsid( REJECT_MESSAGE_NO_WORK ), ) @@ -481,24 +235,31 @@ class TestProcessAssignmentSubmitted: assert f"Rejected assignment: " in caplog.text # It will exist in the db since we can validate the model. - ass = AM.get(amt_assignment_id=amt_assignment_id) + ass = am.get(amt_assignment_id=amt_assignment_id) assert ass.status == AssignmentStatus.Rejected assert ass.requester_feedback == REJECT_MESSAGE_NO_WORK def test_assignment_submitted_no_bonus( self, - mturk_event, - amt_assignment_id, - assignment_stub_in_db, - caplog, - mock_thl_responses, - approved_assignment_stubs, + am: AssignmentManager, + mturk_event: MTurkEvent, + amt_assignment_id: str, + assignment_stub_factory: Assignment, + caplog: pytest.LogCaptureFixture, + mock_thl_responses: Callable[..., None], + approved_assignment_stubs: Callable[..., list[Dict[str, Any]]], ): - _ = assignment_stub_in_db # we need this to make the assignment stub in the db - # The "send bonus" stuff will still run, even if the user didn't get a complete, - # because all we do is check the user's wallet balance (if an assignment is approved) - # and they may have money in their wallet from a prev event or bribe + _ = ( + assignment_stub_factory() + ) # we need this to make the assignment stub in the db + + # The "send bonus" stuff will still run, even if the user didn't get + # a complete, because all we do is check the user's wallet balance (if + # an assignment is approved) and they may have money in their wallet + # from a prev event or bribe + # # So mock the wallet balance as 1cent, so no bonus will be triggered + mock_thl_responses(status_complete=False, wallet_redeemable_amount=1) with amt_stub_context(approved_assignment_stubs()) as stub, caplog.at_level( logging.WARNING @@ -506,20 +267,22 @@ class TestProcessAssignmentSubmitted: process_assignment_submitted(mturk_event) stub.assert_no_pending_responses() - ass = AM.get(amt_assignment_id=amt_assignment_id) + ass = am.get(amt_assignment_id=amt_assignment_id) assert ass.status == AssignmentStatus.Approved assert ass.requester_feedback == APPROVAL_MESSAGE def test_assignment_submitted_w_bonus( self, - mturk_event, - amt_assignment_id, - assignment_stub_in_db, - caplog, - mock_thl_responses, - approved_assignment_stubs_w_bonus, + am: AssignmentManager, + bm: BonusManager, + mturk_event: MTurkEvent, + amt_assignment_id: str, + assignment_stub_factory: Assignment, + caplog: pytest.LogCaptureFixture, + mock_thl_responses: Callable[..., None], + approved_assignment_stubs_w_bonus: list[Dict[str, Any]], ): - _ = assignment_stub_in_db # we need this to make the assignment stub in the db + _ = assignment_stub_factory # we need this to make the assignment stub in the db mock_thl_responses(status_complete=True, wallet_redeemable_amount=10) with amt_stub_context( approved_assignment_stubs_w_bonus @@ -527,9 +290,9 @@ class TestProcessAssignmentSubmitted: process_assignment_submitted(mturk_event) stub.assert_no_pending_responses() - ass = AM.get(amt_assignment_id=amt_assignment_id) + ass = am.get(amt_assignment_id=amt_assignment_id) assert ass.status == AssignmentStatus.Approved assert ass.requester_feedback == APPROVAL_MESSAGE - bonus = BM.filter(amt_assignment_id=amt_assignment_id)[0] + bonus = bm.filter(amt_assignment_id=amt_assignment_id)[0] assert bonus.amount == USDCent(7) diff --git a/tests/http/test_notifications.py b/tests/http/test_notifications.py index 4386863..508b236 100644 --- a/tests/http/test_notifications.py +++ b/tests/http/test_notifications.py @@ -8,6 +8,7 @@ from uuid import uuid4 from jb.config import JB_EVENTS_STREAM, settings from jb.models.event import MTurkEvent from jb.models.hit import Hit +from jb.models.assignment import AssignmentStub class TestNotifications: @@ -74,11 +75,18 @@ class TestNotifications: == assignment_stub_record.amt_assignment_id ) + # Confirm the stream is empty + assert redis.xlen(JB_EVENTS_STREAM) == 0 + res = await client.post( url=f"/{settings.sns_path}/", json=mturk_event_body_record ) res.raise_for_status() + # Now that we POSTed, confirm the stream has 1 event in it + # Confirm the stream is empty + assert redis.xlen(JB_EVENTS_STREAM) == 1 + # AMT SNS needs to receive a 200 response to stop retrying the notification assert res.status_code == 200 assert res.json() == {"status": "ok"} @@ -89,6 +97,9 @@ class TestNotifications: msg_id, msg = msg_res redis.xdel(JB_EVENTS_STREAM, msg_id) + # After running xdel, we can confirm the stream is empty + assert redis.xlen(JB_EVENTS_STREAM) == 0 + msg_json = msg["data"] event = MTurkEvent.model_validate_json(msg_json) diff --git a/tests/http/test_report.py b/tests/http/test_report.py deleted file mode 100644 index e69de29..0000000 --- a/tests/http/test_report.py +++ /dev/null diff --git a/tests/managers/amt.py b/tests/managers/amt.py deleted file mode 100644 index a847582..0000000 --- a/tests/managers/amt.py +++ /dev/null @@ -1,22 +0,0 @@ -from jb.managers.amt import AMTManager - - -def test_create_hit_type(hit_type): - assert hit_type.amt_hit_type_id is None - AMTManager.create_hit_type(hit_type=hit_type) - assert hit_type.amt_hit_type_id is not None - - -def test_create_hit_with_hit_type(hqm, htm, hm, hit_type_with_amt_id, question): - question = hqm.get_or_create(question) - - hit_type = hit_type_with_amt_id - hit_type = [ - x for x in htm.filter_active() if x.amt_hit_type_id == hit_type.amt_hit_type_id - ][0] - - hit = AMTManager.create_hit_with_hit_type(hit_type=hit_type, question=question) - assert hit.amt_hit_id is not None - assert hit.id is None - hm.create(hit) - assert hit.id is not None diff --git a/tests/managers/hit.py b/tests/managers/hit.py deleted file mode 100644 index cb2b35a..0000000 --- a/tests/managers/hit.py +++ /dev/null @@ -1,25 +0,0 @@ -from jb.models import Question - - -class TestHitQuestionManager: - - def test_base(self, question_record): - assert isinstance(question_record, Question) - assert question_record.id is None - - -class TestHitTypeManager: - - def test_create(self, htm, hit_type_with_amt_id): - assert hit_type_with_amt_id.id is None - htm.create(hit_type_with_amt_id) - assert hit_type_with_amt_id.id is not None - - res = htm.filter_active() - assert len(res) == 1 - - hit_type_with_amt_id.min_active = 0 - htm.set_min_active(hit_type_with_amt_id) - - res = htm.filter_active() - assert len(res) == 0 diff --git a/tests/managers/test_amt.py b/tests/managers/test_amt.py new file mode 100644 index 0000000..63d3737 --- /dev/null +++ b/tests/managers/test_amt.py @@ -0,0 +1,36 @@ +from jb.managers.amt import AMTManager +from jb.models.hit import HitType, HitQuestion + +from jb.managers.hit import HitQuestionManager, HitTypeManager, HitManager + + +class TestAMTManager: + + def test_create_hit_type(self, hit_type: HitType): + assert hit_type.amt_hit_type_id is None + AMTManager.create_hit_type(hit_type=hit_type) + assert hit_type.amt_hit_type_id is not None + + def test_create_hit_with_hit_type( + self, + hqm: HitQuestionManager, + htm: HitTypeManager, + hm: HitManager, + hit_type_record_with_amt_id: HitType, + question_record: HitQuestion, + ): + + hit_type = hit_type_record_with_amt_id + hit_type = [ + x + for x in htm.filter_active() + if x.amt_hit_type_id == hit_type.amt_hit_type_id + ][0] + + hit = AMTManager.create_hit_with_hit_type( + hit_type=hit_type, question=question_record + ) + assert hit.amt_hit_id is not None + assert hit.id is None + hm.create(hit) + assert hit.id is not None diff --git a/tests/managers/test_hit.py b/tests/managers/test_hit.py new file mode 100644 index 0000000..56a4f53 --- /dev/null +++ b/tests/managers/test_hit.py @@ -0,0 +1,52 @@ +from jb.models.hit import HitQuestion, HitType, Hit +from jb.managers.hit import HitTypeManager, HitManager + + +class TestHitQuestionManager: + + def test_base(self, question_record: HitQuestion): + assert isinstance(question_record, HitQuestion) + assert isinstance(question_record.id, int) + + +class TestHitTypeManager: + + def test_create(self, htm: HitTypeManager, hit_type_record_with_amt_id: HitType): + + _ = hit_type_record_with_amt_id + + assert isinstance(hit_type_record_with_amt_id.id, int) + assert isinstance(hit_type_record_with_amt_id.amt_hit_type_id, str) + + count1 = len(htm.filter_active()) + # assert count1 == 1 + + hit_type_record_with_amt_id.min_active = 0 + htm.set_min_active(hit_type=hit_type_record_with_amt_id) + + count2 = len(htm.filter_active()) + assert count2 == 0 + + +class TestHitManager: + + # def test_create + + # def update_status + + # def update_hit + + # def get_from_amt_id + + # get_from_amt_id_if_exists + + def test_get_active_count(self, hm: HitManager, hit_record: Hit): + + count = hm.get_active_count(hit_type_id=999_999_999) + assert isinstance(count, int) + assert count == 0 + + count = hm.get_active_count(hit_type_id=hit_record.hit_type_id) + assert count == 1 + + # filter_active_ids diff --git a/tests/amt/conftest.py b/tests/models/__init__.py index e69de29..e69de29 100644 --- a/tests/amt/conftest.py +++ b/tests/models/__init__.py diff --git a/tests/models/test_assignment.py b/tests/models/test_assignment.py new file mode 100644 index 0000000..2a87364 --- /dev/null +++ b/tests/models/test_assignment.py @@ -0,0 +1,41 @@ +from jb.models.assignment import Assignment, AssignmentStub +from mypy_boto3_mturk.type_defs import ( + GetAssignmentResponseTypeDef, +) + + +class TestAssignmentStub: + + def test_init(self, assignment_stub: AssignmentStub): + assert isinstance(assignment_stub, AssignmentStub) + assert assignment_stub.id is None + + def test_record(self, assignment_stub_record: AssignmentStub): + assert isinstance(assignment_stub_record, AssignmentStub) + assert assignment_stub_record.id is not None + + +class TestAssignment: + + def test_get_assignment(self, assignment_record: Assignment): + assert isinstance(assignment_record, Assignment) + assert assignment_record.id is not None + + +class TestAssignmentResponse: + + def test_get_assignment_no_tsid( + self, assignment_response_no_tsid: GetAssignmentResponseTypeDef + ): + assignment = Assignment.from_amt_get_assignment( + assignment_response_no_tsid["Assignment"] + ) + assert assignment.tsid is None + + def test_get_assignment_bad_tsid( + self, assignment_response_bad_tsid: GetAssignmentResponseTypeDef + ): + assignment = Assignment.from_amt_get_assignment( + assignment_response_bad_tsid["Assignment"] + ) + assert assignment.tsid is None diff --git a/tests/models/test_event.py b/tests/models/test_event.py new file mode 100644 index 0000000..0496574 --- /dev/null +++ b/tests/models/test_event.py @@ -0,0 +1,13 @@ +import pytest + + +from jb.models.event import MTurkEvent + + +class TestMTurkEvent: + + @pytest.mark.anyio + def test_init(self, mturk_event: MTurkEvent): + assert isinstance(mturk_event, MTurkEvent) + assert isinstance(mturk_event.amt_assignment_id, str) + assert isinstance(mturk_event.amt_hit_type_id, str) diff --git a/tests/models/test_hit.py b/tests/models/test_hit.py new file mode 100644 index 0000000..3952068 --- /dev/null +++ b/tests/models/test_hit.py @@ -0,0 +1,10 @@ +import pytest +from jb.models.hit import Hit + + +class TestAssignment: + + @pytest.mark.anyio + def test_get_assignment(self, hit_record: Hit): + assert isinstance(hit_record, Hit) + assert hit_record.id is not None diff --git a/tests_sandbox/test_flow.py b/tests_sandbox/test_flow.py index 7925d92..bbf3633 100644 --- a/tests_sandbox/test_flow.py +++ b/tests_sandbox/test_flow.py @@ -1,29 +1,30 @@ -from jb.decorators import HM -from jb.flow.tasks import refill_hits, check_stale_hits, check_expired_hits +# from jb.decorators import HM +# from jb.flow.tasks import refill_hits, check_stale_hits, check_expired_hits -def test_refill_hits( - set_hit_types_in_db_min_active_0, hit_type_in_db, expire_all_hits, amt_manager -): +# def test_refill_hits( +# set_hit_types_in_db_min_active_0, +# hit_type_record, expire_all_hits, amt_manager +# ): - assert HM.get_active_count(hit_type_in_db.id) == 0 - assert hit_type_in_db.min_active > 0 - refill_hits() - assert HM.get_active_count(hit_type_in_db.id) == hit_type_in_db.min_active +# assert HM.get_active_count(hit_type_in_db.id) == 0 +# assert hit_type_in_db.min_active > 0 +# refill_hits() +# assert HM.get_active_count(hit_type_in_db.id) == hit_type_in_db.min_active - amt_hit_ids = HM.filter_active_ids(hit_type_id=hit_type_in_db.id) - amt_hit_id = list(amt_hit_ids)[0] - hit, _ = amt_manager.get_hit_if_exists(amt_hit_id=amt_hit_id) - assert hit +# amt_hit_ids = HM.filter_active_ids(hit_type_id=hit_type_in_db.id) +# amt_hit_id = list(amt_hit_ids)[0] +# hit, _ = amt_manager.get_hit_if_exists(amt_hit_id=amt_hit_id) +# assert hit -def test_check_stale_hits(): - # todo: I'd have to create some purposely stale hits. - # just make sure it runs for now - check_stale_hits() +# def test_check_stale_hits(): +# # todo: I'd have to create some purposely stale hits. +# # just make sure it runs for now +# check_stale_hits() -def test_check_expired_hits(): - # todo: I'd have to create some purposely expired hits. - # just make sure it runs for now - check_expired_hits() +# def test_check_expired_hits(): +# # todo: I'd have to create some purposely expired hits. +# # just make sure it runs for now +# check_expired_hits() |
