fix(market): locust file for testing
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user