diff options
| author | Max Nanis | 2026-02-26 15:51:49 -0500 |
|---|---|---|
| committer | Max Nanis | 2026-02-26 15:51:49 -0500 |
| commit | 0bf32fadd85d5938ae29d489efdd82e2cd137300 (patch) | |
| tree | 814e8128947fb604dc7cc3509e72260d95757590 /tests/fixtures | |
| parent | 04aee0dc7e908ce020d2d2c3f8ffb4a96424b883 (diff) | |
| download | amt-jb-0bf32fadd85d5938ae29d489efdd82e2cd137300.tar.gz amt-jb-0bf32fadd85d5938ae29d489efdd82e2cd137300.zip | |
Passing Managers into flow tasks for better pytest usage. Conftests broken out into seperate fixture files. Extensive type hinting.
Diffstat (limited to 'tests/fixtures')
| -rw-r--r-- | tests/fixtures/__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 | 99 | ||||
| -rw-r--r-- | tests/fixtures/managers.py | 77 | ||||
| -rw-r--r-- | tests/fixtures/models.py | 279 |
6 files changed, 1014 insertions, 0 deletions
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ 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/fixtures/http.py b/tests/fixtures/http.py new file mode 100644 index 0000000..4f11fde --- /dev/null +++ b/tests/fixtures/http.py @@ -0,0 +1,99 @@ +import httpx +import redis +import pytest +import requests_mock +from asgi_lifespan import LifespanManager +from httpx import AsyncClient, ASGITransport +from typing import Dict, Any + +from jb.main import app +import json + +from httpx import AsyncClient +import secrets + +from jb.config import JB_EVENTS_STREAM, settings +from tests import generate_amt_id + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(scope="session") +async def httpxclient(): + # limiter.enabled = True + # limiter.reset() + app.testing = True + + async with LifespanManager(app): + # await FastAPICache.clear() + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, base_url="http://127.0.0.1:8001/" + ) as client: + yield client + await client.aclose() + + +@pytest.fixture() +def no_limit(): + """Fixture to execute asserts before and after a test is run""" + # limiter.enabled = False + yield # this is where the testing happens + # limiter.enabled = True + + +@pytest.fixture() +def httpxclient_ip(httpxclient): + """Fixture to execute asserts before and after a test is run""" + httpxclient._transport = httpx.ASGITransport(app=app, client=("1.2.3.4", 8001)) + yield httpxclient # this is where the testing happens + httpxclient._transport = httpx.ASGITransport(app=app) + + +@pytest.fixture +def mock_requests(): + with requests_mock.Mocker() as m: + yield m + + +def generate_hex_id(length: int = 40) -> str: + # length is number of hex chars, so we need length//2 bytes + return secrets.token_hex(length // 2) + + +@pytest.fixture +def mturk_event_body_record( + hit_record: Hit, assignment_stub_record: AssignmentStub +) -> Dict[str, Any]: + return { + "Type": "Notification", + "Message": json.dumps( + { + "Events": [ + { + "EventType": "AssignmentSubmitted", + "EventTimestamp": "2025-10-16T18:45:51.000000Z", + "HITId": hit_record.amt_hit_id, + "AssignmentId": assignment_stub_record.amt_assignment_id, + "HITTypeId": hit_record.amt_hit_type_id, + } + ], + "EventDocId": generate_hex_id(), + "SourceAccount": settings.aws_owner_id, + "CustomerId": generate_amt_id(length=14), + "EventDocVersion": "2006-05-05", + } + ), + } + + +@pytest.fixture() +def clean_mturk_events_redis_stream(redis: redis.Redis): + redis.xtrim(JB_EVENTS_STREAM, maxlen=0) + assert redis.xlen(JB_EVENTS_STREAM) == 0 + yield + redis.xtrim(JB_EVENTS_STREAM, maxlen=0) + assert redis.xlen(JB_EVENTS_STREAM) == 0 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 |
