aboutsummaryrefslogtreecommitdiff
path: root/tests/models/thl
diff options
context:
space:
mode:
Diffstat (limited to 'tests/models/thl')
-rw-r--r--tests/models/thl/__init__.py1
-rw-r--r--tests/models/thl/question/__init__.py0
-rw-r--r--tests/models/thl/question/test_question_info.py146
-rw-r--r--tests/models/thl/question/test_user_info.py32
-rw-r--r--tests/models/thl/test_adjustments.py688
-rw-r--r--tests/models/thl/test_bucket.py201
-rw-r--r--tests/models/thl/test_buyer.py23
-rw-r--r--tests/models/thl/test_contest/__init__.py0
-rw-r--r--tests/models/thl/test_contest/test_contest.py23
-rw-r--r--tests/models/thl/test_contest/test_leaderboard_contest.py213
-rw-r--r--tests/models/thl/test_contest/test_raffle_contest.py300
-rw-r--r--tests/models/thl/test_ledger.py130
-rw-r--r--tests/models/thl/test_marketplace_condition.py382
-rw-r--r--tests/models/thl/test_payout.py10
-rw-r--r--tests/models/thl/test_payout_format.py46
-rw-r--r--tests/models/thl/test_product.py1130
-rw-r--r--tests/models/thl/test_product_userwalletconfig.py56
-rw-r--r--tests/models/thl/test_soft_pair.py24
-rw-r--r--tests/models/thl/test_upkquestion.py414
-rw-r--r--tests/models/thl/test_user.py688
-rw-r--r--tests/models/thl/test_user_iphistory.py45
-rw-r--r--tests/models/thl/test_user_metadata.py46
-rw-r--r--tests/models/thl/test_user_streak.py96
-rw-r--r--tests/models/thl/test_wall.py207
-rw-r--r--tests/models/thl/test_wall_session.py326
25 files changed, 5227 insertions, 0 deletions
diff --git a/tests/models/thl/__init__.py b/tests/models/thl/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/models/thl/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/models/thl/question/__init__.py b/tests/models/thl/question/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/models/thl/question/__init__.py
diff --git a/tests/models/thl/question/test_question_info.py b/tests/models/thl/question/test_question_info.py
new file mode 100644
index 0000000..945ee7a
--- /dev/null
+++ b/tests/models/thl/question/test_question_info.py
@@ -0,0 +1,146 @@
+from generalresearch.models.thl.profiling.upk_property import (
+ UpkProperty,
+ ProfilingInfo,
+)
+
+
+class TestQuestionInfo:
+
+ def test_init(self):
+
+ s = (
+ '[{"property_label": "hispanic", "cardinality": "*", "prop_type": "i", "country_iso": "us", '
+ '"property_id": "05170ae296ab49178a075cab2a2073a6", "item_id": "7911ec1468b146ee870951f8ae9cbac1", '
+ '"item_label": "panamanian", "gold_standard": 1, "options": [{"id": "c358c11e72c74fa2880358f1d4be85ab", '
+ '"label": "not_hispanic"}, {"id": "b1d6c475770849bc8e0200054975dc9c", "label": "yes_hispanic"}, '
+ '{"id": "bd1eb44495d84b029e107c188003c2bd", "label": "other_hispanic"}, '
+ '{"id": "f290ad5e75bf4f4ea94dc847f57c1bd3", "label": "mexican"}, '
+ '{"id": "49f50f2801bd415ea353063bfc02d252", "label": "puerto_rican"}, '
+ '{"id": "dcbe005e522f4b10928773926601f8bf", "label": "cuban"}, '
+ '{"id": "467ef8ddb7ac4edb88ba9ef817cbb7e9", "label": "salvadoran"}, '
+ '{"id": "3c98e7250707403cba2f4dc7b877c963", "label": "dominican"}, '
+ '{"id": "981ee77f6d6742609825ef54fea824a8", "label": "guatemalan"}, '
+ '{"id": "81c8057b809245a7ae1b8a867ea6c91e", "label": "colombian"}, '
+ '{"id": "513656d5f9e249fa955c3b527d483b93", "label": "honduran"}, '
+ '{"id": "afc8cddd0c7b4581bea24ccd64db3446", "label": "ecuadorian"}, '
+ '{"id": "61f34b36e80747a89d85e1eb17536f84", "label": "argentinian"}, '
+ '{"id": "5330cfa681d44aa8ade3a6d0ea198e44", "label": "peruvian"}, '
+ '{"id": "e7bceaffd76e486596205d8545019448", "label": "nicaraguan"}, '
+ '{"id": "b7bbb2ebf8424714962e6c4f43275985", "label": "spanish"}, '
+ '{"id": "8bf539785e7a487892a2f97e52b1932d", "label": "venezuelan"}, '
+ '{"id": "7911ec1468b146ee870951f8ae9cbac1", "label": "panamanian"}], "category": [{"id": '
+ '"4fd8381d5a1c4409ab007ca254ced084", "label": "Demographic", "path": "/Demographic", '
+ '"adwords_vertical_id": null}]}, {"property_label": "ethnic_group", "cardinality": "*", "prop_type": '
+ '"i", "country_iso": "us", "property_id": "15070958225d4132b7f6674fcfc979f6", "item_id": '
+ '"64b7114cf08143949e3bcc3d00a5d8a0", "item_label": "other_ethnicity", "gold_standard": 1, "options": [{'
+ '"id": "a72e97f4055e4014a22bee4632cbf573", "label": "caucasians"}, '
+ '{"id": "4760353bc0654e46a928ba697b102735", "label": "black_or_african_american"}, '
+ '{"id": "20ff0a2969fa4656bbda5c3e0874e63b", "label": "asian"}, '
+ '{"id": "107e0a79e6b94b74926c44e70faf3793", "label": "native_hawaiian_or_other_pacific_islander"}, '
+ '{"id": "900fa12691d5458c8665bf468f1c98c1", "label": "native_americans"}, '
+ '{"id": "64b7114cf08143949e3bcc3d00a5d8a0", "label": "other_ethnicity"}], "category": [{"id": '
+ '"4fd8381d5a1c4409ab007ca254ced084", "label": "Demographic", "path": "/Demographic", '
+ '"adwords_vertical_id": null}]}, {"property_label": "educational_attainment", "cardinality": "?", '
+ '"prop_type": "i", "country_iso": "us", "property_id": "2637783d4b2b4075b93e2a156e16e1d8", "item_id": '
+ '"934e7b81d6744a1baa31bbc51f0965d5", "item_label": "other_education", "gold_standard": 1, "options": [{'
+ '"id": "df35ef9e474b4bf9af520aa86630202d", "label": "3rd_grade_completion"}, '
+ '{"id": "83763370a1064bd5ba76d1b68c4b8a23", "label": "8th_grade_completion"}, '
+ '{"id": "f0c25a0670c340bc9250099dcce50957", "label": "not_high_school_graduate"}, '
+ '{"id": "02ff74c872bd458983a83847e1a9f8fd", "label": "high_school_completion"}, '
+ '{"id": "ba8beb807d56441f8fea9b490ed7561c", "label": "vocational_program_completion"}, '
+ '{"id": "65373a5f348a410c923e079ddbb58e9b", "label": "some_college_completion"}, '
+ '{"id": "2d15d96df85d4cc7b6f58911fdc8d5e2", "label": "associate_academic_degree_completion"}, '
+ '{"id": "497b1fedec464151b063cd5367643ffa", "label": "bachelors_degree_completion"}, '
+ '{"id": "295133068ac84424ae75e973dc9f2a78", "label": "some_graduate_completion"}, '
+ '{"id": "e64f874faeff4062a5aa72ac483b4b9f", "label": "masters_degree_completion"}, '
+ '{"id": "cbaec19a636d476385fb8e7842b044f5", "label": "doctorate_degree_completion"}, '
+ '{"id": "934e7b81d6744a1baa31bbc51f0965d5", "label": "other_education"}], "category": [{"id": '
+ '"4fd8381d5a1c4409ab007ca254ced084", "label": "Demographic", "path": "/Demographic", '
+ '"adwords_vertical_id": null}]}, {"property_label": "household_spoken_language", "cardinality": "*", '
+ '"prop_type": "i", "country_iso": "us", "property_id": "5a844571073d482a96853a0594859a51", "item_id": '
+ '"62b39c1de141422896ad4ab3c4318209", "item_label": "dut", "gold_standard": 1, "options": [{"id": '
+ '"f65cd57b79d14f0f8460761ce41ec173", "label": "ara"}, {"id": "6d49de1f8f394216821310abd29392d9", '
+ '"label": "zho"}, {"id": "be6dc23c2bf34c3f81e96ddace22800d", "label": "eng"}, '
+ '{"id": "ddc81f28752d47a3b1c1f3b8b01a9b07", "label": "fre"}, {"id": "2dbb67b29bd34e0eb630b1b8385542ca", '
+ '"label": "ger"}, {"id": "a747f96952fc4b9d97edeeee5120091b", "label": "hat"}, '
+ '{"id": "7144b04a3219433baac86273677551fa", "label": "hin"}, {"id": "e07ff3e82c7149eaab7ea2b39ee6a6dc", '
+ '"label": "ita"}, {"id": "b681eff81975432ebfb9f5cc22dedaa3", "label": "jpn"}, '
+ '{"id": "5cb20440a8f64c9ca62fb49c1e80cdef", "label": "kor"}, {"id": "171c4b77d4204bc6ac0c2b81e38a10ff", '
+ '"label": "pan"}, {"id": "8c3ec18e6b6c4a55a00dd6052e8e84fb", "label": "pol"}, '
+ '{"id": "3ce074d81d384dd5b96f1fb48f87bf01", "label": "por"}, {"id": "6138dc951990458fa88a666f6ddd907b", '
+ '"label": "rus"}, {"id": "e66e5ecc07df4ebaa546e0b436f034bd", "label": "spa"}, '
+ '{"id": "5a981b3d2f0d402a96dd2d0392ec2fcb", "label": "tgl"}, {"id": "b446251bd211403487806c4d0a904981", '
+ '"label": "vie"}, {"id": "92fb3ee337374e2db875fb23f52eed46", "label": "xxx"}, '
+ '{"id": "8b1f590f12f24cc1924d7bdcbe82081e", "label": "ind"}, {"id": "bf3f4be556a34ff4b836420149fd2037", '
+ '"label": "tur"}, {"id": "87ca815c43ba4e7f98cbca98821aa508", "label": "zul"}, '
+ '{"id": "0adbf915a7a64d67a87bb3ce5d39ca54", "label": "may"}, {"id": "62b39c1de141422896ad4ab3c4318209", '
+ '"label": "dut"}], "category": [{"id": "4fd8381d5a1c4409ab007ca254ced084", "label": "Demographic", '
+ '"path": "/Demographic", "adwords_vertical_id": null}]}, {"property_label": "gender", "cardinality": '
+ '"?", "prop_type": "i", "country_iso": "us", "property_id": "73175402104741549f21de2071556cd7", '
+ '"item_id": "093593e316344cd3a0ac73669fca8048", "item_label": "other_gender", "gold_standard": 1, '
+ '"options": [{"id": "b9fc5ea07f3a4252a792fd4a49e7b52b", "label": "male"}, '
+ '{"id": "9fdb8e5e18474a0b84a0262c21e17b56", "label": "female"}, '
+ '{"id": "093593e316344cd3a0ac73669fca8048", "label": "other_gender"}], "category": [{"id": '
+ '"4fd8381d5a1c4409ab007ca254ced084", "label": "Demographic", "path": "/Demographic", '
+ '"adwords_vertical_id": null}]}, {"property_label": "age_in_years", "cardinality": "?", "prop_type": '
+ '"n", "country_iso": "us", "property_id": "94f7379437874076b345d76642d4ce6d", "item_id": null, '
+ '"item_label": null, "gold_standard": 1, "category": [{"id": "4fd8381d5a1c4409ab007ca254ced084", '
+ '"label": "Demographic", "path": "/Demographic", "adwords_vertical_id": null}]}, {"property_label": '
+ '"children_age_gender", "cardinality": "*", "prop_type": "i", "country_iso": "us", "property_id": '
+ '"e926142fcea94b9cbbe13dc7891e1e7f", "item_id": "b7b8074e95334b008e8958ccb0a204f1", "item_label": '
+ '"female_18", "gold_standard": 1, "options": [{"id": "16a6448ec24c48d4993d78ebee33f9b4", '
+ '"label": "male_under_1"}, {"id": "809c04cb2e3b4a3bbd8077ab62cdc220", "label": "female_under_1"}, '
+ '{"id": "295e05bb6a0843bc998890b24c99841e", "label": "no_children"}, '
+ '{"id": "142cb948d98c4ae8b0ef2ef10978e023", "label": "male_0"}, '
+ '{"id": "5a5c1b0e9abc48a98b3bc5f817d6e9d0", "label": "male_1"}, '
+ '{"id": "286b1a9afb884bdfb676dbb855479d1e", "label": "male_2"}, '
+ '{"id": "942ca3cda699453093df8cbabb890607", "label": "male_3"}, '
+ '{"id": "995818d432f643ec8dd17e0809b24b56", "label": "male_4"}, '
+ '{"id": "f38f8b57f25f4cdea0f270297a1e7a5c", "label": "male_5"}, '
+ '{"id": "975df709e6d140d1a470db35023c432d", "label": "male_6"}, '
+ '{"id": "f60bd89bbe0f4e92b90bccbc500467c2", "label": "male_7"}, '
+ '{"id": "6714ceb3ed5042c0b605f00b06814207", "label": "male_8"}, '
+ '{"id": "c03c2f8271d443cf9df380e84b4dea4c", "label": "male_9"}, '
+ '{"id": "11690ee0f5a54cb794f7ddd010d74fa2", "label": "male_10"}, '
+ '{"id": "17bef9a9d14b4197b2c5609fa94b0642", "label": "male_11"}, '
+ '{"id": "e79c8338fe28454f89ccc78daf6f409a", "label": "male_12"}, '
+ '{"id": "3a4f87acb3fa41f4ae08dfe2858238c1", "label": "male_13"}, '
+ '{"id": "36ffb79d8b7840a7a8cb8d63bbc8df59", "label": "male_14"}, '
+ '{"id": "1401a508f9664347aee927f6ec5b0a40", "label": "male_15"}, '
+ '{"id": "6e0943c5ec4a4f75869eb195e3eafa50", "label": "male_16"}, '
+ '{"id": "47d4b27b7b5242758a9fff13d3d324cf", "label": "male_17"}, '
+ '{"id": "9ce886459dd44c9395eb77e1386ab181", "label": "female_0"}, '
+ '{"id": "6499ccbf990d4be5b686aec1c7353fd8", "label": "female_1"}, '
+ '{"id": "d85ceaa39f6d492abfc8da49acfd14f2", "label": "female_2"}, '
+ '{"id": "18edb45c138e451d8cb428aefbb80f9c", "label": "female_3"}, '
+ '{"id": "bac6f006ed9f4ccf85f48e91e99fdfd1", "label": "female_4"}, '
+ '{"id": "5a6a1a8ad00c4ce8be52dcb267b034ff", "label": "female_5"}, '
+ '{"id": "6bff0acbf6364c94ad89507bcd5f4f45", "label": "female_6"}, '
+ '{"id": "d0d56a0a6b6f4516a366a2ce139b4411", "label": "female_7"}, '
+ '{"id": "bda6028468044b659843e2bef4db2175", "label": "female_8"}, '
+ '{"id": "dbb6d50325464032b456357b1a6e5e9c", "label": "female_9"}, '
+ '{"id": "b87a93d7dc1348edac5e771684d63fb8", "label": "female_10"}, '
+ '{"id": "11449d0d98f14e27ba47de40b18921d7", "label": "female_11"}, '
+ '{"id": "16156501e97b4263962cbbb743840292", "label": "female_12"}, '
+ '{"id": "04ee971c89a345cc8141a45bce96050c", "label": "female_13"}, '
+ '{"id": "e818d310bfbc4faba4355e5d2ed49d4f", "label": "female_14"}, '
+ '{"id": "440d25e078924ba0973163153c417ed6", "label": "female_15"}, '
+ '{"id": "78ff804cc9b441c5a524bd91e3d1f8bf", "label": "female_16"}, '
+ '{"id": "4b04d804d7d84786b2b1c22e4ed440f5", "label": "female_17"}, '
+ '{"id": "28bc848cd3ff44c3893c76bfc9bc0c4e", "label": "male_18"}, '
+ '{"id": "b7b8074e95334b008e8958ccb0a204f1", "label": "female_18"}], "category": [{"id": '
+ '"e18ba6e9d51e482cbb19acf2e6f505ce", "label": "Parenting", "path": "/People & Society/Family & '
+ 'Relationships/Family/Parenting", "adwords_vertical_id": "58"}]}, {"property_label": "home_postal_code", '
+ '"cardinality": "?", "prop_type": "x", "country_iso": "us", "property_id": '
+ '"f3b32ebe78014fbeb1ed6ff77d6338bf", "item_id": null, "item_label": null, "gold_standard": 1, '
+ '"category": [{"id": "4fd8381d5a1c4409ab007ca254ced084", "label": "Demographic", "path": "/Demographic", '
+ '"adwords_vertical_id": null}]}, {"property_label": "household_income", "cardinality": "?", "prop_type": '
+ '"n", "country_iso": "us", "property_id": "ff5b1d4501d5478f98de8c90ef996ac1", "item_id": null, '
+ '"item_label": null, "gold_standard": 1, "category": [{"id": "4fd8381d5a1c4409ab007ca254ced084", '
+ '"label": "Demographic", "path": "/Demographic", "adwords_vertical_id": null}]}]'
+ )
+ instance_list = ProfilingInfo.validate_json(s)
+
+ assert isinstance(instance_list, list)
+ for i in instance_list:
+ assert isinstance(i, UpkProperty)
diff --git a/tests/models/thl/question/test_user_info.py b/tests/models/thl/question/test_user_info.py
new file mode 100644
index 0000000..0bbbc78
--- /dev/null
+++ b/tests/models/thl/question/test_user_info.py
@@ -0,0 +1,32 @@
+from generalresearch.models.thl.profiling.user_info import UserInfo
+
+
+class TestUserInfo:
+
+ def test_init(self):
+
+ s = (
+ '{"user_profile_knowledge": [], "marketplace_profile_knowledge": [{"source": "d", "question_id": '
+ '"1", "answer": ["1"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": "pr", '
+ '"question_id": "3", "answer": ["1"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": '
+ '"h", "question_id": "60", "answer": ["58"], "created": "2023-11-07T16:41:05.234096Z"}, '
+ '{"source": "c", "question_id": "43", "answer": ["1"], "created": "2023-11-07T16:41:05.234096Z"}, '
+ '{"source": "s", "question_id": "211", "answer": ["111"], "created": '
+ '"2023-11-07T16:41:05.234096Z"}, {"source": "s", "question_id": "1843", "answer": ["111"], '
+ '"created": "2023-11-07T16:41:05.234096Z"}, {"source": "h", "question_id": "13959", "answer": ['
+ '"244155"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": "c", "question_id": "33092", '
+ '"answer": ["1"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": "c", "question_id": "gender", '
+ '"answer": ["10682"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": "e", "question_id": '
+ '"gender", "answer": ["male"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": "f", '
+ '"question_id": "gender", "answer": ["male"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": '
+ '"i", "question_id": "gender", "answer": ["1"], "created": "2023-11-07T16:41:05.234096Z"}, '
+ '{"source": "c", "question_id": "137510", "answer": ["1"], "created": "2023-11-07T16:41:05.234096Z"}, '
+ '{"source": "m", "question_id": "gender", "answer": ["1"], "created": '
+ '"2023-11-07T16:41:05.234096Z"}, {"source": "o", "question_id": "gender", "answer": ["male"], '
+ '"created": "2023-11-07T16:41:05.234096Z"}, {"source": "c", "question_id": "gender_plus", "answer": ['
+ '"7657644"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": "i", "question_id": '
+ '"gender_plus", "answer": ["1"], "created": "2023-11-07T16:41:05.234096Z"}, {"source": "c", '
+ '"question_id": "income_level", "answer": ["9071"], "created": "2023-11-07T16:41:05.234096Z"}]}'
+ )
+ instance = UserInfo.model_validate_json(s)
+ assert isinstance(instance, UserInfo)
diff --git a/tests/models/thl/test_adjustments.py b/tests/models/thl/test_adjustments.py
new file mode 100644
index 0000000..15d01d0
--- /dev/null
+++ b/tests/models/thl/test_adjustments.py
@@ -0,0 +1,688 @@
+from datetime import datetime, timezone, timedelta
+from decimal import Decimal
+
+import pytest
+
+from generalresearch.models import Source
+from generalresearch.models.thl.session import (
+ Wall,
+ Status,
+ StatusCode1,
+ WallAdjustedStatus,
+ SessionAdjustedStatus,
+)
+
+started1 = datetime(2023, 1, 1, tzinfo=timezone.utc)
+started2 = datetime(2023, 1, 1, 0, 10, 0, tzinfo=timezone.utc)
+finished1 = started1 + timedelta(minutes=10)
+finished2 = started2 + timedelta(minutes=10)
+
+adj_ts = datetime(2023, 2, 2, tzinfo=timezone.utc)
+adj_ts2 = datetime(2023, 2, 3, tzinfo=timezone.utc)
+adj_ts3 = datetime(2023, 2, 4, tzinfo=timezone.utc)
+
+
+class TestProductAdjustments:
+
+ @pytest.mark.parametrize("payout", [".6", "1", "1.8", "2", "500.0000"])
+ def test_determine_bp_payment_no_rounding(self, product_factory, payout):
+ p1 = product_factory(commission_pct=Decimal("0.05"))
+ res = p1.determine_bp_payment(thl_net=Decimal(payout))
+ assert isinstance(res, Decimal)
+ assert res == Decimal(payout) * Decimal("0.95")
+
+ @pytest.mark.parametrize("payout", [".01", ".05", ".5"])
+ def test_determine_bp_payment_rounding(self, product_factory, payout):
+ p1 = product_factory(commission_pct=Decimal("0.05"))
+ res = p1.determine_bp_payment(thl_net=Decimal(payout))
+ assert isinstance(res, Decimal)
+ assert res != Decimal(payout) * Decimal("0.95")
+
+
+class TestSessionAdjustments:
+
+ def test_status_complete(self, session_factory, user):
+ # Completed Session with 2 wall events
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpi=Decimal(1),
+ final_status=Status.COMPLETE,
+ started=started1,
+ )
+
+ # Confirm only the last Wall Event is a complete
+ assert not s1.wall_events[0].status == Status.COMPLETE
+ assert s1.wall_events[1].status == Status.COMPLETE
+
+ # Confirm the Session is marked as finished and the simple brokerage
+ # payout calculation is correct.
+ status, status_code_1 = s1.determine_session_status()
+ assert status == Status.COMPLETE
+ assert status_code_1 == StatusCode1.COMPLETE
+
+
+class TestAdjustments:
+
+ def test_finish_with_status(self, session_factory, user, session_manager):
+ # Completed Session with 2 wall events
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpi=Decimal(1),
+ final_status=Status.COMPLETE,
+ started=started1,
+ )
+
+ status, status_code_1 = s1.determine_session_status()
+ payout = user.product.determine_bp_payment(Decimal(1))
+ session_manager.finish_with_status(
+ session=s1,
+ status=status,
+ status_code_1=status_code_1,
+ payout=payout,
+ finished=finished2,
+ )
+
+ assert Decimal("0.95") == payout
+
+ def test_never_adjusted(self, session_factory, user, session_manager):
+ s1 = session_factory(
+ user=user,
+ wall_count=5,
+ wall_req_cpi=Decimal(1),
+ final_status=Status.COMPLETE,
+ started=started1,
+ )
+
+ session_manager.finish_with_status(
+ session=s1,
+ status=Status.COMPLETE,
+ status_code_1=StatusCode1.COMPLETE,
+ payout=Decimal("0.95"),
+ finished=finished2,
+ )
+
+ # Confirm walls and Session are never adjusted in anyway
+ for w in s1.wall_events:
+ w: Wall
+ assert w.adjusted_status is None
+ assert w.adjusted_timestamp is None
+ assert w.adjusted_cpi is None
+
+ assert s1.adjusted_status is None
+ assert s1.adjusted_payout is None
+ assert s1.adjusted_timestamp is None
+
+ def test_adjustment_wall_values(
+ self, session_factory, user, session_manager, wall_manager
+ ):
+ # Completed Session with 2 wall events
+ s1 = session_factory(
+ user=user,
+ wall_count=5,
+ wall_req_cpi=Decimal(1),
+ final_status=Status.COMPLETE,
+ started=started1,
+ )
+
+ session_manager.finish_with_status(
+ session=s1,
+ status=Status.COMPLETE,
+ status_code_1=StatusCode1.COMPLETE,
+ payout=Decimal("0.95"),
+ finished=finished2,
+ )
+
+ # Change the last wall event to a Failure
+ w: Wall = s1.wall_events[-1]
+ wall_manager.adjust_status(
+ wall=w,
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_timestamp=adj_ts,
+ )
+
+ # Original Session and Wall status is still the same, but the Adjusted
+ # values have changed
+ assert s1.status == Status.COMPLETE
+ assert s1.adjusted_status is None
+ assert s1.adjusted_timestamp is None
+ assert s1.adjusted_payout is None
+ assert s1.adjusted_user_payout is None
+
+ assert w.status == Status.COMPLETE
+ assert w.status_code_1 == StatusCode1.COMPLETE
+ assert w.adjusted_status == WallAdjustedStatus.ADJUSTED_TO_FAIL
+ assert w.adjusted_cpi == Decimal(0)
+ assert w.adjusted_timestamp == adj_ts
+
+ # Because the Product doesn't have the Wallet mode enabled, the
+ # user_payout fields should always be None
+ assert not user.product.user_wallet_config.enabled
+ assert s1.adjusted_user_payout is None
+
+ def test_adjustment_session_values(
+ self, wall_manager, session_manager, session_factory, user
+ ):
+ # Completed Session with 2 wall events
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpi=Decimal(1),
+ wall_source=Source.DYNATA,
+ final_status=Status.COMPLETE,
+ started=started1,
+ )
+
+ session_manager.finish_with_status(
+ session=s1,
+ status=Status.COMPLETE,
+ status_code_1=StatusCode1.COMPLETE,
+ payout=Decimal("0.95"),
+ finished=finished2,
+ )
+
+ # Change the last wall event to a Failure
+ wall_manager.adjust_status(
+ wall=s1.wall_events[-1],
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_timestamp=adj_ts,
+ )
+
+ # Refresh the Session with the new Wall Adjustment considerations,
+ session_manager.adjust_status(session=s1)
+ assert s1.status == Status.COMPLETE # Original status should remain
+ assert s1.adjusted_status == SessionAdjustedStatus.ADJUSTED_TO_FAIL
+ assert s1.adjusted_payout == Decimal(0)
+ assert s1.adjusted_timestamp == adj_ts
+
+ # Because the Product doesn't have the Wallet mode enabled, the
+ # user_payout fields should always be None
+ assert not user.product.user_wallet_config.enabled
+ assert s1.adjusted_user_payout is None
+
+ def test_double_adjustment_session_values(
+ self, wall_manager, session_manager, session_factory, user
+ ):
+ # Completed Session with 2 wall events
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpi=Decimal(1),
+ final_status=Status.COMPLETE,
+ started=started1,
+ )
+
+ session_manager.finish_with_status(
+ session=s1,
+ status=Status.COMPLETE,
+ status_code_1=StatusCode1.COMPLETE,
+ payout=Decimal("0.95"),
+ finished=finished2,
+ )
+
+ # Change the last wall event to a Failure
+ w: Wall = s1.wall_events[-1]
+ wall_manager.adjust_status(
+ wall=w,
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_timestamp=adj_ts,
+ )
+
+ # Refresh the Session with the new Wall Adjustment considerations,
+ session_manager.adjust_status(session=s1)
+
+ # Let's take that back again! Buyers love to do this.
+ # So now we're going to "un-reconcile" the last Wall Event which has
+ # already gone from a Complete >> Failure
+ wall_manager.adjust_status(
+ wall=w, adjusted_status=None, adjusted_timestamp=adj_ts2
+ )
+ assert w.adjusted_status is None
+ assert w.adjusted_cpi is None
+ assert w.adjusted_timestamp == adj_ts2
+
+ # Once the wall was unreconciled, "refresh" the Session again
+ assert s1.adjusted_status is not None
+ session_manager.adjust_status(session=s1)
+ assert s1.adjusted_status is None
+ assert s1.adjusted_payout is None
+ assert s1.adjusted_timestamp == adj_ts2
+ assert s1.adjusted_user_payout is None
+
+ def test_double_adjustment_sm_vs_db_values(
+ self, wall_manager, session_manager, session_factory, user
+ ):
+ # Completed Session with 2 wall events
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpi=Decimal(1),
+ wall_source=Source.DYNATA,
+ final_status=Status.COMPLETE,
+ started=started1,
+ )
+
+ session_manager.finish_with_status(
+ session=s1,
+ status=Status.COMPLETE,
+ status_code_1=StatusCode1.COMPLETE,
+ payout=Decimal("0.95"),
+ finished=finished2,
+ )
+
+ # Change the last wall event to a Failure
+ wall_manager.adjust_status(
+ wall=s1.wall_events[-1],
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_timestamp=adj_ts,
+ )
+
+ # Refresh the Session with the new Wall Adjustment considerations,
+ session_manager.adjust_status(session=s1)
+
+ # Let's take that back again! Buyers love to do this.
+ # So now we're going to "un-reconcile" the last Wall Event which has
+ # already gone from a Complete >> Failure
+ # Once the wall was unreconciled, "refresh" the Session again
+ wall_manager.adjust_status(
+ wall=s1.wall_events[-1], adjusted_status=None, adjusted_timestamp=adj_ts2
+ )
+ session_manager.adjust_status(session=s1)
+
+ # Confirm that the sessions wall attributes are still aligned with
+ # what comes back directly from the database
+ db_wall_events = wall_manager.get_wall_events(session_id=s1.id)
+ for idx in range(len(s1.wall_events)):
+ w_sm: Wall = s1.wall_events[idx]
+ w_db: Wall = db_wall_events[idx]
+
+ assert w_sm.uuid == w_db.uuid
+ assert w_sm.session_id == w_db.session_id
+ assert w_sm.status == w_db.status
+ assert w_sm.status_code_1 == w_db.status_code_1
+ assert w_sm.status_code_2 == w_db.status_code_2
+
+ assert w_sm.elapsed == w_db.elapsed
+
+ # Decimal("1.000000") vs Decimal(1) - based on mysql or postgres
+ assert pytest.approx(w_sm.cpi) == w_db.cpi
+ assert pytest.approx(w_sm.req_cpi) == w_db.req_cpi
+
+ assert w_sm.model_dump_json(
+ exclude={"cpi", "req_cpi"}
+ ) == w_db.model_dump_json(exclude={"cpi", "req_cpi"})
+
+ def test_double_adjustment_double_completes(
+ self, wall_manager, session_manager, session_factory, user
+ ):
+ # Completed Session with 2 wall events
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpi=Decimal(2),
+ wall_source=Source.DYNATA,
+ final_status=Status.COMPLETE,
+ started=started1,
+ )
+
+ session_manager.finish_with_status(
+ session=s1,
+ status=Status.COMPLETE,
+ status_code_1=StatusCode1.COMPLETE,
+ payout=Decimal("0.95"),
+ finished=finished2,
+ )
+
+ # Change the last wall event to a Failure
+ wall_manager.adjust_status(
+ wall=s1.wall_events[-1],
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_timestamp=adj_ts,
+ )
+
+ # Refresh the Session with the new Wall Adjustment considerations,
+ session_manager.adjust_status(session=s1)
+
+ # Let's take that back again! Buyers love to do this.
+ # So now we're going to "un-reconcile" the last Wall Event which has
+ # already gone from a Complete >> Failure
+ # Once the wall was unreconciled, "refresh" the Session again
+ wall_manager.adjust_status(
+ wall=s1.wall_events[-1], adjusted_status=None, adjusted_timestamp=adj_ts2
+ )
+ session_manager.adjust_status(session=s1)
+
+ # Reassign them - we already validated they're equal in previous
+ # tests so this is safe to do.
+ s1.wall_events = wall_manager.get_wall_events(session_id=s1.id)
+
+ # The First Wall event was originally a Failure, now let's also set
+ # that as a complete, so now both Wall Events will b a
+ # complete (Fail >> Adj to Complete, Complete >> Adj to Fail >> Adj to Complete)
+ w1: Wall = s1.wall_events[0]
+ assert w1.status == Status.FAIL
+ assert w1.adjusted_status is None
+ assert w1.adjusted_cpi is None
+ assert w1.adjusted_timestamp is None
+
+ wall_manager.adjust_status(
+ wall=w1,
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_COMPLETE,
+ adjusted_timestamp=adj_ts3,
+ )
+
+ assert w1.status == Status.FAIL # original status doesn't change
+ assert w1.adjusted_status == WallAdjustedStatus.ADJUSTED_TO_COMPLETE
+ assert w1.adjusted_cpi == w1.cpi
+ assert w1.adjusted_timestamp == adj_ts3
+
+ session_manager.adjust_status(s1)
+ assert SessionAdjustedStatus.PAYOUT_ADJUSTMENT == s1.adjusted_status
+ assert Decimal("3.80") == s1.adjusted_payout
+ assert s1.adjusted_user_payout is None
+ assert adj_ts3 == s1.adjusted_timestamp
+
+ def test_complete_to_fail(
+ self, session_factory, user, session_manager, wall_manager, utc_hour_ago
+ ):
+ s1 = session_factory(
+ user=user,
+ wall_count=1,
+ wall_req_cpi=Decimal("1"),
+ final_status=Status.COMPLETE,
+ started=utc_hour_ago,
+ )
+
+ status, status_code_1 = s1.determine_session_status()
+ assert status == Status.COMPLETE
+
+ thl_net = Decimal(sum(w.cpi for w in s1.wall_events if w.is_visible_complete()))
+ payout = user.product.determine_bp_payment(thl_net=thl_net)
+
+ session_manager.finish_with_status(
+ session=s1,
+ status=status,
+ status_code_1=status_code_1,
+ finished=utc_hour_ago + timedelta(minutes=10),
+ payout=payout,
+ user_payout=None,
+ )
+
+ w1 = s1.wall_events[0]
+ w1.update(
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_cpi=0,
+ adjusted_timestamp=adj_ts,
+ )
+ assert w1.adjusted_status == WallAdjustedStatus.ADJUSTED_TO_FAIL
+ assert w1.adjusted_cpi == Decimal(0)
+
+ new_status, new_payout, new_user_payout = s1.determine_new_status_and_payouts()
+ assert Status.FAIL == new_status
+ assert Decimal(0) == new_payout
+
+ assert not user.product.user_wallet_config.enabled
+ assert new_user_payout is None
+
+ s1.adjust_status()
+ assert SessionAdjustedStatus.ADJUSTED_TO_FAIL == s1.adjusted_status
+ assert Decimal(0) == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ assert s1.adjusted_user_payout is None
+
+ # cpi adjustment
+ w1.update(
+ adjusted_status=WallAdjustedStatus.CPI_ADJUSTMENT,
+ adjusted_cpi=Decimal("0.69"),
+ adjusted_timestamp=adj_ts,
+ )
+ assert WallAdjustedStatus.CPI_ADJUSTMENT == w1.adjusted_status
+ assert Decimal("0.69") == w1.adjusted_cpi
+ new_status, new_payout, new_user_payout = s1.determine_new_status_and_payouts()
+ assert Status.COMPLETE == new_status
+ assert Decimal("0.66") == new_payout
+
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.33") == new_user_payout
+ assert new_user_payout is None
+
+ s1.adjust_status()
+ assert SessionAdjustedStatus.PAYOUT_ADJUSTMENT == s1.adjusted_status
+ assert Decimal("0.66") == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.33") == s1.adjusted_user_payout
+ assert s1.adjusted_user_payout is None
+
+ # adjust cpi again
+ wall_manager.adjust_status(
+ wall=w1,
+ adjusted_status=WallAdjustedStatus.CPI_ADJUSTMENT,
+ adjusted_cpi=Decimal("0.50"),
+ adjusted_timestamp=adj_ts,
+ )
+ assert WallAdjustedStatus.CPI_ADJUSTMENT == w1.adjusted_status
+ assert Decimal("0.50") == w1.adjusted_cpi
+ new_status, new_payout, new_user_payout = s1.determine_new_status_and_payouts()
+ assert Status.COMPLETE == new_status
+ assert Decimal("0.48") == new_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.24") == new_user_payout
+ assert new_user_payout is None
+
+ s1.adjust_status()
+ assert SessionAdjustedStatus.PAYOUT_ADJUSTMENT == s1.adjusted_status
+ assert Decimal("0.48") == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.24") == s1.adjusted_user_payout
+ assert s1.adjusted_user_payout is None
+
+ def test_complete_to_fail_to_complete(self, user, session_factory, utc_hour_ago):
+ # Setup: Complete, then adjust it to fail
+ s1 = session_factory(
+ user=user,
+ wall_count=1,
+ wall_req_cpi=Decimal("1"),
+ final_status=Status.COMPLETE,
+ started=utc_hour_ago,
+ )
+ w1 = s1.wall_events[0]
+
+ status, status_code_1 = s1.determine_session_status()
+ thl_net, commission_amount, bp_pay, user_pay = s1.determine_payments()
+ s1.update(
+ **{
+ "status": status,
+ "status_code_1": status_code_1,
+ "finished": utc_hour_ago + timedelta(minutes=10),
+ "payout": bp_pay,
+ "user_payout": user_pay,
+ }
+ )
+ w1.update(
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_cpi=0,
+ adjusted_timestamp=adj_ts,
+ )
+ s1.adjust_status()
+
+ # Test: Adjust back to complete
+ w1.update(
+ adjusted_status=None,
+ adjusted_cpi=None,
+ adjusted_timestamp=adj_ts,
+ )
+ assert w1.adjusted_status is None
+ assert w1.adjusted_cpi is None
+ assert adj_ts == w1.adjusted_timestamp
+
+ new_status, new_payout, new_user_payout = s1.determine_new_status_and_payouts()
+ assert Status.COMPLETE == new_status
+ assert Decimal("0.95") == new_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.48") == new_user_payout
+ assert new_user_payout is None
+
+ s1.adjust_status()
+ assert s1.adjusted_status is None
+ assert s1.adjusted_payout is None
+ assert s1.adjusted_user_payout is None
+
+ def test_complete_to_fail_to_complete_adj(
+ self, user, session_factory, utc_hour_ago
+ ):
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpis=[Decimal(1), Decimal(2)],
+ final_status=Status.COMPLETE,
+ started=utc_hour_ago,
+ )
+
+ w1 = s1.wall_events[0]
+ w2 = s1.wall_events[1]
+
+ status, status_code_1 = s1.determine_session_status()
+ thl_net = Decimal(sum(w.cpi for w in s1.wall_events if w.is_visible_complete()))
+ payout = user.product.determine_bp_payment(thl_net=thl_net)
+ s1.update(
+ **{
+ "status": status,
+ "status_code_1": status_code_1,
+ "finished": utc_hour_ago + timedelta(minutes=25),
+ "payout": payout,
+ "user_payout": None,
+ }
+ )
+
+ # Test. Adjust first fail to complete. Now we have 2 completes.
+ w1.update(
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_COMPLETE,
+ adjusted_cpi=w1.cpi,
+ adjusted_timestamp=adj_ts,
+ )
+ s1.adjust_status()
+ assert SessionAdjustedStatus.PAYOUT_ADJUSTMENT == s1.adjusted_status
+ assert Decimal("2.85") == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("1.42") == s1.adjusted_user_payout
+ assert s1.adjusted_user_payout is None
+
+ # Now we have [Fail, Complete ($2)] -> [Complete ($1), Fail]
+ w2.update(
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_cpi=0,
+ adjusted_timestamp=adj_ts2,
+ )
+ s1.adjust_status()
+ assert SessionAdjustedStatus.PAYOUT_ADJUSTMENT == s1.adjusted_status
+ assert Decimal("0.95") == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.48") == s1.adjusted_user_payout
+ assert s1.adjusted_user_payout is None
+
+ def test_complete_to_fail_to_complete_adj1(
+ self, user, session_factory, utc_hour_ago
+ ):
+ # Same as test_complete_to_fail_to_complete_adj but in opposite order
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpis=[Decimal(1), Decimal(2)],
+ final_status=Status.COMPLETE,
+ started=utc_hour_ago,
+ )
+
+ w1 = s1.wall_events[0]
+ w2 = s1.wall_events[1]
+
+ status, status_code_1 = s1.determine_session_status()
+ thl_net = Decimal(sum(w.cpi for w in s1.wall_events if w.is_visible_complete()))
+ payout = user.product.determine_bp_payment(thl_net)
+ s1.update(
+ **{
+ "status": status,
+ "status_code_1": status_code_1,
+ "finished": utc_hour_ago + timedelta(minutes=25),
+ "payout": payout,
+ "user_payout": None,
+ }
+ )
+
+ # Test. Adjust complete to fail. Now we have 2 fails.
+ w2.update(
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_FAIL,
+ adjusted_cpi=0,
+ adjusted_timestamp=adj_ts,
+ )
+ s1.adjust_status()
+ assert SessionAdjustedStatus.ADJUSTED_TO_FAIL == s1.adjusted_status
+ assert Decimal(0) == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal(0) == s.adjusted_user_payout
+ assert s1.adjusted_user_payout is None
+ # Now we have [Fail, Complete ($2)] -> [Complete ($1), Fail]
+ w1.update(
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_COMPLETE,
+ adjusted_cpi=w1.cpi,
+ adjusted_timestamp=adj_ts2,
+ )
+ s1.adjust_status()
+ assert SessionAdjustedStatus.PAYOUT_ADJUSTMENT == s1.adjusted_status
+ assert Decimal("0.95") == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.48") == s.adjusted_user_payout
+ assert s1.adjusted_user_payout is None
+
+ def test_fail_to_complete_to_fail(self, user, session_factory, utc_hour_ago):
+ # End with an abandon
+ s1 = session_factory(
+ user=user,
+ wall_count=2,
+ wall_req_cpis=[Decimal(1), Decimal(2)],
+ final_status=Status.ABANDON,
+ started=utc_hour_ago,
+ )
+
+ w1 = s1.wall_events[0]
+ w2 = s1.wall_events[1]
+
+ # abandon adjust to complete
+ w2.update(
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_COMPLETE,
+ adjusted_cpi=w2.cpi,
+ adjusted_timestamp=adj_ts,
+ )
+ assert WallAdjustedStatus.ADJUSTED_TO_COMPLETE == w2.adjusted_status
+ s1.adjust_status()
+ assert SessionAdjustedStatus.ADJUSTED_TO_COMPLETE == s1.adjusted_status
+ assert Decimal("1.90") == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.95") == s1.adjusted_user_payout
+ assert s1.adjusted_user_payout is None
+
+ # back to fail
+ w2.update(
+ adjusted_status=None,
+ adjusted_cpi=None,
+ adjusted_timestamp=adj_ts,
+ )
+ assert w2.adjusted_status is None
+ s1.adjust_status()
+ assert s1.adjusted_status is None
+ assert s1.adjusted_payout is None
+ assert s1.adjusted_user_payout is None
+
+ # other is now complete
+ w1.update(
+ adjusted_status=WallAdjustedStatus.ADJUSTED_TO_COMPLETE,
+ adjusted_cpi=w1.cpi,
+ adjusted_timestamp=adj_ts,
+ )
+ assert WallAdjustedStatus.ADJUSTED_TO_COMPLETE == w1.adjusted_status
+ s1.adjust_status()
+ assert SessionAdjustedStatus.ADJUSTED_TO_COMPLETE == s1.adjusted_status
+ assert Decimal("0.95") == s1.adjusted_payout
+ assert not user.product.user_wallet_config.enabled
+ # assert Decimal("0.48") == s1.adjusted_user_payout
+ assert s1.adjusted_user_payout is None
diff --git a/tests/models/thl/test_bucket.py b/tests/models/thl/test_bucket.py
new file mode 100644
index 0000000..0aa5843
--- /dev/null
+++ b/tests/models/thl/test_bucket.py
@@ -0,0 +1,201 @@
+from datetime import timedelta
+from decimal import Decimal
+
+import pytest
+from pydantic import ValidationError
+
+
+class TestBucket:
+
+ def test_raises_payout(self):
+ from generalresearch.models.legacy.bucket import Bucket
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(user_payout_min=123)
+ assert "Must pass a Decimal" in str(e.value)
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(user_payout_min=Decimal(1 / 3))
+ assert "Must have 2 or fewer decimal places" in str(e.value)
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(user_payout_min=Decimal(10000))
+ assert "should be less than 1000" in str(e.value)
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(user_payout_min=Decimal(1), user_payout_max=Decimal("0.01"))
+ assert "user_payout_min should be <= user_payout_max" in str(e.value)
+
+ def test_raises_loi(self):
+ from generalresearch.models.legacy.bucket import Bucket
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(loi_min=123)
+ assert "Input should be a valid timedelta" in str(e.value)
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(loi_min=timedelta(seconds=9999))
+ assert "should be less than 90 minutes" in str(e.value)
+
+ with pytest.raises(ValidationError) as e:
+ Bucket(loi_min=timedelta(seconds=0))
+ assert "should be greater than 0" in str(e.value)
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(loi_min=timedelta(seconds=10), loi_max=timedelta(seconds=9))
+ assert "loi_min should be <= loi_max" in str(e.value)
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(
+ loi_min=timedelta(seconds=10),
+ loi_max=timedelta(seconds=90),
+ loi_q1=timedelta(seconds=20),
+ )
+ assert "loi_q1, q2, and q3 should all be set" in str(e.value)
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Bucket(
+ loi_min=timedelta(seconds=10),
+ loi_max=timedelta(seconds=90),
+ loi_q1=timedelta(seconds=200),
+ loi_q2=timedelta(seconds=20),
+ loi_q3=timedelta(seconds=12),
+ )
+ assert "loi_q1 should be <= loi_q2" in str(e.value)
+
+ def test_parse_1(self):
+ from generalresearch.models.legacy.bucket import Bucket
+
+ b1 = Bucket.parse_from_offerwall({"payout": {"min": 123}})
+ b_exp = Bucket(
+ user_payout_min=Decimal("1.23"),
+ user_payout_max=None,
+ loi_min=None,
+ loi_max=None,
+ )
+ assert b_exp == b1
+
+ b2 = Bucket.parse_from_offerwall({"payout": {"min": 123, "max": 230}})
+ b_exp = Bucket(
+ user_payout_min=Decimal("1.23"),
+ user_payout_max=Decimal("2.30"),
+ loi_min=None,
+ loi_max=None,
+ )
+ assert b_exp == b2
+
+ b3 = Bucket.parse_from_offerwall(
+ {"payout": {"min": 123, "max": 230}, "duration": {"min": 600, "max": 1800}}
+ )
+ b_exp = Bucket(
+ user_payout_min=Decimal("1.23"),
+ user_payout_max=Decimal("2.30"),
+ loi_min=timedelta(seconds=600),
+ loi_max=timedelta(seconds=1800),
+ )
+ assert b_exp == b3
+
+ b4 = Bucket.parse_from_offerwall(
+ {
+ "payout": {"max": 80, "min": 28, "q1": 43, "q2": 43, "q3": 56},
+ "duration": {"max": 1172, "min": 266, "q1": 746, "q2": 918, "q3": 1002},
+ }
+ )
+ b_exp = Bucket(
+ user_payout_min=Decimal("0.28"),
+ user_payout_max=Decimal("0.80"),
+ user_payout_q1=Decimal("0.43"),
+ user_payout_q2=Decimal("0.43"),
+ user_payout_q3=Decimal("0.56"),
+ loi_min=timedelta(seconds=266),
+ loi_max=timedelta(seconds=1172),
+ loi_q1=timedelta(seconds=746),
+ loi_q2=timedelta(seconds=918),
+ loi_q3=timedelta(seconds=1002),
+ )
+ assert b_exp == b4
+
+ def test_parse_2(self):
+ from generalresearch.models.legacy.bucket import Bucket
+
+ b1 = Bucket.parse_from_offerwall({"min_payout": 123})
+ b_exp = Bucket(
+ user_payout_min=Decimal("1.23"),
+ user_payout_max=None,
+ loi_min=None,
+ loi_max=None,
+ )
+ assert b_exp == b1
+
+ b2 = Bucket.parse_from_offerwall({"min_payout": 123, "max_payout": 230})
+ b_exp = Bucket(
+ user_payout_min=Decimal("1.23"),
+ user_payout_max=Decimal("2.30"),
+ loi_min=None,
+ loi_max=None,
+ )
+ assert b_exp == b2
+
+ b3 = Bucket.parse_from_offerwall(
+ {
+ "min_payout": 123,
+ "max_payout": 230,
+ "min_duration": 600,
+ "max_duration": 1800,
+ }
+ )
+ b_exp = Bucket(
+ user_payout_min=Decimal("1.23"),
+ user_payout_max=Decimal("2.30"),
+ loi_min=timedelta(seconds=600),
+ loi_max=timedelta(seconds=1800),
+ )
+ assert b_exp, b3
+
+ b4 = Bucket.parse_from_offerwall(
+ {
+ "min_payout": 28,
+ "max_payout": 99,
+ "min_duration": 205,
+ "max_duration": 1113,
+ "q1_payout": 43,
+ "q2_payout": 43,
+ "q3_payout": 46,
+ "q1_duration": 561,
+ "q2_duration": 891,
+ "q3_duration": 918,
+ }
+ )
+ b_exp = Bucket(
+ user_payout_min=Decimal("0.28"),
+ user_payout_max=Decimal("0.99"),
+ user_payout_q1=Decimal("0.43"),
+ user_payout_q2=Decimal("0.43"),
+ user_payout_q3=Decimal("0.46"),
+ loi_min=timedelta(seconds=205),
+ loi_max=timedelta(seconds=1113),
+ loi_q1=timedelta(seconds=561),
+ loi_q2=timedelta(seconds=891),
+ loi_q3=timedelta(seconds=918),
+ )
+ assert b_exp == b4
+
+ def test_parse_3(self):
+ from generalresearch.models.legacy.bucket import Bucket
+
+ b1 = Bucket.parse_from_offerwall({"payout": 123})
+ b_exp = Bucket(
+ user_payout_min=Decimal("1.23"),
+ user_payout_max=None,
+ loi_min=None,
+ loi_max=None,
+ )
+ assert b_exp == b1
+
+ b2 = Bucket.parse_from_offerwall({"payout": 123, "duration": 1800})
+ b_exp = Bucket(
+ user_payout_min=Decimal("1.23"),
+ user_payout_max=None,
+ loi_min=None,
+ loi_max=timedelta(seconds=1800),
+ )
+ assert b_exp == b2
diff --git a/tests/models/thl/test_buyer.py b/tests/models/thl/test_buyer.py
new file mode 100644
index 0000000..eebb828
--- /dev/null
+++ b/tests/models/thl/test_buyer.py
@@ -0,0 +1,23 @@
+from generalresearch.models import Source
+from generalresearch.models.thl.survey.buyer import BuyerCountryStat
+
+
+def test_buyer_country_stat():
+ bcs = BuyerCountryStat(
+ country_iso="us",
+ source=Source.TESTING,
+ code="123",
+ task_count=100,
+ conversion_alpha=40,
+ conversion_beta=190,
+ dropoff_alpha=20,
+ dropoff_beta=50,
+ long_fail_rate=1,
+ loi_excess_ratio=1,
+ user_report_coeff=1,
+ recon_likelihood=0.05,
+ )
+ assert bcs.score
+ print(bcs.score)
+ print(bcs.conversion_p20)
+ print(bcs.dropoff_p60)
diff --git a/tests/models/thl/test_contest/__init__.py b/tests/models/thl/test_contest/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/models/thl/test_contest/__init__.py
diff --git a/tests/models/thl/test_contest/test_contest.py b/tests/models/thl/test_contest/test_contest.py
new file mode 100644
index 0000000..d53eee5
--- /dev/null
+++ b/tests/models/thl/test_contest/test_contest.py
@@ -0,0 +1,23 @@
+import pytest
+from generalresearch.models.thl.user import User
+
+
+class TestContest:
+ """In many of the Contest related tests, we often want a consistent
+ Product throughout, and multiple different users that may be
+ involved in the Contest... so redefine the product fixture along with
+ some users in here that are scoped="class" so they stay around for
+ each of the test functions
+ """
+
+ @pytest.fixture(scope="function")
+ def user_1(self, user_factory, product) -> User:
+ return user_factory(product=product)
+
+ @pytest.fixture(scope="function")
+ def user_2(self, user_factory, product) -> User:
+ return user_factory(product=product)
+
+ @pytest.fixture(scope="function")
+ def user_3(self, user_factory, product) -> User:
+ return user_factory(product=product)
diff --git a/tests/models/thl/test_contest/test_leaderboard_contest.py b/tests/models/thl/test_contest/test_leaderboard_contest.py
new file mode 100644
index 0000000..98f3215
--- /dev/null
+++ b/tests/models/thl/test_contest/test_leaderboard_contest.py
@@ -0,0 +1,213 @@
+from datetime import timezone
+from uuid import uuid4
+
+import pytest
+
+from generalresearch.currency import USDCent
+from generalresearch.managers.leaderboard.manager import LeaderboardManager
+from generalresearch.models.thl.contest import ContestPrize
+from generalresearch.models.thl.contest.definitions import (
+ ContestType,
+ ContestPrizeKind,
+)
+from generalresearch.models.thl.contest.leaderboard import (
+ LeaderboardContest,
+)
+from generalresearch.models.thl.contest.utils import (
+ distribute_leaderboard_prizes,
+)
+from generalresearch.models.thl.leaderboard import LeaderboardRow
+from tests.models.thl.test_contest.test_contest import TestContest
+
+
+class TestLeaderboardContest(TestContest):
+
+ @pytest.fixture
+ def leaderboard_contest(
+ self, product, thl_redis, user_manager
+ ) -> "LeaderboardContest":
+ board_key = f"leaderboard:{product.uuid}:us:weekly:2025-05-26:complete_count"
+
+ c = LeaderboardContest(
+ uuid=uuid4().hex,
+ product_id=product.uuid,
+ contest_type=ContestType.LEADERBOARD,
+ leaderboard_key=board_key,
+ name="$15 1st place, $10 2nd, $5 3rd place US weekly",
+ prizes=[
+ ContestPrize(
+ name="$15 Cash",
+ estimated_cash_value=USDCent(15_00),
+ cash_amount=USDCent(15_00),
+ kind=ContestPrizeKind.CASH,
+ leaderboard_rank=1,
+ ),
+ ContestPrize(
+ name="$10 Cash",
+ estimated_cash_value=USDCent(10_00),
+ cash_amount=USDCent(10_00),
+ kind=ContestPrizeKind.CASH,
+ leaderboard_rank=2,
+ ),
+ ContestPrize(
+ name="$5 Cash",
+ estimated_cash_value=USDCent(5_00),
+ cash_amount=USDCent(5_00),
+ kind=ContestPrizeKind.CASH,
+ leaderboard_rank=3,
+ ),
+ ],
+ )
+ c._redis_client = thl_redis
+ c._user_manager = user_manager
+ return c
+
+ def test_init(self, leaderboard_contest, thl_redis, user_1, user_2):
+ model = leaderboard_contest.leaderboard_model
+ assert leaderboard_contest.end_condition.ends_at is not None
+
+ lbm = LeaderboardManager(
+ redis_client=thl_redis,
+ board_code=model.board_code,
+ country_iso=model.country_iso,
+ freq=model.freq,
+ product_id=leaderboard_contest.product_id,
+ within_time=model.period_start_local,
+ )
+
+ lbm.hit_complete_count(product_user_id=user_1.product_user_id)
+ lbm.hit_complete_count(product_user_id=user_2.product_user_id)
+ lbm.hit_complete_count(product_user_id=user_2.product_user_id)
+
+ lb = leaderboard_contest.get_leaderboard()
+ print(lb)
+
+ def test_win(self, leaderboard_contest, thl_redis, user_1, user_2, user_3):
+ model = leaderboard_contest.leaderboard_model
+ lbm = LeaderboardManager(
+ redis_client=thl_redis,
+ board_code=model.board_code,
+ country_iso=model.country_iso,
+ freq=model.freq,
+ product_id=leaderboard_contest.product_id,
+ within_time=model.period_start_local.astimezone(tz=timezone.utc),
+ )
+
+ lbm.hit_complete_count(product_user_id=user_1.product_user_id)
+ lbm.hit_complete_count(product_user_id=user_1.product_user_id)
+
+ lbm.hit_complete_count(product_user_id=user_2.product_user_id)
+
+ lbm.hit_complete_count(product_user_id=user_3.product_user_id)
+
+ leaderboard_contest.end_contest()
+ assert len(leaderboard_contest.all_winners) == 3
+
+ # Prizes are $15, $10, $5. user 2 and 3 ties for 2nd place, so they split (10 + 5)
+ assert leaderboard_contest.all_winners[0].awarded_cash_amount == USDCent(15_00)
+ assert (
+ leaderboard_contest.all_winners[0].user.product_user_id
+ == user_1.product_user_id
+ )
+ assert leaderboard_contest.all_winners[0].prize == leaderboard_contest.prizes[0]
+ assert leaderboard_contest.all_winners[1].awarded_cash_amount == USDCent(
+ 15_00 / 2
+ )
+ assert leaderboard_contest.all_winners[2].awarded_cash_amount == USDCent(
+ 15_00 / 2
+ )
+
+
+class TestLeaderboardContestPrizes:
+
+ def test_distribute_prizes_1(self):
+ prizes = [USDCent(15_00)]
+ leaderboard_rows = [
+ LeaderboardRow(bpuid="a", value=20, rank=1),
+ LeaderboardRow(bpuid="b", value=10, rank=2),
+ ]
+ result = distribute_leaderboard_prizes(prizes, leaderboard_rows)
+
+ # a gets first prize, b gets nothing.
+ assert result == {
+ "a": USDCent(15_00),
+ }
+
+ def test_distribute_prizes_2(self):
+ prizes = [USDCent(15_00), USDCent(10_00)]
+ leaderboard_rows = [
+ LeaderboardRow(bpuid="a", value=20, rank=1),
+ LeaderboardRow(bpuid="b", value=10, rank=2),
+ ]
+ result = distribute_leaderboard_prizes(prizes, leaderboard_rows)
+
+ # a gets first prize, b gets 2nd prize
+ assert result == {
+ "a": USDCent(15_00),
+ "b": USDCent(10_00),
+ }
+
+ def test_distribute_prizes_3(self):
+ prizes = [USDCent(15_00), USDCent(10_00)]
+ leaderboard_rows = [
+ LeaderboardRow(bpuid="a", value=20, rank=1),
+ ]
+ result = distribute_leaderboard_prizes(prizes, leaderboard_rows)
+
+ # A gets first prize, no-one gets $10
+ assert result == {
+ "a": USDCent(15_00),
+ }
+
+ def test_distribute_prizes_4(self):
+ prizes = [USDCent(15_00)]
+ leaderboard_rows = [
+ LeaderboardRow(bpuid="a", value=20, rank=1),
+ LeaderboardRow(bpuid="b", value=20, rank=1),
+ LeaderboardRow(bpuid="c", value=20, rank=1),
+ LeaderboardRow(bpuid="d", value=20, rank=1),
+ ]
+ result = distribute_leaderboard_prizes(prizes, leaderboard_rows)
+
+ # 4-way tie for the $15 prize; it gets split
+ assert result == {
+ "a": USDCent(3_75),
+ "b": USDCent(3_75),
+ "c": USDCent(3_75),
+ "d": USDCent(3_75),
+ }
+
+ def test_distribute_prizes_5(self):
+ prizes = [USDCent(15_00), USDCent(10_00)]
+ leaderboard_rows = [
+ LeaderboardRow(bpuid="a", value=20, rank=1),
+ LeaderboardRow(bpuid="b", value=20, rank=1),
+ LeaderboardRow(bpuid="c", value=10, rank=3),
+ ]
+ result = distribute_leaderboard_prizes(prizes, leaderboard_rows)
+
+ # 2-way tie for the $15 prize; the top two prizes get split. Rank 3
+ # and below get nothing
+ assert result == {
+ "a": USDCent(12_50),
+ "b": USDCent(12_50),
+ }
+
+ def test_distribute_prizes_6(self):
+ prizes = [USDCent(15_00), USDCent(10_00), USDCent(5_00)]
+ leaderboard_rows = [
+ LeaderboardRow(bpuid="a", value=20, rank=1),
+ LeaderboardRow(bpuid="b", value=10, rank=2),
+ LeaderboardRow(bpuid="c", value=10, rank=2),
+ LeaderboardRow(bpuid="d", value=10, rank=2),
+ ]
+ result = distribute_leaderboard_prizes(prizes, leaderboard_rows)
+
+ # A gets first prize, 3 way tie for 2nd rank: they split the 2nd and
+ # 3rd place prizes (10 + 5)/3
+ assert result == {
+ "a": USDCent(15_00),
+ "b": USDCent(5_00),
+ "c": USDCent(5_00),
+ "d": USDCent(5_00),
+ }
diff --git a/tests/models/thl/test_contest/test_raffle_contest.py b/tests/models/thl/test_contest/test_raffle_contest.py
new file mode 100644
index 0000000..e1c0a15
--- /dev/null
+++ b/tests/models/thl/test_contest/test_raffle_contest.py
@@ -0,0 +1,300 @@
+from collections import Counter
+from uuid import uuid4
+
+import pytest
+from pytest import approx
+
+from generalresearch.currency import USDCent
+from generalresearch.models.thl.contest import (
+ ContestPrize,
+ ContestEndCondition,
+)
+from generalresearch.models.thl.contest.contest_entry import ContestEntry
+from generalresearch.models.thl.contest.definitions import (
+ ContestEntryType,
+ ContestPrizeKind,
+ ContestType,
+ ContestStatus,
+ ContestEndReason,
+)
+from generalresearch.models.thl.contest.raffle import RaffleContest
+
+from tests.models.thl.test_contest.test_contest import TestContest
+
+
+class TestRaffleContest(TestContest):
+
+ @pytest.fixture(scope="function")
+ def raffle_contest(self, product) -> RaffleContest:
+ return RaffleContest(
+ product_id=product.uuid,
+ name=f"Raffle Contest {uuid4().hex}",
+ contest_type=ContestType.RAFFLE,
+ entry_type=ContestEntryType.CASH,
+ prizes=[
+ ContestPrize(
+ name="iPod 64GB White",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ )
+ ],
+ end_condition=ContestEndCondition(target_entry_amount=100),
+ )
+
+ @pytest.fixture(scope="function")
+ def ended_raffle_contest(self, raffle_contest, utc_now) -> RaffleContest:
+ # Fake ending the contest
+ raffle_contest = raffle_contest.model_copy()
+ raffle_contest.update(
+ status=ContestStatus.COMPLETED,
+ ended_at=utc_now,
+ end_reason=ContestEndReason.ENDS_AT,
+ )
+ return raffle_contest
+
+
+class TestRaffleContestUserView(TestRaffleContest):
+
+ def test_user_view(self, raffle_contest, user):
+ from generalresearch.models.thl.contest.raffle import RaffleUserView
+
+ data = {
+ "current_amount": USDCent(1_00),
+ "product_user_id": user.product_user_id,
+ "user_amount": USDCent(1),
+ "user_amount_today": USDCent(1),
+ }
+ r = RaffleUserView.model_validate(raffle_contest.model_dump() | data)
+ res = r.model_dump(mode="json")
+
+ assert res["product_user_id"] == user.product_user_id
+ assert res["user_amount_today"] == 1
+ assert res["current_win_probability"] == approx(0.01, rel=0.000001)
+ assert res["projected_win_probability"] == approx(0.01, rel=0.000001)
+
+ # Now change the amount
+ r.current_amount = USDCent(1_01)
+ res = r.model_dump(mode="json")
+ assert res["current_win_probability"] == approx(0.0099, rel=0.001)
+ assert res["projected_win_probability"] == approx(0.0099, rel=0.001)
+
+ def test_win_pct(self, raffle_contest, user):
+ from generalresearch.models.thl.contest.raffle import RaffleUserView
+
+ data = {
+ "current_amount": USDCent(10),
+ "product_user_id": user.product_user_id,
+ "user_amount": USDCent(1),
+ "user_amount_today": USDCent(1),
+ }
+ r = RaffleUserView.model_validate(raffle_contest.model_dump() | data)
+ r.prizes = [
+ ContestPrize(
+ name="iPod 64GB White",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ContestPrize(
+ name="iPod 64GB White",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ]
+ # Raffle has 10 entries, user has 1 entry.
+ # There are 2 prizes.
+ assert r.current_win_probability == approx(expected=0.2, rel=0.01)
+ # He can only possibly win 1 prize
+ assert r.current_prize_count_probability[1] == approx(expected=0.2, rel=0.01)
+ # He has a 0 prob of winning 2 prizes
+ assert r.current_prize_count_probability[2] == 0
+ # Contest end when there are 100 entries, so 1/100 * 2 prizes
+ assert r.projected_win_probability == approx(expected=0.02, rel=0.01)
+
+ # Change to user having 2 entries (out of 10)
+ # Still with 2 prizes
+ r.user_amount = USDCent(2)
+ assert r.current_win_probability == approx(expected=0.3777, rel=0.01)
+ # 2/10 chance of winning 1st, 8/9 change of not winning 2nd, plus the
+ # same in the other order
+ p = (2 / 10) * (8 / 9) * 2 # 0.355555
+ assert r.current_prize_count_probability[1] == approx(p, rel=0.01)
+ p = (2 / 10) * (1 / 9) # 0.02222
+ assert r.current_prize_count_probability[2] == approx(p, rel=0.01)
+
+
+class TestRaffleContestWinners(TestRaffleContest):
+
+ def test_winners_1_prize(self, ended_raffle_contest, user_1, user_2, user_3):
+ ended_raffle_contest.entries = [
+ ContestEntry(
+ user=user_1,
+ amount=USDCent(1),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ContestEntry(
+ user=user_2,
+ amount=USDCent(2),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ContestEntry(
+ user=user_3,
+ amount=USDCent(3),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ]
+
+ # There is 1 prize. If we select a winner 1000 times, we'd expect user 1
+ # to win ~ 1/6th of the time, user 2 ~2/6th and 3 3/6th.
+ winners = ended_raffle_contest.select_winners()
+ assert len(winners) == 1
+
+ c = Counter(
+ [
+ ended_raffle_contest.select_winners()[0].user.user_id
+ for _ in range(10000)
+ ]
+ )
+ assert c[user_1.user_id] == approx(
+ 10000 * 1 / 6, rel=0.1
+ ) # 10% relative tolerance
+ assert c[user_2.user_id] == approx(10000 * 2 / 6, rel=0.1)
+ assert c[user_3.user_id] == approx(10000 * 3 / 6, rel=0.1)
+
+ def test_winners_2_prizes(self, ended_raffle_contest, user_1, user_2, user_3):
+ ended_raffle_contest.prizes.append(
+ ContestPrize(
+ name="iPod 64GB Black",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ )
+ )
+ ended_raffle_contest.entries = [
+ ContestEntry(
+ user=user_3,
+ amount=USDCent(1),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ContestEntry(
+ user=user_1,
+ amount=USDCent(9999999),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ContestEntry(
+ user=user_2,
+ amount=USDCent(1),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ]
+ # In this scenario, user 1 should win both prizes
+ winners = ended_raffle_contest.select_winners()
+ assert len(winners) == 2
+ # Two different prizes
+ assert len({w.prize.name for w in winners}) == 2
+ # Same user
+ assert all(w.user.user_id == user_1.user_id for w in winners)
+
+ def test_winners_2_prizes_1_entry(self, ended_raffle_contest, user_3):
+ ended_raffle_contest.prizes = [
+ ContestPrize(
+ name="iPod 64GB White",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ContestPrize(
+ name="iPod 64GB Black",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ]
+ ended_raffle_contest.entries = [
+ ContestEntry(
+ user=user_3,
+ amount=USDCent(1),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ]
+
+ # One prize goes unclaimed
+ winners = ended_raffle_contest.select_winners()
+ assert len(winners) == 1
+
+ def test_winners_2_prizes_1_entry_2_pennies(self, ended_raffle_contest, user_3):
+ ended_raffle_contest.prizes = [
+ ContestPrize(
+ name="iPod 64GB White",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ContestPrize(
+ name="iPod 64GB Black",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ]
+ ended_raffle_contest.entries = [
+ ContestEntry(
+ user=user_3,
+ amount=USDCent(2),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ]
+ # User wins both prizes
+ winners = ended_raffle_contest.select_winners()
+ assert len(winners) == 2
+
+ def test_winners_3_prizes_3_entries(
+ self, ended_raffle_contest, product, user_1, user_2, user_3
+ ):
+ ended_raffle_contest.prizes = [
+ ContestPrize(
+ name="iPod 64GB White",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ContestPrize(
+ name="iPod 64GB Black",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ContestPrize(
+ name="iPod 64GB Red",
+ kind=ContestPrizeKind.PHYSICAL,
+ estimated_cash_value=USDCent(100_00),
+ ),
+ ]
+ ended_raffle_contest.entries = [
+ ContestEntry(
+ user=user_1,
+ amount=USDCent(1),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ContestEntry(
+ user=user_2,
+ amount=USDCent(2),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ContestEntry(
+ user=user_3,
+ amount=USDCent(3),
+ entry_type=ContestEntryType.CASH,
+ ),
+ ]
+
+ winners = ended_raffle_contest.select_winners()
+ assert len(winners) == 3
+
+ winners = [ended_raffle_contest.select_winners() for _ in range(10000)]
+
+ # There's 3 winners, the 1st should follow the same percentages
+ c = Counter([w[0].user.user_id for w in winners])
+
+ assert c[user_1.user_id] == approx(10000 * 1 / 6, rel=0.1)
+ assert c[user_2.user_id] == approx(10000 * 2 / 6, rel=0.1)
+ assert c[user_3.user_id] == approx(10000 * 3 / 6, rel=0.1)
+
+ # Assume the 1st user won
+ ended_raffle_contest.entries.pop(0)
+ winners = [ended_raffle_contest.select_winners() for _ in range(10000)]
+ c = Counter([w[0].user.user_id for w in winners])
+ assert c[user_2.user_id] == approx(10000 * 2 / 5, rel=0.1)
+ assert c[user_3.user_id] == approx(10000 * 3 / 5, rel=0.1)
diff --git a/tests/models/thl/test_ledger.py b/tests/models/thl/test_ledger.py
new file mode 100644
index 0000000..d706357
--- /dev/null
+++ b/tests/models/thl/test_ledger.py
@@ -0,0 +1,130 @@
+from datetime import datetime, timezone
+from decimal import Decimal
+from uuid import uuid4
+
+import pytest
+from pydantic import ValidationError
+
+from generalresearch.models.thl.ledger import LedgerAccount, Direction, AccountType
+from generalresearch.models.thl.ledger import LedgerTransaction, LedgerEntry
+
+
+class TestLedgerTransaction:
+
+ def test_create(self):
+ # Can create with nothing ...
+ t = LedgerTransaction()
+ assert [] == t.entries
+ assert {} == t.metadata
+ t = LedgerTransaction(
+ created=datetime.now(tz=timezone.utc),
+ metadata={"a": "b", "user": "1234"},
+ ext_description="foo",
+ )
+
+ def test_ledger_entry(self):
+ with pytest.raises(expected_exception=ValidationError) as cm:
+ LedgerEntry(
+ direction=Direction.CREDIT,
+ account_uuid="3f3735eaed264c2a9f8a114934afa121",
+ amount=0,
+ )
+ assert "Input should be greater than 0" in str(cm.value)
+
+ with pytest.raises(ValidationError) as cm:
+ LedgerEntry(
+ direction=Direction.CREDIT,
+ account_uuid="3f3735eaed264c2a9f8a114934afa121",
+ amount=2**65,
+ )
+ assert "Input should be less than 9223372036854775807" in str(cm.value)
+
+ with pytest.raises(ValidationError) as cm:
+ LedgerEntry(
+ direction=Direction.CREDIT,
+ account_uuid="3f3735eaed264c2a9f8a114934afa121",
+ amount=Decimal("1"),
+ )
+ assert "Input should be a valid integer" in str(cm.value)
+
+ with pytest.raises(ValidationError) as cm:
+ LedgerEntry(
+ direction=Direction.CREDIT,
+ account_uuid="3f3735eaed264c2a9f8a114934afa121",
+ amount=1.2,
+ )
+ assert "Input should be a valid integer" in str(cm.value)
+
+ def test_entries(self):
+ entries = [
+ LedgerEntry(
+ direction=Direction.CREDIT,
+ account_uuid="3f3735eaed264c2a9f8a114934afa121",
+ amount=100,
+ ),
+ LedgerEntry(
+ direction=Direction.DEBIT,
+ account_uuid="5927621462814f9893be807db850a31b",
+ amount=100,
+ ),
+ ]
+ LedgerTransaction(entries=entries)
+
+ def test_raises_entries(self):
+ entries = [
+ LedgerEntry(
+ direction=Direction.DEBIT,
+ account_uuid="3f3735eaed264c2a9f8a114934afa121",
+ amount=100,
+ ),
+ LedgerEntry(
+ direction=Direction.DEBIT,
+ account_uuid="5927621462814f9893be807db850a31b",
+ amount=100,
+ ),
+ ]
+ with pytest.raises(ValidationError) as e:
+ LedgerTransaction(entries=entries)
+ assert "ledger entries must balance" in str(e.value)
+
+ entries = [
+ LedgerEntry(
+ direction=Direction.DEBIT,
+ account_uuid="3f3735eaed264c2a9f8a114934afa121",
+ amount=100,
+ ),
+ LedgerEntry(
+ direction=Direction.CREDIT,
+ account_uuid="5927621462814f9893be807db850a31b",
+ amount=101,
+ ),
+ ]
+ with pytest.raises(ValidationError) as cm:
+ LedgerTransaction(entries=entries)
+ assert "ledger entries must balance" in str(cm.value)
+
+
+class TestLedgerAccount:
+
+ def test_initialization(self):
+ u = uuid4().hex
+ name = f"test-{u[:8]}"
+
+ with pytest.raises(ValidationError) as cm:
+ LedgerAccount(
+ display_name=name,
+ qualified_name="bad bunny",
+ normal_balance=Direction.DEBIT,
+ account_type=AccountType.BP_WALLET,
+ )
+ assert "qualified name should start with" in str(cm.value)
+
+ with pytest.raises(ValidationError) as cm:
+ LedgerAccount(
+ display_name=name,
+ qualified_name="fish sticks:bp_wallet",
+ normal_balance=Direction.DEBIT,
+ account_type=AccountType.BP_WALLET,
+ currency="fish sticks",
+ )
+ assert "Invalid UUID" in str(cm.value)
diff --git a/tests/models/thl/test_marketplace_condition.py b/tests/models/thl/test_marketplace_condition.py
new file mode 100644
index 0000000..217616d
--- /dev/null
+++ b/tests/models/thl/test_marketplace_condition.py
@@ -0,0 +1,382 @@
+import pytest
+from pydantic import ValidationError
+
+
+class TestMarketplaceCondition:
+
+ def test_list_or(self):
+ from generalresearch.models import LogicalOperator
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_qas = {"q1": {"a2"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2", "a3"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a2"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a3"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q2",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2", "a3"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas) is None
+
+ def test_list_or_negate(self):
+ from generalresearch.models import LogicalOperator
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_qas = {"q1": {"a2"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2", "a3"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.LIST,
+ values=["a2"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a3"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q2",
+ negate=True,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2", "a3"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas) is None
+
+ def test_list_and(self):
+ from generalresearch.models import LogicalOperator
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_qas = {"q1": {"a1", "a2"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2", "a3"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a2"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert c.evaluate_criterion(user_qas)
+ user_qas = {"q1": {"a1"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a3"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q2",
+ negate=False,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2", "a3"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert c.evaluate_criterion(user_qas) is None
+
+ def test_list_and_negate(self):
+ from generalresearch.models import LogicalOperator
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_qas = {"q1": {"a1", "a2"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2", "a3"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.LIST,
+ values=["a2"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a3"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q2",
+ negate=True,
+ value_type=ConditionValueType.LIST,
+ values=["a1", "a2", "a3"],
+ logical_operator=LogicalOperator.AND,
+ )
+ assert c.evaluate_criterion(user_qas) is None
+
+ def test_ranges(self):
+ from generalresearch.models import LogicalOperator
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_qas = {"q1": {"2", "50"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["1-4", "10-20"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["1-4"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["10-20"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q2",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["1-4", "10-20"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas) is None
+ # --- negate
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.RANGE,
+ values=["1-4", "10-20"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.RANGE,
+ values=["10-20"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas)
+ # --- AND
+ with pytest.raises(expected_exception=ValidationError):
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["1-4", "10-20"],
+ logical_operator=LogicalOperator.AND,
+ )
+
+ def test_ranges_to_list(self):
+ from generalresearch.models import LogicalOperator
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_qas = {"q1": {"2", "50"}}
+ MarketplaceCondition._CONVERT_LIST_TO_RANGE = ["q1"]
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["1-4", "10-12", "3-5"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas)
+ assert ConditionValueType.LIST == c.value_type
+ assert ["1", "10", "11", "12", "2", "3", "4", "5"] == c.values
+
+ def test_ranges_infinity(self):
+ from generalresearch.models import LogicalOperator
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_qas = {"q1": {"2", "50"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["1-4", "10-inf"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion(user_qas)
+ user_qas = {"q1": {"5", "50"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["1-4", "60-inf"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert not c.evaluate_criterion(user_qas)
+
+ # need to test negative infinity!
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["inf-40"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert c.evaluate_criterion({"q1": {"5", "50"}})
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.RANGE,
+ values=["inf-40"],
+ logical_operator=LogicalOperator.OR,
+ )
+ assert not c.evaluate_criterion({"q1": {"50"}})
+
+ def test_answered(self):
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_qas = {"q1": {"a2"}}
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=False,
+ value_type=ConditionValueType.ANSWERED,
+ values=[],
+ )
+ assert c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q2",
+ negate=False,
+ value_type=ConditionValueType.ANSWERED,
+ values=[],
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q1",
+ negate=True,
+ value_type=ConditionValueType.ANSWERED,
+ values=[],
+ )
+ assert not c.evaluate_criterion(user_qas)
+ c = MarketplaceCondition(
+ question_id="q2",
+ negate=True,
+ value_type=ConditionValueType.ANSWERED,
+ values=[],
+ )
+ assert c.evaluate_criterion(user_qas)
+
+ def test_invite(self):
+ from generalresearch.models.thl.survey.condition import (
+ MarketplaceCondition,
+ ConditionValueType,
+ )
+
+ user_groups = {"g1", "g2", "g3"}
+ c = MarketplaceCondition(
+ question_id=None,
+ negate=False,
+ value_type=ConditionValueType.RECONTACT,
+ values=["g1", "g4"],
+ )
+ assert c.evaluate_criterion(user_qas=dict(), user_groups=user_groups)
+ c = MarketplaceCondition(
+ question_id=None,
+ negate=False,
+ value_type=ConditionValueType.RECONTACT,
+ values=["g4"],
+ )
+ assert not c.evaluate_criterion(user_qas=dict(), user_groups=user_groups)
+
+ c = MarketplaceCondition(
+ question_id=None,
+ negate=True,
+ value_type=ConditionValueType.RECONTACT,
+ values=["g1", "g4"],
+ )
+ assert not c.evaluate_criterion(user_qas=dict(), user_groups=user_groups)
+ c = MarketplaceCondition(
+ question_id=None,
+ negate=True,
+ value_type=ConditionValueType.RECONTACT,
+ values=["g4"],
+ )
+ assert c.evaluate_criterion(user_qas=dict(), user_groups=user_groups)
diff --git a/tests/models/thl/test_payout.py b/tests/models/thl/test_payout.py
new file mode 100644
index 0000000..3a51328
--- /dev/null
+++ b/tests/models/thl/test_payout.py
@@ -0,0 +1,10 @@
+class TestBusinessPayoutEvent:
+
+ def test_validate(self):
+ from generalresearch.models.gr.business import Business
+
+ instance = Business.model_validate_json(
+ json_data='{"id":123,"uuid":"947f6ba5250d442b9a66cde9ee33605a","name":"Example » Demo","kind":"c","tax_number":null,"contact":null,"addresses":[],"teams":[{"id":53,"uuid":"8e4197dcaefe4f1f831a02b212e6b44a","name":"Example » Demo","memberships":null,"gr_users":null,"businesses":null,"products":null}],"products":[{"id":"fc23e741b5004581b30e6478363525df","id_int":1234,"name":"Example","enabled":true,"payments_enabled":true,"created":"2025-04-14T13:25:37.279403Z","team_id":"9e4197dcaefe4f1f831a02b212e6b44a","business_id":"857f6ba6160d442b9a66cde9ee33605a","tags":[],"commission_pct":"0.050000","redirect_url":"https://pam-api-us.reppublika.com/v2/public/4970ef00-0ef7-11f0-9962-05cb6323c84c/grl/status","harmonizer_domain":"https://talk.generalresearch.com/","sources_config":{"user_defined":[{"name":"w","active":false,"banned_countries":[],"allow_mobile_ip":true,"supplier_id":null,"allow_pii_only_buyers":false,"allow_unhashed_buyers":false,"withhold_profiling":false,"pass_unconditional_eligible_unknowns":true,"address":null,"allow_vpn":null,"distribute_harmonizer_active":null}]},"session_config":{"max_session_len":600,"max_session_hard_retry":5,"min_payout":"0.14"},"payout_config":{"payout_format":null,"payout_transformation":null},"user_wallet_config":{"enabled":false,"amt":false,"supported_payout_types":["CASH_IN_MAIL","PAYPAL","TANGO"],"min_cashout":null},"user_create_config":{"min_hourly_create_limit":0,"max_hourly_create_limit":null},"offerwall_config":{},"profiling_config":{"enabled":true,"grs_enabled":true,"n_questions":null,"max_questions":10,"avg_question_count":5.0,"task_injection_freq_mult":1.0,"non_us_mult":2.0,"hidden_questions_expiration_hours":168},"user_health_config":{"banned_countries":[],"allow_ban_iphist":true},"yield_man_config":{},"balance":null,"payouts_total_str":null,"payouts_total":null,"payouts":null,"user_wallet":{"enabled":false,"amt":false,"supported_payout_types":["CASH_IN_MAIL","PAYPAL","TANGO"],"min_cashout":null}}],"bank_accounts":[],"balance":{"product_balances":[{"product_id":"fc14e741b5004581b30e6478363414df","last_event":null,"bp_payment_credit":780251,"adjustment_credit":4678,"adjustment_debit":26446,"supplier_credit":0,"supplier_debit":451513,"user_bonus_credit":0,"user_bonus_debit":0,"issued_payment":0,"payout":780251,"payout_usd_str":"$7,802.51","adjustment":-21768,"expense":0,"net":758483,"payment":451513,"payment_usd_str":"$4,515.13","balance":306970,"retainer":76742,"retainer_usd_str":"$767.42","available_balance":230228,"available_balance_usd_str":"$2,302.28","recoup":0,"recoup_usd_str":"$0.00","adjustment_percent":0.027898714644390074}],"payout":780251,"payout_usd_str":"$7,802.51","adjustment":-21768,"expense":0,"net":758483,"net_usd_str":"$7,584.83","payment":451513,"payment_usd_str":"$4,515.13","balance":306970,"balance_usd_str":"$3,069.70","retainer":76742,"retainer_usd_str":"$767.42","available_balance":230228,"available_balance_usd_str":"$2,302.28","adjustment_percent":0.027898714644390074,"recoup":0,"recoup_usd_str":"$0.00"},"payouts_total_str":"$4,515.13","payouts_total":451513,"payouts":[{"bp_payouts":[{"uuid":"40cf2c3c341e4f9d985be4bca43e6116","debit_account_uuid":"3a058056da85493f9b7cdfe375aad0e0","cashout_method_uuid":"602113e330cf43ae85c07d94b5100291","created":"2025-08-02T09:18:20.433329Z","amount":345735,"status":"COMPLETE","ext_ref_id":null,"payout_type":"ACH","request_data":{},"order_data":null,"product_id":"fc14e741b5004581b30e6478363414df","method":"ACH","amount_usd":345735,"amount_usd_str":"$3,457.35"}],"amount":345735,"amount_usd_str":"$3,457.35","created":"2025-08-02T09:18:20.433329Z","line_items":1,"ext_ref_id":null},{"bp_payouts":[{"uuid":"63ce1787087248978919015c8fcd5ab9","debit_account_uuid":"3a058056da85493f9b7cdfe375aad0e0","cashout_method_uuid":"602113e330cf43ae85c07d94b5100291","created":"2025-06-10T22:16:18.765668Z","amount":105778,"status":"COMPLETE","ext_ref_id":"11175997868","payout_type":"ACH","request_data":{},"order_data":null,"product_id":"fc14e741b5004581b30e6478363414df","method":"ACH","amount_usd":105778,"amount_usd_str":"$1,057.78"}],"amount":105778,"amount_usd_str":"$1,057.78","created":"2025-06-10T22:16:18.765668Z","line_items":1,"ext_ref_id":"11175997868"}]}'
+ )
+
+ assert isinstance(instance, Business)
diff --git a/tests/models/thl/test_payout_format.py b/tests/models/thl/test_payout_format.py
new file mode 100644
index 0000000..dc91f39
--- /dev/null
+++ b/tests/models/thl/test_payout_format.py
@@ -0,0 +1,46 @@
+import pytest
+from pydantic import BaseModel
+
+from generalresearch.models.thl.payout_format import (
+ PayoutFormatType,
+ PayoutFormatField,
+ format_payout_format,
+)
+
+
+class PayoutFormatTestClass(BaseModel):
+ payout_format: PayoutFormatType = PayoutFormatField
+
+
+class TestPayoutFormat:
+ def test_payout_format_cls(self):
+ # valid
+ PayoutFormatTestClass(payout_format="{payout*10:,.0f} Points")
+ PayoutFormatTestClass(payout_format="{payout:.0f}")
+ PayoutFormatTestClass(payout_format="${payout/100:.2f}")
+
+ # invalid
+ with pytest.raises(expected_exception=Exception) as e:
+ PayoutFormatTestClass(payout_format="{payout10:,.0f} Points")
+
+ with pytest.raises(expected_exception=Exception) as e:
+ PayoutFormatTestClass(payout_format="payout:,.0f} Points")
+
+ with pytest.raises(expected_exception=Exception):
+ PayoutFormatTestClass(payout_format="payout")
+
+ with pytest.raises(expected_exception=Exception):
+ PayoutFormatTestClass(payout_format="{payout;import sys:.0f}")
+
+ def test_payout_format(self):
+ assert "1,230 Points" == format_payout_format(
+ payout_format="{payout*10:,.0f} Points", payout_int=123
+ )
+
+ assert "123" == format_payout_format(
+ payout_format="{payout:.0f}", payout_int=123
+ )
+
+ assert "$1.23" == format_payout_format(
+ payout_format="${payout/100:.2f}", payout_int=123
+ )
diff --git a/tests/models/thl/test_product.py b/tests/models/thl/test_product.py
new file mode 100644
index 0000000..52f60c2
--- /dev/null
+++ b/tests/models/thl/test_product.py
@@ -0,0 +1,1130 @@
+import os
+import shutil
+from datetime import datetime, timezone, timedelta
+from decimal import Decimal
+from typing import Optional
+from uuid import uuid4
+
+import pytest
+from pydantic import ValidationError
+
+from generalresearch.currency import USDCent
+from generalresearch.models import Source
+from generalresearch.models.thl.product import (
+ Product,
+ PayoutConfig,
+ PayoutTransformation,
+ ProfilingConfig,
+ SourcesConfig,
+ IntegrationMode,
+ SupplyConfig,
+ SourceConfig,
+ SupplyPolicy,
+)
+
+
+class TestProduct:
+
+ def test_init(self):
+ # By default, just a Pydantic instance doesn't have an id_int
+ instance = Product.model_validate(
+ dict(
+ id="968a9acc79b74b6fb49542d82516d284",
+ name="test-968a9acc",
+ redirect_url="https://www.google.com/hey",
+ )
+ )
+ assert instance.id_int is None
+
+ res = instance.model_dump_json()
+ # We're not excluding anything here, only in the "*Out" variants
+ assert "id_int" in res
+
+ def test_init_db(self, product_manager):
+ # By default, just a Pydantic instance doesn't have an id_int
+ instance = product_manager.create_dummy()
+ assert isinstance(instance.id_int, int)
+
+ res = instance.model_dump_json()
+
+ # we json skip & exclude
+ res = instance.model_dump()
+
+ def test_redirect_url(self):
+ p = Product.model_validate(
+ dict(
+ id="968a9acc79b74b6fb49542d82516d284",
+ created="2023-09-21T22:13:09.274672Z",
+ commission_pct=Decimal("0.05"),
+ enabled=True,
+ sources=[{"name": "d", "active": True}],
+ name="test-968a9acc",
+ max_session_len=600,
+ team_id="8b5e94afd8a246bf8556ad9986486baa",
+ redirect_url="https://www.google.com/hey",
+ )
+ )
+
+ with pytest.raises(expected_exception=ValidationError):
+ p.redirect_url = ""
+
+ with pytest.raises(expected_exception=ValidationError):
+ p.redirect_url = None
+
+ with pytest.raises(expected_exception=ValidationError):
+ p.redirect_url = "http://www.example.com/test/?a=1&b=2"
+
+ with pytest.raises(expected_exception=ValidationError):
+ p.redirect_url = "http://www.example.com/test/?a=1&b=2&tsid="
+
+ p.redirect_url = "https://www.example.com/test/?a=1&b=2"
+ c = p.generate_bp_redirect(tsid="c6ab6ba1e75b44e2bf5aab00fc68e3b7")
+ assert (
+ c
+ == "https://www.example.com/test/?a=1&b=2&tsid=c6ab6ba1e75b44e2bf5aab00fc68e3b7"
+ )
+
+ def test_harmonizer_domain(self):
+ p = Product(
+ id="968a9acc79b74b6fb49542d82516d284",
+ created="2023-09-21T22:13:09.274672Z",
+ commission_pct=Decimal("0.05"),
+ enabled=True,
+ name="test-968a9acc",
+ team_id="8b5e94afd8a246bf8556ad9986486baa",
+ harmonizer_domain="profile.generalresearch.com",
+ redirect_url="https://www.google.com/hey",
+ )
+ assert p.harmonizer_domain == "https://profile.generalresearch.com/"
+ p.harmonizer_domain = "https://profile.generalresearch.com/"
+ p.harmonizer_domain = "https://profile.generalresearch.com"
+ assert p.harmonizer_domain == "https://profile.generalresearch.com/"
+ with pytest.raises(expected_exception=Exception):
+ p.harmonizer_domain = ""
+ with pytest.raises(expected_exception=Exception):
+ p.harmonizer_domain = None
+ with pytest.raises(expected_exception=Exception):
+ # no https
+ p.harmonizer_domain = "http://profile.generalresearch.com"
+ with pytest.raises(expected_exception=Exception):
+ # "/a" at the end
+ p.harmonizer_domain = "https://profile.generalresearch.com/a"
+
+ def test_payout_xform(self):
+ p = Product(
+ id="968a9acc79b74b6fb49542d82516d284",
+ created="2023-09-21T22:13:09.274672Z",
+ commission_pct=Decimal("0.05"),
+ enabled=True,
+ name="test-968a9acc",
+ team_id="8b5e94afd8a246bf8556ad9986486baa",
+ harmonizer_domain="profile.generalresearch.com",
+ redirect_url="https://www.google.com/hey",
+ )
+
+ p.payout_config.payout_transformation = PayoutTransformation.model_validate(
+ {
+ "f": "payout_transformation_percent",
+ "kwargs": {"pct": "0.5", "min_payout": "0.10"},
+ }
+ )
+
+ assert (
+ "payout_transformation_percent" == p.payout_config.payout_transformation.f
+ )
+ assert 0.5 == p.payout_config.payout_transformation.kwargs.pct
+ assert (
+ Decimal("0.10") == p.payout_config.payout_transformation.kwargs.min_payout
+ )
+ assert p.payout_config.payout_transformation.kwargs.max_payout is None
+
+ # This calls get_payout_transformation_func
+ # 50% of $1.00
+ assert Decimal("0.50") == p.calculate_user_payment(Decimal(1))
+ # with a min
+ assert Decimal("0.10") == p.calculate_user_payment(Decimal("0.15"))
+
+ with pytest.raises(expected_exception=ValidationError) as cm:
+ p.payout_config.payout_transformation = PayoutTransformation.model_validate(
+ {"f": "payout_transformation_percent", "kwargs": {}}
+ )
+ assert "1 validation error for PayoutTransformation\nkwargs.pct" in str(
+ cm.value
+ )
+
+ with pytest.raises(expected_exception=ValidationError) as cm:
+ p.payout_config.payout_transformation = PayoutTransformation.model_validate(
+ {"f": "payout_transformation_percent"}
+ )
+
+ assert "1 validation error for PayoutTransformation\nkwargs" in str(cm.value)
+
+ with pytest.warns(expected_warning=Warning) as w:
+ p.payout_config.payout_transformation = PayoutTransformation.model_validate(
+ {
+ "f": "payout_transformation_percent",
+ "kwargs": {"pct": 1, "min_payout": "0.5"},
+ }
+ )
+ assert "Are you sure you want to pay respondents >95% of CPI?" in "".join(
+ [str(i.message) for i in w]
+ )
+
+ p.payout_config = PayoutConfig()
+ assert p.calculate_user_payment(Decimal("0.15")) is None
+
+ def test_payout_xform_amt(self):
+ p = Product(
+ id="968a9acc79b74b6fb49542d82516d284",
+ created="2023-09-21T22:13:09.274672Z",
+ commission_pct=Decimal("0.05"),
+ enabled=True,
+ name="test-968a9acc",
+ team_id="8b5e94afd8a246bf8556ad9986486baa",
+ harmonizer_domain="profile.generalresearch.com",
+ redirect_url="https://www.google.com/hey",
+ )
+
+ p.payout_config.payout_transformation = PayoutTransformation.model_validate(
+ {
+ "f": "payout_transformation_amt",
+ }
+ )
+
+ assert "payout_transformation_amt" == p.payout_config.payout_transformation.f
+
+ # This calls get_payout_transformation_func
+ # 95% of $1.00
+ assert p.calculate_user_payment(Decimal(1)) == Decimal("0.95")
+ assert p.calculate_user_payment(Decimal("1.05")) == Decimal("1.00")
+
+ assert p.calculate_user_payment(
+ Decimal("0.10"), user_wallet_balance=Decimal(0)
+ ) == Decimal("0.07")
+ assert p.calculate_user_payment(
+ Decimal("1.05"), user_wallet_balance=Decimal(0)
+ ) == Decimal("0.97")
+ assert p.calculate_user_payment(
+ Decimal(".05"), user_wallet_balance=Decimal(1)
+ ) == Decimal("0.02")
+ # final balance will be <0, so pay the full amount
+ assert p.calculate_user_payment(
+ Decimal(".50"), user_wallet_balance=Decimal(-1)
+ ) == p.calculate_user_payment(Decimal("0.50"))
+ # final balance will be >0, so do the 7c rounding
+ assert p.calculate_user_payment(
+ Decimal(".50"), user_wallet_balance=Decimal("-0.10")
+ ) == (
+ p.calculate_user_payment(Decimal(".40"), user_wallet_balance=Decimal(0))
+ - Decimal("-0.10")
+ )
+
+ def test_payout_xform_none(self):
+ p = Product(
+ id="968a9acc79b74b6fb49542d82516d284",
+ created="2023-09-21T22:13:09.274672Z",
+ commission_pct=Decimal("0.05"),
+ enabled=True,
+ name="test-968a9acc",
+ team_id="8b5e94afd8a246bf8556ad9986486baa",
+ harmonizer_domain="profile.generalresearch.com",
+ redirect_url="https://www.google.com/hey",
+ payout_config=PayoutConfig(payout_format=None, payout_transformation=None),
+ )
+ assert p.format_payout_format(Decimal("1.00")) is None
+
+ pt = PayoutTransformation.model_validate(
+ {"kwargs": {"pct": 0.5}, "f": "payout_transformation_percent"}
+ )
+ p.payout_config = PayoutConfig(
+ payout_format="{payout*10:,.0f} Points", payout_transformation=pt
+ )
+ assert p.format_payout_format(Decimal("1.00")) == "1,000 Points"
+
+ def test_profiling(self):
+ p = Product(
+ id="968a9acc79b74b6fb49542d82516d284",
+ created="2023-09-21T22:13:09.274672Z",
+ commission_pct=Decimal("0.05"),
+ enabled=True,
+ name="test-968a9acc",
+ team_id="8b5e94afd8a246bf8556ad9986486baa",
+ harmonizer_domain="profile.generalresearch.com",
+ redirect_url="https://www.google.com/hey",
+ )
+ assert p.profiling_config.enabled is True
+
+ p.profiling_config = ProfilingConfig(max_questions=1)
+ assert p.profiling_config.max_questions == 1
+
+ def test_bp_account(self, product, thl_lm):
+ assert product.bp_account is None
+
+ product.prefetch_bp_account(thl_lm=thl_lm)
+
+ from generalresearch.models.thl.ledger import LedgerAccount
+
+ assert isinstance(product.bp_account, LedgerAccount)
+
+
+class TestGlobalProduct:
+ # We have one product ID that is special; we call it the Global
+ # Product ID and in prod the. This product stores a bunch of extra
+ # things in the SourcesConfig
+
+ def test_init_and_props(self):
+ instance = Product(
+ name="Global Config",
+ redirect_url="https://www.example.com",
+ sources_config=SupplyConfig(
+ policies=[
+ # This is the config for Dynata that any BP is allowed to use
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ integration_mode=IntegrationMode.PLATFORM,
+ ),
+ # Spectrum that is using OUR credentials, that anyone is allowed to use.
+ # Same as the dynata config above, just that the dynata supplier_id is
+ # inferred by the dynata-grpc; it's not required to be set.
+ SupplyPolicy(
+ address=["https://spectrum.internal:50051"],
+ active=True,
+ name=Source.SPECTRUM,
+ supplier_id="example-supplier-id",
+ # implicit Scope = GLOBAL
+ # default integration_mode=IntegrationMode.PLATFORM,
+ ),
+ # A spectrum config with a different supplier_id, but
+ # it is OUR supplier, and we are paid for the completes. Only a certain BP
+ # can use this config.
+ SupplyPolicy(
+ address=["https://spectrum.internal:50051"],
+ active=True,
+ name=Source.SPECTRUM,
+ supplier_id="example-supplier-id",
+ team_ids=["d42194c2dfe44d7c9bec98123bc4a6c0"],
+ # implicit Scope = TEAM
+ # default integration_mode=IntegrationMode.PLATFORM,
+ ),
+ # The supplier ID is associated with THEIR
+ # credentials, and we do not get paid for this activity.
+ SupplyPolicy(
+ address=["https://cint.internal:50051"],
+ active=True,
+ name=Source.CINT,
+ supplier_id="example-supplier-id",
+ product_ids=["db8918b3e87d4444b60241d0d3a54caa"],
+ integration_mode=IntegrationMode.PASS_THROUGH,
+ ),
+ # We could have another global cint integration available
+ # to anyone also, or we could have another like above
+ SupplyPolicy(
+ address=["https://cint.internal:50051"],
+ active=True,
+ name=Source.CINT,
+ supplier_id="example-supplier-id",
+ team_ids=["b163972a59584de881e5eab01ad10309"],
+ integration_mode=IntegrationMode.PASS_THROUGH,
+ ),
+ ]
+ ),
+ )
+
+ assert Product.model_validate_json(instance.model_dump_json()) == instance
+
+ s = instance.sources_config
+ # Cint should NOT have a global config
+ assert set(s.global_scoped_policies_dict.keys()) == {
+ Source.DYNATA,
+ Source.SPECTRUM,
+ }
+
+ # The spectrum global config is the one that isn't scoped to a
+ # specific supplier
+ assert (
+ s.global_scoped_policies_dict[Source.SPECTRUM].supplier_id
+ == "grl-supplier-id"
+ )
+
+ assert set(s.team_scoped_policies_dict.keys()) == {
+ "b163972a59584de881e5eab01ad10309",
+ "d42194c2dfe44d7c9bec98123bc4a6c0",
+ }
+ # This team has one team-scoped config, and it's for spectrum
+ assert s.team_scoped_policies_dict[
+ "d42194c2dfe44d7c9bec98123bc4a6c0"
+ ].keys() == {Source.SPECTRUM}
+
+ # For a random product/team, it'll just have the globally-scoped config
+ random_product = uuid4().hex
+ random_team = uuid4().hex
+ res = instance.sources_config.get_policies_for(
+ product_id=random_product, team_id=random_team
+ )
+ assert res == s.global_scoped_policies_dict
+
+ # It'll have the global config plus cint, and it should use the PRODUCT
+ # scoped config, not the TEAM scoped!
+ res = instance.sources_config.get_policies_for(
+ product_id="db8918b3e87d4444b60241d0d3a54caa",
+ team_id="b163972a59584de881e5eab01ad10309",
+ )
+ assert set(res.keys()) == {
+ Source.DYNATA,
+ Source.SPECTRUM,
+ Source.CINT,
+ }
+ assert res[Source.CINT].supplier_id == "example-supplier-id"
+
+ def test_source_vs_supply_validate(self):
+ # sources_config can be a SupplyConfig or SourcesConfig.
+ # make sure they get model_validated correctly
+ gp = Product(
+ name="Global Config",
+ redirect_url="https://www.example.com",
+ sources_config=SupplyConfig(
+ policies=[
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ integration_mode=IntegrationMode.PLATFORM,
+ )
+ ]
+ ),
+ )
+ bp = Product(
+ name="test product config",
+ redirect_url="https://www.example.com",
+ sources_config=SourcesConfig(
+ user_defined=[
+ SourceConfig(
+ active=False,
+ name=Source.DYNATA,
+ )
+ ]
+ ),
+ )
+ assert Product.model_validate_json(gp.model_dump_json()) == gp
+ assert Product.model_validate_json(bp.model_dump_json()) == bp
+
+ def test_validations(self):
+ with pytest.raises(
+ ValidationError, match="Can only have one GLOBAL policy per Source"
+ ):
+ SupplyConfig(
+ policies=[
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ integration_mode=IntegrationMode.PLATFORM,
+ ),
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ integration_mode=IntegrationMode.PASS_THROUGH,
+ ),
+ ]
+ )
+ with pytest.raises(
+ ValidationError,
+ match="Can only have one PRODUCT policy per Source per BP",
+ ):
+ SupplyConfig(
+ policies=[
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ product_ids=["7e417dec1c8a406e8554099b46e518ca"],
+ integration_mode=IntegrationMode.PLATFORM,
+ ),
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ product_ids=["7e417dec1c8a406e8554099b46e518ca"],
+ integration_mode=IntegrationMode.PASS_THROUGH,
+ ),
+ ]
+ )
+ with pytest.raises(
+ ValidationError,
+ match="Can only have one TEAM policy per Source per Team",
+ ):
+ SupplyConfig(
+ policies=[
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ team_ids=["7e417dec1c8a406e8554099b46e518ca"],
+ integration_mode=IntegrationMode.PLATFORM,
+ ),
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ team_ids=["7e417dec1c8a406e8554099b46e518ca"],
+ integration_mode=IntegrationMode.PASS_THROUGH,
+ ),
+ ]
+ )
+
+
+class TestGlobalProductConfigFor:
+ def test_no_user_defined(self):
+ sc = SupplyConfig(
+ policies=[
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ )
+ ]
+ )
+ product = Product(
+ name="Test Product Config",
+ redirect_url="https://www.example.com",
+ sources_config=SourcesConfig(),
+ )
+ res = sc.get_config_for_product(product=product)
+ assert len(res.policies) == 1
+
+ def test_user_defined_merge(self):
+ sc = SupplyConfig(
+ policies=[
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ banned_countries=["mx"],
+ active=True,
+ name=Source.DYNATA,
+ ),
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ banned_countries=["ca"],
+ active=True,
+ name=Source.DYNATA,
+ team_ids=[uuid4().hex],
+ ),
+ ]
+ )
+ product = Product(
+ name="Test Product Config",
+ redirect_url="https://www.example.com",
+ sources_config=SourcesConfig(
+ user_defined=[
+ SourceConfig(
+ name=Source.DYNATA,
+ active=False,
+ banned_countries=["us"],
+ )
+ ]
+ ),
+ )
+ res = sc.get_config_for_product(product=product)
+ assert len(res.policies) == 1
+ assert not res.policies[0].active
+ assert res.policies[0].banned_countries == ["mx", "us"]
+
+ def test_no_eligible(self):
+ sc = SupplyConfig(
+ policies=[
+ SupplyPolicy(
+ address=["https://dynata.internal:50051"],
+ active=True,
+ name=Source.DYNATA,
+ team_ids=["7e417dec1c8a406e8554099b46e518ca"],
+ integration_mode=IntegrationMode.PLATFORM,
+ )
+ ]
+ )
+ product = Product(
+ name="Test Product Config",
+ redirect_url="https://www.example.com",
+ sources_config=SourcesConfig(),
+ )
+ res = sc.get_config_for_product(product=product)
+ assert len(res.policies) == 0
+
+
+class TestProductFinancials:
+
+ @pytest.fixture
+ def start(self) -> "datetime":
+ return datetime(year=2018, month=3, day=14, hour=0, tzinfo=timezone.utc)
+
+ @pytest.fixture
+ def offset(self) -> str:
+ return "30d"
+
+ @pytest.fixture
+ def duration(self) -> Optional["timedelta"]:
+ return None
+
+ def test_balance(
+ self,
+ business,
+ product_factory,
+ user_factory,
+ mnt_filepath,
+ bp_payout_factory,
+ thl_lm,
+ lm,
+ duration,
+ offset,
+ thl_redis_config,
+ start,
+ thl_web_rr,
+ brokerage_product_payout_event_manager,
+ session_with_tx_factory,
+ delete_ledger_db,
+ create_main_accounts,
+ client_no_amm,
+ ledger_collection,
+ pop_ledger_merge,
+ delete_df_collection,
+ ):
+ delete_ledger_db()
+ create_main_accounts()
+ delete_df_collection(coll=ledger_collection)
+
+ from generalresearch.models.thl.product import Product
+ from generalresearch.models.thl.user import User
+ from generalresearch.models.thl.finance import ProductBalances
+ from generalresearch.currency import USDCent
+
+ p1: Product = product_factory(business=business)
+ u1: User = user_factory(product=p1)
+ bp_wallet = thl_lm.get_account_or_create_bp_wallet(product=p1)
+ thl_lm.get_account_or_create_user_wallet(user=u1)
+ brokerage_product_payout_event_manager.set_account_lookup_table(thl_lm=thl_lm)
+
+ assert len(thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet.uuid)) == 0
+
+ session_with_tx_factory(
+ user=u1,
+ wall_req_cpi=Decimal(".50"),
+ started=start + timedelta(days=1),
+ )
+ assert thl_lm.get_account_balance(account=bp_wallet) == 48
+ assert len(thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet.uuid)) == 1
+
+ session_with_tx_factory(
+ user=u1,
+ wall_req_cpi=Decimal("1.00"),
+ started=start + timedelta(days=2),
+ )
+ assert thl_lm.get_account_balance(account=bp_wallet) == 143
+ assert len(thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet.uuid)) == 2
+
+ with pytest.raises(expected_exception=AssertionError) as cm:
+ p1.prebuild_balance(
+ thl_lm=thl_lm,
+ ds=mnt_filepath,
+ client=client_no_amm,
+ )
+ assert "Cannot build Product Balance" in str(cm.value)
+
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ p1.prebuild_balance(
+ thl_lm=thl_lm,
+ ds=mnt_filepath,
+ client=client_no_amm,
+ )
+ assert isinstance(p1.balance, ProductBalances)
+ assert p1.balance.payout == 143
+ assert p1.balance.adjustment == 0
+ assert p1.balance.expense == 0
+ assert p1.balance.net == 143
+ assert p1.balance.balance == 143
+ assert p1.balance.retainer == 35
+ assert p1.balance.available_balance == 108
+
+ p1.prebuild_payouts(
+ thl_lm=thl_lm,
+ bp_pem=brokerage_product_payout_event_manager,
+ )
+ assert p1.payouts is not None
+ assert len(p1.payouts) == 0
+ assert p1.payouts_total == 0
+ assert p1.payouts_total_str == "$0.00"
+
+ # -- Now pay them out...
+
+ bp_payout_factory(
+ product=p1,
+ amount=USDCent(50),
+ created=start + timedelta(days=3),
+ skip_wallet_balance_check=True,
+ skip_one_per_day_check=True,
+ )
+ assert len(thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet.uuid)) == 3
+
+ # RM the entire directories
+ shutil.rmtree(ledger_collection.archive_path)
+ os.makedirs(ledger_collection.archive_path, exist_ok=True)
+ shutil.rmtree(pop_ledger_merge.archive_path)
+ os.makedirs(pop_ledger_merge.archive_path, exist_ok=True)
+
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ p1.prebuild_balance(
+ thl_lm=thl_lm,
+ ds=mnt_filepath,
+ client=client_no_amm,
+ )
+ assert isinstance(p1.balance, ProductBalances)
+ assert p1.balance.payout == 143
+ assert p1.balance.adjustment == 0
+ assert p1.balance.expense == 0
+ assert p1.balance.net == 143
+ assert p1.balance.balance == 93
+ assert p1.balance.retainer == 23
+ assert p1.balance.available_balance == 70
+
+ p1.prebuild_payouts(
+ thl_lm=thl_lm,
+ bp_pem=brokerage_product_payout_event_manager,
+ )
+ assert p1.payouts is not None
+ assert len(p1.payouts) == 1
+ assert p1.payouts_total == 50
+ assert p1.payouts_total_str == "$0.50"
+
+ # -- Now pay ou another!.
+
+ bp_payout_factory(
+ product=p1,
+ amount=USDCent(5),
+ created=start + timedelta(days=4),
+ skip_wallet_balance_check=True,
+ skip_one_per_day_check=True,
+ )
+ assert len(thl_lm.get_tx_filtered_by_account(account_uuid=bp_wallet.uuid)) == 4
+
+ # RM the entire directories
+ shutil.rmtree(ledger_collection.archive_path)
+ os.makedirs(ledger_collection.archive_path, exist_ok=True)
+ shutil.rmtree(pop_ledger_merge.archive_path)
+ os.makedirs(pop_ledger_merge.archive_path, exist_ok=True)
+
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ p1.prebuild_balance(
+ thl_lm=thl_lm,
+ ds=mnt_filepath,
+ client=client_no_amm,
+ )
+ assert isinstance(p1.balance, ProductBalances)
+ assert p1.balance.payout == 143
+ assert p1.balance.adjustment == 0
+ assert p1.balance.expense == 0
+ assert p1.balance.net == 143
+ assert p1.balance.balance == 88
+ assert p1.balance.retainer == 22
+ assert p1.balance.available_balance == 66
+
+ p1.prebuild_payouts(
+ thl_lm=thl_lm,
+ bp_pem=brokerage_product_payout_event_manager,
+ )
+ assert p1.payouts is not None
+ assert len(p1.payouts) == 2
+ assert p1.payouts_total == 55
+ assert p1.payouts_total_str == "$0.55"
+
+
+class TestProductBalance:
+
+ @pytest.fixture
+ def start(self) -> "datetime":
+ return datetime(year=2018, month=3, day=14, hour=0, tzinfo=timezone.utc)
+
+ @pytest.fixture
+ def offset(self) -> str:
+ return "30d"
+
+ @pytest.fixture
+ def duration(self) -> Optional["timedelta"]:
+ return None
+
+ def test_inconsistent(
+ self,
+ product,
+ mnt_filepath,
+ thl_lm,
+ client_no_amm,
+ thl_redis_config,
+ brokerage_product_payout_event_manager,
+ delete_ledger_db,
+ create_main_accounts,
+ delete_df_collection,
+ ledger_collection,
+ business,
+ user_factory,
+ product_factory,
+ session_with_tx_factory,
+ pop_ledger_merge,
+ start,
+ bp_payout_factory,
+ payout_event_manager,
+ ):
+ # Now let's load it up and actually test some things
+ delete_ledger_db()
+ create_main_accounts()
+ delete_df_collection(coll=ledger_collection)
+
+ from generalresearch.models.thl.user import User
+
+ u1: User = user_factory(product=product)
+
+ # 1. Complete and Build Parquets 1st time
+ session_with_tx_factory(
+ user=u1,
+ wall_req_cpi=Decimal(".75"),
+ started=start + timedelta(days=1),
+ )
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ # 2. Payout and build Parquets 2nd time
+ payout_event_manager.set_account_lookup_table(thl_lm=thl_lm)
+ bp_payout_factory(
+ product=product,
+ amount=USDCent(71),
+ ext_ref_id=uuid4().hex,
+ created=start + timedelta(days=1, minutes=1),
+ skip_wallet_balance_check=True,
+ skip_one_per_day_check=True,
+ )
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ with pytest.raises(expected_exception=AssertionError) as cm:
+ product.prebuild_balance(
+ thl_lm=thl_lm, ds=mnt_filepath, client=client_no_amm
+ )
+ assert "Sql and Parquet Balance inconsistent" in str(cm)
+
+ def test_not_inconsistent(
+ self,
+ product,
+ mnt_filepath,
+ thl_lm,
+ client_no_amm,
+ thl_redis_config,
+ brokerage_product_payout_event_manager,
+ delete_ledger_db,
+ create_main_accounts,
+ delete_df_collection,
+ ledger_collection,
+ business,
+ user_factory,
+ product_factory,
+ session_with_tx_factory,
+ pop_ledger_merge,
+ start,
+ bp_payout_factory,
+ payout_event_manager,
+ ):
+ # This is very similar to the test_complete_payout_pq_inconsistent
+ # test, however this time we're only going to assign the payout
+ # in real time, and not in the past. This means that even if we
+ # build the parquet files multiple times, they will include the
+ # payout.
+
+ # Now let's load it up and actually test some things
+ delete_ledger_db()
+ create_main_accounts()
+ delete_df_collection(coll=ledger_collection)
+
+ from generalresearch.models.thl.user import User
+
+ u1: User = user_factory(product=product)
+
+ # 1. Complete and Build Parquets 1st time
+ session_with_tx_factory(
+ user=u1,
+ wall_req_cpi=Decimal(".75"),
+ started=start + timedelta(days=1),
+ )
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ # 2. Payout and build Parquets 2nd time but this payout is "now"
+ # so it hasn't already been archived
+ payout_event_manager.set_account_lookup_table(thl_lm=thl_lm)
+ bp_payout_factory(
+ product=product,
+ amount=USDCent(71),
+ ext_ref_id=uuid4().hex,
+ created=datetime.now(tz=timezone.utc),
+ skip_wallet_balance_check=True,
+ skip_one_per_day_check=True,
+ )
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ # We just want to call this to confirm it doesn't raise.
+ product.prebuild_balance(thl_lm=thl_lm, ds=mnt_filepath, client=client_no_amm)
+
+
+class TestProductPOPFinancial:
+
+ @pytest.fixture
+ def start(self) -> "datetime":
+ return datetime(year=2018, month=3, day=14, hour=0, tzinfo=timezone.utc)
+
+ @pytest.fixture
+ def offset(self) -> str:
+ return "30d"
+
+ @pytest.fixture
+ def duration(self) -> Optional["timedelta"]:
+ return None
+
+ def test_base(
+ self,
+ product,
+ mnt_filepath,
+ thl_lm,
+ client_no_amm,
+ thl_redis_config,
+ brokerage_product_payout_event_manager,
+ delete_ledger_db,
+ create_main_accounts,
+ delete_df_collection,
+ ledger_collection,
+ business,
+ user_factory,
+ product_factory,
+ session_with_tx_factory,
+ pop_ledger_merge,
+ start,
+ bp_payout_factory,
+ payout_event_manager,
+ ):
+ # This is very similar to the test_complete_payout_pq_inconsistent
+ # test, however this time we're only going to assign the payout
+ # in real time, and not in the past. This means that even if we
+ # build the parquet files multiple times, they will include the
+ # payout.
+
+ # Now let's load it up and actually test some things
+ delete_ledger_db()
+ create_main_accounts()
+ delete_df_collection(coll=ledger_collection)
+
+ from generalresearch.models.thl.user import User
+
+ u1: User = user_factory(product=product)
+
+ # 1. Complete and Build Parquets 1st time
+ session_with_tx_factory(
+ user=u1,
+ wall_req_cpi=Decimal(".75"),
+ started=start + timedelta(days=1),
+ )
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ # --- test ---
+ assert product.pop_financial is None
+ product.prebuild_pop_financial(
+ thl_lm=thl_lm,
+ ds=mnt_filepath,
+ client=client_no_amm,
+ pop_ledger=pop_ledger_merge,
+ )
+
+ from generalresearch.models.thl.finance import POPFinancial
+
+ assert isinstance(product.pop_financial, list)
+ assert isinstance(product.pop_financial[0], POPFinancial)
+ pf1: POPFinancial = product.pop_financial[0]
+ assert isinstance(pf1.time, datetime)
+ assert pf1.payout == 71
+ assert pf1.net == 71
+ assert pf1.adjustment == 0
+ for adj in pf1.adjustment_types:
+ assert adj.amount == 0
+
+
+class TestProductCache:
+
+ @pytest.fixture
+ def start(self) -> "datetime":
+ return datetime(year=2018, month=3, day=14, hour=0, tzinfo=timezone.utc)
+
+ @pytest.fixture
+ def offset(self) -> str:
+ return "30d"
+
+ @pytest.fixture
+ def duration(self) -> Optional["timedelta"]:
+ return None
+
+ def test_basic(
+ self,
+ product,
+ mnt_filepath,
+ thl_lm,
+ client_no_amm,
+ thl_redis_config,
+ brokerage_product_payout_event_manager,
+ delete_ledger_db,
+ create_main_accounts,
+ delete_df_collection,
+ ledger_collection,
+ business,
+ user_factory,
+ product_factory,
+ session_with_tx_factory,
+ pop_ledger_merge,
+ start,
+ ):
+ # Now let's load it up and actually test some things
+ delete_ledger_db()
+ create_main_accounts()
+ delete_df_collection(coll=ledger_collection)
+
+ # Confirm the default / null behavior
+ rc = thl_redis_config.create_redis_client()
+ res: Optional[str] = rc.get(product.cache_key)
+ assert res is None
+ with pytest.raises(expected_exception=AssertionError):
+ product.set_cache(
+ thl_lm=thl_lm,
+ ds=mnt_filepath,
+ client=client_no_amm,
+ bp_pem=brokerage_product_payout_event_manager,
+ redis_config=thl_redis_config,
+ )
+
+ from generalresearch.models.thl.product import Product
+ from generalresearch.models.thl.user import User
+
+ u1: User = user_factory(product=product)
+
+ session_with_tx_factory(
+ user=u1,
+ wall_req_cpi=Decimal(".75"),
+ started=start + timedelta(days=1),
+ )
+
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ # Now try again with everything in place
+ product.set_cache(
+ thl_lm=thl_lm,
+ ds=mnt_filepath,
+ client=client_no_amm,
+ bp_pem=brokerage_product_payout_event_manager,
+ redis_config=thl_redis_config,
+ )
+
+ # Fetch from cache and assert the instance loaded from redis
+ res: Optional[str] = rc.get(product.cache_key)
+ assert isinstance(res, str)
+ from generalresearch.models.thl.ledger import LedgerAccount
+
+ assert isinstance(product.bp_account, LedgerAccount)
+
+ p1: Product = Product.model_validate_json(res)
+ assert p1.balance.product_id == product.uuid
+ assert p1.balance.payout_usd_str == "$0.71"
+ assert p1.balance.retainer_usd_str == "$0.17"
+ assert p1.balance.available_balance_usd_str == "$0.54"
+
+ def test_neg_balance_cache(
+ self,
+ product,
+ mnt_filepath,
+ thl_lm,
+ client_no_amm,
+ thl_redis_config,
+ brokerage_product_payout_event_manager,
+ delete_ledger_db,
+ create_main_accounts,
+ delete_df_collection,
+ ledger_collection,
+ business,
+ user_factory,
+ product_factory,
+ session_with_tx_factory,
+ pop_ledger_merge,
+ start,
+ bp_payout_factory,
+ payout_event_manager,
+ adj_to_fail_with_tx_factory,
+ ):
+ # Now let's load it up and actually test some things
+ delete_ledger_db()
+ create_main_accounts()
+ delete_df_collection(coll=ledger_collection)
+
+ from generalresearch.models.thl.product import Product
+ from generalresearch.models.thl.user import User
+
+ u1: User = user_factory(product=product)
+
+ # 1. Complete
+ s1 = session_with_tx_factory(
+ user=u1,
+ wall_req_cpi=Decimal(".75"),
+ started=start + timedelta(days=1),
+ )
+
+ # 2. Payout
+ payout_event_manager.set_account_lookup_table(thl_lm=thl_lm)
+ bp_payout_factory(
+ product=product,
+ amount=USDCent(71),
+ ext_ref_id=uuid4().hex,
+ created=start + timedelta(days=1, minutes=1),
+ skip_wallet_balance_check=True,
+ skip_one_per_day_check=True,
+ )
+
+ # 3. Recon
+ adj_to_fail_with_tx_factory(
+ session=s1,
+ created=start + timedelta(days=1, minutes=1),
+ )
+
+ # Finally, process everything:
+ ledger_collection.initial_load(client=None, sync=True)
+ pop_ledger_merge.build(client=client_no_amm, ledger_coll=ledger_collection)
+
+ product.set_cache(
+ thl_lm=thl_lm,
+ ds=mnt_filepath,
+ client=client_no_amm,
+ bp_pem=brokerage_product_payout_event_manager,
+ redis_config=thl_redis_config,
+ )
+
+ # Fetch from cache and assert the instance loaded from redis
+ rc = thl_redis_config.create_redis_client()
+ res: Optional[str] = rc.get(product.cache_key)
+ assert isinstance(res, str)
+
+ p1: Product = Product.model_validate_json(res)
+ assert p1.balance.product_id == product.uuid
+ assert p1.balance.payout_usd_str == "$0.71"
+ assert p1.balance.adjustment == -71
+ assert p1.balance.expense == 0
+ assert p1.balance.net == 0
+ assert p1.balance.balance == -71
+ assert p1.balance.retainer_usd_str == "$0.00"
+ assert p1.balance.available_balance_usd_str == "$0.00"
diff --git a/tests/models/thl/test_product_userwalletconfig.py b/tests/models/thl/test_product_userwalletconfig.py
new file mode 100644
index 0000000..4583c46
--- /dev/null
+++ b/tests/models/thl/test_product_userwalletconfig.py
@@ -0,0 +1,56 @@
+from itertools import groupby
+from random import shuffle as rshuffle
+
+from generalresearch.models.thl.product import (
+ UserWalletConfig,
+)
+
+from generalresearch.models.thl.wallet import PayoutType
+
+
+def all_equal(iterable):
+ g = groupby(iterable)
+ return next(g, True) and not next(g, False)
+
+
+class TestProductUserWalletConfig:
+
+ def test_init(self):
+ instance = UserWalletConfig()
+
+ assert isinstance(instance, UserWalletConfig)
+
+ # Check the defaults
+ assert not instance.enabled
+ assert not instance.amt
+
+ assert isinstance(instance.supported_payout_types, set)
+ assert len(instance.supported_payout_types) == 3
+
+ assert instance.min_cashout is None
+
+ def test_model_dump(self):
+ instance = UserWalletConfig()
+
+ # If we use the defaults, the supported_payout_types are always
+ # in the same order because they're the same
+ assert isinstance(instance.model_dump_json(), str)
+ res = []
+ for idx in range(100):
+ res.append(instance.model_dump_json())
+ assert all_equal(res)
+
+ def test_model_dump_payout_types(self):
+ res = []
+ for idx in range(100):
+
+ # Generate a random order of PayoutTypes each time
+ payout_types = [e for e in PayoutType]
+ rshuffle(payout_types)
+ instance = UserWalletConfig.model_validate(
+ {"supported_payout_types": payout_types}
+ )
+
+ res.append(instance.model_dump_json())
+
+ assert all_equal(res)
diff --git a/tests/models/thl/test_soft_pair.py b/tests/models/thl/test_soft_pair.py
new file mode 100644
index 0000000..bac0e8d
--- /dev/null
+++ b/tests/models/thl/test_soft_pair.py
@@ -0,0 +1,24 @@
+from generalresearch.models import Source
+from generalresearch.models.thl.soft_pair import SoftPairResult, SoftPairResultType
+
+
+def test_model():
+ from generalresearch.models.dynata.survey import (
+ DynataCondition,
+ ConditionValueType,
+ )
+
+ c1 = DynataCondition(
+ question_id="1", value_type=ConditionValueType.LIST, values=["a", "b"]
+ )
+ c2 = DynataCondition(
+ question_id="2", value_type=ConditionValueType.LIST, values=["c", "d"]
+ )
+ sr = SoftPairResult(
+ pair_type=SoftPairResultType.CONDITIONAL,
+ source=Source.DYNATA,
+ survey_id="xxx",
+ conditions={c1, c2},
+ )
+ assert sr.grpc_string == "xxx:1;2"
+ assert sr.survey_sid == "d:xxx"
diff --git a/tests/models/thl/test_upkquestion.py b/tests/models/thl/test_upkquestion.py
new file mode 100644
index 0000000..e67427e
--- /dev/null
+++ b/tests/models/thl/test_upkquestion.py
@@ -0,0 +1,414 @@
+import pytest
+from pydantic import ValidationError
+
+
+class TestUpkQuestion:
+
+ def test_importance(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UPKImportance,
+ )
+
+ ui = UPKImportance(task_score=1, task_count=None)
+ ui = UPKImportance(task_score=0)
+ with pytest.raises(ValidationError) as e:
+ UPKImportance(task_score=-1)
+ assert "Input should be greater than or equal to 0" in str(e.value)
+
+ def test_pattern(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ PatternValidation,
+ )
+
+ s = PatternValidation(message="hi", pattern="x")
+ with pytest.raises(ValidationError) as e:
+ s.message = "sfd"
+ assert "Instance is frozen" in str(e.value)
+
+ def test_mc(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestionChoice,
+ UpkQuestionSelectorMC,
+ UpkQuestionType,
+ UpkQuestion,
+ UpkQuestionConfigurationMC,
+ )
+
+ q = UpkQuestion(
+ id="601377a0d4c74529afc6293a8e5c3b5e",
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.MULTIPLE_CHOICE,
+ selector=UpkQuestionSelectorMC.MULTIPLE_ANSWER,
+ text="whats up",
+ choices=[
+ UpkQuestionChoice(id="1", text="sky", order=1),
+ UpkQuestionChoice(id="2", text="moon", order=2),
+ ],
+ configuration=UpkQuestionConfigurationMC(max_select=2),
+ )
+ assert q == UpkQuestion.model_validate(q.model_dump(mode="json"))
+
+ q = UpkQuestion(
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.MULTIPLE_CHOICE,
+ selector=UpkQuestionSelectorMC.SINGLE_ANSWER,
+ text="yes or no",
+ choices=[
+ UpkQuestionChoice(id="1", text="yes", order=1),
+ UpkQuestionChoice(id="2", text="no", order=2),
+ ],
+ configuration=UpkQuestionConfigurationMC(max_select=1),
+ )
+ assert q == UpkQuestion.model_validate(q.model_dump(mode="json"))
+
+ q = UpkQuestion(
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.MULTIPLE_CHOICE,
+ selector=UpkQuestionSelectorMC.MULTIPLE_ANSWER,
+ text="yes or no",
+ choices=[
+ UpkQuestionChoice(id="1", text="yes", order=1),
+ UpkQuestionChoice(id="2", text="no", order=2),
+ ],
+ )
+ assert q == UpkQuestion.model_validate(q.model_dump(mode="json"))
+
+ with pytest.raises(ValidationError) as e:
+ q = UpkQuestion(
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.MULTIPLE_CHOICE,
+ selector=UpkQuestionSelectorMC.SINGLE_ANSWER,
+ text="yes or no",
+ choices=[
+ UpkQuestionChoice(id="1", text="yes", order=1),
+ UpkQuestionChoice(id="2", text="no", order=2),
+ ],
+ configuration=UpkQuestionConfigurationMC(max_select=2),
+ )
+ assert "max_select must be 1 if the selector is SA" in str(e.value)
+
+ with pytest.raises(ValidationError) as e:
+ q = UpkQuestion(
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.MULTIPLE_CHOICE,
+ selector=UpkQuestionSelectorMC.MULTIPLE_ANSWER,
+ text="yes or no",
+ choices=[
+ UpkQuestionChoice(id="1", text="yes", order=1),
+ UpkQuestionChoice(id="2", text="no", order=2),
+ ],
+ configuration=UpkQuestionConfigurationMC(max_select=4),
+ )
+ assert "max_select must be >= len(choices)" in str(e.value)
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ q = UpkQuestion(
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.MULTIPLE_CHOICE,
+ selector=UpkQuestionSelectorMC.MULTIPLE_ANSWER,
+ text="yes or no",
+ choices=[
+ UpkQuestionChoice(id="1", text="yes", order=1),
+ UpkQuestionChoice(id="2", text="no", order=2),
+ ],
+ configuration=UpkQuestionConfigurationMC(max_length=2),
+ )
+ assert "Extra inputs are not permitted" in str(e.value)
+
+ def test_te(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestionType,
+ UpkQuestion,
+ UpkQuestionSelectorTE,
+ UpkQuestionValidation,
+ PatternValidation,
+ UpkQuestionConfigurationTE,
+ )
+
+ q = UpkQuestion(
+ id="601377a0d4c74529afc6293a8e5c3b5e",
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.TEXT_ENTRY,
+ selector=UpkQuestionSelectorTE.MULTI_LINE,
+ text="whats up",
+ choices=[],
+ configuration=UpkQuestionConfigurationTE(max_length=2),
+ validation=UpkQuestionValidation(
+ patterns=[PatternValidation(pattern=".", message="x")]
+ ),
+ )
+ assert q == UpkQuestion.model_validate(q.model_dump(mode="json"))
+ assert q.choices is None
+
+ def test_deserialization(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestion,
+ )
+
+ q = UpkQuestion.model_validate(
+ {
+ "id": "601377a0d4c74529afc6293a8e5c3b5e",
+ "ext_question_id": "m:2342",
+ "country_iso": "us",
+ "language_iso": "eng",
+ "text": "whats up",
+ "choices": [
+ {"id": "1", "text": "yes", "order": 1},
+ {"id": "2", "text": "no", "order": 2},
+ ],
+ "importance": None,
+ "type": "MC",
+ "selector": "SA",
+ "configuration": None,
+ }
+ )
+ assert q == UpkQuestion.model_validate(q.model_dump(mode="json"))
+
+ q = UpkQuestion.model_validate(
+ {
+ "id": "601377a0d4c74529afc6293a8e5c3b5e",
+ "ext_question_id": "m:2342",
+ "country_iso": "us",
+ "language_iso": "eng",
+ "text": "whats up",
+ "choices": [
+ {"id": "1", "text": "yes", "order": 1},
+ {"id": "2", "text": "no", "order": 2},
+ ],
+ "importance": None,
+ "question_type": "MC",
+ "selector": "MA",
+ "configuration": {"max_select": 2},
+ }
+ )
+ assert q == UpkQuestion.model_validate(q.model_dump(mode="json"))
+
+ def test_from_morning(self):
+ from generalresearch.models.morning.question import (
+ MorningQuestion,
+ MorningQuestionType,
+ )
+
+ q = MorningQuestion(
+ **{
+ "id": "gender",
+ "country_iso": "us",
+ "language_iso": "eng",
+ "name": "Gender",
+ "text": "What is your gender?",
+ "type": "s",
+ "options": [
+ {"id": "1", "text": "yes", "order": 1},
+ {"id": "2", "text": "no", "order": 2},
+ ],
+ }
+ )
+ q.to_upk_question()
+ q = MorningQuestion(
+ country_iso="us",
+ language_iso="eng",
+ type=MorningQuestionType.text_entry,
+ text="how old r u",
+ id="a",
+ name="age",
+ )
+ q.to_upk_question()
+
+ def test_order(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestionChoice,
+ UpkQuestionSelectorMC,
+ UpkQuestionType,
+ UpkQuestion,
+ order_exclusive_options,
+ )
+
+ q = UpkQuestion(
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.MULTIPLE_CHOICE,
+ selector=UpkQuestionSelectorMC.MULTIPLE_ANSWER,
+ text="yes, no, or NA?",
+ choices=[
+ UpkQuestionChoice(id="1", text="NA", order=0),
+ UpkQuestionChoice(id="2", text="no", order=1),
+ UpkQuestionChoice(id="3", text="yes", order=2),
+ ],
+ )
+ order_exclusive_options(q)
+ assert (
+ UpkQuestion(
+ country_iso="us",
+ language_iso="eng",
+ type=UpkQuestionType.MULTIPLE_CHOICE,
+ selector=UpkQuestionSelectorMC.MULTIPLE_ANSWER,
+ text="yes, no, or NA?",
+ choices=[
+ UpkQuestionChoice(id="2", text="no", order=0),
+ UpkQuestionChoice(id="3", text="yes", order=1),
+ UpkQuestionChoice(id="1", text="NA", order=2, exclusive=True),
+ ],
+ )
+ == q
+ )
+
+
+class TestUpkQuestionValidateAnswer:
+ def test_validate_answer_SA(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestion,
+ )
+
+ question = UpkQuestion.model_validate(
+ {
+ "choices": [
+ {"order": 0, "choice_id": "0", "choice_text": "Male"},
+ {"order": 1, "choice_id": "1", "choice_text": "Female"},
+ {"order": 2, "choice_id": "2", "choice_text": "Other"},
+ ],
+ "selector": "SA",
+ "country_iso": "us",
+ "question_id": "5d6d9f3c03bb40bf9d0a24f306387d7c",
+ "language_iso": "eng",
+ "question_text": "What is your gender?",
+ "question_type": "MC",
+ }
+ )
+ answer = ("0",)
+ assert question.validate_question_answer(answer)[0] is True
+ answer = ("3",)
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Invalid Options Selected",
+ )
+ answer = ("0", "0")
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Multiple of the same answer submitted",
+ )
+ answer = ("0", "1")
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Single Answer MC question with >1 selected " "answers",
+ )
+
+ def test_validate_answer_MA(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestion,
+ )
+
+ question = UpkQuestion.model_validate(
+ {
+ "choices": [
+ {
+ "order": 0,
+ "choice_id": "none",
+ "exclusive": True,
+ "choice_text": "None of the above",
+ },
+ {
+ "order": 1,
+ "choice_id": "female_under_1",
+ "choice_text": "Female under age 1",
+ },
+ {
+ "order": 2,
+ "choice_id": "male_under_1",
+ "choice_text": "Male under age 1",
+ },
+ {
+ "order": 3,
+ "choice_id": "female_1",
+ "choice_text": "Female age 1",
+ },
+ {"order": 4, "choice_id": "male_1", "choice_text": "Male age 1"},
+ {
+ "order": 5,
+ "choice_id": "female_2",
+ "choice_text": "Female age 2",
+ },
+ ],
+ # I removed a bunch of choices fyi
+ "selector": "MA",
+ "country_iso": "us",
+ "question_id": "3b65220db85f442ca16bb0f1c0e3a456",
+ "language_iso": "eng",
+ "question_text": "Please indicate the age and gender of your child or children:",
+ "question_type": "MC",
+ }
+ )
+ answer = ("none",)
+ assert question.validate_question_answer(answer)[0] is True
+ answer = ("male_1",)
+ assert question.validate_question_answer(answer)[0] is True
+ answer = ("male_1", "female_1")
+ assert question.validate_question_answer(answer)[0] is True
+ answer = ("xxx",)
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Invalid Options Selected",
+ )
+ answer = ("male_1", "male_1")
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Multiple of the same answer submitted",
+ )
+ answer = ("male_1", "xxx")
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Invalid Options Selected",
+ )
+ answer = ("male_1", "none")
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Invalid exclusive selection",
+ )
+
+ def test_validate_answer_TE(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestion,
+ )
+
+ question = UpkQuestion.model_validate(
+ {
+ "selector": "SL",
+ "validation": {
+ "patterns": [
+ {
+ "message": "Must enter a valid zip code: XXXXX",
+ "pattern": "^[0-9]{5}$",
+ }
+ ]
+ },
+ "country_iso": "us",
+ "question_id": "543de254e9ca4d9faded4377edab82a9",
+ "language_iso": "eng",
+ "configuration": {"max_length": 5, "min_length": 5},
+ "question_text": "What is your zip code?",
+ "question_type": "TE",
+ }
+ )
+ answer = ("33143",)
+ assert question.validate_question_answer(answer)[0] is True
+ answer = ("33143", "33143")
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Multiple of the same answer submitted",
+ )
+ answer = ("33143", "12345")
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Only one answer allowed",
+ )
+ answer = ("111",)
+ assert question.validate_question_answer(answer) == (
+ False,
+ "Must enter a valid zip code: XXXXX",
+ )
diff --git a/tests/models/thl/test_user.py b/tests/models/thl/test_user.py
new file mode 100644
index 0000000..4f10861
--- /dev/null
+++ b/tests/models/thl/test_user.py
@@ -0,0 +1,688 @@
+import json
+from datetime import datetime, timezone, timedelta
+from decimal import Decimal
+from random import randint, choice as rand_choice
+from uuid import uuid4
+
+import pytest
+from pydantic import ValidationError
+
+
+class TestUserUserID:
+
+ def test_valid(self):
+ from generalresearch.models.thl.user import User
+
+ val = randint(1, 2**30)
+ user = User(user_id=val)
+ assert user.user_id == val
+
+ def test_type(self):
+ from generalresearch.models.thl.user import User
+
+ # It will cast str to int
+ assert User(user_id="1").user_id == 1
+
+ # It will cast float to int
+ assert User(user_id=1.0).user_id == 1
+
+ # It will cast Decimal to int
+ assert User(user_id=Decimal("1.0")).user_id == 1
+
+ # pydantic Validation error is a ValueError, let's check both..
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=Decimal("1.00000001"))
+ assert "1 validation error for User" in str(cm.value)
+ assert "user_id" in str(cm.value)
+ assert "Input should be a valid integer," in str(cm.value)
+
+ with pytest.raises(expected_exception=ValidationError) as cm:
+ User(user_id=Decimal("1.00000001"))
+ assert "1 validation error for User" in str(cm.value)
+ assert "user_id" in str(cm.value)
+ assert "Input should be a valid integer," in str(cm.value)
+
+ def test_zero(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(expected_exception=ValidationError) as cm:
+ User(user_id=0)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be greater than 0" in str(cm.value)
+
+ def test_negative(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(expected_exception=ValidationError) as cm:
+ User(user_id=-1)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be greater than 0" in str(cm.value)
+
+ def test_too_big(self):
+ from generalresearch.models.thl.user import User
+
+ val = 2**31
+ with pytest.raises(expected_exception=ValidationError) as cm:
+ User(user_id=val)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be less than 2147483648" in str(cm.value)
+
+ def test_identifiable(self):
+ from generalresearch.models.thl.user import User
+
+ val = randint(1, 2**30)
+ user = User(user_id=val)
+ assert user.is_identifiable
+
+
+class TestUserProductID:
+ user_id = randint(1, 2**30)
+
+ def test_valid(self):
+ from generalresearch.models.thl.user import User
+
+ product_id = uuid4().hex
+
+ user = User(user_id=self.user_id, product_id=product_id)
+ assert user.user_id == self.user_id
+ assert user.product_id == product_id
+
+ def test_type(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_id=0)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_id=0.0)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_id=Decimal("0"))
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ def test_empty(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_id="")
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at least 32 characters" in str(cm.value)
+
+ def test_invalid_len(self):
+ from generalresearch.models.thl.user import User
+
+ # Valid uuid4s are 32 char long
+ product_id = uuid4().hex[:31]
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_id=product_id)
+ assert "1 validation error for User", str(cm.value)
+ assert "String should have at least 32 characters", str(cm.value)
+
+ product_id = uuid4().hex * 2
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, product_id=product_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at most 32 characters" in str(cm.value)
+
+ product_id = uuid4().hex
+ product_id *= 2
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_id=product_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at most 32 characters" in str(cm.value)
+
+ def test_invalid_uuid(self):
+ from generalresearch.models.thl.user import User
+
+ # Modify the UUID to break it
+ product_id = uuid4().hex[:31] + "x"
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_id=product_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Invalid UUID" in str(cm.value)
+
+ def test_invalid_hex_form(self):
+ from generalresearch.models.thl.user import User
+
+ # Sure not in hex form, but it'll get caught for being the
+ # wrong length before anything else
+ product_id = str(uuid4()) # '1a93447e-c77b-4cfa-b58e-ed4777d57110'
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_id=product_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at most 32 characters" in str(cm.value)
+
+ def test_identifiable(self):
+ """Can't create a User with only a product_id because it also
+ needs to the product_user_id"""
+ from generalresearch.models.thl.user import User
+
+ product_id = uuid4().hex
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(product_id=product_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Value error, User is not identifiable" in str(cm.value)
+
+
+class TestUserProductUserID:
+ user_id = randint(1, 2**30)
+
+ def randomword(self, length: int = 50):
+ # Raw so nothing is escaped to add additional backslashes
+ _bpuid_allowed = r"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%&()*+,-.:;<=>?@[]^_{|}~"
+ return "".join(rand_choice(_bpuid_allowed) for i in range(length))
+
+ def test_valid(self):
+ from generalresearch.models.thl.user import User
+
+ product_user_id = uuid4().hex[:12]
+ user = User(user_id=self.user_id, product_user_id=product_user_id)
+
+ assert user.user_id == self.user_id
+ assert user.product_user_id == product_user_id
+
+ def test_type(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=0)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=0.0)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=Decimal("0"))
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ def test_empty(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id="")
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at least 3 characters" in str(cm.value)
+
+ def test_invalid_len(self):
+ from generalresearch.models.thl.user import User
+
+ product_user_id = self.randomword(251)
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=product_user_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at most 128 characters" in str(cm.value)
+
+ product_user_id = self.randomword(2)
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=product_user_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at least 3 characters" in str(cm.value)
+
+ def test_invalid_chars_space(self):
+ from generalresearch.models.thl.user import User
+
+ product_user_id = f"{self.randomword(50)} {self.randomword(50)}"
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=product_user_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String cannot contain spaces" in str(cm.value)
+
+ def test_invalid_chars_slash(self):
+ from generalresearch.models.thl.user import User
+
+ product_user_id = f"{self.randomword(50)}\{self.randomword(50)}"
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=product_user_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String cannot contain backslash" in str(cm.value)
+
+ product_user_id = f"{self.randomword(50)}/{self.randomword(50)}"
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=product_user_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String cannot contain slash" in str(cm.value)
+
+ def test_invalid_chars_backtick(self):
+ """Yes I could keep doing these specific character checks. However,
+ I wanted a test that made sure the regex was hit. I do not know
+ how we want to provide with the level of specific String checks
+ we do in here for specific error messages."""
+ from generalresearch.models.thl.user import User
+
+ product_user_id = f"{self.randomword(50)}`{self.randomword(50)}"
+ with pytest.raises(expected_exception=ValueError) as cm:
+ User(user_id=self.user_id, product_user_id=product_user_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String is not valid regex" in str(cm.value)
+
+ def test_unique_from_product_id(self):
+ # We removed this filter b/c these users already exist. the manager checks for this
+ # though and we can't create new users like this
+ pass
+ # product_id = uuid4().hex
+ #
+ # with pytest.raises(ValueError) as cm:
+ # User(product_id=product_id, product_user_id=product_id)
+ # assert "1 validation error for User", str(cm.exception))
+ # assert "product_user_id must not equal the product_id", str(cm.exception))
+
+ def test_identifiable(self):
+ """Can't create a User with only a product_user_id because it also
+ needs to the product_id"""
+ from generalresearch.models.thl.user import User
+
+ product_user_id = uuid4().hex
+ with pytest.raises(ValueError) as cm:
+ User(product_user_id=product_user_id)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Value error, User is not identifiable" in str(cm.value)
+
+
+class TestUserUUID:
+ user_id = randint(1, 2**30)
+
+ def test_valid(self):
+ from generalresearch.models.thl.user import User
+
+ uuid_pk = uuid4().hex
+
+ user = User(user_id=self.user_id, uuid=uuid_pk)
+ assert user.user_id == self.user_id
+ assert user.uuid == uuid_pk
+
+ def test_type(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid=0)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid=0.0)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid=Decimal("0"))
+ assert "1 validation error for User", str(cm.value)
+ assert "Input should be a valid string" in str(cm.value)
+
+ def test_empty(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid="")
+ assert "1 validation error for User", str(cm.value)
+ assert "String should have at least 32 characters", str(cm.value)
+
+ def test_invalid_len(self):
+ from generalresearch.models.thl.user import User
+
+ # Valid uuid4s are 32 char long
+ uuid_pk = uuid4().hex[:31]
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid=uuid_pk)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at least 32 characters" in str(cm.value)
+
+ # Valid uuid4s are 32 char long
+ uuid_pk = uuid4().hex
+ uuid_pk *= 2
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid=uuid_pk)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at most 32 characters" in str(cm.value)
+
+ def test_invalid_uuid(self):
+ from generalresearch.models.thl.user import User
+
+ # Modify the UUID to break it
+ uuid_pk = uuid4().hex[:31] + "x"
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid=uuid_pk)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Invalid UUID" in str(cm.value)
+
+ def test_invalid_hex_form(self):
+ from generalresearch.models.thl.user import User
+
+ # Sure not in hex form, but it'll get caught for being the
+ # wrong length before anything else
+ uuid_pk = str(uuid4()) # '1a93447e-c77b-4cfa-b58e-ed4777d57110'
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid=uuid_pk)
+ assert "1 validation error for User" in str(cm.value)
+ assert "String should have at most 32 characters" in str(cm.value)
+
+ uuid_pk = str(uuid4())[:32] # '1a93447e-c77b-4cfa-b58e-ed4777d57110'
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, uuid=uuid_pk)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Invalid UUID" in str(cm.value)
+
+ def test_identifiable(self):
+ from generalresearch.models.thl.user import User
+
+ user_uuid = uuid4().hex
+ user = User(uuid=user_uuid)
+ assert user.is_identifiable
+
+
+class TestUserCreated:
+ user_id = randint(1, 2**30)
+
+ def test_valid(self):
+ from generalresearch.models.thl.user import User
+
+ user = User(user_id=self.user_id)
+ dt = datetime.now(tz=timezone.utc)
+ user.created = dt
+
+ assert user.created == dt
+
+ def test_tz_naive_throws_init(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, created=datetime.now(tz=None))
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should have timezone info" in str(cm.value)
+
+ def test_tz_naive_throws_setter(self):
+ from generalresearch.models.thl.user import User
+
+ user = User(user_id=self.user_id)
+ with pytest.raises(ValueError) as cm:
+ user.created = datetime.now(tz=None)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should have timezone info" in str(cm.value)
+
+ def test_tz_utc(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(ValueError) as cm:
+ User(
+ user_id=self.user_id,
+ created=datetime.now(tz=timezone(-timedelta(hours=8))),
+ )
+ assert "1 validation error for User" in str(cm.value)
+ assert "Timezone is not UTC" in str(cm.value)
+
+ def test_not_in_future(self):
+ from generalresearch.models.thl.user import User
+
+ the_future = datetime.now(tz=timezone.utc) + timedelta(minutes=1)
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, created=the_future)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input is in the future" in str(cm.value)
+
+ def test_after_anno_domini(self):
+ from generalresearch.models.thl.user import User
+
+ before_ad = datetime(
+ year=2015, month=1, day=1, tzinfo=timezone.utc
+ ) + timedelta(minutes=1)
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, created=before_ad)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input is before Anno Domini" in str(cm.value)
+
+
+class TestUserLastSeen:
+ user_id = randint(1, 2**30)
+
+ def test_valid(self):
+ from generalresearch.models.thl.user import User
+
+ user = User(user_id=self.user_id)
+ dt = datetime.now(tz=timezone.utc)
+ user.last_seen = dt
+
+ assert user.last_seen == dt
+
+ def test_tz_naive_throws_init(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, last_seen=datetime.now(tz=None))
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should have timezone info" in str(cm.value)
+
+ def test_tz_naive_throws_setter(self):
+ from generalresearch.models.thl.user import User
+
+ user = User(user_id=self.user_id)
+ with pytest.raises(ValueError) as cm:
+ user.last_seen = datetime.now(tz=None)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should have timezone info" in str(cm.value)
+
+ def test_tz_utc(self):
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(ValueError) as cm:
+ User(
+ user_id=self.user_id,
+ last_seen=datetime.now(tz=timezone(-timedelta(hours=8))),
+ )
+ assert "1 validation error for User" in str(cm.value)
+ assert "Timezone is not UTC" in str(cm.value)
+
+ def test_not_in_future(self):
+ from generalresearch.models.thl.user import User
+
+ the_future = datetime.now(tz=timezone.utc) + timedelta(minutes=1)
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, last_seen=the_future)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input is in the future" in str(cm.value)
+
+ def test_after_anno_domini(self):
+ from generalresearch.models.thl.user import User
+
+ before_ad = datetime(
+ year=2015, month=1, day=1, tzinfo=timezone.utc
+ ) + timedelta(minutes=1)
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, last_seen=before_ad)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input is before Anno Domini" in str(cm.value)
+
+
+class TestUserBlocked:
+ user_id = randint(1, 2**30)
+
+ def test_valid(self):
+ from generalresearch.models.thl.user import User
+
+ user = User(user_id=self.user_id, blocked=True)
+ assert user.blocked
+
+ def test_str_casting(self):
+ """We don't want any of these to work, and that's why
+ we set strict=True on the column"""
+ from generalresearch.models.thl.user import User
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, blocked="true")
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid boolean" in str(cm.value)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, blocked="True")
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid boolean" in str(cm.value)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, blocked="1")
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid boolean" in str(cm.value)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, blocked="yes")
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid boolean" in str(cm.value)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, blocked="no")
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid boolean" in str(cm.value)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, blocked=uuid4().hex)
+ assert "1 validation error for User" in str(cm.value)
+ assert "Input should be a valid boolean" in str(cm.value)
+
+
+class TestUserTiming:
+ user_id = randint(1, 2**30)
+
+ def test_valid(self):
+ from generalresearch.models.thl.user import User
+
+ created = datetime.now(tz=timezone.utc) - timedelta(minutes=60)
+ last_seen = datetime.now(tz=timezone.utc) - timedelta(minutes=59)
+
+ user = User(user_id=self.user_id, created=created, last_seen=last_seen)
+ assert user.created == created
+ assert user.last_seen == last_seen
+
+ def test_created_first(self):
+ from generalresearch.models.thl.user import User
+
+ created = datetime.now(tz=timezone.utc) - timedelta(minutes=60)
+ last_seen = datetime.now(tz=timezone.utc) - timedelta(minutes=59)
+
+ with pytest.raises(ValueError) as cm:
+ User(user_id=self.user_id, created=last_seen, last_seen=created)
+ assert "1 validation error for User" in str(cm.value)
+ assert "User created time invalid" in str(cm.value)
+
+
+class TestUserModelVerification:
+ """Tests that may be dependent on more than 1 attribute"""
+
+ def test_identifiable(self):
+ from generalresearch.models.thl.user import User
+
+ product_id = uuid4().hex
+ product_user_id = uuid4().hex
+ user = User(product_id=product_id, product_user_id=product_user_id)
+ assert user.is_identifiable
+
+ def test_valid_helper(self):
+ from generalresearch.models.thl.user import User
+
+ user_bool = User.is_valid_ubp(
+ product_id=uuid4().hex, product_user_id=uuid4().hex
+ )
+ assert user_bool
+
+ user_bool = User.is_valid_ubp(product_id=uuid4().hex, product_user_id=" - - - ")
+ assert not user_bool
+
+
+class TestUserSerialization:
+
+ def test_basic_json(self):
+ from generalresearch.models.thl.user import User
+
+ product_id = uuid4().hex
+ product_user_id = uuid4().hex
+
+ user = User(
+ product_id=product_id,
+ product_user_id=product_user_id,
+ created=datetime.now(tz=timezone.utc),
+ blocked=False,
+ )
+
+ d = json.loads(user.to_json())
+ assert d.get("product_id") == product_id
+ assert d.get("product_user_id") == product_user_id
+ assert not d.get("blocked")
+
+ assert d.get("product") is None
+ assert d.get("created").endswith("Z")
+
+ def test_basic_dict(self):
+ from generalresearch.models.thl.user import User
+
+ product_id = uuid4().hex
+ product_user_id = uuid4().hex
+
+ user = User(
+ product_id=product_id,
+ product_user_id=product_user_id,
+ created=datetime.now(tz=timezone.utc),
+ blocked=False,
+ )
+
+ d = user.to_dict()
+ assert d.get("product_id") == product_id
+ assert d.get("product_user_id") == product_user_id
+ assert not d.get("blocked")
+
+ assert d.get("product") is None
+ assert d.get("created").tzinfo == timezone.utc
+
+ def test_from_json(self):
+ from generalresearch.models.thl.user import User
+
+ product_id = uuid4().hex
+ product_user_id = uuid4().hex
+
+ user = User(
+ product_id=product_id,
+ product_user_id=product_user_id,
+ created=datetime.now(tz=timezone.utc),
+ blocked=False,
+ )
+
+ u = User.model_validate_json(user.to_json())
+ assert u.product_id == product_id
+ assert u.product is None
+ assert u.created.tzinfo == timezone.utc
+
+
+class TestUserMethods:
+
+ def test_audit_log(self, user, audit_log_manager):
+ assert user.audit_log is None
+ user.prefetch_audit_log(audit_log_manager=audit_log_manager)
+ assert user.audit_log == []
+
+ audit_log_manager.create_dummy(user_id=user.user_id)
+ user.prefetch_audit_log(audit_log_manager=audit_log_manager)
+ assert len(user.audit_log) == 1
+
+ def test_transactions(
+ self, user_factory, thl_lm, session_with_tx_factory, product_user_wallet_yes
+ ):
+ u1 = user_factory(product=product_user_wallet_yes)
+
+ assert u1.transactions is None
+ u1.prefetch_transactions(thl_lm=thl_lm)
+ assert u1.transactions == []
+
+ session_with_tx_factory(user=u1)
+
+ u1.prefetch_transactions(thl_lm=thl_lm)
+ assert len(u1.transactions) == 1
+
+ @pytest.mark.skip(reason="TODO")
+ def test_location_history(self, user):
+ assert user.location_history is None
diff --git a/tests/models/thl/test_user_iphistory.py b/tests/models/thl/test_user_iphistory.py
new file mode 100644
index 0000000..46018e0
--- /dev/null
+++ b/tests/models/thl/test_user_iphistory.py
@@ -0,0 +1,45 @@
+from datetime import timezone, datetime, timedelta
+
+from generalresearch.models.thl.user_iphistory import (
+ UserIPHistory,
+ UserIPRecord,
+)
+
+
+def test_collapse_ip_records():
+ # This does not exist in a db, so we do not need fixtures/ real user ids, whatever
+ now = datetime.now(tz=timezone.utc) - timedelta(days=1)
+ # Gets stored most recent first. This is reversed, but the validator will order it
+ records = [
+ UserIPRecord(ip="1.2.3.5", created=now + timedelta(minutes=1)),
+ UserIPRecord(
+ ip="1e5c:de49:165a:6aa0:4f89:1433:9af7:aaaa",
+ created=now + timedelta(minutes=2),
+ ),
+ UserIPRecord(
+ ip="1e5c:de49:165a:6aa0:4f89:1433:9af7:bbbb",
+ created=now + timedelta(minutes=3),
+ ),
+ UserIPRecord(ip="1.2.3.5", created=now + timedelta(minutes=4)),
+ UserIPRecord(
+ ip="1e5c:de49:165a:6aa0:4f89:1433:9af7:cccc",
+ created=now + timedelta(minutes=5),
+ ),
+ UserIPRecord(
+ ip="6666:de49:165a:6aa0:4f89:1433:9af7:aaaa",
+ created=now + timedelta(minutes=6),
+ ),
+ UserIPRecord(ip="1.2.3.6", created=now + timedelta(minutes=7)),
+ ]
+ iph = UserIPHistory(user_id=1, ips=records)
+ res = iph.collapse_ip_records()
+
+ # We should be left with one of the 1.2.3.5 ipv4s,
+ # and only the 1e5c::cccc and the 6666 ipv6 addresses
+ assert len(res) == 4
+ assert [x.ip for x in res] == [
+ "1.2.3.6",
+ "6666:de49:165a:6aa0:4f89:1433:9af7:aaaa",
+ "1e5c:de49:165a:6aa0:4f89:1433:9af7:cccc",
+ "1.2.3.5",
+ ]
diff --git a/tests/models/thl/test_user_metadata.py b/tests/models/thl/test_user_metadata.py
new file mode 100644
index 0000000..3d851dc
--- /dev/null
+++ b/tests/models/thl/test_user_metadata.py
@@ -0,0 +1,46 @@
+import pytest
+
+from generalresearch.models import MAX_INT32
+from generalresearch.models.thl.user_profile import UserMetadata
+
+
+class TestUserMetadata:
+
+ def test_default(self):
+ # You can initialize it with nothing
+ um = UserMetadata()
+ assert um.email_address is None
+ assert um.email_sha1 is None
+
+ def test_user_id(self):
+ # This does NOT validate that the user_id exists. When we attempt a db operation,
+ # at that point it will fail b/c of the foreign key constraint.
+ UserMetadata(user_id=MAX_INT32 - 1)
+
+ with pytest.raises(expected_exception=ValueError) as cm:
+ UserMetadata(user_id=MAX_INT32)
+ assert "Input should be less than 2147483648" in str(cm.value)
+
+ def test_email(self):
+ um = UserMetadata(email_address="e58375d80f5f4a958138004aae44c7ca@example.com")
+ assert (
+ um.email_sha256
+ == "fd219d8b972b3d82e70dc83284027acc7b4a6de66c42261c1684e3f05b545bc0"
+ )
+ assert um.email_sha1 == "a82578f02b0eed28addeb81317417cf239ede1c3"
+ assert um.email_md5 == "9073a7a3c21cfd6160d1899fb736cd1c"
+
+ # You cannot set the hashes directly
+ with pytest.raises(expected_exception=AttributeError) as cm:
+ um.email_md5 = "x" * 32
+ # assert "can't set attribute 'email_md5'" in str(cm.value)
+ assert "property 'email_md5' of 'UserMetadata' object has no setter" in str(
+ cm.value
+ )
+
+ # assert it hasn't changed anything
+ assert um.email_md5 == "9073a7a3c21cfd6160d1899fb736cd1c"
+
+ # If you update the email, all the hashes change
+ um.email_address = "greg@example.com"
+ assert um.email_md5 != "9073a7a3c21cfd6160d1899fb736cd1c"
diff --git a/tests/models/thl/test_user_streak.py b/tests/models/thl/test_user_streak.py
new file mode 100644
index 0000000..72efd05
--- /dev/null
+++ b/tests/models/thl/test_user_streak.py
@@ -0,0 +1,96 @@
+from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
+
+import pytest
+from pydantic import ValidationError
+
+from generalresearch.models.thl.user_streak import (
+ UserStreak,
+ StreakPeriod,
+ StreakFulfillment,
+ StreakState,
+)
+
+
+def test_user_streak_empty_fail():
+ us = UserStreak(
+ period=StreakPeriod.DAY,
+ fulfillment=StreakFulfillment.COMPLETE,
+ country_iso="us",
+ user_id=1,
+ last_fulfilled_period_start=None,
+ current_streak=0,
+ longest_streak=0,
+ state=StreakState.BROKEN,
+ )
+ assert us.time_remaining_in_period is None
+
+ with pytest.raises(
+ ValidationError, match="StreakState.BROKEN but current_streak not 0"
+ ):
+ UserStreak(
+ period=StreakPeriod.DAY,
+ fulfillment=StreakFulfillment.COMPLETE,
+ country_iso="us",
+ user_id=1,
+ last_fulfilled_period_start=None,
+ current_streak=1,
+ longest_streak=0,
+ state=StreakState.BROKEN,
+ )
+
+ with pytest.raises(
+ ValidationError, match="Current streak can't be longer than longest streak"
+ ):
+ UserStreak(
+ period=StreakPeriod.DAY,
+ fulfillment=StreakFulfillment.COMPLETE,
+ country_iso="us",
+ user_id=1,
+ last_fulfilled_period_start=None,
+ current_streak=1,
+ longest_streak=0,
+ state=StreakState.ACTIVE,
+ )
+
+
+def test_user_streak_remaining():
+ us = UserStreak(
+ period=StreakPeriod.DAY,
+ fulfillment=StreakFulfillment.COMPLETE,
+ country_iso="us",
+ user_id=1,
+ last_fulfilled_period_start=None,
+ current_streak=1,
+ longest_streak=1,
+ state=StreakState.AT_RISK,
+ )
+ now = datetime.now(tz=ZoneInfo("America/New_York"))
+ end_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(
+ days=1
+ )
+ print(f"{now.isoformat()=}, {end_of_today.isoformat()=}")
+ expected = (end_of_today - now).total_seconds()
+ assert us.time_remaining_in_period.total_seconds() == pytest.approx(expected, abs=1)
+
+
+def test_user_streak_remaining_month():
+ us = UserStreak(
+ period=StreakPeriod.MONTH,
+ fulfillment=StreakFulfillment.COMPLETE,
+ country_iso="us",
+ user_id=1,
+ last_fulfilled_period_start=None,
+ current_streak=1,
+ longest_streak=1,
+ state=StreakState.AT_RISK,
+ )
+ now = datetime.now(tz=ZoneInfo("America/New_York"))
+ end_of_month = (
+ now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ + timedelta(days=32)
+ ).replace(day=1)
+ print(f"{now.isoformat()=}, {end_of_month.isoformat()=}")
+ expected = (end_of_month - now).total_seconds()
+ assert us.time_remaining_in_period.total_seconds() == pytest.approx(expected, abs=1)
+ print(us.time_remaining_in_period)
diff --git a/tests/models/thl/test_wall.py b/tests/models/thl/test_wall.py
new file mode 100644
index 0000000..057aad2
--- /dev/null
+++ b/tests/models/thl/test_wall.py
@@ -0,0 +1,207 @@
+from datetime import datetime, timezone, timedelta
+from decimal import Decimal
+from uuid import uuid4
+
+import pytest
+from pydantic import ValidationError
+
+from generalresearch.models import Source
+from generalresearch.models.thl.definitions import (
+ Status,
+ StatusCode1,
+ WallStatusCode2,
+)
+from generalresearch.models.thl.session import Wall
+
+
+class TestWall:
+
+ def test_wall_json(self):
+ w = Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ ext_status_code_1="1.0",
+ status=Status.FAIL,
+ status_code_1=StatusCode1.BUYER_FAIL,
+ started=datetime(2023, 1, 1, 0, 0, 1, tzinfo=timezone.utc),
+ finished=datetime(2023, 1, 1, 0, 10, 1, tzinfo=timezone.utc),
+ )
+ s = w.to_json()
+ w2 = Wall.from_json(s)
+ assert w == w2
+
+ def test_status_status_code_agreement(self):
+ # should not raise anything
+ Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ status=Status.FAIL,
+ status_code_1=StatusCode1.BUYER_FAIL,
+ started=datetime.now(timezone.utc),
+ finished=datetime.now(timezone.utc) + timedelta(seconds=1),
+ )
+ Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ status=Status.FAIL,
+ status_code_1=StatusCode1.MARKETPLACE_FAIL,
+ status_code_2=WallStatusCode2.COMPLETE_TOO_FAST,
+ started=datetime.now(timezone.utc),
+ finished=datetime.now(timezone.utc) + timedelta(seconds=1),
+ )
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ status=Status.FAIL,
+ status_code_1=StatusCode1.GRS_ABANDON,
+ started=datetime.now(timezone.utc),
+ finished=datetime.now(timezone.utc) + timedelta(seconds=1),
+ )
+ assert "If status is f, status_code_1 should be in" in str(e.value)
+
+ with pytest.raises(expected_exception=ValidationError) as cm:
+ Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ status=Status.FAIL,
+ status_code_1=StatusCode1.GRS_ABANDON,
+ status_code_2=WallStatusCode2.COMPLETE_TOO_FAST,
+ started=datetime.now(timezone.utc),
+ finished=datetime.now(timezone.utc) + timedelta(seconds=1),
+ )
+ assert "If status is f, status_code_1 should be in" in str(e.value)
+
+ def test_status_code_1_2_agreement(self):
+ # should not raise anything
+ Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ status=Status.FAIL,
+ status_code_1=StatusCode1.MARKETPLACE_FAIL,
+ status_code_2=WallStatusCode2.COMPLETE_TOO_FAST,
+ started=datetime.now(timezone.utc),
+ finished=datetime.now(timezone.utc) + timedelta(seconds=1),
+ )
+ Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ status=Status.FAIL,
+ status_code_1=StatusCode1.BUYER_FAIL,
+ status_code_2=None,
+ started=datetime.now(timezone.utc),
+ finished=datetime.now(timezone.utc) + timedelta(seconds=1),
+ )
+ Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ status=Status.COMPLETE,
+ status_code_1=StatusCode1.COMPLETE,
+ status_code_2=None,
+ started=datetime.now(timezone.utc),
+ finished=datetime.now(timezone.utc) + timedelta(seconds=1),
+ )
+
+ with pytest.raises(expected_exception=ValidationError) as e:
+ Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ status=Status.FAIL,
+ status_code_1=StatusCode1.BUYER_FAIL,
+ status_code_2=WallStatusCode2.COMPLETE_TOO_FAST,
+ started=datetime.now(timezone.utc),
+ finished=datetime.now(timezone.utc) + timedelta(seconds=1),
+ )
+ assert "If status_code_1 is 1, status_code_2 should be in" in str(e.value)
+
+ def test_annotate_status_code(self):
+ w = Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ )
+ w.annotate_status_codes("1.0")
+ assert Status.COMPLETE == w.status
+ assert StatusCode1.COMPLETE == w.status_code_1
+ assert w.status_code_2 is None
+ assert "1.0" == w.ext_status_code_1
+ assert w.ext_status_code_2 is None
+
+ def test_buyer_too_long(self):
+ buyer_id = uuid4().hex
+ w = Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ buyer_id=buyer_id,
+ )
+ assert buyer_id == w.buyer_id
+
+ w = Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ buyer_id=None,
+ )
+ assert w.buyer_id is None
+
+ w = Wall(
+ user_id=1,
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ survey_id="yyy",
+ buyer_id=buyer_id + "abc123",
+ )
+ assert buyer_id == w.buyer_id
+
+ @pytest.mark.skip(reason="TODO")
+ def test_more_stuff(self):
+ # todo: .update, test status logic
+ pass
diff --git a/tests/models/thl/test_wall_session.py b/tests/models/thl/test_wall_session.py
new file mode 100644
index 0000000..ab140e9
--- /dev/null
+++ b/tests/models/thl/test_wall_session.py
@@ -0,0 +1,326 @@
+from datetime import datetime, timezone, timedelta
+from decimal import Decimal
+
+import pytest
+
+from generalresearch.models import Source
+from generalresearch.models.thl.definitions import Status, StatusCode1
+from generalresearch.models.thl.session import Session, Wall
+from generalresearch.models.thl.user import User
+
+
+class TestWallSession:
+
+ def test_session_with_no_wall_events(self):
+ started = datetime(2023, 1, 1, tzinfo=timezone.utc)
+ s = Session(user=User(user_id=1), started=started)
+ assert s.status is None
+ assert s.status_code_1 is None
+
+ # todo: this needs to be set explicitly, not this way
+ # # If I have no wall events, it's a fail
+ # s.determine_session_status()
+ # assert s.status == Status.FAIL
+ # assert s.status_code_1 == StatusCode1.SESSION_START_FAIL
+
+ def test_session_timeout_with_only_grs(self):
+ started = datetime(2023, 1, 1, tzinfo=timezone.utc)
+ s = Session(user=User(user_id=1), started=started)
+ w = Wall(
+ user_id=1,
+ source=Source.GRS,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ )
+ s.append_wall_event(w)
+ status, status_code_1 = s.determine_session_status()
+ s.update(status=status, status_code_1=status_code_1)
+ assert Status.TIMEOUT == s.status
+ assert StatusCode1.GRS_ABANDON == s.status_code_1
+
+ def test_session_with_only_grs_fail(self):
+ # todo: this needs to be set explicitly, not this way
+ pass
+ # started = datetime(2023, 1, 1, tzinfo=timezone.utc)
+ # s = Session(user=User(user_id=1), started=started)
+ # w = Wall(user_id=1, source=Source.GRS, req_survey_id='xxx',
+ # req_cpi=Decimal(1), session_id=1)
+ # s.append_wall_event(w)
+ # w.finish(status=Status.FAIL, status_code_1=StatusCode1.PS_FAIL)
+ # s.determine_session_status()
+ # assert s.status == Status.FAIL
+ # assert s.status_code_1 == StatusCode1.GRS_FAIL
+
+ def test_session_with_only_grs_complete(self):
+ started = datetime(year=2023, month=1, day=1, tzinfo=timezone.utc)
+
+ # A Session is started
+ s = Session(user=User(user_id=1), started=started)
+
+ # The User goes into a GRS survey, and completes it
+ # @gstupp - should a GRS be allowed with a req_cpi > 0?
+ w = Wall(
+ user_id=1,
+ source=Source.GRS,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ )
+ s.append_wall_event(w)
+ w.finish(status=Status.COMPLETE, status_code_1=StatusCode1.COMPLETE)
+
+ status, status_code_1 = s.determine_session_status()
+ s.update(status=status, status_code_1=status_code_1)
+
+ assert s.status == Status.FAIL
+
+ # @gstupp changed this behavior on 11/2023 (51471b6ae671f21212a8b1fad60b508181cbb8ca)
+ # I don't know which is preferred or the consequences of each. However,
+ # now it's a SESSION_CONTINUE_FAIL instead of a SESSION_START_FAIL so
+ # change this so the test passes
+ # self.assertEqual(s.status_code_1, StatusCode1.SESSION_START_FAIL)
+ assert s.status_code_1 == StatusCode1.SESSION_CONTINUE_FAIL
+
+ @pytest.mark.skip(reason="TODO")
+ def test_session_with_only_non_grs_complete(self):
+ # todo: this needs to be set explicitly, not this way
+ pass
+ # # This fails... until payout stuff is done
+ # started = datetime(2023, 1, 1, tzinfo=timezone.utc)
+ # s = Session(user=User(user_id=1), started=started)
+ # w = Wall(source=Source.DYNATA, req_survey_id='xxx', req_cpi=Decimal('1.00001'),
+ # session_id=1, user_id=1)
+ # s.append_wall_event(w)
+ # w.finish(status=Status.COMPLETE, status_code_1=StatusCode1.COMPLETE)
+ # s.determine_session_status()
+ # assert s.status == Status.COMPLETE
+ # assert s.status_code_1 is None
+
+ def test_session_with_only_non_grs_fail(self):
+ started = datetime(year=2023, month=1, day=1, tzinfo=timezone.utc)
+
+ s = Session(user=User(user_id=1), started=started)
+ w = Wall(
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal("1.00001"),
+ session_id=1,
+ user_id=1,
+ )
+
+ s.append_wall_event(w)
+ w.finish(status=Status.FAIL, status_code_1=StatusCode1.BUYER_FAIL)
+ status, status_code_1 = s.determine_session_status()
+ s.update(status=status, status_code_1=status_code_1)
+
+ assert s.status == Status.FAIL
+ assert s.status_code_1 == StatusCode1.BUYER_FAIL
+ assert s.payout is None
+
+ def test_session_with_only_non_grs_timeout(self):
+ started = datetime(year=2023, month=1, day=1, tzinfo=timezone.utc)
+
+ s = Session(user=User(user_id=1), started=started)
+ w = Wall(
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal("1.00001"),
+ session_id=1,
+ user_id=1,
+ )
+
+ s.append_wall_event(w)
+ status, status_code_1 = s.determine_session_status()
+ s.update(status=status, status_code_1=status_code_1)
+
+ assert s.status == Status.TIMEOUT
+ assert s.status_code_1 == StatusCode1.BUYER_ABANDON
+ assert s.payout is None
+
+ def test_session_with_grs_and_external(self):
+ started = datetime(year=2023, month=1, day=1, tzinfo=timezone.utc)
+
+ s = Session(user=User(user_id=1), started=started)
+ w = Wall(
+ source=Source.GRS,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ user_id=1,
+ started=started,
+ )
+
+ s.append_wall_event(w)
+ w.finish(
+ status=Status.COMPLETE,
+ status_code_1=StatusCode1.COMPLETE,
+ finished=started + timedelta(minutes=10),
+ )
+
+ w = Wall(
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal("1.00001"),
+ session_id=1,
+ user_id=1,
+ )
+ s.append_wall_event(w)
+ w.finish(
+ status=Status.ABANDON,
+ finished=datetime.now(tz=timezone.utc) + timedelta(minutes=10),
+ status_code_1=StatusCode1.BUYER_ABANDON,
+ )
+ status, status_code_1 = s.determine_session_status()
+ s.update(status=status, status_code_1=status_code_1)
+
+ assert s.status == Status.ABANDON
+ assert s.status_code_1 == StatusCode1.BUYER_ABANDON
+ assert s.payout is None
+
+ s = Session(user=User(user_id=1), started=started)
+ w = Wall(
+ source=Source.GRS,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ user_id=1,
+ )
+ s.append_wall_event(w)
+ w.finish(status=Status.COMPLETE, status_code_1=StatusCode1.COMPLETE)
+ w = Wall(
+ source=Source.DYNATA,
+ req_survey_id="xxx",
+ req_cpi=Decimal("1.00001"),
+ session_id=1,
+ user_id=1,
+ )
+ s.append_wall_event(w)
+ w.finish(status=Status.FAIL, status_code_1=StatusCode1.PS_DUPLICATE)
+
+ status, status_code_1 = s.determine_session_status()
+ s.update(status=status, status_code_1=status_code_1)
+
+ assert s.status == Status.FAIL
+ assert s.status_code_1 == StatusCode1.PS_DUPLICATE
+ assert s.payout is None
+
+ def test_session_marketplace_fail(self):
+ started = datetime(2023, 1, 1, tzinfo=timezone.utc)
+
+ s = Session(user=User(user_id=1), started=started)
+ w = Wall(
+ source=Source.CINT,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ user_id=1,
+ started=started,
+ )
+ s.append_wall_event(w)
+ w.finish(
+ status=Status.FAIL,
+ status_code_1=StatusCode1.MARKETPLACE_FAIL,
+ finished=started + timedelta(minutes=10),
+ )
+ status, status_code_1 = s.determine_session_status()
+ s.update(status=status, status_code_1=status_code_1)
+ assert Status.FAIL == s.status
+ assert StatusCode1.SESSION_CONTINUE_QUALITY_FAIL == s.status_code_1
+
+ def test_session_unknown(self):
+ started = datetime(2023, 1, 1, tzinfo=timezone.utc)
+
+ s = Session(user=User(user_id=1), started=started)
+ w = Wall(
+ source=Source.CINT,
+ req_survey_id="xxx",
+ req_cpi=Decimal(1),
+ session_id=1,
+ user_id=1,
+ started=started,
+ )
+ s.append_wall_event(w)
+ w.finish(
+ status=Status.FAIL,
+ status_code_1=StatusCode1.UNKNOWN,
+ finished=started + timedelta(minutes=10),
+ )
+ status, status_code_1 = s.determine_session_status()
+ s.update(status=status, status_code_1=status_code_1)
+ assert Status.FAIL == s.status
+ assert StatusCode1.BUYER_FAIL == s.status_code_1
+
+
+# class TestWallSessionPayout:
+# product_id = uuid4().hex
+#
+# def test_session_payout_with_only_non_grs_complete(self):
+# sql_helper = self.make_sql_helper()
+# user = User(user_id=1, product_id=self.product_id)
+# s = Session(user=user, started=datetime(2023, 1, 1, tzinfo=timezone.utc))
+# w = Wall(source=Source.DYNATA, req_survey_id='xxx', req_cpi=Decimal('1.00001'))
+# s.append_wall_event(w)
+# w.handle_callback(status=Status.COMPLETE)
+# s.determine_session_status()
+# s.determine_payout(sql_helper=sql_helper)
+# assert s.status == Status.COMPLETE
+# assert s.status_code_1 is None
+# # we're assuming here the commission on this BP is 8.5% and doesn't get changed by someone!
+# assert s.payout == Decimal('0.88')
+#
+# def test_session_payout(self):
+# sql_helper = self.make_sql_helper()
+# user = User(user_id=1, product_id=self.product_id)
+# s = Session(user=user, started=datetime(2023, 1, 1, tzinfo=timezone.utc))
+# w = Wall(source=Source.GRS, req_survey_id='xxx', req_cpi=1)
+# s.append_wall_event(w)
+# w.handle_callback(status=Status.COMPLETE)
+# w = Wall(source=Source.DYNATA, req_survey_id='xxx', req_cpi=Decimal('1.00001'))
+# s.append_wall_event(w)
+# w.handle_callback(status=Status.COMPLETE)
+# s.determine_session_status()
+# s.determine_payout(commission_pct=Decimal('0.05'))
+# assert s.status == Status.COMPLETE
+# assert s.status_code_1 is None
+# assert s.payout == Decimal('0.93')
+
+
+# def test_get_from_uuid_vendor_wall(self):
+# sql_helper = self.make_sql_helper()
+# sql_helper.get_or_create("auth_user", "id", {"id": 1}, {
+# "id": 1, "password": "1",
+# "last_login": None, "is_superuser": 0,
+# "username": "a", "first_name": "a",
+# "last_name": "a", "email": "a",
+# "is_staff": 0, "is_active": 1,
+# "date_joined": "2023-10-13 14:03:20.000000"})
+# sql_helper.get_or_create("vendor_wallsession", "id", {"id": 324}, {"id": 324})
+# sql_helper.create("vendor_wall", {
+# "id": "7b3e380babc840b79abf0030d408bbd9",
+# "status": "c",
+# "started": "2023-10-10 00:51:13.415444",
+# "finished": "2023-10-10 01:08:00.676947",
+# "req_loi": 1200,
+# "req_cpi": 0.63,
+# "req_survey_id": "8070750",
+# "survey_id": "8070750",
+# "cpi": 0.63,
+# "user_id": 1,
+# "report_notes": None,
+# "report_status": None,
+# "status_code": "1",
+# "req_survey_hashed_opp": None,
+# "session_id": 324,
+# "source": "i",
+# "ubp_id": None
+# })
+# Wall
+# w = Wall.get_from_uuid_vendor_wall('7b3e380babc840b79abf0030d408bbd9', sql_helper=sql_helper,
+# session_id=1)
+# assert w.status == Status.COMPLETE
+# assert w.source == Source.INNOVATE
+# assert w.uuid == '7b3e380babc840b79abf0030d408bbd9'
+# assert w.cpi == Decimal('0.63')
+# assert w.survey_id == '8070750'
+# assert w.user_id == 1