Speed up and rationalise the test suite (158s -> 16s)

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 13:48:02 +02:00
parent 581cc81ba7
commit ffe8a3d301
13 changed files with 575 additions and 322 deletions

View File

@@ -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"], ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"],
) )
class ApiTestBase(TestCase): class ApiTestBase(TestCase):
def setUp(self): # The tenant, its current season and its one team back every API test and are read
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") # 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() 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)) cls.season = Season.objects.create(club=cls.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.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
def api_get(self, path, **params): def api_get(self, path, **params):
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app") 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): class TeamsApiTests(ApiTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW", ordering=1) super().setUpTestData()
self.defense = Position.objects.create(club=self.club, name="Defense", short_name="DF", ordering=2) cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW", ordering=1)
self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) 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): def test_list_teams(self):
data = self.api_get("/teams/").json() data = self.api_get("/teams/").json()
@@ -323,10 +329,11 @@ class TeamsApiTests(ApiTestBase):
class GamesApiTests(ApiTestBase): class GamesApiTests(ApiTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
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) super().setUpTestData()
self.opponent = Opponent.objects.create(club=self.club, name="Rivals FC") 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): 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} defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent}

View File

@@ -145,8 +145,10 @@ class WebAuthnRelyingPartyTests(TestCase):
class MFARequirementTests(TestCase): class MFARequirementTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") 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): def make_user(self, email, **kwargs):
return User.objects.create_user(email=email, password="pw-secret-123", **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). # The original destination survives the hop (percent-encoded).
self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"]) 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): class AuthFormRenderingTests(TestCase):
"""Every allauth form must actually render its fields. """Every allauth form must actually render its fields.
@@ -257,9 +256,12 @@ class AuthFormRenderingTests(TestCase):
class TwoFactorPageTests(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): def setUp(self):
user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123") # The test client is per-test, so the sign-in itself cannot be hoisted.
enrol_mfa(user)
# Password accepted, second factor still owed: this is the 2FA challenge page. # 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) 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, """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.""" 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): 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 # A real password login (not force_login) so allauth counts it as a recent
# authentication and doesn't bounce the sensitive pages to reauthenticate. # 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) 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. 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): 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) 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): def test_the_sign_out_page_has_its_button(self):
@@ -381,8 +389,11 @@ class ActionBarTests(TestCase):
class SignOutPageTests(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): def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
self.client.force_login(self.user) self.client.force_login(self.user)
self.response = self.client.get(reverse("account_logout")) self.response = self.client.get(reverse("account_logout"))
@@ -407,8 +418,11 @@ class SignOutPageTests(TestCase):
class ChangePasswordPageTests(TestCase): class ChangePasswordPageTests(TestCase):
def setUp(self): @classmethod
def setUpTestData(cls):
User.objects.create_user(email="mfa@example.com", password="pw-secret-123") 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.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
self.response = self.client.get(reverse("account_change_password")) 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 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.""" 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): 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 # Sign in *before* enrolling: a user who already holds a second factor is stopped at
# the 2FA challenge and never reaches these pages. # the 2FA challenge and never reaches these pages.
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True) self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)

View File

@@ -26,13 +26,18 @@ from .services.reminders import admin_emails, reminders_to_send, send_reminder
class BillingTestBase(TestCase): class BillingTestBase(TestCase):
def setUp(self): # setUpTestData, not setUp: the club and the priced plan are read-only scaffolding for
self.today = timezone.localdate() # every subclass, so they are built once per class. Django hands each test its own deep
self.club = Club.objects.create(name="Ajax United") # copy and rolls the database back afterwards, so the tests that archive the club or
self.plan = Plan.objects.create(name="Standard") # 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 — # 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. # 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): def bill(self, start=None, club=None):
return open_period(club or self.club, start=start, plan=self.plan) return open_period(club or self.club, start=start, plan=self.plan)
@@ -116,9 +121,10 @@ class PeriodTests(BillingTestBase):
class PaymentTests(BillingTestBase): class PaymentTests(BillingTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.due = self.bill() super().setUpTestData()
cls.due = open_period(cls.club, plan=cls.plan)
def test_a_part_payment_leaves_the_due_partially_paid(self): def test_a_part_payment_leaves_the_due_partially_paid(self):
record_payment(self.due, Decimal("200.00")) record_payment(self.due, Decimal("200.00"))
@@ -243,9 +249,10 @@ class GraceAndArchiveTests(BillingTestBase):
class ArchiveCommandTests(BillingTestBase): class ArchiveCommandTests(BillingTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10)) super().setUpTestData()
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10))
def run_command(self, *args): def run_command(self, *args):
out = StringIO() out = StringIO()
@@ -275,13 +282,14 @@ class ArchiveCommandTests(BillingTestBase):
class ReactivationTests(BillingTestBase): class ReactivationTests(BillingTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
super().setUpTestData()
# Through subscribe(), not open_period(): reactivating reads the club's plan off its # 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. # subscription, and a club billed without one cannot be re-billed later.
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=400)) subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=400))
self.first = self.club.dues.first() cls.first = cls.club.dues.first()
self.club.archive() cls.club.archive()
def test_reactivating_continues_from_the_lapsed_period_by_default(self): def test_reactivating_continues_from_the_lapsed_period_by_default(self):
due = reactivate(self.club) due = reactivate(self.club)
@@ -531,12 +539,13 @@ class TrialTests(BillingTestBase):
see billing.services.dues.start_trial and the trial-conversion check in see billing.services.dues.start_trial and the trial-conversion check in
open_period().""" open_period()."""
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
super().setUpTestData()
# A trial is a plan whose own duration_months IS the trial length -- there is no # A trial is a plan whose own duration_months IS the trial length -- there is no
# trial_months argument any more. # 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) 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=self.trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00")) 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): def test_start_trial_creates_a_short_trial_period(self):
due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan) 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 """Reminder emails -- see billing/services/reminders.py. Sent once per escalation
level, because the command is on a daily cron.""" level, because the command is on a daily cron."""
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
super().setUpTestData()
user = User.objects.create_user(email="admin@ajax.example", password="pw-secret-123") 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") member = Member.objects.create(user=user, first_name="Ada", last_name="Admin")
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN) ClubRole.objects.create(club=cls.club, member=member, role=ClubRole.Roles.ADMIN)
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1)) subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=1))
self.due = self.club.dues.first() cls.due = cls.club.dues.first()
def test_a_reminder_goes_to_the_club_admins(self): def test_a_reminder_goes_to_the_club_admins(self):
self.assertEqual(admin_emails(self.club), ["admin@ajax.example"]) self.assertEqual(admin_emails(self.club), ["admin@ajax.example"])

