aboutsummaryrefslogtreecommitdiff
path: root/tests/models/thl/test_contest
diff options
context:
space:
mode:
Diffstat (limited to 'tests/models/thl/test_contest')
-rw-r--r--tests/models/thl/test_contest/__init__.py0
-rw-r--r--tests/models/thl/test_contest/test_contest.py23
-rw-r--r--tests/models/thl/test_contest/test_leaderboard_contest.py213
-rw-r--r--tests/models/thl/test_contest/test_raffle_contest.py300
4 files changed, 536 insertions, 0 deletions
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)