Files
RosterChief/teams/tests.py
Bernard Siebens ffe8a3d301 Speed up and rationalise the test suite (158s -> 16s)
Nearly all of the wall clock was password hashing: there was no test-time
PASSWORD_HASHERS override, so Django's PBKDF2 default (~1.2M iterations) ran on
every create_user and every login, hundreds of times over. The fix lives in a
DiscoverRunner subclass wired in via TEST_RUNNER rather than a "test" in
sys.argv sniff in settings: a runner is only ever instantiated by `manage.py
test`, so there is no env var to mis-set and no import path by which a deployed
process can reach the weak hasher. Verified: outside the runner the hasher is
still PBKDF2. It also enables the cached template loader (the runner forces
DEBUG off *after* settings are read, so Django never turns it on by itself) and
silences django.request, whose 4xx/5xx logging buried real test output.

Second, the fixtures. Base classes were rebuilding a club, season, admin user,
membership, role and MFA authenticator once per test; those are read-only for
almost every test, so they move to setUpTestData and are built once per class.
Django hands each test its own deep copy and the per-test transaction rolls the
rows back, so the handful of tests that mutate them stay isolated -- proved with
--shuffle, --reverse and --parallel rather than assumed. Per-test work that
genuinely must stay per-test (client sign-ins, waffle cache clears that leak
across the transaction boundary) is left in setUp with a comment saying why.

Five tests removed, each strictly subsumed by another that asserts a superset;
their intent was folded into a comment on the survivor. Regression-pinning
tests -- the ones carrying comments naming the exact bug they catch -- were
left verbatim throughout.

Also closes a real gap this surfaced: teams had a cross-club position test for
TeamMembership but not for StaffAssignment, with an unused `other_coach`
fixture sitting there waiting for it.

Rejected: --parallel by default (every worker re-runs all 88 migrations, buying
~4s of wall clock for ~5x the CPU), and disabling migrations in tests (~3.5s,
but the schema would then come from models and the suite would stop catching a
broken migration).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:48:02 +02:00

305 lines
14 KiB
Python