View File

@@ -87,10 +87,14 @@ def make_season(club, start_year=2026):
class ClubMembershipModelTests(TestCase): class ClubMembershipModelTests(TestCase):
def setUp(self): # setUpTestData, not setUp: these three are read-only scaffolding, built once per class
self.club = Club.objects.create(name="City Swim Club") # instead of once per test. Django hands each test its own deep copy, and the database
self.season = make_season(self.club) # is rolled back after every one, so the tests that archive or delete them stay isolated.
self.member = Member.objects.create( @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", first_name="Jane",
last_name="Doe", last_name="Doe",
email="jane@example.com", email="jane@example.com",
@@ -241,9 +245,13 @@ class ClubSlugTests(TestCase):
ALLOWED_HOSTS=[".rosterchief.app", ".example.com", ".example.org"], ALLOWED_HOSTS=[".rosterchief.app", ".example.com", ".example.org"],
) )
class ClubTenantMiddlewareTests(TestCase): class ClubTenantMiddlewareTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def setUp(self): def setUp(self):
self.factory = RequestFactory() 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.captured = {}
self.middleware = ClubTenantMiddleware(self._capture) self.middleware = ClubTenantMiddleware(self._capture)
@@ -331,8 +339,9 @@ class GetSubdomainTests(TestCase):
class TenantContextTests(TestCase): class TenantContextTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_require_current_club_returns_active_club(self): def test_require_current_club_returns_active_club(self):
with with_club(self.club): with with_club(self.club):
@@ -351,10 +360,11 @@ class TenantContextTests(TestCase):
class TenantScopedModelTests(TestCase): class TenantScopedModelTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.other = Club.objects.create(name="Rival FC", slug="rival-fc") cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.dates = { cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
cls.dates = {
"start_date": datetime.date(2026, 8, 1), "start_date": datetime.date(2026, 8, 1),
"end_date": datetime.date(2027, 5, 31), "end_date": datetime.date(2027, 5, 31),
} }
@@ -408,11 +418,12 @@ class TenantScopedModelTests(TestCase):
class SeasonGetCurrentTests(TestCase): class SeasonGetCurrentTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.other = Club.objects.create(name="Rival FC", slug="rival-fc") cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.season = Season.objects.create( cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
club=self.club, cls.season = Season.objects.create(
club=cls.club,
start_date=datetime.date(2026, 8, 1), start_date=datetime.date(2026, 8, 1),
end_date=datetime.date(2027, 5, 31), end_date=datetime.date(2027, 5, 31),
) )
@@ -465,10 +476,11 @@ class SeasonGetCurrentTests(TestCase):
class SeasonNextAfterTests(TestCase): class SeasonNextAfterTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.current = Season.objects.create(club=self.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31)) cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.next_season = Season.objects.create(club=self.club, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31)) 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): 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) 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): class SponsorModelTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_str_returns_name(self): def test_str_returns_name(self):
sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) 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 / each changelist and add page to catch bad list_display / search_fields /
fieldsets / autocomplete targets in any app's admin config.""" fieldsets / autocomplete targets in any app's admin config."""
def setUp(self): @classmethod
self.admin = get_user_model().objects.create_superuser(email="root@club.test", password="pw-secret-123") 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 # Staff must hold a second factor (RequireMFAMiddleware), else they are
# redirected to enrolment instead of reaching the admin. # 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) self.client.force_login(self.admin)
def test_every_model_is_registered_in_admin(self): def test_every_model_is_registered_in_admin(self):
@@ -552,8 +569,9 @@ class AdminRegistrationSmokeTests(TestCase):
class ClubArchivingTests(TestCase): class ClubArchivingTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_a_new_club_is_active(self): def test_a_new_club_is_active(self):
self.assertFalse(self.club.is_archived) 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 """An archived club's subdomain must stop resolving — that is what makes
archiving a real deactivation rather than a cosmetic flag.""" 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): def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.middleware = ClubTenantMiddleware(lambda request: "response") self.middleware = ClubTenantMiddleware(lambda request: "response")
def resolve(self): def resolve(self):
@@ -635,13 +656,14 @@ class ClubRoleTests(TestCase):
class ClubMembershipCleanTests(TestCase): class ClubMembershipCleanTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.other = Club.objects.create(name="Rival FC", slug="rival-fc") 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() today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300)) cls.season = Season.objects.create(club=cls.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)) cls.other_season = Season.objects.create(club=cls.other, start_date=today, end_date=today + datetime.timedelta(days=300))
self.member = Member.objects.create(first_name="Jane", last_name="Doe") cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
def test_rejects_cross_club_season(self): def test_rejects_cross_club_season(self):
membership = ClubMembership(club=self.club, member=self.member, season=self.other_season) membership = ClubMembership(club=self.club, member=self.member, season=self.other_season)
@@ -654,17 +676,21 @@ class ClubMembershipCleanTests(TestCase):
class AccessServiceTests(TestCase): class AccessServiceTests(TestCase):
def setUp(self): # The clubs, season, teams and positions are pure scaffolding here -- every test
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") # builds its *own* people and assignments on top of them -- so they are created once
self.other_club = Club.objects.create(name="Rival FC", slug="rival-fc") # 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() today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300)) cls.season = Season.objects.create(club=cls.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") cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
self.second_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") cls.second_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd")
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW") 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). # 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) cls.coach_position = Position.objects.create(club=cls.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.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): def make_user_member(self, email):
user = get_user_model().objects.create_user(email=email, password="pw") user = get_user_model().objects.create_user(email=email, password="pw")
@@ -922,11 +948,12 @@ class AccessServiceTests(TestCase):
class ClubRoleStatusSyncTests(TestCase): class ClubRoleStatusSyncTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate() today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300)) cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300))
self.member = Member.objects.create(first_name="Jane", last_name="Doe") cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
def roles(self): def roles(self):
return ClubRole.objects.filter(club=self.club, member=self.member) return ClubRole.objects.filter(club=self.club, member=self.member)
@@ -1028,8 +1055,9 @@ class ClubRoleStatusSyncTests(TestCase):
class BrandingTests(TestCase): class BrandingTests(TestCase):
"""The auth screens are shared; only the skin they inherit differs per tenant.""" """The auth screens are shared; only the skin they inherit differs per tenant."""
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def login_page(self, host): def login_page(self, host):
return self.client.get(reverse("account_login"), HTTP_HOST=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 error still looks like the app, not a bare Django error page, and the navbar
(sign out, theme toggle, home link) stays reachable.""" (sign out, theme toggle, home link) stays reachable."""
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") 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): 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") 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"], ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
) )
class RootViewTests(TestCase): class RootViewTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.user = get_user_model().objects.create_user(email="member@example.com", password="pw-secret-123") 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): def test_the_base_domain_hands_off_to_the_control_panel(self):
response = self.client.get("/", HTTP_HOST="rosterchief.app") 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 """club.services.fees -- record_payment/mark_as_paid/remaining_balance, the
service layer behind the Memberships page's per-row payment actions.""" service layer behind the Memberships page's per-row payment actions."""
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.season = make_season(self.club) cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.member = Member.objects.create(first_name="Jane", last_name="Doe") cls.season = make_season(cls.club)
self.membership = ClubMembership.objects.create( cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00") 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): def roles(self):
@@ -1286,8 +1317,11 @@ class SeasonStartEndTests(TestCase):
season_start/season_duration_months drive them instead of a fixed Aug-May season_start/season_duration_months drive them instead of a fixed Aug-May
window.""" window."""
def setUp(self): # Every test here reassigns a field on its own copy of the club without saving it;
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") # 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): def test_a_date_past_this_years_anchor_uses_this_year(self):
self.club.season_start = datetime.date(2000, 8, 1) 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 """club.services.seasons.generate_seasons -- the service behind the
generate_seasons management command.""" generate_seasons management command."""
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_generates_a_season_covering_today(self): def test_generates_a_season_covering_today(self):
today = timezone.localdate() today = timezone.localdate()
@@ -1399,9 +1434,10 @@ class ResyncSeasonsTests(TestCase):
match a club's current settings (e.g. left over from a since-changed match a club's current settings (e.g. left over from a since-changed
season_start/season_duration_months).""" season_start/season_duration_months)."""
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.until = timezone.localdate() + relativedelta(years=2) 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): 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)) 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): class GenerateSeasonsCommandTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_default_years_is_two(self): def test_default_years_is_two(self):
call_command("generate_seasons", stdout=StringIO()) call_command("generate_seasons", stdout=StringIO())

