"""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