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)