View File

@@ -63,11 +63,19 @@ def enrol_mfa(user):
class ControlPanelTestBase(TestCase): class ControlPanelTestBase(TestCase):
def setUp(self): # setUpTestData, not setUp: the club and the signed-in staff account are read-only
self.club = Club.objects.create(name="Ajax United") # scaffolding for almost every test here, and hashing a password per test costs more
self.staff = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True) # 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. # 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) self.client.force_login(self.staff)
@@ -321,11 +329,12 @@ class ClubHomeLocationTests(ControlPanelTestBase):
class StatisticsTests(TestCase): class StatisticsTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United")
today = timezone.localdate() today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today) cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today)
self.member = Member.objects.create(first_name="Jane", last_name="Doe") cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
def groups_for(self, club): def groups_for(self, club):
return {group["title"]: dict(group["stats"]) for group in club_statistics(club)} return {group["title"]: dict(group["stats"]) for group in club_statistics(club)}
@@ -413,9 +422,12 @@ class PlatformAdminAccessTests(ControlPanelTestBase):
class PlatformAdminTests(TestCase): 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): 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) self.client.force_login(self.root)
def test_superuser_sees_the_admins_section(self): 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 --- # --- guardrails: it must be impossible to lock the platform out of itself ---
def test_cannot_revoke_your_own_access(self): 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) response = self.client.post(reverse("controlpanel:admin_revoke", args=[self.root.pk]), follow=True)
self.root.refresh_from_db() self.root.refresh_from_db()
@@ -480,6 +494,8 @@ class PlatformAdminTests(TestCase):
self.assertTrue(self.root.is_superuser) self.assertTrue(self.root.is_superuser)
self.assertContains(response, "cannot remove your own superuser rights") 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): def test_the_last_superuser_cannot_be_demoted(self):
other = User.objects.create_superuser(email="other@example.com", password="pw-secret-123") 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... # Now demote self is blocked by the self-rule; demote `other` is fine...
@@ -492,20 +508,19 @@ class PlatformAdminTests(TestCase):
with self.assertRaises(PlatformAdminError): with self.assertRaises(PlatformAdminError):
set_platform_access(other, self.root, is_staff=True, is_superuser=False) 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): class FeatureViewTests(ControlPanelTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.flag = Flag.objects.create(name="shop")
def setUp(self): def setUp(self):
super().setUp() 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() cache.clear()
self.addCleanup(cache.clear) self.addCleanup(cache.clear)
self.flag = Flag.objects.create(name="shop")
def test_features_page_lists_flags_and_switches(self): def test_features_page_lists_flags_and_switches(self):
Switch.objects.create(name="maintenance", active=False) Switch.objects.create(name="maintenance", active=False)
@@ -717,9 +732,8 @@ class LoginFormRenderingTests(TestCase):
self.assertNotContains(self.response, '<span class="label-text">Email</span>') self.assertNotContains(self.response, '<span class="label-text">Email</span>')
self.assertContains(self.response, 'placeholder="Email address"') self.assertContains(self.response, 'placeholder="Email address"')
def test_the_checkbox_keeps_its_visible_label(self): # ("Remember Me" keeping its visible label is asserted by LoginLayoutTests below, which
self.assertContains(self.response, '<span class="label-text">Remember Me</span>') # pins the same <span> *and* that only one element renders it — a strict superset.)
def test_the_password_reset_link_is_spaced_and_addressable(self): def test_the_password_reset_link_is_spaced_and_addressable(self):
# The input's aria-describedby points here; without the id it dangles. # The input's aria-describedby points here; without the id it dangles.
self.assertContains(self.response, 'id="id_password_helptext"') self.assertContains(self.response, 'id="id_password_helptext"')
@@ -770,9 +784,10 @@ class ExcludedFilterTests(TestCase):
class PlatformAttentionTests(TestCase): class PlatformAttentionTests(TestCase):
"""The numbers that are supposed to be zero.""" """The numbers that are supposed to be zero."""
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United") def setUpTestData(cls):
self.today = timezone.localdate() cls.club = Club.objects.create(name="Ajax United")
cls.today = timezone.localdate()
def season(self, club, start, end): def season(self, club, start, end):
return Season.objects.create(club=club, start_date=start, end_date=end) return Season.objects.create(club=club, start_date=start, end_date=end)
@@ -814,8 +829,9 @@ class PlatformAttentionTests(TestCase):
class MfaPendingTests(TestCase): class MfaPendingTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United")
def test_staff_without_a_second_factor_are_pending(self): 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) 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): class FlagAdoptionTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United")
def setUp(self): def setUp(self):
# Waffle's flag cache is process-wide and would otherwise leak into the next test.
cache.clear() cache.clear()
self.addCleanup(cache.clear) self.addCleanup(cache.clear)
self.club = Club.objects.create(name="Ajax United")
def test_clubs_are_counted_per_flag(self): def test_clubs_are_counted_per_flag(self):
# Not an exact-list assertion: migration 0018 seeds real "CEHL"/"RBIHF" # Not an exact-list assertion: migration 0018 seeds real "CEHL"/"RBIHF"
@@ -892,10 +912,11 @@ class FlagAdoptionTests(TestCase):
class PlatformChartTests(TestCase): class PlatformChartTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United") def setUpTestData(cls):
self.season = Season.objects.create(club=self.club, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30)) cls.club = Club.objects.create(name="Ajax United")
self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") 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): def test_the_series_is_dense(self):
# Zero-filled: a chart that skips empty months draws a smooth line over a month # 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): class ClubAttentionTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United") def setUpTestData(cls):
self.today = timezone.localdate() cls.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)) cls.today = timezone.localdate()
self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") 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): 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) 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): class NewMemberTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United") def setUpTestData(cls):
self.today = timezone.localdate() cls.club = Club.objects.create(name="Ajax United")
self.previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) cls.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)) cls.previous = Season.objects.create(club=cls.club, start_date=cls.today - datetime.timedelta(days=400), end_date=cls.today - datetime.timedelta(days=40))
self.veteran = Member.objects.create(first_name="Ada", last_name="Lovelace") cls.season = Season.objects.create(club=cls.club, start_date=cls.today - datetime.timedelta(days=30), end_date=cls.today + datetime.timedelta(days=300))
self.rookie = Member.objects.create(first_name="Bob", last_name="Bobson") 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): 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) 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): class ClubHealthTableTests(TestCase):
def setUp(self): @classmethod
self.today = timezone.localdate() def setUpTestData(cls):
self.club = Club.objects.create(name="Ajax United") cls.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)) cls.club = Club.objects.create(name="Ajax United")
self.member = Member.objects.create(first_name="Ada", last_name="Lovelace") 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): def health(self):
return clubs_with_health().get(pk=self.club.pk) return clubs_with_health().get(pk=self.club.pk)
@@ -1277,11 +1301,12 @@ class TemplateCommentTests(TestCase):
class PlatformDuesMetricTests(TestCase): class PlatformDuesMetricTests(TestCase):
"""What the clubs owe US — kept strictly apart from what members owe their clubs.""" """What the clubs owe US — kept strictly apart from what members owe their clubs."""
def setUp(self): @classmethod
self.today = timezone.localdate() def setUpTestData(cls):
self.club = Club.objects.create(name="Ajax United") cls.today = timezone.localdate()
self.plan = Plan.objects.create(name="Standard") cls.club = Club.objects.create(name="Ajax United")
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.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 test_dues_owed_is_the_unpaid_balance_across_every_club(self): def test_dues_owed_is_the_unpaid_balance_across_every_club(self):
subscribe(self.club, self.plan) subscribe(self.club, self.plan)
@@ -1383,11 +1408,12 @@ class PlatformDuesMetricTests(TestCase):
class BillingPanelTests(ControlPanelTestBase): class BillingPanelTests(ControlPanelTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.today = timezone.localdate() super().setUpTestData()
self.plan = Plan.objects.create(name="Standard") cls.today = timezone.localdate()
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.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 test_the_billing_page_lists_plans_and_what_is_owed(self): def test_the_billing_page_lists_plans_and_what_is_owed(self):
subscribe(self.club, self.plan) subscribe(self.club, self.plan)
@@ -1507,14 +1533,15 @@ class TrialPanelTests(ControlPanelTestBase):
"""The club detail page's "Start trial" modal -- see """The club detail page's "Start trial" modal -- see
controlpanel.views.ClubStartTrialView / billing.services.dues.start_trial.""" controlpanel.views.ClubStartTrialView / billing.services.dues.start_trial."""
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.today = timezone.localdate() super().setUpTestData()
cls.today = timezone.localdate()
# A trial plan carries its own length; the form only offers plans flagged is_trial. # 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) cls.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")) PlanPrice.objects.create(plan=cls.trial_plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
self.plan = Plan.objects.create(name="Standard") cls.plan = Plan.objects.create(name="Standard")
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 start_trial(self, **data): def start_trial(self, **data):
# "on" for both, matching how a freshly opened modal actually renders: unbound, # "on" for both, matching how a freshly opened modal actually renders: unbound,
@@ -1585,11 +1612,12 @@ class TrialPanelTests(ControlPanelTestBase):
class BillingFormRenderTests(ControlPanelTestBase): class BillingFormRenderTests(ControlPanelTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.today = timezone.localdate() super().setUpTestData()
self.plan = Plan.objects.create(name="Standard") cls.today = timezone.localdate()
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.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 test_the_billing_forms_are_post_only(self): def test_the_billing_forms_are_post_only(self):
# Every one of these is reachable only through a modal on the billing or club # 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): class PlanDeleteTests(ControlPanelTestBase):
"""billing.services.plans and controlpanel.views.PlanDeleteView.""" """billing.services.plans and controlpanel.views.PlanDeleteView."""
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.today = timezone.localdate() super().setUpTestData()
self.plan = Plan.objects.create(name="Standard") cls.today = timezone.localdate()
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.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 test_a_never_used_plan_is_removed_completely(self): def test_a_never_used_plan_is_removed_completely(self):
unused = Plan.objects.create(name="Unused") unused = Plan.objects.create(name="Unused")

View File

@@ -33,21 +33,26 @@ from .services.referees import RefereeAssignmentError, add_external_referee, ass
class EventsTestBase(TestCase): class EventsTestBase(TestCase):
def setUp(self): # One club, one current season, one two-player roster: shared by every events test
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") # 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() today = timezone.localdate()
self.season = Season.objects.create( cls.season = Season.objects.create(
club=self.club, club=cls.club,
start_date=today - timedelta(days=30), start_date=today - timedelta(days=30),
end_date=today + timedelta(days=300), end_date=today + timedelta(days=300),
) )
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW") cls.position = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
self.alice = Member.objects.create(first_name="Alice", last_name="Ash") cls.alice = Member.objects.create(first_name="Alice", last_name="Ash")
self.bob = Member.objects.create(first_name="Bob", last_name="Birch") cls.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=cls.team, member=cls.alice, season=cls.season, position=cls.position)
TeamMembership.objects.create(team=self.team, member=self.bob, season=self.season, position=self.position) TeamMembership.objects.create(team=cls.team, member=cls.bob, season=cls.season, position=cls.position)
self.future = timezone.now() + timedelta(days=7) cls.future = timezone.now() + timedelta(days=7)
def make_event(self, **kwargs): def make_event(self, **kwargs):
kwargs.setdefault("club", self.club) kwargs.setdefault("club", self.club)
@@ -140,15 +145,16 @@ class EventAdminFormCompetitionTests(EventsTestBase):
"""`competition` is a plain CharField, but the admin should only ever offer """`competition` is a plain CharField, but the admin should only ever offer
competitions this club is actually allowed to use -- see events/admin.py.""" competitions this club is actually allowed to use -- see events/admin.py."""
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
super().setUpTestData()
Flag = get_waffle_flag_model() Flag = get_waffle_flag_model()
self.active_flag = Flag.objects.create(name="active-competition") cls.active_flag = Flag.objects.create(name="active-competition")
self.active_flag.clubs.add(self.club) cls.active_flag.clubs.add(cls.club)
self.inactive_flag = Flag.objects.create(name="inactive-competition") cls.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) cls.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=cls.active_flag)
self.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=self.inactive_flag) cls.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=cls.inactive_flag)
self.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless") cls.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless")
def test_a_flagless_competition_never_appears(self): def test_a_flagless_competition_never_appears(self):
form = EventAdminForm(instance=Event()) form = EventAdminForm(instance=Event())
@@ -366,9 +372,10 @@ class RosterChangeSyncTests(EventsTestBase):
class RecurrenceTestBase(EventsTestBase): class RecurrenceTestBase(EventsTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0) super().setUpTestData()
cls.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0)
def make_series(self, **kwargs): def make_series(self, **kwargs):
kwargs.setdefault("club", self.club) kwargs.setdefault("club", self.club)
@@ -551,14 +558,15 @@ class ExtendSeriesCommandTests(RecurrenceTestBase):
class EventClubScopeTests(EventsTestBase): class EventClubScopeTests(EventsTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.other = Club.objects.create(name="Rival FC", slug="rival-fc") super().setUpTestData()
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
today = timezone.localdate() today = timezone.localdate()
self.other_season = Season.objects.create(club=self.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300)) cls.other_season = Season.objects.create(club=cls.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") cls.other_location = Location.objects.create(club=cls.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE")
self.other_opponent = Opponent.objects.create(club=self.other, name="Rivals") cls.other_opponent = Opponent.objects.create(club=cls.other, name="Rivals")
self.other_team = Team.objects.create(club=self.other, name="First", short_name="1") cls.other_team = Team.objects.create(club=cls.other, name="First", short_name="1")
def test_event_rejects_cross_club_season(self): def test_event_rejects_cross_club_season(self):
event = Event(club=self.club, title="Match", start=self.future, season=self.other_season) event = Event(club=self.club, title="Match", start=self.future, season=self.other_season)
@@ -797,10 +805,11 @@ class RBIHFExtractTeamIdTests(TestCase):
class RBIHFImportPlanTests(EventsTestBase): class RBIHFImportPlanTests(EventsTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
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) super().setUpTestData()
self.away_location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE") 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"): 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"} 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 RefereeProfile.eligible_teams), conflict detection (a soft warning, never
a block), and the max_referees hard ceiling.""" a block), and the max_referees hard ceiling."""
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
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) super().setUpTestData()
self.away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE") 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") cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
self.level.teams.add(self.team) cls.level.teams.add(cls.team)
self.referee = Member.objects.create(first_name="Ref", last_name="Eree") cls.referee = Member.objects.create(first_name="Ref", last_name="Eree")
self.referee_profile = self.make_eligible_profile(self.referee) 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): 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)) return RefereeProfile.objects.create(member=member, level=level or self.level, valid_until=timezone.localdate() + timedelta(days=30))

