diff --git a/polylan_submitter/loadtest/market_locustfile.py b/polylan_submitter/loadtest/market_locustfile.py new file mode 100644 index 0000000..b68b18d --- /dev/null +++ b/polylan_submitter/loadtest/market_locustfile.py @@ -0,0 +1,197 @@ +"""Locust load test for the market (betting) page. + +Simulates real bettors: log in, load the market page the way the frontend +does (user info, markets, own bets, own event points, then one bet-history +fetch per market since the charts are open by default), then keep placing +or increasing bets, refreshing the page, and occasionally reloading it. + +Bets follow the same rules as the real UI: one option per market, the +amount sent is the new total (so an increase re-sends the same option with +a bigger amount), and a user never bets more than their event points. A +400 the server is expected to give under load ("Insufficient points", +"Market is not open for betting", ...) counts as a success - it's the +business rule doing its job, not the server failing. + +Authentication uses local Django accounts (ModelBackend), never the real +CAS server at polylan.ch - see testing_auth_views.py. This ONLY works +against a target where LOAD_TEST_LOGIN_ENABLED=true; never point it at +production. + +Setup, on the target server: + export LOAD_TEST_LOGIN_ENABLED=true + In the Django admin: + - Accounts > Users > "Create load test users" (see accounts/admin.py) + creates/resets loadtest0..loadtest499. + - Market > Markets: create a few markets with status "Open" (and a few + options each) on the event under test. Without open markets the users + only browse. + +Run: + uv run locust -f loadtest/market_locustfile.py --host http://localhost:7777 + +Env vars: + MARKET_EVENT_PATH event slug under test (default: "loadtest") + LOAD_TEST_USER_COUNT must match accounts/admin.py's LOAD_TEST_USER_COUNT (default: 500) + LOAD_TEST_USER_PREFIX must match accounts/admin.py's LOAD_TEST_USER_PREFIX (default: "loadtest") + LOAD_TEST_PASSWORD must match accounts/admin.py's LOAD_TEST_PASSWORD (default: "loadtest-only-password") + MIN_BET_INCREMENT smallest amount added per bet (default: 10) + MAX_BET_INCREMENT largest amount added per bet (default: 50) +""" + +import os +import random + +from locust import HttpUser, between, task + +EVENT_PATH = os.environ.get("MARKET_EVENT_PATH", "loadtest") +USER_COUNT = int(os.environ.get("LOAD_TEST_USER_COUNT", "500")) +USER_PREFIX = os.environ.get("LOAD_TEST_USER_PREFIX", "loadtest") +PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "loadtest-only-password") +MIN_BET_INCREMENT = int(os.environ.get("MIN_BET_INCREMENT", "10")) +MAX_BET_INCREMENT = int(os.environ.get("MAX_BET_INCREMENT", "50")) + +API = f"/api/events/{EVENT_PATH}/market" + +# 400s the market API returns on purpose - concurrent bettors racing for +# the same points, or a market closed mid-test. Anything else is a failure. +EXPECTED_BET_REJECTIONS = ( + "Insufficient points", + "Market is not open", + "only bet on one option", + "Cannot decrease bet", +) + +_next_account_index = 0 + + +def _next_username() -> str: + """Round-robins through the pool of pre-created accounts (same scheme as + locustfile.py - kept separate so running this file doesn't also spawn + PlaceUser).""" + global _next_account_index + username = f"{USER_PREFIX}{_next_account_index % USER_COUNT}" + _next_account_index += 1 + return username + + +class MarketUser(HttpUser): + wait_time = between(2, 6) + + def on_start(self): + self.username = _next_username() + self.markets: list[dict] = [] + # market uuid -> {"option": option uuid, "amount": total} + self.bets: dict[str, dict] = {} + self.points = 0 + self._login() + self._load_page() + + def _login(self): + response = self.client.post( + "/testing/login/", + data={"username": self.username, "password": PASSWORD}, + name="/testing/login/", + ) + if response.status_code != 200: + raise RuntimeError( + f"login failed for {self.username}: {response.status_code} " + f"{response.text}. Is LOAD_TEST_LOGIN_ENABLED set on the " + "target, and did you run the 'Create load test users' admin " + "action (accounts/admin.py)?" + ) + + def _load_page(self): + """Full page load: HTML, then what useMarketStore.initializeMarketPage + fetches, then one history fetch per card (charts are shown by + default).""" + self.client.get(f"/{EVENT_PATH}/market", name="/[event]/market") + self.client.get("/api/user", name="/api/user") + self._fetch_markets() + self._fetch_bets() + self._fetch_points() + for market in self.markets: + self._fetch_history(market) + + def _fetch_markets(self) -> dict[str, int]: + """Returns each market's total pot, to tell which charts refetch.""" + response = self.client.get(f"{API}/", name="/api/events/[event]/market/") + if response.ok: + self.markets = response.json() + return { + market["uuid"]: sum(opt["total_bets"] for opt in market["options"]) + for market in self.markets + } + + def _fetch_bets(self): + response = self.client.get( + f"{API}/user/bets", name="/api/events/[event]/market/user/bets" + ) + if response.ok: + self.bets = { + bet["market"]["uuid"]: { + "option": bet["option"]["uuid"], + "amount": bet["amount"], + } + for bet in response.json() + if bet.get("market") + } + + def _fetch_points(self): + response = self.client.get( + f"{API}/user/points", name="/api/events/[event]/market/user/points" + ) + if response.ok: + self.points = response.json()["points"] + + def _fetch_history(self, market: dict): + self.client.get( + f"{API}/{market['uuid']}/history", + name="/api/events/[event]/market/[market]/history", + ) + + @task(5) + def place_bet(self): + open_markets = [m for m in self.markets if m["status"] == "open" and m["options"]] + if not open_markets or self.points < MIN_BET_INCREMENT: + return + + market = random.choice(open_markets) + existing = self.bets.get(market["uuid"]) + option = existing["option"] if existing else random.choice(market["options"])["uuid"] + increment = random.randint(MIN_BET_INCREMENT, min(MAX_BET_INCREMENT, self.points)) + amount = (existing["amount"] if existing else 0) + increment + + with self.client.post( + f"{API}/{market['uuid']}/bets", + json={"option_uuid": option, "amount": amount}, + name="/api/events/[event]/market/[market]/bets", + catch_response=True, + ) as response: + if response.status_code == 400 and any( + reason in response.text for reason in EXPECTED_BET_REJECTIONS + ): + response.success() + + # MarketCard.placeBet reloads bets + points after every attempt + self._fetch_bets() + self._fetch_points() + + @task(3) + def refresh(self): + """useMarketStore.refreshPage, plus the history refetch the charts do + whenever a market's pot changed.""" + previous_pots = { + market["uuid"]: sum(opt["total_bets"] for opt in market["options"]) + for market in self.markets + } + self.client.get("/api/user", name="/api/user") + pots = self._fetch_markets() + self._fetch_bets() + self._fetch_points() + for market in self.markets: + if pots.get(market["uuid"]) != previous_pots.get(market["uuid"]): + self._fetch_history(market) + + @task(1) + def reload(self): + self._load_page()