Files
team-tryouts/tests/test_query_shape.py
GGThed d7a8907953
CI - Security, Lint & Tests / validate (push) Failing after 19m37s
fix(audit): moderniser les accès ORM
2026-08-17 15:02:29 -04:00

423 lines
15 KiB
Python

"""Two rewrites that had to keep answering the same thing — PERF-003/004.
Both replaced a Python loop over rows with a query. The risk of that kind
of change is not that it breaks loudly; it is that it quietly answers
something slightly different — a player counted twice, an inactive account
included, a NULL swallowed. So these tests state the answer, not the plan.
The query counts are asserted too: the whole point was the number of round
trips, and nothing else in the suite would notice it creeping back.
"""
from datetime import date, time
import pytest
from sqlalchemy import event
from app.extensions import db
from app.models import Evaluation, PlayerDisponibility, Tryout, TryoutRegistration, User
@pytest.fixture
def count_queries(app):
"""Count SELECT statements issued inside the block."""
class Counter:
def __init__(self):
self.total = 0
def _counter():
counter = Counter()
def _before(conn, cursor, statement, parameters, context, executemany):
if statement.lstrip().upper().startswith('SELECT'):
counter.total += 1
event.listen(db.engine, 'before_cursor_execute', _before)
counter.stop = lambda: event.remove(db.engine, 'before_cursor_execute', _before)
return counter
return _counter
class TestAvailableAtTime:
"""get_players_available_at_time ran one query per active player."""
@staticmethod
def _slot(player_id, day, start, end):
db.session.add(
PlayerDisponibility(
player_id=player_id, day_of_week=day, start_time=start, end_time=end
)
)
def test_a_player_inside_the_window_is_returned(self, app, make_user):
from app.routes.matches import get_players_available_at_time
player_id = make_user('player')
with app.app_context():
# 2030-04-01 is a Monday, weekday() == 0.
self._slot(player_id, 0, time(18, 0), time(21, 0))
db.session.commit()
assert get_players_available_at_time('2030-04-01', '19:00') == [player_id]
def test_the_end_of_the_window_is_exclusive(self, app, make_user):
from app.routes.matches import get_players_available_at_time
player_id = make_user('player')
with app.app_context():
self._slot(player_id, 0, time(18, 0), time(21, 0))
db.session.commit()
assert get_players_available_at_time('2030-04-01', '21:00') == []
assert get_players_available_at_time('2030-04-01', '18:00') == [player_id]
def test_another_day_does_not_count(self, app, make_user):
from app.routes.matches import get_players_available_at_time
player_id = make_user('player')
with app.app_context():
self._slot(player_id, 2, time(18, 0), time(21, 0))
db.session.commit()
assert get_players_available_at_time('2030-04-01', '19:00') == []
def test_a_deactivated_account_is_excluded(self, app, make_user):
from app.routes.matches import get_players_available_at_time
player_id = make_user('player')
with app.app_context():
db.session.get(User, player_id).is_active_account = False
self._slot(player_id, 0, time(18, 0), time(21, 0))
db.session.commit()
assert get_players_available_at_time('2030-04-01', '19:00') == []
def test_two_overlapping_slots_report_the_player_once(self, app, make_user):
from app.routes.matches import get_players_available_at_time
player_id = make_user('player')
with app.app_context():
self._slot(player_id, 0, time(18, 0), time(21, 0))
self._slot(player_id, 0, time(19, 0), time(22, 0))
db.session.commit()
assert get_players_available_at_time('2030-04-01', '19:30') == [player_id]
def test_bad_input_answers_nothing(self, app):
from app.routes.matches import get_players_available_at_time
with app.app_context():
assert get_players_available_at_time('not-a-date', '19:00') == []
assert get_players_available_at_time('2030-04-01', '99:99') == []
def test_the_cost_does_not_grow_with_the_squad(self, app, make_user, count_queries):
"""One query per player is what this replaced."""
player_ids = [make_user('player') for _ in range(6)]
with app.app_context():
for player_id in player_ids:
self._slot(player_id, 0, time(18, 0), time(21, 0))
db.session.commit()
from app.routes.matches import get_players_available_at_time
counter = count_queries()
try:
result = get_players_available_at_time('2030-04-01', '19:00')
finally:
counter.stop()
assert sorted(result) == sorted(player_ids)
# The lower bound matters as much as the upper one: a listener
# that never fires would make this assertion vacuous.
assert 1 <= counter.total <= 2, f'{counter.total} SELECTs for 6 players'
class TestPendingEvaluations:
"""The coach dashboard loaded every registration in the club and every
evaluation the coach had written, to produce one integer."""
@staticmethod
def _register(player_id, tryout_id, status='registered'):
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id, status=status))
@pytest.fixture
def tryout_id(self, app, make_user):
with app.app_context():
tryout = Tryout(
title='Open', game='Valorant', date=date(2030, 3, 1), created_by=make_user('admin')
)
db.session.add(tryout)
db.session.commit()
return tryout.id
def _pending(self, client):
"""The number the dashboard renders, read back from the view."""
from app.routes import main
captured = {}
original = main.render_template
def _capture(template, **context):
captured.update(context)
return original(template, **context)
main.render_template = _capture
try:
client.get('/dashboard')
finally:
main.render_template = original
return captured['stats']['pending_evaluations']
def test_a_registered_player_is_pending(self, app, client, as_role, make_user, tryout_id):
player_id = make_user('player')
as_role('coach')
with app.app_context():
self._register(player_id, tryout_id)
db.session.commit()
assert self._pending(client) == 1
def test_an_evaluated_player_is_not(self, app, client, as_role, make_user, tryout_id):
player_id = make_user('player')
coach_id = as_role('coach')
with app.app_context():
self._register(player_id, tryout_id)
db.session.add(
Evaluation(player_id=player_id, evaluator_id=coach_id, tryout_id=tryout_id)
)
db.session.commit()
assert self._pending(client) == 0
def test_another_coachs_evaluation_does_not_clear_it(
self, app, client, as_role, make_user, tryout_id
):
"""The tally is per coach: someone else's work is not yours."""
player_id = make_user('player')
other_coach = make_user('coach')
as_role('coach')
with app.app_context():
self._register(player_id, tryout_id)
db.session.add(
Evaluation(player_id=player_id, evaluator_id=other_coach, tryout_id=tryout_id)
)
db.session.commit()
assert self._pending(client) == 1
def test_a_withdrawn_registration_is_not_counted(
self, app, client, as_role, make_user, tryout_id
):
player_id = make_user('player')
as_role('coach')
with app.app_context():
self._register(player_id, tryout_id, status='withdrawn')
db.session.commit()
assert self._pending(client) == 0
def test_a_player_registered_twice_counts_once(
self, app, client, as_role, make_user, tryout_id
):
"""DB-006 has not landed yet, so double registrations are possible;
the old set() absorbed them and the new count must too."""
player_id = make_user('player')
as_role('coach')
with app.app_context():
self._register(player_id, tryout_id)
self._register(player_id, tryout_id, status='attended')
db.session.commit()
assert self._pending(client) == 1
class TestRegisteredPlayersForMatchForm:
"""The match forms used to issue one or two user lookups per registration."""
def test_the_result_is_unique_ordered_and_constant_cost(self, app, make_user, count_queries):
admin_id = make_user('admin')
player_ids = [make_user('player', username=name) for name in ('zulu', 'alpha', 'mike')]
with app.app_context():
tryout = Tryout(
title='Match form',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
)
db.session.add(tryout)
db.session.flush()
for player_id in player_ids:
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
# DB-006 is pending, so prove the UI remains unique even when the
# current database already contains a duplicate registration.
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_ids[0]))
db.session.commit()
tryout_id = tryout.id
from app.routes.matches import registered_players
counter = count_queries()
try:
players = registered_players(tryout_id)
finally:
counter.stop()
assert [player.username for player in players] == ['alpha', 'mike', 'zulu']
assert counter.total == 1
class TestEvaluationLists:
"""Evaluation pages must not issue one lookup per player or evaluator."""
def test_players_to_evaluate_has_a_fixed_query_budget(
self, app, client, as_role, make_user, count_queries
):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_ids = [make_user('player') for _ in range(12)]
with app.app_context():
tryout = Tryout(
title='Evaluation budget',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
coach_id=coach_id,
)
db.session.add(tryout)
db.session.flush()
for player_id in player_ids:
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
db.session.commit()
tryout_id = tryout.id
counter = count_queries()
try:
response = client.get(f'/evaluations/{tryout_id}/players')
finally:
counter.stop()
assert response.status_code == 200
assert 1 <= counter.total <= 10, f'{counter.total} SELECTs for 12 players'
def test_scout_top_players_are_loaded_with_the_aggregate(
self, app, client, as_role, make_user, count_queries
):
as_role('scout')
evaluator_id = make_user('coach')
admin_id = make_user('admin')
player_ids = [make_user('player') for _ in range(12)]
with app.app_context():
tryout = Tryout(
title='Scout budget',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
)
db.session.add(tryout)
db.session.flush()
for score, player_id in enumerate(player_ids, start=1):
db.session.add(
Evaluation(
player_id=player_id,
evaluator_id=evaluator_id,
tryout_id=tryout.id,
overall_score=score,
)
)
db.session.commit()
counter = count_queries()
try:
response = client.get('/dashboard')
finally:
counter.stop()
assert response.status_code == 200
assert 1 <= counter.total <= 6, f'{counter.total} SELECTs for the scout dashboard'
class TestViewTryout:
"""PERF-001 — the most-visited page in the application ran one query
per registration, one per player evaluated, one per team, and one per
team member. Thirty registrants and four teams put it past a hundred
round trips for a single render."""
@pytest.fixture
def populated_tryout(self, app, make_user):
"""A tryout with ten registrants split across two teams."""
from app.models import Match, MatchParticipant, Team, TeamMember
admin_id = make_user('admin')
player_ids = [make_user('player') for _ in range(10)]
with app.app_context():
tryout = Tryout(
title='Spring', game='Valorant', date=date(2030, 3, 1), created_by=admin_id
)
db.session.add(tryout)
db.session.flush()
for player_id in player_ids:
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
for index in range(2):
team = Team(tryout_id=tryout.id, name=f'Team {index}', created_by=admin_id)
db.session.add(team)
db.session.flush()
for player_id in player_ids[index * 5 : (index + 1) * 5]:
db.session.add(TeamMember(team_id=team.id, player_id=player_id))
match = Match(
tryout_id=tryout.id,
title='Scrim',
date=date(2030, 3, 2),
match_type='player_vs_player',
created_by=admin_id,
)
db.session.add(match)
db.session.flush()
for side, player_id in enumerate(player_ids[:4]):
db.session.add(
MatchParticipant(
match_id=match.id, player_id=player_id, team_side=(side % 2) + 1
)
)
db.session.commit()
return tryout.id, admin_id, player_ids
def test_the_page_still_shows_everyone(self, app, client, as_role, populated_tryout):
tryout_id, _admin_id, player_ids = populated_tryout
as_role('admin')
body = client.get(f'/tryouts/{tryout_id}').get_data(as_text=True)
with app.app_context():
usernames = [db.session.get(User, pid).username for pid in player_ids]
for username in usernames:
assert username in body, f'{username} is missing from the page'
def test_the_cost_does_not_grow_with_the_squad(
self, app, client, as_role, populated_tryout, count_queries
):
tryout_id, _admin_id, _player_ids = populated_tryout
as_role('admin')
with app.app_context():
counter = count_queries()
try:
response = client.get(f'/tryouts/{tryout_id}')
finally:
counter.stop()
assert response.status_code == 200
# Ten registrants, two teams of five, one match of four. The old
# shape spent more than thirty queries on those rows alone; the
# budget here is deliberately loose but far below that, and it can
# only be lowered.
assert 1 <= counter.total <= 25, f'{counter.total} SELECTs to render one tryout'