Files
team-tryouts/tests/test_tryout_rules.py
GGThedandClaude Opus 5 3b7b9182d7 fix(authz): balayer le motif au lieu d attendre la passe suivante
Le commit precedent finissait teams.py en notant que le defaut venait d'une
correction appliquee a un seul endroit. Balayer les autres modules
immediatement, plutot que d'attendre qu'une passe d'audit les retrouve, a
sorti les deux derniers.

tryouts.register_player lisait int(request.form.get('player_id')) -- 500 sur
une valeur non numerique -- et verifiait le role sans regarder
is_active_account. Un compte desactive pouvait donc etre inscrit a une
selection.

users/contracts._selectable_players ne filtrait pas non plus les comptes
desactives dans sa branche non-coach : la liste de depot de contrat proposait
encore des gens partis du club. Un contrat est un document nominatif signe.

PlayerSelectionSchema porte desormais le champ, et TeamPlayerSchema en herite
en ajoutant son statut. Un schema partage est ce qui empeche le prochain
appelant d'etre oublie -- c'est precisement parce que chaque route avait le
sien, ecrit a la main, que la correction a du etre faite trois fois.

Verifie par mutation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 20:48:55 -04:00

362 lines
13 KiB
Python

"""Tryout business rules: who may register, and when things close (QUA-003).
The suite covered security, authorisation, i18n, CSP and query shape. What it
did not cover was the rules the application exists to enforce — who gets on a
tryout, how many, and what stops being editable once a tryout is over.
None of these is a defect being fixed. They are the guarantees the routes
already make, written down so that a refactor cannot quietly drop one. Two
are worth reading anyway, because they are the kind of rule that looks
enforced and is not:
- the player cap is a `count() >= max_players` check followed by an
`add()`. Two simultaneous registrations both pass the count and both
insert. There is no unique or check constraint behind it (DB-005, DB-006,
blocked on Alembic). The test states the single-request rule, and says
plainly that the concurrent one is not covered;
- `is_ended` closes on `end_date`, falling back to `date`. Its boundary is
a `<`, so a tryout is still open on its own last day. That is a choice,
and an easy one to invert by accident.
"""
from datetime import date, timedelta
import pytest
from app.extensions import db
from app.models import OrgTeam, Tryout, TryoutRegistration
@pytest.fixture
def make_tryout(app, make_user):
"""Build a tryout, owned by an admin, with the given attributes."""
def _make(created_by=None, **kwargs):
owner = created_by or make_user('admin')
with app.app_context():
org_team = OrgTeam(name=f'Team {owner}', created_by=owner)
db.session.add(org_team)
db.session.flush()
tryout = Tryout(
title=kwargs.pop('title', 'Spring'),
game=kwargs.pop('game', 'Valorant'),
date=kwargs.pop('date', date.today() + timedelta(days=30)),
created_by=owner,
target_org_team_id=org_team.id,
**kwargs,
)
db.session.add(tryout)
db.session.commit()
return tryout.id
return _make
def _registrations(app, tryout_id):
with app.app_context():
return TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
class TestIsEnded:
"""Pure date arithmetic on the model."""
def test_a_tryout_is_still_open_on_its_own_day(self):
tryout = Tryout(date=date.today())
assert tryout.is_ended is False, 'the comparison is <, not <=, on purpose'
def test_yesterday_is_ended(self):
tryout = Tryout(date=date.today() - timedelta(days=1))
assert tryout.is_ended is True
def test_the_end_date_wins_over_the_start_date(self):
"""A tryout that started last week and runs until next week is open."""
tryout = Tryout(
date=date.today() - timedelta(days=7),
end_date=date.today() + timedelta(days=7),
)
assert tryout.is_ended is False
def test_a_past_end_date_closes_it_even_if_the_start_is_future(self):
"""Nonsensical dates, but the property must not disagree with itself:
end_date is consulted first and answers alone."""
tryout = Tryout(
date=date.today() + timedelta(days=7),
end_date=date.today() - timedelta(days=1),
)
assert tryout.is_ended is True
class TestSelfRegistration:
def test_a_player_can_register(self, app, client, as_role, make_tryout):
as_role('player')
tryout_id = make_tryout()
client.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
assert _registrations(app, tryout_id) == 1
@pytest.mark.parametrize('role', ['coach', 'manager', 'admin', 'scout'])
def test_only_players_register_themselves(self, app, client, as_role, make_tryout, role):
as_role(role)
tryout_id = make_tryout()
client.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
assert _registrations(app, tryout_id) == 0
def test_registering_twice_adds_nothing(self, app, client, as_role, make_tryout):
as_role('player')
tryout_id = make_tryout()
client.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
client.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
assert _registrations(app, tryout_id) == 1
def test_a_completed_tryout_takes_no_more_registrations(
self, app, client, as_role, make_tryout
):
as_role('player')
tryout_id = make_tryout(status='completed')
client.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
assert _registrations(app, tryout_id) == 0
def test_a_tryout_in_progress_still_takes_registrations(
self, app, client, as_role, make_tryout
):
"""Deliberate: someone can join on the day."""
as_role('player')
tryout_id = make_tryout(status='in_progress')
client.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
assert _registrations(app, tryout_id) == 1
def test_the_player_cap_is_enforced(self, app, client, as_role, make_user, make_tryout):
"""One request at a time.
Two concurrent registrations both pass the count and both insert:
the cap is a read-then-write with nothing behind it in the schema.
Closing that needs a constraint, which needs Alembic (DB-005/006).
"""
tryout_id = make_tryout(max_players=1)
as_role('player')
client.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
second = app.test_client()
make_user('player', username='second')
signed_in = second.post(
'/auth/login', data={'username': 'second', 'password': 'Password123'}
)
# Without this the test passes for the wrong reason: a failed login
# also leaves exactly one registration behind.
assert signed_in.status_code in (301, 302), 'the second player never got in'
second.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
assert _registrations(app, tryout_id) == 1
def test_without_a_cap_the_second_player_does_get_in(
self, app, client, as_role, make_user, make_tryout
):
"""The control for the test above. If this one failed too, the cap
test would be proving nothing about the cap."""
tryout_id = make_tryout()
as_role('player')
client.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
second = app.test_client()
make_user('player', username='second')
second.post('/auth/login', data={'username': 'second', 'password': 'Password123'})
second.post(f'/tryouts/{tryout_id}/register', follow_redirects=True)
assert _registrations(app, tryout_id) == 2
class TestStaffRegistration:
def test_a_manager_can_register_a_player(self, app, client, as_role, make_user, make_tryout):
admin_id = as_role('admin')
player_id = make_user('player')
tryout_id = make_tryout(created_by=admin_id)
client.post(
f'/tryouts/{tryout_id}/register_player',
data={'player_id': str(player_id)},
follow_redirects=True,
)
assert _registrations(app, tryout_id) == 1
def test_only_a_player_can_be_registered(self, app, client, as_role, make_user, make_tryout):
"""A coach on the roster of a tryout would be evaluated as a
candidate for it."""
admin_id = as_role('admin')
coach_id = make_user('coach')
tryout_id = make_tryout(created_by=admin_id)
client.post(
f'/tryouts/{tryout_id}/register_player',
data={'player_id': str(coach_id)},
follow_redirects=True,
)
assert _registrations(app, tryout_id) == 0
def test_the_cap_applies_to_staff_registrations_too(
self, app, client, as_role, make_user, make_tryout
):
admin_id = as_role('admin')
tryout_id = make_tryout(created_by=admin_id, max_players=1)
first, second = make_user('player'), make_user('player')
for player_id in (first, second):
client.post(
f'/tryouts/{tryout_id}/register_player',
data={'player_id': str(player_id)},
follow_redirects=True,
)
assert _registrations(app, tryout_id) == 1
class TestStatusTransitions:
def _set_status(self, client, tryout_id, status):
return client.post(
f'/tryouts/{tryout_id}/status', data={'status': status}, follow_redirects=True
)
@pytest.mark.parametrize('status', ['upcoming', 'in_progress', 'completed'])
def test_the_three_states_are_reachable(self, app, client, as_role, make_tryout, status):
admin_id = as_role('admin')
tryout_id = make_tryout(created_by=admin_id)
self._set_status(client, tryout_id, status)
with app.app_context():
assert db.session.get(Tryout, tryout_id).status == status
def test_an_unknown_state_changes_nothing(self, app, client, as_role, make_tryout):
admin_id = as_role('admin')
tryout_id = make_tryout(created_by=admin_id)
self._set_status(client, tryout_id, 'cancelled')
with app.app_context():
assert db.session.get(Tryout, tryout_id).status == 'upcoming'
def test_a_player_cannot_move_a_tryout_along(self, app, client, as_role, make_tryout):
tryout_id = make_tryout()
as_role('player')
self._set_status(client, tryout_id, 'completed')
with app.app_context():
assert db.session.get(Tryout, tryout_id).status == 'upcoming'
class TestEndedTryoutsAreClosed:
"""`is_ended` is what stops a finished tryout being reopened by editing."""
def test_an_ended_tryout_cannot_be_edited(self, app, client, as_role, make_tryout):
admin_id = as_role('admin')
tryout_id = make_tryout(created_by=admin_id, date=date.today() - timedelta(days=2))
client.post(
f'/tryouts/{tryout_id}/edit',
data={'title': 'Reopened', 'game': 'Valorant', 'date': '2030-04-01'},
follow_redirects=True,
)
with app.app_context():
assert db.session.get(Tryout, tryout_id).title == 'Spring'
def test_no_match_can_be_scheduled_in_an_ended_tryout(self, app, client, as_role, make_tryout):
from app.models import Match
admin_id = as_role('admin')
tryout_id = make_tryout(created_by=admin_id, date=date.today() - timedelta(days=2))
client.post(
f'/matches/create/{tryout_id}',
data={
'title': 'Late scrim',
'date': '2030-04-01',
'start_time': '18:00',
'match_type': 'player_scrim',
},
follow_redirects=True,
)
with app.app_context():
assert Match.query.count() == 0
class TestManualRegistrationChecksTheAccount:
"""SEC-16, the last two places the pattern survived.
`register_player` read `int(request.form.get('player_id'))` — a 500 on a
non-numeric value — and checked the role but not whether the account had
been deactivated. The contract listing had the same gap in its player
select. Both were found by sweeping for the pattern after fixing
teams.py, rather than by waiting for the next audit pass to find them.
"""
@pytest.fixture
def tryout_id(self, app, make_user):
from app.models import Tryout
admin_id = make_user('admin')
with app.app_context():
tryout = Tryout(
title='Spring',
game='Valorant',
date=date(2030, 4, 1),
created_by=admin_id,
)
db.session.add(tryout)
db.session.commit()
return tryout.id
def test_a_non_numeric_id_is_not_a_500(self, app, client, tryout_id, as_role):
as_role('admin')
response = client.post(
f'/tryouts/{tryout_id}/register_player', data={'player_id': 'not-a-number'}
)
assert response.status_code < 500
def test_an_active_player_registers(self, app, client, tryout_id, as_role, make_user):
"""The premise: without it the test below passes against a route
that registers nobody."""
from app.models import TryoutRegistration
player_id = make_user('player')
as_role('admin')
client.post(f'/tryouts/{tryout_id}/register_player', data={'player_id': str(player_id)})
with app.app_context():
assert TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() == 1
def test_a_deactivated_player_is_refused(self, app, client, tryout_id, as_role, make_user):
from app.models import TryoutRegistration, User
player_id = make_user('player')
with app.app_context():
db.session.get(User, player_id).is_active_account = False
db.session.commit()
as_role('admin')
client.post(f'/tryouts/{tryout_id}/register_player', data={'player_id': str(player_id)})
with app.app_context():
assert TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() == 0