"""Team representative ("joueur responsable") — the rule and the routes. A player an admin names as their team's responsible may create, edit and delete that team's matches and practices, and nothing else. Designation itself is the president's decision, never the responsible's. """ from datetime import date, time import pytest from app.extensions import db from app.models import AppSettings, OrgTeam, TeamMatch, TeamPlayer, User from app.permissions import ( can_manage_team_matches, is_team_representative, representative_org_teams, ) def _redirected(response): return response.status_code in (301, 302) def _build_world(app, make_user): """Two teams, a responsible on the first, a plain player on it too.""" with app.app_context(): admin_id = make_user('admin') rep_id = make_user('player') plain_id = make_user('player') team = OrgTeam(name='Varsity', created_by=admin_id) other = OrgTeam(name='JV', created_by=admin_id) db.session.add_all([team, other]) db.session.flush() # Added plain-first on purpose: the roster must still show the # responsible at the top, not last, and a test whose world added the # responsible first would pass without the ordering ever running. db.session.add_all( [ TeamPlayer(player_id=plain_id, org_team_id=team.id), TeamPlayer(player_id=rep_id, org_team_id=team.id, is_team_representative=True), ] ) db.session.commit() # create/delete matches are gated on the season for non-presidents, # and the responsible is one of them. AppSettings.set_bool('season_active', True) return { 'admin': admin_id, 'rep': rep_id, 'plain': plain_id, 'team': team.id, 'other': other.id, 'rep_username': db.session.get(User, rep_id).username, 'plain_username': db.session.get(User, plain_id).username, } def _make_match(app, world, title, team_key='team'): with app.app_context(): match = TeamMatch( org_team_id=world[team_key], title=title, date=date(2026, 10, 1), start_time=time(10, 0), end_time=time(11, 0), created_by=world['admin'], ) db.session.add(match) db.session.commit() return match.id def _login_as(client, login, username): """Switch the test client to another existing account.""" client.post('/auth/logout', follow_redirects=False) response = login(username) assert _redirected(response), f'could not log in as {username}' class TestTheRule: """The permission itself, without a request in the way.""" def test_a_rep_manages_their_team(self, app, make_user): world = _build_world(app, make_user) with app.app_context(): rep = db.session.get(User, world['rep']) team = db.session.get(OrgTeam, world['team']) assert is_team_representative(rep, team) is True assert can_manage_team_matches(rep, team) is True assert rep.can_manage_team_matches(team) is True def test_a_rep_manages_no_other_team(self, app, make_user): world = _build_world(app, make_user) with app.app_context(): rep = db.session.get(User, world['rep']) other = db.session.get(OrgTeam, world['other']) assert is_team_representative(rep, other) is False assert can_manage_team_matches(rep, other) is False def test_a_rep_is_not_a_roster_manager(self, app, make_user): """Match rights only: the roster stays staff-only.""" world = _build_world(app, make_user) with app.app_context(): rep = db.session.get(User, world['rep']) team = db.session.get(OrgTeam, world['team']) assert rep.can_manage_this_org_team(team) is False assert rep.can_manage_teams() is False assert rep.can_schedule_matches() is False def test_a_plain_player_manages_nothing(self, app, make_user): world = _build_world(app, make_user) with app.app_context(): plain = db.session.get(User, world['plain']) team = db.session.get(OrgTeam, world['team']) assert can_manage_team_matches(plain, team) is False def test_representative_org_teams_is_scoped(self, app, make_user): world = _build_world(app, make_user) with app.app_context(): rep = db.session.get(User, world['rep']) assert [team.id for team in representative_org_teams(rep)] == [world['team']] @pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout']) def test_only_the_president_may_designate(self, app, make_user, role): with app.app_context(): user = db.session.get(User, make_user(role)) assert user.can_designate_team_representative() == (role == 'admin') class TestMatchManagement: """The routes the feature exists for: matches and practices.""" def test_a_rep_schedules_a_match(self, app, client, login, make_user): world = _build_world(app, make_user) _login_as(client, login, world['rep_username']) response = client.post( f'/team-matches/{world["team"]}/create', data={'title': 'Rep Match', 'date': '2026-10-01', 'start_time': '10:00'}, follow_redirects=False, ) assert _redirected(response) with app.app_context(): assert TeamMatch.query.filter_by(title='Rep Match').count() == 1 def test_a_rep_schedules_a_practice(self, app, client, login, make_user): world = _build_world(app, make_user) _login_as(client, login, world['rep_username']) response = client.post( f'/team-matches/{world["team"]}/create?type=practice', data={'title': 'Evening Scrim', 'date': '2026-10-02', 'start_time': '18:00'}, follow_redirects=False, ) assert _redirected(response) with app.app_context(): match = TeamMatch.query.filter_by(title='Evening Scrim').first() assert match is not None assert match.opponent is None def test_a_rep_edits_a_match(self, app, client, login, make_user): world = _build_world(app, make_user) match_id = _make_match(app, world, title='Before') _login_as(client, login, world['rep_username']) response = client.post( f'/team-matches/{match_id}/edit', data={'title': 'After', 'date': '2026-10-03', 'start_time': '12:00'}, follow_redirects=False, ) assert _redirected(response) with app.app_context(): assert db.session.get(TeamMatch, match_id).title == 'After' def test_a_rep_deletes_a_match(self, app, client, login, make_user): world = _build_world(app, make_user) match_id = _make_match(app, world, title='Doomed') _login_as(client, login, world['rep_username']) response = client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False) assert _redirected(response) with app.app_context(): assert db.session.get(TeamMatch, match_id) is None def test_a_rep_does_not_schedule_for_another_team(self, app, client, login, make_user): world = _build_world(app, make_user) _login_as(client, login, world['rep_username']) client.post( f'/team-matches/{world["other"]}/create', data={'title': 'Sneaky', 'date': '2026-10-01', 'start_time': '10:00'}, follow_redirects=False, ) with app.app_context(): assert TeamMatch.query.filter_by(title='Sneaky').count() == 0 def test_a_rep_does_not_delete_another_teams_match(self, app, client, login, make_user): world = _build_world(app, make_user) match_id = _make_match(app, world, title='Elsewhere', team_key='other') _login_as(client, login, world['rep_username']) client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False) with app.app_context(): assert db.session.get(TeamMatch, match_id) is not None def test_a_plain_player_does_not_schedule(self, app, client, login, make_user): world = _build_world(app, make_user) _login_as(client, login, world['plain_username']) client.post( f'/team-matches/{world["team"]}/create', data={'title': 'Nope', 'date': '2026-10-01', 'start_time': '10:00'}, follow_redirects=False, ) with app.app_context(): assert TeamMatch.query.filter_by(title='Nope').count() == 0 def test_a_rep_does_not_touch_the_roster(self, app, client, login, make_user): """Decision 3: matches only, never the roster.""" world = _build_world(app, make_user) newcomer_id = make_user('player') _login_as(client, login, world['rep_username']) client.post( f'/teams/{world["team"]}/add_player', data={'player_id': str(newcomer_id), 'status': 'starter'}, follow_redirects=False, ) with app.app_context(): added = TeamPlayer.query.filter_by( player_id=newcomer_id, org_team_id=world['team'] ).first() assert added is None def test_a_rep_does_not_toggle_a_roster_status(self, app, client, login, make_user): world = _build_world(app, make_user) _login_as(client, login, world['rep_username']) response = client.post(f'/teams/{world["team"]}/toggle_status/{world["plain"]}', json={}) assert response.status_code == 403 class TestDesignation: """Only the president names a responsible.""" @pytest.mark.parametrize('role', ['manager', 'coach', 'player', 'scout']) def test_a_non_president_cannot_designate(self, app, client, as_role, make_user, role): world = _build_world(app, make_user) target_id = make_user('player') as_role(role) response = client.post(f'/teams/{world["team"]}/toggle_representative/{target_id}', json={}) assert response.status_code == 403 def test_the_president_designates_a_rep(self, app, client, as_role, make_user): world = _build_world(app, make_user) as_role('admin') response = client.post( f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={} ) assert response.status_code == 200 assert response.get_json()['is_team_representative'] is True with app.app_context(): row = TeamPlayer.query.filter_by( player_id=world['plain'], org_team_id=world['team'] ).one() assert row.is_team_representative is True def test_a_rep_cannot_promote_another_player(self, app, client, login, make_user): world = _build_world(app, make_user) _login_as(client, login, world['rep_username']) response = client.post( f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={} ) assert response.status_code == 403 with app.app_context(): row = TeamPlayer.query.filter_by( player_id=world['plain'], org_team_id=world['team'] ).one() assert row.is_team_representative is False class TestReachingThePage: """Reuse of the teams page, scoped (decision 4).""" def test_a_plain_player_is_sent_to_their_own_view(self, client, login, app, make_user): world = _build_world(app, make_user) _login_as(client, login, world['plain_username']) response = client.get('/teams', follow_redirects=False) assert _redirected(response) assert '/teams/my-teams' in response.headers['Location'] def test_a_rep_settles_on_the_teams_page(self, client, login, app, make_user): world = _build_world(app, make_user) _login_as(client, login, world['rep_username']) assert client.get('/teams').status_code == 200 def test_the_page_offers_matches_but_not_the_roster(self, client, login, app, make_user): world = _build_world(app, make_user) _login_as(client, login, world['rep_username']) html = client.get('/teams').get_data(as_text=True) assert f'/team-matches/{world["team"]}/create' in html assert f'/teams/{world["team"]}/add_player' not in html def test_the_my_teams_page_offers_the_schedule_buttons(self, client, login, app, make_user): """A player's nav only has "My Team(s)", so the rep schedules there.""" world = _build_world(app, make_user) _login_as(client, login, world['rep_username']) html = client.get('/teams/my-teams').get_data(as_text=True) assert f'/team-matches/{world["team"]}/create' in html assert 'create?type=practice' in html def test_the_my_teams_page_is_silent_for_a_plain_player(self, client, login, app, make_user): world = _build_world(app, make_user) _login_as(client, login, world['plain_username']) html = client.get('/teams/my-teams').get_data(as_text=True) assert f'/team-matches/{world["team"]}/create' not in html class TestRosterOrder: """The responsible heads the roster, on both pages that render it.""" def test_the_responsible_is_listed_first(self, app, make_user): world = _build_world(app, make_user) with app.app_context(): team = db.session.get(OrgTeam, world['team']) roster = team.get_players_with_status() assert [row['player'].id for row in roster] == [world['rep'], world['plain']] assert roster[0]['is_team_representative'] is True assert roster[1]['is_team_representative'] is False