aboutsummaryrefslogtreecommitdiff
path: root/tests/fixtures
diff options
context:
space:
mode:
authorMax Nanis2026-02-26 15:51:49 -0500
committerMax Nanis2026-02-26 15:51:49 -0500
commit0bf32fadd85d5938ae29d489efdd82e2cd137300 (patch)
tree814e8128947fb604dc7cc3509e72260d95757590 /tests/fixtures
parent04aee0dc7e908ce020d2d2c3f8ffb4a96424b883 (diff)
downloadamt-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__.py0
-rw-r--r--tests/fixtures/amt.py263
-rw-r--r--tests/fixtures/flow.py296
-rw-r--r--tests/fixtures/http.py99
-rw-r--r--tests/fixtures/managers.py77
-rw-r--r--tests/fixtures/models.py279
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