aboutsummaryrefslogtreecommitdiff
path: root/tests
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
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')
-rw-r--r--tests/__init__.py2
-rw-r--r--tests/amt/test_models.py47
-rw-r--r--tests/conftest.py508
-rw-r--r--tests/fixtures/__init__.py (renamed from tests/amt/__init__.py)0
-rw-r--r--tests/fixtures/amt.py263
-rw-r--r--tests/fixtures/flow.py296
-rw-r--r--tests/fixtures/http.py (renamed from tests/http/conftest.py)0
-rw-r--r--tests/fixtures/managers.py77
-rw-r--r--tests/fixtures/models.py279
-rw-r--r--tests/flow/test_tasks.py511
-rw-r--r--tests/http/test_notifications.py11
-rw-r--r--tests/http/test_report.py0
-rw-r--r--tests/managers/amt.py22
-rw-r--r--tests/managers/hit.py25
-rw-r--r--tests/managers/test_amt.py36
-rw-r--r--tests/managers/test_hit.py52
-rw-r--r--tests/models/__init__.py (renamed from tests/amt/conftest.py)0
-rw-r--r--tests/models/test_assignment.py41
-rw-r--r--tests/models/test_event.py13
-rw-r--r--tests/models/test_hit.py10
20 files changed, 1244 insertions, 949 deletions
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