This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user