diff --git a/tests/test_tryout_rules.py b/tests/test_tryout_rules.py new file mode 100644 index 0000000..d7445fc --- /dev/null +++ b/tests/test_tryout_rules.py @@ -0,0 +1,298 @@ +"""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