import datetime
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import ProtectedError
from django.test import TestCase
from django.utils import timezone
from club.models import Club, Season
from members.models import Member
from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
class TeamsTestCase(TestCase):
# Shared read-only scaffolding for every teams test. The few that delete a fixture
# (member, team, season) get a per-test copy from setUpTestData and the rows come
# back with the transaction rollback.
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = Season.objects.create(
club=cls.club,
start_date=datetime.date(2026, 8, 1),
end_date=datetime.date(2027, 5, 31),
)
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
cls.coach = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True)
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
class TeamModelTests(TeamsTestCase):
def test_str_returns_name(self):
self.assertEqual(str(self.team), "First Team")
def test_team_name_is_unique_per_club(self):
with self.assertRaises(IntegrityError):
Team.objects.create(club=self.club, name="First Team", short_name="dup")
def test_same_team_name_allowed_in_other_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
Team.objects.create(club=other, name="First Team", short_name="1st")
self.assertEqual(Team.objects.filter(name="First Team").count(), 2)
def test_referee_management_defaults_to_club(self):
self.assertEqual(self.team.referee_management, Team.RefereeManagement.CLUB)
class PositionModelTests(TeamsTestCase):
def test_str_returns_name(self):
self.assertEqual(str(self.forward), "Forward")
def test_defaults(self):
self.assertFalse(self.forward.staff_position)
self.assertEqual(self.forward.ordering, 0)
def test_positions_ordered_by_ordering_then_name(self):
keeper = Position.objects.create(club=self.club, name="Keeper", short_name="GK", ordering=1)
ordered = list(Position.objects.filter(club=self.club).values_list("name", flat=True))
# ordering=0 entries first (alphabetical), then ordering=1.
self.assertEqual(ordered, ["Forward", "Head Coach", "Keeper"])
self.assertEqual(keeper.ordering, 1)
def test_position_name_is_unique_per_club(self):
with self.assertRaises(IntegrityError):
Position.objects.create(club=self.club, name="Forward", short_name="dup")
def test_management_position_must_also_be_a_staff_position(self):
with self.assertRaises(IntegrityError):
Position.objects.create(club=self.club, name="Bogus", short_name="BG", staff_position=False, management_position=True)
def test_management_staff_position_is_allowed(self):
position = Position.objects.create(club=self.club, name="Manager", short_name="MG", staff_position=True, management_position=True)
self.assertTrue(position.management_position)
class TeamMembershipModelTests(TeamsTestCase):
def test_can_create_roster_entry(self):
entry = TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward, jersey_number=9)
self.assertEqual(entry.position, self.forward)
self.assertFalse(entry.is_captain)
self.assertEqual(str(entry), "First Team - Jane Doe")
self.assertEqual(list(self.team.roster.all()), [entry])
def test_member_is_unique_per_team_and_season(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward, jersey_number=9)
with self.assertRaises(IntegrityError):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward, jersey_number=10)
def test_jersey_number_is_unique_per_team_and_season(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward, jersey_number=9)
other = Member.objects.create(first_name="John", last_name="Smith")
with self.assertRaises(IntegrityError):
TeamMembership.objects.create(team=self.team, member=other, season=self.season, position=self.forward, jersey_number=9)
def test_season_is_protected_while_referenced(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward)
with self.assertRaises(ProtectedError):
self.season.delete()
class StaffAssignmentModelTests(TeamsTestCase):
def test_can_assign_staff(self):
assignment = StaffAssignment.objects.create(team=self.team, member=self.member, season=self.season, position=self.coach)
self.assertEqual(assignment.position, self.coach)
self.assertEqual(str(assignment), "First Team - Jane Doe")
self.assertEqual(list(self.team.staff_assignments.all()), [assignment])
def test_member_is_unique_per_team_and_season(self):
StaffAssignment.objects.create(team=self.team, member=self.member, season=self.season, position=self.coach)
with self.assertRaises(IntegrityError):
StaffAssignment.objects.create(team=self.team, member=self.member, season=self.season, position=self.coach)
def test_roster_and_staff_use_separate_reverse_accessors(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward)
coach_member = Member.objects.create(first_name="Coach", last_name="Carter")
StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=self.coach)
self.assertEqual(self.forward.team_memberships.count(), 1)
self.assertEqual(self.coach.staff_assignments.count(), 1)
class RosterCleanTests(TeamsTestCase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
cls.other_season = Season.objects.create(club=cls.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
cls.other_position = Position.objects.create(club=cls.other, name="Forward", short_name="FW")
cls.other_coach = Position.objects.create(club=cls.other, name="Coach", short_name="C", staff_position=True)
def test_teammembership_rejects_cross_club_season(self):
entry = TeamMembership(team=self.team, member=self.member, season=self.other_season, position=self.forward)
with self.assertRaises(ValidationError) as ctx:
entry.full_clean()
self.assertIn("season", ctx.exception.error_dict)
def test_teammembership_rejects_cross_club_position(self):
entry = TeamMembership(team=self.team, member=self.member, season=self.season, position=self.other_position)
with self.assertRaises(ValidationError) as ctx:
entry.full_clean()
self.assertIn("position", ctx.exception.error_dict)
def test_teammembership_accepts_same_club(self):
TeamMembership(team=self.team, member=self.member, season=self.season, position=self.forward).full_clean()
def test_staffassignment_rejects_cross_club_season(self):
assignment = StaffAssignment(team=self.team, member=self.member, season=self.other_season, position=self.coach)
with self.assertRaises(ValidationError) as ctx:
assignment.full_clean()
self.assertIn("season", ctx.exception.error_dict)
def test_staffassignment_rejects_cross_club_position(self):
# The StaffAssignment half of test_teammembership_rejects_cross_club_position:
# clean() validates `position` as well as `season` against the team's club, and
# nothing exercised that branch -- `other_coach` was sitting unused waiting for it.
assignment = StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.other_coach)
with self.assertRaises(ValidationError) as ctx:
assignment.full_clean()
self.assertIn("position", ctx.exception.error_dict)
def test_staffassignment_accepts_same_club(self):
StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.coach).full_clean()
class TeamPhotoModelTests(TeamsTestCase):
def test_can_set_a_photo(self):
photo = TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
self.assertEqual(str(photo), "First Team - 26-27")
self.assertEqual(list(self.team.photos.all()), [photo])
def test_only_one_photo_per_team_and_season(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
with self.assertRaises(IntegrityError):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic2.jpg")
def test_the_same_team_can_have_a_photo_in_a_different_season(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
TeamPhoto.objects.create(team=self.team, season=other_season, image="clubs/ajax-united/teams/x/00-00/pic.jpg")
self.assertEqual(self.team.photos.count(), 2)
def test_season_is_protected_while_a_photo_references_it(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
with self.assertRaises(ProtectedError):
self.season.delete()
def test_deleting_the_team_deletes_its_photos(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
self.team.delete()
self.assertEqual(TeamPhoto.objects.count(), 0)
class RefereeLevelModelTests(TeamsTestCase):
def test_str(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.assertEqual(str(level), "Regional")
def test_name_is_unique_per_club(self):
RefereeLevel.objects.create(club=self.club, name="Regional")
with self.assertRaises(IntegrityError):
RefereeLevel.objects.create(club=self.club, name="Regional")
def test_same_name_allowed_in_another_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
RefereeLevel.objects.create(club=self.club, name="Regional")
RefereeLevel.objects.create(club=other, name="Regional")
self.assertEqual(RefereeLevel.objects.filter(name="Regional").count(), 2)
def test_teams_starts_empty(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.assertEqual(list(level.teams.all()), [])
def test_can_qualify_for_multiple_teams(self):
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
level = RefereeLevel.objects.create(club=self.club, name="Regional")
level.teams.set([self.team, other_team])
self.assertEqual(set(level.teams.all()), {self.team, other_team})
def test_team_reverse_accessor(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
level.teams.add(self.team)
self.assertEqual(list(self.team.referee_levels.all()), [level])
class RefereeProfileModelTests(TeamsTestCase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
def test_str(self):
profile = RefereeProfile.objects.create(member=self.member)
self.assertEqual(str(profile), "Jane Doe (referee)")
def test_member_is_one_to_one(self):
RefereeProfile.objects.create(member=self.member)
with self.assertRaises(IntegrityError):
RefereeProfile.objects.create(member=self.member)
def test_no_level_is_never_eligible_even_with_a_future_validity(self):
profile = RefereeProfile.objects.create(member=self.member, valid_until=datetime.date(2099, 1, 1))
self.assertTrue(profile.is_currently_valid) # the date itself is fine...
self.assertFalse(profile.is_eligible) # ...but there's no level, so not eligible
self.assertEqual(list(profile.eligible_teams), [])
def test_no_validity_set_is_not_currently_valid_or_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level)
self.assertFalse(profile.is_currently_valid)
self.assertFalse(profile.is_eligible)
self.assertEqual(list(profile.eligible_teams), [])
def test_valid_until_today_is_currently_valid_and_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())
self.assertTrue(profile.is_currently_valid)
self.assertTrue(profile.is_eligible)
def test_valid_until_in_the_past_is_not_currently_valid_or_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1))
self.assertFalse(profile.is_currently_valid)
self.assertFalse(profile.is_eligible)
self.assertEqual(list(profile.eligible_teams), [])
def test_eligible_teams_come_from_the_level_when_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=1))
self.assertEqual(list(profile.eligible_teams), [self.team])
def test_deleting_a_referenced_level_is_protected(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())
with self.assertRaises(ProtectedError):
self.level.delete()
def test_deleting_the_member_deletes_the_profile(self):
profile = RefereeProfile.objects.create(member=self.member)
self.member.delete()
self.assertFalse(RefereeProfile.objects.filter(pk=profile.pk).exists())