View File

@@ -17,15 +17,19 @@ User = get_user_model()
class ClubScopedFlagTests(TestCase): 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): def setUp(self):
# waffle caches flags by name, and its cache is NOT rolled back with the # 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 transaction -- a flag row whose targeting changed in the previous
# test would otherwise be shadowed by the previous test's cached object. # 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() cache.clear()
self.addCleanup(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): def request_for(self, club):
request = RequestFactory().get("/") request = RequestFactory().get("/")
@@ -118,12 +122,17 @@ class ClubScopedFlagTests(TestCase):
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
) )
class MaintenanceModeTests(TestCase): 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): 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() cache.clear()
self.addCleanup(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="/"): def club_get(self, path="/"):
return self.client.get(path, HTTP_HOST="ajax-united.rosterchief.app") return self.client.get(path, HTTP_HOST="ajax-united.rosterchief.app")

View File

@@ -22,12 +22,16 @@ from .services import (
class FormbuilderTestBase(TestCase): class FormbuilderTestBase(TestCase):
def setUp(self): # One two-field form, shared by every test here. The tests that reconfigure it
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") # (closing the window, flipping login_required) mutate a per-test copy handed out
self.form = Form.objects.create(club=self.club, title="Sign-up", slug="sign-up") # by setUpTestData, and their saves roll back with the transaction.
self.name = Field.objects.create(form=self.form, key="name", label="Name", field_type=Field.FieldType.TEXT, required=True, order=1) @classmethod
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"]) def setUpTestData(cls):
self.member = Member.objects.create(first_name="Jane", last_name="Doe") 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): class ModelTests(FormbuilderTestBase):

