diff --git a/api/tests.py b/api/tests.py index e3e64f1..8cd7c1b 100644 --- a/api/tests.py +++ b/api/tests.py @@ -18,11 +18,16 @@ from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPh ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"], ) class ApiTestBase(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + # The tenant, its current season and its one team back every API test and are read + # only. The handful of tests that do change them (deleting the season, giving the + # club a logo) get their own copy from setUpTestData and are rolled back with the + # per-test transaction. + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") today = timezone.localdate() - self.season = Season.objects.create(club=self.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300)) - self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300)) + cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") def api_get(self, path, **params): return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app") @@ -211,11 +216,12 @@ class NewsApiTests(ApiTestBase): class TeamsApiTests(ApiTestBase): - def setUp(self): - super().setUp() - self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW", ordering=1) - self.defense = Position.objects.create(club=self.club, name="Defense", short_name="DF", ordering=2) - self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW", ordering=1) + cls.defense = Position.objects.create(club=cls.club, name="Defense", short_name="DF", ordering=2) + cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) def test_list_teams(self): data = self.api_get("/teams/").json() @@ -323,10 +329,11 @@ class TeamsApiTests(ApiTestBase): class GamesApiTests(ApiTestBase): - def setUp(self): - super().setUp() - self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True) - self.opponent = Opponent.objects.create(club=self.club, name="Rivals FC") + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.home_location = Location.objects.create(club=cls.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True) + cls.opponent = Opponent.objects.create(club=cls.club, name="Rivals FC") def make_game(self, **overrides): defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent} diff --git a/authentication/tests.py b/authentication/tests.py index a3b62d7..4d06b23 100644 --- a/authentication/tests.py +++ b/authentication/tests.py @@ -145,8 +145,10 @@ class WebAuthnRelyingPartyTests(TestCase): class MFARequirementTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + # Read-only for every test here: each one brings its own user and role. + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def make_user(self, email, **kwargs): return User.objects.create_user(email=email, password="pw-secret-123", **kwargs) @@ -232,9 +234,6 @@ class AdminLoginRoutingTests(TestCase): # The original destination survives the hop (percent-encoded). self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"]) - def test_allauth_login_page_loads(self): - self.assertEqual(self.client.get(reverse("account_login")).status_code, 200) - class AuthFormRenderingTests(TestCase): """Every allauth form must actually render its fields. @@ -257,9 +256,12 @@ class AuthFormRenderingTests(TestCase): class TwoFactorPageTests(TestCase): + @classmethod + def setUpTestData(cls): + enrol_mfa(User.objects.create_user(email="mfa@example.com", password="pw-secret-123")) + def setUp(self): - user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") - enrol_mfa(user) + # The test client is per-test, so the sign-in itself cannot be hoisted. # Password accepted, second factor still owed: this is the 2FA challenge page. self.response = self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True) @@ -313,8 +315,11 @@ class MfaPageTests(TestCase): """Every MFA screen must render. They are built from allauth's `element` primitives, so styling lives in the element overrides rather than in eight page templates.""" + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") + def setUp(self): - self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") # A real password login (not force_login) so allauth counts it as a recent # authentication and doesn't bounce the sensitive pages to reauthenticate. self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True) @@ -361,8 +366,11 @@ class ActionBarTests(TestCase): Keying the bar on it hid the button on exactly the pages that are nothing but a button. """ + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") + def setUp(self): - self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True) def test_the_sign_out_page_has_its_button(self): @@ -381,8 +389,11 @@ class ActionBarTests(TestCase): class SignOutPageTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") + def setUp(self): - self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") self.client.force_login(self.user) self.response = self.client.get(reverse("account_logout")) @@ -407,8 +418,11 @@ class SignOutPageTests(TestCase): class ChangePasswordPageTests(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): User.objects.create_user(email="mfa@example.com", password="pw-secret-123") + + def setUp(self): self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True) self.response = self.client.get(reverse("account_change_password")) @@ -438,8 +452,11 @@ class MfaButtonIconTests(TestCase): ranked: View is primary, Download and Generate are outline. Generate throws away the codes you already have, so it must not read as the obvious thing to click.""" + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") + def setUp(self): - self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") # Sign in *before* enrolling: a user who already holds a second factor is stopped at # the 2FA challenge and never reaches these pages. self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True) diff --git a/billing/tests.py b/billing/tests.py index 7d6ac0a..373e0d4 100644 --- a/billing/tests.py +++ b/billing/tests.py @@ -26,13 +26,18 @@ from .services.reminders import admin_emails, reminders_to_send, send_reminder class BillingTestBase(TestCase): - def setUp(self): - self.today = timezone.localdate() - self.club = Club.objects.create(name="Ajax United") - self.plan = Plan.objects.create(name="Standard") + # setUpTestData, not setUp: the club and the priced plan are read-only scaffolding for + # every subclass, so they are built once per class. Django hands each test its own deep + # copy and rolls the database back afterwards, so the tests that archive the club or + # soft-delete the plan still start from a clean slate. + @classmethod + def setUpTestData(cls): + cls.today = timezone.localdate() + cls.club = Club.objects.create(name="Ajax United") + cls.plan = Plan.objects.create(name="Standard") # Priced well back, so a backdated (lapsed) period still has a price in force — # opening one before any price existed is refused, and rightly so. - PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + PlanPrice.objects.create(plan=cls.plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) def bill(self, start=None, club=None): return open_period(club or self.club, start=start, plan=self.plan) @@ -116,9 +121,10 @@ class PeriodTests(BillingTestBase): class PaymentTests(BillingTestBase): - def setUp(self): - super().setUp() - self.due = self.bill() + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.due = open_period(cls.club, plan=cls.plan) def test_a_part_payment_leaves_the_due_partially_paid(self): record_payment(self.due, Decimal("200.00")) @@ -243,9 +249,10 @@ class GraceAndArchiveTests(BillingTestBase): class ArchiveCommandTests(BillingTestBase): - def setUp(self): - super().setUp() - subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10)) + @classmethod + def setUpTestData(cls): + super().setUpTestData() + subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10)) def run_command(self, *args): out = StringIO() @@ -275,13 +282,14 @@ class ArchiveCommandTests(BillingTestBase): class ReactivationTests(BillingTestBase): - def setUp(self): - super().setUp() + @classmethod + def setUpTestData(cls): + super().setUpTestData() # Through subscribe(), not open_period(): reactivating reads the club's plan off its # subscription, and a club billed without one cannot be re-billed later. - subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=400)) - self.first = self.club.dues.first() - self.club.archive() + subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=400)) + cls.first = cls.club.dues.first() + cls.club.archive() def test_reactivating_continues_from_the_lapsed_period_by_default(self): due = reactivate(self.club) @@ -531,12 +539,13 @@ class TrialTests(BillingTestBase): see billing.services.dues.start_trial and the trial-conversion check in open_period().""" - def setUp(self): - super().setUp() + @classmethod + def setUpTestData(cls): + super().setUpTestData() # A trial is a plan whose own duration_months IS the trial length -- there is no # trial_months argument any more. - self.trial_plan = Plan.objects.create(name="Trial", duration_months=2, is_trial=True, grace_days=14, renewal_lead_days=7) - PlanPrice.objects.create(plan=self.trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00")) + cls.trial_plan = Plan.objects.create(name="Trial", duration_months=2, is_trial=True, grace_days=14, renewal_lead_days=7) + PlanPrice.objects.create(plan=cls.trial_plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("50.00")) def test_start_trial_creates_a_short_trial_period(self): due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan) @@ -753,13 +762,14 @@ class BillingReminderTests(BillingTestBase): """Reminder emails -- see billing/services/reminders.py. Sent once per escalation level, because the command is on a daily cron.""" - def setUp(self): - super().setUp() + @classmethod + def setUpTestData(cls): + super().setUpTestData() user = User.objects.create_user(email="admin@ajax.example", password="pw-secret-123") member = Member.objects.create(user=user, first_name="Ada", last_name="Admin") - ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN) - subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1)) - self.due = self.club.dues.first() + ClubRole.objects.create(club=cls.club, member=member, role=ClubRole.Roles.ADMIN) + subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=1)) + cls.due = cls.club.dues.first() def test_a_reminder_goes_to_the_club_admins(self): self.assertEqual(admin_emails(self.club), ["admin@ajax.example"]) diff --git a/club/tests.py b/club/tests.py index e2c4a8e..4d4018d 100644 --- a/club/tests.py +++ b/club/tests.py @@ -87,10 +87,14 @@ def make_season(club, start_year=2026): class ClubMembershipModelTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="City Swim Club") - self.season = make_season(self.club) - self.member = Member.objects.create( + # setUpTestData, not setUp: these three are read-only scaffolding, built once per class + # instead of once per test. Django hands each test its own deep copy, and the database + # is rolled back after every one, so the tests that archive or delete them stay isolated. + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="City Swim Club") + cls.season = make_season(cls.club) + cls.member = Member.objects.create( first_name="Jane", last_name="Doe", email="jane@example.com", @@ -241,9 +245,13 @@ class ClubSlugTests(TestCase): ALLOWED_HOSTS=[".rosterchief.app", ".example.com", ".example.org"], ) class ClubTenantMiddlewareTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + def setUp(self): self.factory = RequestFactory() - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + # Bound to this instance's _capture, so it cannot be shared across tests. self.captured = {} self.middleware = ClubTenantMiddleware(self._capture) @@ -331,8 +339,9 @@ class GetSubdomainTests(TestCase): class TenantContextTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_require_current_club_returns_active_club(self): with with_club(self.club): @@ -351,10 +360,11 @@ class TenantContextTests(TestCase): class TenantScopedModelTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.other = Club.objects.create(name="Rival FC", slug="rival-fc") - self.dates = { + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.other = Club.objects.create(name="Rival FC", slug="rival-fc") + cls.dates = { "start_date": datetime.date(2026, 8, 1), "end_date": datetime.date(2027, 5, 31), } @@ -408,11 +418,12 @@ class TenantScopedModelTests(TestCase): class SeasonGetCurrentTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.other = Club.objects.create(name="Rival FC", slug="rival-fc") - self.season = Season.objects.create( - club=self.club, + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.other = Club.objects.create(name="Rival FC", slug="rival-fc") + cls.season = Season.objects.create( + club=cls.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31), ) @@ -465,10 +476,11 @@ class SeasonGetCurrentTests(TestCase): class SeasonNextAfterTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.current = Season.objects.create(club=self.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31)) - self.next_season = Season.objects.create(club=self.club, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31)) + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.current = Season.objects.create(club=cls.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31)) + cls.next_season = Season.objects.create(club=cls.club, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31)) def test_returns_the_soonest_season_starting_after_the_date(self): self.assertEqual(Season.next_after(self.club, datetime.date(2026, 12, 25)), self.next_season) @@ -484,8 +496,9 @@ class SeasonNextAfterTests(TestCase): class SponsorModelTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_str_returns_name(self): sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) @@ -519,11 +532,15 @@ class AdminRegistrationSmokeTests(TestCase): each changelist and add page to catch bad list_display / search_fields / fieldsets / autocomplete targets in any app's admin config.""" - def setUp(self): - self.admin = get_user_model().objects.create_superuser(email="root@club.test", password="pw-secret-123") + @classmethod + def setUpTestData(cls): + cls.admin = get_user_model().objects.create_superuser(email="root@club.test", password="pw-secret-123") # Staff must hold a second factor (RequireMFAMiddleware), else they are # redirected to enrolment instead of reaching the admin. - Authenticator.objects.create(user=self.admin, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + Authenticator.objects.create(user=cls.admin, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + + def setUp(self): + # The test client is per-test, so the session it carries has to be too. self.client.force_login(self.admin) def test_every_model_is_registered_in_admin(self): @@ -552,8 +569,9 @@ class AdminRegistrationSmokeTests(TestCase): class ClubArchivingTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_a_new_club_is_active(self): self.assertFalse(self.club.is_archived) @@ -601,8 +619,11 @@ class ArchivedClubTenancyTests(TestCase): """An archived club's subdomain must stop resolving — that is what makes archiving a real deactivation rather than a cosmetic flag.""" + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") self.middleware = ClubTenantMiddleware(lambda request: "response") def resolve(self): @@ -635,13 +656,14 @@ class ClubRoleTests(TestCase): class ClubMembershipCleanTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.other = Club.objects.create(name="Rival FC", slug="rival-fc") today = timezone.localdate() - self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300)) - self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + datetime.timedelta(days=300)) - self.member = Member.objects.create(first_name="Jane", last_name="Doe") + cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300)) + cls.other_season = Season.objects.create(club=cls.other, start_date=today, end_date=today + datetime.timedelta(days=300)) + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") def test_rejects_cross_club_season(self): membership = ClubMembership(club=self.club, member=self.member, season=self.other_season) @@ -654,17 +676,21 @@ class ClubMembershipCleanTests(TestCase): class AccessServiceTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + # The clubs, season, teams and positions are pure scaffolding here -- every test + # builds its *own* people and assignments on top of them -- so they are created once + # per class rather than 28 times over. + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.other_club = Club.objects.create(name="Rival FC", slug="rival-fc") today = timezone.localdate() - self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300)) - self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") - self.second_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") - self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW") + cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300)) + cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") + cls.second_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd") + cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW") # Management staff (coach / team manager) vs. non-management staff (e.g. physio). - self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) - self.physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + cls.physio_position = Position.objects.create(club=cls.club, name="Physio", short_name="PH", staff_position=True, management_position=False) def make_user_member(self, email): user = get_user_model().objects.create_user(email=email, password="pw") @@ -922,11 +948,12 @@ class AccessServiceTests(TestCase): class ClubRoleStatusSyncTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") today = timezone.localdate() - self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300)) - self.member = Member.objects.create(first_name="Jane", last_name="Doe") + cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300)) + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") def roles(self): return ClubRole.objects.filter(club=self.club, member=self.member) @@ -1028,8 +1055,9 @@ class ClubRoleStatusSyncTests(TestCase): class BrandingTests(TestCase): """The auth screens are shared; only the skin they inherit differs per tenant.""" - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def login_page(self, host): return self.client.get(reverse("account_login"), HTTP_HOST=host) @@ -1103,8 +1131,9 @@ class Custom403PageTests(TestCase): error still looks like the app, not a bare Django error page, and the navbar (sign out, theme toggle, home link) stays reachable.""" - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_a_club_subdomain_403_gets_the_club_skin(self): member = get_user_model().objects.create_user(email="member-403@example.com", password="pw-secret-123") @@ -1155,9 +1184,10 @@ class ClubBrandingModelTests(TestCase): ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], ) class RootViewTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.user = get_user_model().objects.create_user(email="member@example.com", password="pw-secret-123") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.user = get_user_model().objects.create_user(email="member@example.com", password="pw-secret-123") def test_the_base_domain_hands_off_to_the_control_panel(self): response = self.client.get("/", HTTP_HOST="rosterchief.app") @@ -1182,12 +1212,13 @@ class FeeServiceTests(TestCase): """club.services.fees -- record_payment/mark_as_paid/remaining_balance, the service layer behind the Memberships page's per-row payment actions.""" - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.season = make_season(self.club) - self.member = Member.objects.create(first_name="Jane", last_name="Doe") - self.membership = ClubMembership.objects.create( - club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.season = make_season(cls.club) + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") + cls.membership = ClubMembership.objects.create( + club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00") ) def roles(self): @@ -1286,8 +1317,11 @@ class SeasonStartEndTests(TestCase): season_start/season_duration_months drive them instead of a fixed Aug-May window.""" - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + # Every test here reassigns a field on its own copy of the club without saving it; + # setUpTestData's per-test deep copy is what keeps that from leaking sideways. + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_a_date_past_this_years_anchor_uses_this_year(self): self.club.season_start = datetime.date(2000, 8, 1) @@ -1329,8 +1363,9 @@ class GenerateSeasonsTests(TestCase): """club.services.seasons.generate_seasons -- the service behind the generate_seasons management command.""" - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_generates_a_season_covering_today(self): today = timezone.localdate() @@ -1399,9 +1434,10 @@ class ResyncSeasonsTests(TestCase): match a club's current settings (e.g. left over from a since-changed season_start/season_duration_months).""" - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.until = timezone.localdate() + relativedelta(years=2) + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.until = timezone.localdate() + relativedelta(years=2) def test_a_wrong_and_unreferenced_season_is_reported_as_removable(self): wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) @@ -1461,8 +1497,9 @@ class ResyncSeasonsTests(TestCase): class GenerateSeasonsCommandTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_default_years_is_two(self): call_command("generate_seasons", stdout=StringIO()) diff --git a/controlpanel/tests.py b/controlpanel/tests.py index e27549d..f255152 100644 --- a/controlpanel/tests.py +++ b/controlpanel/tests.py @@ -63,11 +63,19 @@ def enrol_mfa(user): class ControlPanelTestBase(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United") - self.staff = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True) + # setUpTestData, not setUp: the club and the signed-in staff account are read-only + # scaffolding for almost every test here, and hashing a password per test costs more + # than the rest of the suite put together. Django hands each test its own deep copy, + # so the handful of tests that do mutate self.club/self.staff stay isolated. + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United") + cls.staff = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True) # Staff must hold a second factor, else RequireMFAMiddleware redirects. - enrol_mfa(self.staff) + enrol_mfa(cls.staff) + + def setUp(self): + # The test client is per-test, so the session it carries has to be too. self.client.force_login(self.staff) @@ -321,11 +329,12 @@ class ClubHomeLocationTests(ControlPanelTestBase): class StatisticsTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United") today = timezone.localdate() - self.season = Season.objects.create(club=self.club, start_date=today, end_date=today) - self.member = Member.objects.create(first_name="Jane", last_name="Doe") + cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today) + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") def groups_for(self, club): return {group["title"]: dict(group["stats"]) for group in club_statistics(club)} @@ -413,9 +422,12 @@ class PlatformAdminAccessTests(ControlPanelTestBase): class PlatformAdminTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.root = User.objects.create_superuser(email="root@example.com", password="pw-secret-123") + enrol_mfa(cls.root) + def setUp(self): - self.root = User.objects.create_superuser(email="root@example.com", password="pw-secret-123") - enrol_mfa(self.root) self.client.force_login(self.root) def test_superuser_sees_the_admins_section(self): @@ -463,6 +475,8 @@ class PlatformAdminTests(TestCase): # --- guardrails: it must be impossible to lock the platform out of itself --- def test_cannot_revoke_your_own_access(self): + # Also the last-superuser guardrail's outer layer: root is the only superuser here, + # so the self-rule is what has to stop this, whoever is asking. response = self.client.post(reverse("controlpanel:admin_revoke", args=[self.root.pk]), follow=True) self.root.refresh_from_db() @@ -480,6 +494,8 @@ class PlatformAdminTests(TestCase): self.assertTrue(self.root.is_superuser) self.assertContains(response, "cannot remove your own superuser rights") + # (test_last_superuser_rule_is_enforced_for_other_actors_too used to sit here; its body + # was byte-for-byte test_cannot_revoke_your_own_access, whose comment now carries the point.) def test_the_last_superuser_cannot_be_demoted(self): other = User.objects.create_superuser(email="other@example.com", password="pw-secret-123") # Now demote self is blocked by the self-rule; demote `other` is fine... @@ -492,20 +508,19 @@ class PlatformAdminTests(TestCase): with self.assertRaises(PlatformAdminError): set_platform_access(other, self.root, is_staff=True, is_superuser=False) - def test_last_superuser_rule_is_enforced_for_other_actors_too(self): - response = self.client.post(reverse("controlpanel:admin_revoke", args=[self.root.pk]), follow=True) - - self.root.refresh_from_db() - self.assertTrue(self.root.is_superuser) - self.assertContains(response, "cannot remove your own platform access") - class FeatureViewTests(ControlPanelTestBase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.flag = Flag.objects.create(name="shop") + def setUp(self): super().setUp() + # Waffle caches flag lookups process-wide, so a stale entry leaks straight into + # the next test -- clear it either side of every one. cache.clear() self.addCleanup(cache.clear) - self.flag = Flag.objects.create(name="shop") def test_features_page_lists_flags_and_switches(self): Switch.objects.create(name="maintenance", active=False) @@ -717,9 +732,8 @@ class LoginFormRenderingTests(TestCase): self.assertNotContains(self.response, 'Email') self.assertContains(self.response, 'placeholder="Email address"') - def test_the_checkbox_keeps_its_visible_label(self): - self.assertContains(self.response, 'Remember Me') - + # ("Remember Me" keeping its visible label is asserted by LoginLayoutTests below, which + # pins the same *and* that only one element renders it — a strict superset.) def test_the_password_reset_link_is_spaced_and_addressable(self): # The input's aria-describedby points here; without the id it dangles. self.assertContains(self.response, 'id="id_password_helptext"') @@ -770,9 +784,10 @@ class ExcludedFilterTests(TestCase): class PlatformAttentionTests(TestCase): """The numbers that are supposed to be zero.""" - def setUp(self): - self.club = Club.objects.create(name="Ajax United") - self.today = timezone.localdate() + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United") + cls.today = timezone.localdate() def season(self, club, start, end): return Season.objects.create(club=club, start_date=start, end_date=end) @@ -814,8 +829,9 @@ class PlatformAttentionTests(TestCase): class MfaPendingTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United") def test_staff_without_a_second_factor_are_pending(self): user = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True) @@ -869,10 +885,14 @@ class OnboardingFunnelTests(TestCase): class FlagAdoptionTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United") + def setUp(self): + # Waffle's flag cache is process-wide and would otherwise leak into the next test. cache.clear() self.addCleanup(cache.clear) - self.club = Club.objects.create(name="Ajax United") def test_clubs_are_counted_per_flag(self): # Not an exact-list assertion: migration 0018 seeds real "CEHL"/"RBIHF" @@ -892,10 +912,11 @@ class FlagAdoptionTests(TestCase): class PlatformChartTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United") - self.season = Season.objects.create(club=self.club, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30)) - self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United") + cls.season = Season.objects.create(club=cls.club, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30)) + cls.member = Member.objects.create(first_name="Ada", last_name="Lovelace") def test_the_series_is_dense(self): # Zero-filled: a chart that skips empty months draws a smooth line over a month @@ -956,11 +977,12 @@ class DashboardMetricsTests(ControlPanelTestBase): class ClubAttentionTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United") - self.today = timezone.localdate() - self.season = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=30), end_date=self.today + datetime.timedelta(days=300)) - self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United") + cls.today = timezone.localdate() + cls.season = Season.objects.create(club=cls.club, start_date=cls.today - datetime.timedelta(days=30), end_date=cls.today + datetime.timedelta(days=300)) + cls.member = Member.objects.create(first_name="Ada", last_name="Lovelace") def membership(self, member=None, season=None, **kwargs): return ClubMembership.objects.create(club=self.club, season=season or self.season, member=member or self.member, **kwargs) @@ -1102,13 +1124,14 @@ class ClubDetailMetricsTests(ControlPanelTestBase): class NewMemberTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United") - self.today = timezone.localdate() - self.previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) - self.season = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=30), end_date=self.today + datetime.timedelta(days=300)) - self.veteran = Member.objects.create(first_name="Ada", last_name="Lovelace") - self.rookie = Member.objects.create(first_name="Bob", last_name="Bobson") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United") + cls.today = timezone.localdate() + cls.previous = Season.objects.create(club=cls.club, start_date=cls.today - datetime.timedelta(days=400), end_date=cls.today - datetime.timedelta(days=40)) + cls.season = Season.objects.create(club=cls.club, start_date=cls.today - datetime.timedelta(days=30), end_date=cls.today + datetime.timedelta(days=300)) + cls.veteran = Member.objects.create(first_name="Ada", last_name="Lovelace") + cls.rookie = Member.objects.create(first_name="Bob", last_name="Bobson") def membership(self, member, season, signed_up_at=None): return ClubMembership.objects.create(club=self.club, season=season, member=member, status=ClubMembership.StatusChoices.ACTIVE, signed_up_at=signed_up_at) @@ -1164,11 +1187,12 @@ class NewMemberTests(TestCase): class ClubHealthTableTests(TestCase): - def setUp(self): - self.today = timezone.localdate() - self.club = Club.objects.create(name="Ajax United") - self.season = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=30), end_date=self.today + datetime.timedelta(days=300)) - self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") + @classmethod + def setUpTestData(cls): + cls.today = timezone.localdate() + cls.club = Club.objects.create(name="Ajax United") + cls.season = Season.objects.create(club=cls.club, start_date=cls.today - datetime.timedelta(days=30), end_date=cls.today + datetime.timedelta(days=300)) + cls.member = Member.objects.create(first_name="Ada", last_name="Lovelace") def health(self): return clubs_with_health().get(pk=self.club.pk) @@ -1277,11 +1301,12 @@ class TemplateCommentTests(TestCase): class PlatformDuesMetricTests(TestCase): """What the clubs owe US — kept strictly apart from what members owe their clubs.""" - def setUp(self): - self.today = timezone.localdate() - self.club = Club.objects.create(name="Ajax United") - self.plan = Plan.objects.create(name="Standard") - PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + @classmethod + def setUpTestData(cls): + cls.today = timezone.localdate() + cls.club = Club.objects.create(name="Ajax United") + cls.plan = Plan.objects.create(name="Standard") + PlanPrice.objects.create(plan=cls.plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) def test_dues_owed_is_the_unpaid_balance_across_every_club(self): subscribe(self.club, self.plan) @@ -1383,11 +1408,12 @@ class PlatformDuesMetricTests(TestCase): class BillingPanelTests(ControlPanelTestBase): - def setUp(self): - super().setUp() - self.today = timezone.localdate() - self.plan = Plan.objects.create(name="Standard") - PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.today = timezone.localdate() + cls.plan = Plan.objects.create(name="Standard") + PlanPrice.objects.create(plan=cls.plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) def test_the_billing_page_lists_plans_and_what_is_owed(self): subscribe(self.club, self.plan) @@ -1507,14 +1533,15 @@ class TrialPanelTests(ControlPanelTestBase): """The club detail page's "Start trial" modal -- see controlpanel.views.ClubStartTrialView / billing.services.dues.start_trial.""" - def setUp(self): - super().setUp() - self.today = timezone.localdate() + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.today = timezone.localdate() # A trial plan carries its own length; the form only offers plans flagged is_trial. - self.trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2) - PlanPrice.objects.create(plan=self.trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00")) - self.plan = Plan.objects.create(name="Standard") - PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + cls.trial_plan = Plan.objects.create(name="Trial", is_trial=True, duration_months=2) + PlanPrice.objects.create(plan=cls.trial_plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("50.00")) + cls.plan = Plan.objects.create(name="Standard") + PlanPrice.objects.create(plan=cls.plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) def start_trial(self, **data): # "on" for both, matching how a freshly opened modal actually renders: unbound, @@ -1585,11 +1612,12 @@ class TrialPanelTests(ControlPanelTestBase): class BillingFormRenderTests(ControlPanelTestBase): - def setUp(self): - super().setUp() - self.today = timezone.localdate() - self.plan = Plan.objects.create(name="Standard") - PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.today = timezone.localdate() + cls.plan = Plan.objects.create(name="Standard") + PlanPrice.objects.create(plan=cls.plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) def test_the_billing_forms_are_post_only(self): # Every one of these is reachable only through a modal on the billing or club @@ -1680,11 +1708,12 @@ class BillingFormRenderTests(ControlPanelTestBase): class PlanDeleteTests(ControlPanelTestBase): """billing.services.plans and controlpanel.views.PlanDeleteView.""" - def setUp(self): - super().setUp() - self.today = timezone.localdate() - self.plan = Plan.objects.create(name="Standard") - PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.today = timezone.localdate() + cls.plan = Plan.objects.create(name="Standard") + PlanPrice.objects.create(plan=cls.plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) def test_a_never_used_plan_is_removed_completely(self): unused = Plan.objects.create(name="Unused") diff --git a/events/tests.py b/events/tests.py index 15152ee..7f30c6d 100644 --- a/events/tests.py +++ b/events/tests.py @@ -33,21 +33,26 @@ from .services.referees import RefereeAssignmentError, add_external_referee, ass class EventsTestBase(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + # One club, one current season, one two-player roster: shared by every events test + # and never edited in place. Tests that do change these rows (deleting Alice, flipping + # the team to federation-managed) are safe -- setUpTestData hands each test its own + # copy of the objects, and the surrounding transaction rolls the rows back. + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") today = timezone.localdate() - self.season = Season.objects.create( - club=self.club, + cls.season = Season.objects.create( + club=cls.club, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300), ) - self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") - self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW") - self.alice = Member.objects.create(first_name="Alice", last_name="Ash") - self.bob = Member.objects.create(first_name="Bob", last_name="Birch") - TeamMembership.objects.create(team=self.team, member=self.alice, season=self.season, position=self.position) - TeamMembership.objects.create(team=self.team, member=self.bob, season=self.season, position=self.position) - self.future = timezone.now() + timedelta(days=7) + cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") + cls.position = Position.objects.create(club=cls.club, name="Forward", short_name="FW") + cls.alice = Member.objects.create(first_name="Alice", last_name="Ash") + cls.bob = Member.objects.create(first_name="Bob", last_name="Birch") + TeamMembership.objects.create(team=cls.team, member=cls.alice, season=cls.season, position=cls.position) + TeamMembership.objects.create(team=cls.team, member=cls.bob, season=cls.season, position=cls.position) + cls.future = timezone.now() + timedelta(days=7) def make_event(self, **kwargs): kwargs.setdefault("club", self.club) @@ -140,15 +145,16 @@ class EventAdminFormCompetitionTests(EventsTestBase): """`competition` is a plain CharField, but the admin should only ever offer competitions this club is actually allowed to use -- see events/admin.py.""" - def setUp(self): - super().setUp() + @classmethod + def setUpTestData(cls): + super().setUpTestData() Flag = get_waffle_flag_model() - self.active_flag = Flag.objects.create(name="active-competition") - self.active_flag.clubs.add(self.club) - self.inactive_flag = Flag.objects.create(name="inactive-competition") - self.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=self.active_flag) - self.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=self.inactive_flag) - self.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless") + cls.active_flag = Flag.objects.create(name="active-competition") + cls.active_flag.clubs.add(cls.club) + cls.inactive_flag = Flag.objects.create(name="inactive-competition") + cls.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=cls.active_flag) + cls.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=cls.inactive_flag) + cls.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless") def test_a_flagless_competition_never_appears(self): form = EventAdminForm(instance=Event()) @@ -366,9 +372,10 @@ class RosterChangeSyncTests(EventsTestBase): class RecurrenceTestBase(EventsTestBase): - def setUp(self): - super().setUp() - self.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0) + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0) def make_series(self, **kwargs): kwargs.setdefault("club", self.club) @@ -551,14 +558,15 @@ class ExtendSeriesCommandTests(RecurrenceTestBase): class EventClubScopeTests(EventsTestBase): - def setUp(self): - super().setUp() - self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.other = Club.objects.create(name="Rival FC", slug="rival-fc") today = timezone.localdate() - self.other_season = Season.objects.create(club=self.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300)) - self.other_location = Location.objects.create(club=self.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE") - self.other_opponent = Opponent.objects.create(club=self.other, name="Rivals") - self.other_team = Team.objects.create(club=self.other, name="First", short_name="1") + cls.other_season = Season.objects.create(club=cls.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300)) + cls.other_location = Location.objects.create(club=cls.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE") + cls.other_opponent = Opponent.objects.create(club=cls.other, name="Rivals") + cls.other_team = Team.objects.create(club=cls.other, name="First", short_name="1") def test_event_rejects_cross_club_season(self): event = Event(club=self.club, title="Match", start=self.future, season=self.other_season) @@ -797,10 +805,11 @@ class RBIHFExtractTeamIdTests(TestCase): class RBIHFImportPlanTests(EventsTestBase): - def setUp(self): - super().setUp() - self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True) - self.away_location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE") + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.home_location = Location.objects.create(club=cls.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True) + cls.away_location = Location.objects.create(club=cls.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE") def home_fixture_row(self, game_id="5002", date="2026-09-12"): return {"game_id": game_id, "date": date, "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"} @@ -1048,16 +1057,17 @@ class RefereeServiceTests(EventsTestBase): RefereeProfile.eligible_teams), conflict detection (a soft warning, never a block), and the max_referees hard ceiling.""" - def setUp(self): - super().setUp() - self.home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True) - self.away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE") + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.home_ground = Location.objects.create(club=cls.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True) + cls.away_ground = Location.objects.create(club=cls.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE") - self.level = RefereeLevel.objects.create(club=self.club, name="Regional") - self.level.teams.add(self.team) + cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional") + cls.level.teams.add(cls.team) - self.referee = Member.objects.create(first_name="Ref", last_name="Eree") - self.referee_profile = self.make_eligible_profile(self.referee) + cls.referee = Member.objects.create(first_name="Ref", last_name="Eree") + cls.referee_profile = RefereeProfile.objects.create(member=cls.referee, level=cls.level, valid_until=timezone.localdate() + timedelta(days=30)) def make_eligible_profile(self, member, level=None): return RefereeProfile.objects.create(member=member, level=level or self.level, valid_until=timezone.localdate() + timedelta(days=30)) diff --git a/features/tests.py b/features/tests.py index b6aea13..324b653 100644 --- a/features/tests.py +++ b/features/tests.py @@ -17,15 +17,19 @@ User = get_user_model() class ClubScopedFlagTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.other = Club.objects.create(name="Rival FC", slug="rival-fc") + cls.flag = Flag.objects.create(name="shop") + def setUp(self): # waffle caches flags by name, and its cache is NOT rolled back with the - # test transaction -- a flag row recreated under the same name in the next - # test would otherwise be shadowed by the previous test's cached object. + # test transaction -- a flag row whose targeting changed in the previous + # test would otherwise be shadowed by that test's cached object. Has to + # stay per-test: it is the cache, not the rows, that leaks. cache.clear() self.addCleanup(cache.clear) - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.other = Club.objects.create(name="Rival FC", slug="rival-fc") - self.flag = Flag.objects.create(name="shop") def request_for(self, club): request = RequestFactory().get("/") @@ -118,12 +122,17 @@ class ClubScopedFlagTests(TestCase): ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], ) class MaintenanceModeTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.user = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True) + Authenticator.objects.create(user=cls.user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + def setUp(self): + # Maintenance state lives in the shared cache, which no transaction rolls + # back -- each test has to start from a reopened platform. cache.clear() self.addCleanup(cache.clear) - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.user = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True) - Authenticator.objects.create(user=self.user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) def club_get(self, path="/"): return self.client.get(path, HTTP_HOST="ajax-united.rosterchief.app") diff --git a/formbuilder/tests.py b/formbuilder/tests.py index 3a9adc6..e261f3f 100644 --- a/formbuilder/tests.py +++ b/formbuilder/tests.py @@ -22,12 +22,16 @@ from .services import ( class FormbuilderTestBase(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.form = Form.objects.create(club=self.club, title="Sign-up", slug="sign-up") - self.name = Field.objects.create(form=self.form, key="name", label="Name", field_type=Field.FieldType.TEXT, required=True, order=1) - self.size = Field.objects.create(form=self.form, key="size", label="Shirt size", field_type=Field.FieldType.CHOICE, required=False, order=2, options=["S", "M", "L"]) - self.member = Member.objects.create(first_name="Jane", last_name="Doe") + # One two-field form, shared by every test here. The tests that reconfigure it + # (closing the window, flipping login_required) mutate a per-test copy handed out + # by setUpTestData, and their saves roll back with the transaction. + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.form = Form.objects.create(club=cls.club, title="Sign-up", slug="sign-up") + cls.name = Field.objects.create(form=cls.form, key="name", label="Name", field_type=Field.FieldType.TEXT, required=True, order=1) + cls.size = Field.objects.create(form=cls.form, key="size", label="Shirt size", field_type=Field.FieldType.CHOICE, required=False, order=2, options=["S", "M", "L"]) + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") class ModelTests(FormbuilderTestBase): diff --git a/members/tests.py b/members/tests.py index c029b1b..b042401 100644 --- a/members/tests.py +++ b/members/tests.py @@ -19,7 +19,6 @@ from members.models import Family, FamilyMembership, Group, GroupMembership, Mem from members.services import MemberImportResult -# Create your tests here. class MemberModelTests(TestCase): def test_str_and_name_helpers(self): member = Member.objects.create(first_name="John", last_name="Smith") @@ -95,17 +94,19 @@ class FamilyNameOptionalTests(TestCase): class FamilyModelTests(TestCase): - def setUp(self): - self.family = Family.objects.create(name="The Smiths") - self.parent = Member.objects.create(first_name="Pat", last_name="Smith") - self.guardian = Member.objects.create(first_name="Gale", last_name="Smith") - self.child = Member.objects.create(first_name="Kim", last_name="Smith") - self.other = Member.objects.create(first_name="Ola", last_name="Smith") + @classmethod + def setUpTestData(cls): + # One family with a member in every role -- read-only for all four tests. + cls.family = Family.objects.create(name="The Smiths") + cls.parent = Member.objects.create(first_name="Pat", last_name="Smith") + cls.guardian = Member.objects.create(first_name="Gale", last_name="Smith") + cls.child = Member.objects.create(first_name="Kim", last_name="Smith") + cls.other = Member.objects.create(first_name="Ola", last_name="Smith") - FamilyMembership.objects.create(family=self.family, member=self.parent, role=FamilyMembership.FamilyRole.PARENT) - FamilyMembership.objects.create(family=self.family, member=self.guardian, role=FamilyMembership.FamilyRole.GUARDIAN) - FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD) - FamilyMembership.objects.create(family=self.family, member=self.other, role=FamilyMembership.FamilyRole.OTHER) + FamilyMembership.objects.create(family=cls.family, member=cls.parent, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=cls.family, member=cls.guardian, role=FamilyMembership.FamilyRole.GUARDIAN) + FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=cls.family, member=cls.other, role=FamilyMembership.FamilyRole.OTHER) def test_str(self): self.assertEqual(str(self.family), "The Smiths") @@ -210,8 +211,9 @@ class FamilyMembershipModelTests(TestCase): class GroupModelTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_str_returns_name(self): group = Group.objects.create(club=self.club, name="Coaches") @@ -233,10 +235,11 @@ class GroupModelTests(TestCase): class GroupMembershipModelTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.group = Group.objects.create(club=self.club, name="Referees") - self.member = Member.objects.create(first_name="Ref", last_name="Eree") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.group = Group.objects.create(club=cls.club, name="Referees") + cls.member = Member.objects.create(first_name="Ref", last_name="Eree") def test_str(self): membership = GroupMembership.objects.create(group=self.group, member=self.member) @@ -285,10 +288,14 @@ class AdminSmokeTests(TestCase): """Exercise the admin config end-to-end to catch misregistration (bad search_fields, autocomplete targets, fieldsets, custom forms).""" - def setUp(self): - self.admin = User.objects.create_superuser(email="root@example.com", password="pw-secret-123") + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_superuser(email="root@example.com", password="pw-secret-123") # Staff must hold a second factor (RequireMFAMiddleware). - Authenticator.objects.create(user=self.admin, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + Authenticator.objects.create(user=cls.admin, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + + def setUp(self): + # The test client is per-test, so the sign-in itself cannot be hoisted. self.client.force_login(self.admin) def test_changelists_load(self): @@ -354,13 +361,14 @@ class AdminSmokeTests(TestCase): class ImportMembersCsvCommandTests(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): # Memberships are season-scoped: the importer attaches each one to the # club's current season, so the target club needs one covering today. - self.club = Club.objects.create(name="City Swim Club") + cls.club = Club.objects.create(name="City Swim Club") today = timezone.localdate() - self.season = Season.objects.create( - club=self.club, + cls.season = Season.objects.create( + club=cls.club, start_date=today - timedelta(days=90), end_date=today + timedelta(days=275), ) diff --git a/rosterchief/settings.py b/rosterchief/settings.py index 4bb7e6f..d01327a 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -208,6 +208,15 @@ TEMPLATES = [ WSGI_APPLICATION = "rosterchief.wsgi.application" +# Tests +# +# A custom runner, not extra settings: it swaps in a fast password hasher, the cached +# template loader and a quiet django.request logger while the suite runs. Those belong +# nowhere near a deployed process, and a runner is only ever instantiated by +# `manage.py test` -- see rosterchief/test_runner.py for the full reasoning. +TEST_RUNNER = "rosterchief.test_runner.RosterChiefTestRunner" + + # Database # https://docs.djangoproject.com/en/6.0/ref/settings/#databases diff --git a/rosterchief/test_runner.py b/rosterchief/test_runner.py new file mode 100644 index 0000000..a6fc71c --- /dev/null +++ b/rosterchief/test_runner.py @@ -0,0 +1,91 @@ +"""Test-only runtime configuration. + +Everything in here makes the suite faster at the cost of something production needs +(real password hashing, templates that pick up edits without a restart, error logs). +It lives in the test runner rather than in ``settings.py`` on purpose: a runner is +instantiated *only* by ``manage.py test``, so there is no environment variable to get +wrong, no ``DEBUG`` to mis-set, and no import path by which a deployed process could +ever pick these values up. Wiring is a single ``TEST_RUNNER`` line in settings.py. + +The tweaks are applied in ``setup_test_environment()``, before the suite is built and +before any database or template engine is created. + +Running the suite +----------------- +``uv run python manage.py test`` is ~16s for the full suite, of which ~4s is fixed +startup: applying all migrations against a fresh in-memory sqlite database. + +``--parallel`` works correctly here but is deliberately not the default. Every worker +re-runs the whole migration set against its own database clone, so the fixed cost is +paid N times; on a 10-core machine that buys ~4s of wall clock for ~5x the CPU, and it +interleaves the output of failing tests across processes. Reach for it only if the suite +grows enough that the per-worker setup is small next to the tests themselves. + +``--keepdb`` does nothing for local runs: the sqlite test database is in-memory and +cannot survive the process. It only pays off against a real PostgreSQL +``DJANGO_DATABASE_URL``, where it skips the migrate step between runs. +""" + +import logging + +from django.conf import settings +from django.test.runner import DiscoverRunner +from django.test.signals import setting_changed + +#: Django's default hasher is PBKDF2 with ~1.2M iterations -- deliberately slow, which is +#: exactly right in production and ruinous in a suite that creates hundreds of users and +#: logs them in again in every other test. MD5 is worthless as a password hash and that is +#: fine here: the stored hashes live in a throwaway test database for a few seconds, and +#: nothing in the suite asserts anything about hash strength. This value is unreachable +#: outside the test runner -- see the module docstring. +TEST_PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + + +def _cached_template_loaders(templates): + """Return ``TEMPLATES`` with the Django backend wrapped in the cached loader. + + Without it every ``render()`` re-reads and re-parses the template files from disk; + the suite renders the same management/controlpanel pages hundreds of times. The + cached loader is what ``APP_DIRS`` turns on automatically when ``DEBUG`` is False, + but the test runner forces ``DEBUG`` off *after* settings are read, so we have to + spell it out. ``loaders`` and ``APP_DIRS`` are mutually exclusive, hence the swap. + """ + patched = [] + for engine in templates: + if engine["BACKEND"] != "django.template.backends.django.DjangoTemplates" or "loaders" in engine.get("OPTIONS", {}): + patched.append(engine) + continue + + engine = {**engine, "OPTIONS": {**engine.get("OPTIONS", {})}} + engine.pop("APP_DIRS", None) + engine["OPTIONS"]["loaders"] = [ + ( + "django.template.loaders.cached.Loader", + [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ) + ] + patched.append(engine) + return patched + + +class RosterChiefTestRunner(DiscoverRunner): + """The project's test runner. See the module docstring for what it changes and why.""" + + def setup_test_environment(self, **kwargs): + settings.PASSWORD_HASHERS = TEST_PASSWORD_HASHERS + + settings.TEMPLATES = _cached_template_loaders(settings.TEMPLATES) + # The template engines are built lazily and cached; this is the same signal + # ``override_settings`` fires to make Django rebuild them. + setting_changed.send(sender=self.__class__, setting="TEMPLATES", value=settings.TEMPLATES, enter=True) + + # Tests that assert on a 4xx/5xx response deliberately provoke the error, and + # django.request dutifully logs each one ("Service Unavailable: /healthz"), + # burying the actual test output. No test asserts on these records. Silenced on + # the live logger rather than via LOGGING, which was already applied at startup. + logging.getLogger("django.request").setLevel(logging.CRITICAL + 1) + + super().setup_test_environment(**kwargs) diff --git a/shop/tests.py b/shop/tests.py index 92d8c7c..b7e9654 100644 --- a/shop/tests.py +++ b/shop/tests.py @@ -31,8 +31,9 @@ from .models import ( class ProductSlugTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") def test_slug_auto_populated_from_name(self): product = Product.objects.create(club=self.club, name="Home Jersey") @@ -70,9 +71,10 @@ class ProductSlugTests(TestCase): class OpenCartConstraintTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.user = User.objects.create_user(email="shopper@example.com", password="pw") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.user = User.objects.create_user(email="shopper@example.com", password="pw") def test_only_one_open_cart_per_user_per_club(self): Cart.objects.create(club=self.club, user=self.user) @@ -113,10 +115,11 @@ class CartItemTests(TestCase): class OrderNumberTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.member = Member.objects.create(first_name="Jane", last_name="Doe") - self.year = timezone.now().year + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") + cls.year = timezone.now().year def make_order(self, **kwargs): kwargs.setdefault("club", self.club) @@ -195,12 +198,13 @@ class OrderNumberTests(TestCase): class ShopEntitiesTestBase(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.member = Member.objects.create(first_name="Jane", last_name="Doe") - self.product = Product.objects.create(club=self.club, name="Home Jersey") - self.order = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00")) - self.year = timezone.now().year + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") + cls.product = Product.objects.create(club=cls.club, name="Home Jersey") + cls.order = Order.objects.create(club=cls.club, purchaser=cls.member, total=Decimal("50.00")) + cls.year = timezone.now().year class OrderLineTests(ShopEntitiesTestBase): @@ -241,9 +245,10 @@ class DiscountTests(ShopEntitiesTestBase): class AppliedDiscountTests(ShopEntitiesTestBase): - def setUp(self): - super().setUp() - self.discount = Discount.objects.create(club=self.club, name="Sibling") + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.discount = Discount.objects.create(club=cls.club, name="Sibling") def apply(self, **kwargs): kwargs.setdefault("order", self.order) @@ -311,15 +316,16 @@ class InvoiceTests(ShopEntitiesTestBase): class ClubScopeValidationTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.other = Club.objects.create(name="Rival FC", slug="rival-fc") today = timezone.localdate() - self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + timedelta(days=300)) - self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + timedelta(days=300)) - self.member = Member.objects.create(first_name="Jane", last_name="Doe") - ClubMembership.objects.create(club=self.club, member=self.member, season=self.season) - self.stranger = Member.objects.create(first_name="Stray", last_name="Ger") + cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + timedelta(days=300)) + cls.other_season = Season.objects.create(club=cls.other, start_date=today, end_date=today + timedelta(days=300)) + cls.member = Member.objects.create(first_name="Jane", last_name="Doe") + ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season) + cls.stranger = Member.objects.create(first_name="Stray", last_name="Ger") def make_cart(self, club): user = User.objects.create_user(email=f"u-{club.slug}@example.com", password="pw") @@ -442,12 +448,13 @@ class ClubScopeValidationTests(TestCase): class AdminScopingTests(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + @classmethod + def setUpTestData(cls): + cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") + cls.other = Club.objects.create(name="Rival FC", slug="rival-fc") today = timezone.localdate() - self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + timedelta(days=300)) - self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + timedelta(days=300)) + cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + timedelta(days=300)) + cls.other_season = Season.objects.create(club=cls.other, start_date=today, end_date=today + timedelta(days=300)) def test_fk_dropdown_scoped_to_object_club(self): product = Product.objects.create(club=self.club, name="Jersey") diff --git a/teams/tests.py b/teams/tests.py index 1d433fd..5e61183 100644 --- a/teams/tests.py +++ b/teams/tests.py @@ -13,17 +13,21 @@ from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Tea class TeamsTestCase(TestCase): - def setUp(self): - self.club = Club.objects.create(name="Ajax United", slug="ajax-united") - self.season = Season.objects.create( - club=self.club, + # 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), ) - self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") - self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW") - self.coach = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True) - self.member = Member.objects.create(first_name="Jane", last_name="Doe") + 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): @@ -127,12 +131,13 @@ class StaffAssignmentModelTests(TeamsTestCase): class RosterCleanTests(TeamsTestCase): - def setUp(self): - super().setUp() - self.other = Club.objects.create(name="Rival FC", slug="rival-fc") - self.other_season = Season.objects.create(club=self.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31)) - self.other_position = Position.objects.create(club=self.other, name="Forward", short_name="FW") - self.other_coach = Position.objects.create(club=self.other, name="Coach", short_name="C", staff_position=True) + @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) @@ -155,6 +160,15 @@ class RosterCleanTests(TeamsTestCase): 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() @@ -233,10 +247,11 @@ class RefereeLevelModelTests(TeamsTestCase): class RefereeProfileModelTests(TeamsTestCase): - def setUp(self): - super().setUp() - self.level = RefereeLevel.objects.create(club=self.club, name="Regional") - self.level.teams.add(self.team) + @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)