diff options
Diffstat (limited to 'tests/models/thl')
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 |