View File

@@ -19,7 +19,6 @@ from members.models import Family, FamilyMembership, Group, GroupMembership, Mem
from members.services import MemberImportResult from members.services import MemberImportResult
# Create your tests here.
class MemberModelTests(TestCase): class MemberModelTests(TestCase):
def test_str_and_name_helpers(self): def test_str_and_name_helpers(self):
member = Member.objects.create(first_name="John", last_name="Smith") member = Member.objects.create(first_name="John", last_name="Smith")
@@ -95,17 +94,19 @@ class FamilyNameOptionalTests(TestCase):
class FamilyModelTests(TestCase): class FamilyModelTests(TestCase):
def setUp(self): @classmethod
self.family = Family.objects.create(name="The Smiths") def setUpTestData(cls):
self.parent = Member.objects.create(first_name="Pat", last_name="Smith") # One family with a member in every role -- read-only for all four tests.
self.guardian = Member.objects.create(first_name="Gale", last_name="Smith") cls.family = Family.objects.create(name="The Smiths")
self.child = Member.objects.create(first_name="Kim", last_name="Smith") cls.parent = Member.objects.create(first_name="Pat", last_name="Smith")
self.other = Member.objects.create(first_name="Ola", 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=cls.family, member=cls.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=self.family, member=self.guardian, role=FamilyMembership.FamilyRole.GUARDIAN) FamilyMembership.objects.create(family=cls.family, member=cls.guardian, role=FamilyMembership.FamilyRole.GUARDIAN)
FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD) FamilyMembership.objects.create(family=cls.family, member=cls.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.other, role=FamilyMembership.FamilyRole.OTHER)
def test_str(self): def test_str(self):
self.assertEqual(str(self.family), "The Smiths") self.assertEqual(str(self.family), "The Smiths")
@@ -210,8 +211,9 @@ class FamilyMembershipModelTests(TestCase):
class GroupModelTests(TestCase): class GroupModelTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_str_returns_name(self): def test_str_returns_name(self):
group = Group.objects.create(club=self.club, name="Coaches") group = Group.objects.create(club=self.club, name="Coaches")
@@ -233,10 +235,11 @@ class GroupModelTests(TestCase):
class GroupMembershipModelTests(TestCase): class GroupMembershipModelTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.group = Group.objects.create(club=self.club, name="Referees") cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.member = Member.objects.create(first_name="Ref", last_name="Eree") 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): def test_str(self):
membership = GroupMembership.objects.create(group=self.group, member=self.member) 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 """Exercise the admin config end-to-end to catch misregistration
(bad search_fields, autocomplete targets, fieldsets, custom forms).""" (bad search_fields, autocomplete targets, fieldsets, custom forms)."""
def setUp(self): @classmethod
self.admin = User.objects.create_superuser(email="root@example.com", password="pw-secret-123") def setUpTestData(cls):
cls.admin = User.objects.create_superuser(email="root@example.com", password="pw-secret-123")
# Staff must hold a second factor (RequireMFAMiddleware). # 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) self.client.force_login(self.admin)
def test_changelists_load(self): def test_changelists_load(self):
@@ -354,13 +361,14 @@ class AdminSmokeTests(TestCase):
class ImportMembersCsvCommandTests(TestCase): class ImportMembersCsvCommandTests(TestCase):
def setUp(self): @classmethod
def setUpTestData(cls):
# Memberships are season-scoped: the importer attaches each one to the # Memberships are season-scoped: the importer attaches each one to the
# club's current season, so the target club needs one covering today. # 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() today = timezone.localdate()
self.season = Season.objects.create( cls.season = Season.objects.create(
club=self.club, club=cls.club,
start_date=today - timedelta(days=90), start_date=today - timedelta(days=90),
end_date=today + timedelta(days=275), end_date=today + timedelta(days=275),
) )

View File

@@ -208,6 +208,15 @@ TEMPLATES = [
WSGI_APPLICATION = "rosterchief.wsgi.application" 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 # Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases # https://docs.djangoproject.com/en/6.0/ref/settings/#databases

View File

@@ -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)

View File

@@ -31,8 +31,9 @@ from .models import (
class ProductSlugTests(TestCase): class ProductSlugTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_slug_auto_populated_from_name(self): def test_slug_auto_populated_from_name(self):
product = Product.objects.create(club=self.club, name="Home Jersey") product = Product.objects.create(club=self.club, name="Home Jersey")
@@ -70,9 +71,10 @@ class ProductSlugTests(TestCase):
class OpenCartConstraintTests(TestCase): class OpenCartConstraintTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.user = User.objects.create_user(email="shopper@example.com", password="pw") 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): def test_only_one_open_cart_per_user_per_club(self):
Cart.objects.create(club=self.club, user=self.user) Cart.objects.create(club=self.club, user=self.user)
@@ -113,10 +115,11 @@ class CartItemTests(TestCase):
class OrderNumberTests(TestCase): class OrderNumberTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.member = Member.objects.create(first_name="Jane", last_name="Doe") cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.year = timezone.now().year cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
cls.year = timezone.now().year
def make_order(self, **kwargs): def make_order(self, **kwargs):
kwargs.setdefault("club", self.club) kwargs.setdefault("club", self.club)
@@ -195,12 +198,13 @@ class OrderNumberTests(TestCase):
class ShopEntitiesTestBase(TestCase): class ShopEntitiesTestBase(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.member = Member.objects.create(first_name="Jane", last_name="Doe") cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.product = Product.objects.create(club=self.club, name="Home Jersey") cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
self.order = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00")) cls.product = Product.objects.create(club=cls.club, name="Home Jersey")
self.year = timezone.now().year cls.order = Order.objects.create(club=cls.club, purchaser=cls.member, total=Decimal("50.00"))
cls.year = timezone.now().year
class OrderLineTests(ShopEntitiesTestBase): class OrderLineTests(ShopEntitiesTestBase):
@@ -241,9 +245,10 @@ class DiscountTests(ShopEntitiesTestBase):
class AppliedDiscountTests(ShopEntitiesTestBase): class AppliedDiscountTests(ShopEntitiesTestBase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.discount = Discount.objects.create(club=self.club, name="Sibling") super().setUpTestData()
cls.discount = Discount.objects.create(club=cls.club, name="Sibling")
def apply(self, **kwargs): def apply(self, **kwargs):
kwargs.setdefault("order", self.order) kwargs.setdefault("order", self.order)
@@ -311,15 +316,16 @@ class InvoiceTests(ShopEntitiesTestBase):
class ClubScopeValidationTests(TestCase): class ClubScopeValidationTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.other = Club.objects.create(name="Rival FC", slug="rival-fc") 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() today = timezone.localdate()
self.season = Season.objects.create(club=self.club, 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))
self.other_season = Season.objects.create(club=self.other, 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))
self.member = Member.objects.create(first_name="Jane", last_name="Doe") cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season) ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
self.stranger = Member.objects.create(first_name="Stray", last_name="Ger") cls.stranger = Member.objects.create(first_name="Stray", last_name="Ger")
def make_cart(self, club): def make_cart(self, club):
user = User.objects.create_user(email=f"u-{club.slug}@example.com", password="pw") user = User.objects.create_user(email=f"u-{club.slug}@example.com", password="pw")
@@ -442,12 +448,13 @@ class ClubScopeValidationTests(TestCase):
class AdminScopingTests(TestCase): class AdminScopingTests(TestCase):
def setUp(self): @classmethod
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") def setUpTestData(cls):
self.other = Club.objects.create(name="Rival FC", slug="rival-fc") 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() today = timezone.localdate()
self.season = Season.objects.create(club=self.club, 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))
self.other_season = Season.objects.create(club=self.other, 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): def test_fk_dropdown_scoped_to_object_club(self):
product = Product.objects.create(club=self.club, name="Jersey") product = Product.objects.create(club=self.club, name="Jersey")

View File

@@ -13,17 +13,21 @@ from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Tea
class TeamsTestCase(TestCase): class TeamsTestCase(TestCase):
def setUp(self): # Shared read-only scaffolding for every teams test. The few that delete a fixture
self.club = Club.objects.create(name="Ajax United", slug="ajax-united") # (member, team, season) get a per-test copy from setUpTestData and the rows come
self.season = Season.objects.create( # back with the transaction rollback.
club=self.club, @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), start_date=datetime.date(2026, 8, 1),
end_date=datetime.date(2027, 5, 31), end_date=datetime.date(2027, 5, 31),
) )
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW") cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
self.coach = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True) cls.coach = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True)
self.member = Member.objects.create(first_name="Jane", last_name="Doe") cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
class TeamModelTests(TeamsTestCase): class TeamModelTests(TeamsTestCase):
@@ -127,12 +131,13 @@ class StaffAssignmentModelTests(TeamsTestCase):
class RosterCleanTests(TeamsTestCase): class RosterCleanTests(TeamsTestCase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.other = Club.objects.create(name="Rival FC", slug="rival-fc") super().setUpTestData()
self.other_season = Season.objects.create(club=self.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31)) cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
self.other_position = Position.objects.create(club=self.other, name="Forward", short_name="FW") cls.other_season = Season.objects.create(club=cls.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
self.other_coach = Position.objects.create(club=self.other, name="Coach", short_name="C", staff_position=True) 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): def test_teammembership_rejects_cross_club_season(self):
entry = TeamMembership(team=self.team, member=self.member, season=self.other_season, position=self.forward) 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() assignment.full_clean()
self.assertIn("season", ctx.exception.error_dict) 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): def test_staffassignment_accepts_same_club(self):
StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.coach).full_clean() 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): class RefereeProfileModelTests(TeamsTestCase):
def setUp(self): @classmethod
super().setUp() def setUpTestData(cls):
self.level = RefereeLevel.objects.create(club=self.club, name="Regional") super().setUpTestData()
self.level.teams.add(self.team) cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
def test_str(self): def test_str(self):
profile = RefereeProfile.objects.create(member=self.member) profile = RefereeProfile.objects.create(member=self.member)