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"],
)
class ApiTestBase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
# The tenant, its current season and its one team back every API test and are read
# only. The handful of tests that do change them (deleting the season, giving the
# club a logo) get their own copy from setUpTestData and are rolled back with the
# per-test transaction.
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
def api_get(self, path, **params):
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app")
@@ -211,11 +216,12 @@ class NewsApiTests(ApiTestBase):
class TeamsApiTests(ApiTestBase):
def setUp(self):
super().setUp()
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW", ordering=1)
self.defense = Position.objects.create(club=self.club, name="Defense", short_name="DF", ordering=2)
self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW", ordering=1)
cls.defense = Position.objects.create(club=cls.club, name="Defense", short_name="DF", ordering=2)
cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
def test_list_teams(self):
data = self.api_get("/teams/").json()
@@ -323,10 +329,11 @@ class TeamsApiTests(ApiTestBase):
class GamesApiTests(ApiTestBase):
def setUp(self):
super().setUp()
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
self.opponent = Opponent.objects.create(club=self.club, name="Rivals FC")
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.home_location = Location.objects.create(club=cls.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.opponent = Opponent.objects.create(club=cls.club, name="Rivals FC")
def make_game(self, **overrides):
defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent}

View File

@@ -145,8 +145,10 @@ class WebAuthnRelyingPartyTests(TestCase):
class MFARequirementTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
@classmethod
def setUpTestData(cls):
# Read-only for every test here: each one brings its own user and role.
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def make_user(self, email, **kwargs):
return User.objects.create_user(email=email, password="pw-secret-123", **kwargs)
@@ -232,9 +234,6 @@ class AdminLoginRoutingTests(TestCase):
# The original destination survives the hop (percent-encoded).
self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"])
def test_allauth_login_page_loads(self):
self.assertEqual(self.client.get(reverse("account_login")).status_code, 200)
class AuthFormRenderingTests(TestCase):
"""Every allauth form must actually render its fields.
@@ -257,9 +256,12 @@ class AuthFormRenderingTests(TestCase):
class TwoFactorPageTests(TestCase):
@classmethod
def setUpTestData(cls):
enrol_mfa(User.objects.create_user(email="mfa@example.com", password="pw-secret-123"))
def setUp(self):
user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
enrol_mfa(user)
# The test client is per-test, so the sign-in itself cannot be hoisted.
# Password accepted, second factor still owed: this is the 2FA challenge page.
self.response = self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
@@ -313,8 +315,11 @@ class MfaPageTests(TestCase):
"""Every MFA screen must render. They are built from allauth's `element` primitives,
so styling lives in the element overrides rather than in eight page templates."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
# A real password login (not force_login) so allauth counts it as a recent
# authentication and doesn't bounce the sensitive pages to reauthenticate.
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
@@ -361,8 +366,11 @@ class ActionBarTests(TestCase):
Keying the bar on it hid the button on exactly the pages that are nothing but a button.
"""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
def test_the_sign_out_page_has_its_button(self):
@@ -381,8 +389,11 @@ class ActionBarTests(TestCase):
class SignOutPageTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
self.client.force_login(self.user)
self.response = self.client.get(reverse("account_logout"))
@@ -407,8 +418,11 @@ class SignOutPageTests(TestCase):
class ChangePasswordPageTests(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
self.response = self.client.get(reverse("account_change_password"))
@@ -438,8 +452,11 @@ class MfaButtonIconTests(TestCase):
ranked: View is primary, Download and Generate are outline. Generate throws away the
codes you already have, so it must not read as the obvious thing to click."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
# Sign in *before* enrolling: a user who already holds a second factor is stopped at
# the 2FA challenge and never reaches these pages.
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)

View File

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

View File

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

View File

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

View File

@@ -33,21 +33,26 @@ from .services.referees import RefereeAssignmentError, add_external_referee, ass
class EventsTestBase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
# One club, one current season, one two-player roster: shared by every events test
# and never edited in place. Tests that do change these rows (deleting Alice, flipping
# the team to federation-managed) are safe -- setUpTestData hands each test its own
# copy of the objects, and the surrounding transaction rolls the rows back.
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate()
self.season = Season.objects.create(
club=self.club,
cls.season = Season.objects.create(
club=cls.club,
start_date=today - timedelta(days=30),
end_date=today + timedelta(days=300),
)
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW")
self.alice = Member.objects.create(first_name="Alice", last_name="Ash")
self.bob = Member.objects.create(first_name="Bob", last_name="Birch")
TeamMembership.objects.create(team=self.team, member=self.alice, season=self.season, position=self.position)
TeamMembership.objects.create(team=self.team, member=self.bob, season=self.season, position=self.position)
self.future = timezone.now() + timedelta(days=7)
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.position = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
cls.alice = Member.objects.create(first_name="Alice", last_name="Ash")
cls.bob = Member.objects.create(first_name="Bob", last_name="Birch")
TeamMembership.objects.create(team=cls.team, member=cls.alice, season=cls.season, position=cls.position)
TeamMembership.objects.create(team=cls.team, member=cls.bob, season=cls.season, position=cls.position)
cls.future = timezone.now() + timedelta(days=7)
def make_event(self, **kwargs):
kwargs.setdefault("club", self.club)
@@ -140,15 +145,16 @@ class EventAdminFormCompetitionTests(EventsTestBase):
"""`competition` is a plain CharField, but the admin should only ever offer
competitions this club is actually allowed to use -- see events/admin.py."""
def setUp(self):
super().setUp()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
Flag = get_waffle_flag_model()
self.active_flag = Flag.objects.create(name="active-competition")
self.active_flag.clubs.add(self.club)
self.inactive_flag = Flag.objects.create(name="inactive-competition")
self.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=self.active_flag)
self.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=self.inactive_flag)
self.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless")
cls.active_flag = Flag.objects.create(name="active-competition")
cls.active_flag.clubs.add(cls.club)
cls.inactive_flag = Flag.objects.create(name="inactive-competition")
cls.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=cls.active_flag)
cls.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=cls.inactive_flag)
cls.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless")
def test_a_flagless_competition_never_appears(self):
form = EventAdminForm(instance=Event())
@@ -366,9 +372,10 @@ class RosterChangeSyncTests(EventsTestBase):
class RecurrenceTestBase(EventsTestBase):
def setUp(self):
super().setUp()
self.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0)
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0)
def make_series(self, **kwargs):
kwargs.setdefault("club", self.club)
@@ -551,14 +558,15 @@ class ExtendSeriesCommandTests(RecurrenceTestBase):
class EventClubScopeTests(EventsTestBase):
def setUp(self):
super().setUp()
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
today = timezone.localdate()
self.other_season = Season.objects.create(club=self.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300))
self.other_location = Location.objects.create(club=self.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE")
self.other_opponent = Opponent.objects.create(club=self.other, name="Rivals")
self.other_team = Team.objects.create(club=self.other, name="First", short_name="1")
cls.other_season = Season.objects.create(club=cls.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300))
cls.other_location = Location.objects.create(club=cls.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE")
cls.other_opponent = Opponent.objects.create(club=cls.other, name="Rivals")
cls.other_team = Team.objects.create(club=cls.other, name="First", short_name="1")
def test_event_rejects_cross_club_season(self):
event = Event(club=self.club, title="Match", start=self.future, season=self.other_season)
@@ -797,10 +805,11 @@ class RBIHFExtractTeamIdTests(TestCase):
class RBIHFImportPlanTests(EventsTestBase):
def setUp(self):
super().setUp()
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True)
self.away_location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE")
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.home_location = Location.objects.create(club=cls.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True)
cls.away_location = Location.objects.create(club=cls.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE")
def home_fixture_row(self, game_id="5002", date="2026-09-12"):
return {"game_id": game_id, "date": date, "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}
@@ -1048,16 +1057,17 @@ class RefereeServiceTests(EventsTestBase):
RefereeProfile.eligible_teams), conflict detection (a soft warning, never
a block), and the max_referees hard ceiling."""
def setUp(self):
super().setUp()
self.home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
self.away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.home_ground = Location.objects.create(club=cls.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.away_ground = Location.objects.create(club=cls.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
self.level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.level.teams.add(self.team)
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
self.referee = Member.objects.create(first_name="Ref", last_name="Eree")
self.referee_profile = self.make_eligible_profile(self.referee)
cls.referee = Member.objects.create(first_name="Ref", last_name="Eree")
cls.referee_profile = RefereeProfile.objects.create(member=cls.referee, level=cls.level, valid_until=timezone.localdate() + timedelta(days=30))
def make_eligible_profile(self, member, level=None):
return RefereeProfile.objects.create(member=member, level=level or self.level, valid_until=timezone.localdate() + timedelta(days=30))

View File

@@ -17,15 +17,19 @@ User = get_user_model()
class ClubScopedFlagTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
cls.flag = Flag.objects.create(name="shop")
def setUp(self):
# waffle caches flags by name, and its cache is NOT rolled back with the
# test transaction -- a flag row recreated under the same name in the next
# test would otherwise be shadowed by the previous test's cached object.
# test transaction -- a flag row whose targeting changed in the previous
# test would otherwise be shadowed by that test's cached object. Has to
# stay per-test: it is the cache, not the rows, that leaks.
cache.clear()
self.addCleanup(cache.clear)
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
self.flag = Flag.objects.create(name="shop")
def request_for(self, club):
request = RequestFactory().get("/")
@@ -118,12 +122,17 @@ class ClubScopedFlagTests(TestCase):
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
)
class MaintenanceModeTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.user = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True)
Authenticator.objects.create(user=cls.user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
def setUp(self):
# Maintenance state lives in the shared cache, which no transaction rolls
# back -- each test has to start from a reopened platform.
cache.clear()
self.addCleanup(cache.clear)
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.user = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True)
Authenticator.objects.create(user=self.user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
def club_get(self, path="/"):
return self.client.get(path, HTTP_HOST="ajax-united.rosterchief.app")

View File

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

View File

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

View File

@@ -208,6 +208,15 @@ TEMPLATES = [
WSGI_APPLICATION = "rosterchief.wsgi.application"
# Tests
#
# A custom runner, not extra settings: it swaps in a fast password hasher, the cached
# template loader and a quiet django.request logger while the suite runs. Those belong
# nowhere near a deployed process, and a runner is only ever instantiated by
# `manage.py test` -- see rosterchief/test_runner.py for the full reasoning.
TEST_RUNNER = "rosterchief.test_runner.RosterChiefTestRunner"
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases

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

View File

@@ -13,17 +13,21 @@ from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Tea
class TeamsTestCase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.season = Season.objects.create(
club=self.club,
# Shared read-only scaffolding for every teams test. The few that delete a fixture
# (member, team, season) get a per-test copy from setUpTestData and the rows come
# back with the transaction rollback.
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = Season.objects.create(
club=cls.club,
start_date=datetime.date(2026, 8, 1),
end_date=datetime.date(2027, 5, 31),
)
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW")
self.coach = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True)
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
cls.coach = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True)
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
class TeamModelTests(TeamsTestCase):
@@ -127,12 +131,13 @@ class StaffAssignmentModelTests(TeamsTestCase):
class RosterCleanTests(TeamsTestCase):
def setUp(self):
super().setUp()
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
self.other_season = Season.objects.create(club=self.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
self.other_position = Position.objects.create(club=self.other, name="Forward", short_name="FW")
self.other_coach = Position.objects.create(club=self.other, name="Coach", short_name="C", staff_position=True)
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
cls.other_season = Season.objects.create(club=cls.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
cls.other_position = Position.objects.create(club=cls.other, name="Forward", short_name="FW")
cls.other_coach = Position.objects.create(club=cls.other, name="Coach", short_name="C", staff_position=True)
def test_teammembership_rejects_cross_club_season(self):
entry = TeamMembership(team=self.team, member=self.member, season=self.other_season, position=self.forward)
@@ -155,6 +160,15 @@ class RosterCleanTests(TeamsTestCase):
assignment.full_clean()
self.assertIn("season", ctx.exception.error_dict)
def test_staffassignment_rejects_cross_club_position(self):
# The StaffAssignment half of test_teammembership_rejects_cross_club_position:
# clean() validates `position` as well as `season` against the team's club, and
# nothing exercised that branch -- `other_coach` was sitting unused waiting for it.
assignment = StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.other_coach)
with self.assertRaises(ValidationError) as ctx:
assignment.full_clean()
self.assertIn("position", ctx.exception.error_dict)
def test_staffassignment_accepts_same_club(self):
StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.coach).full_clean()
@@ -233,10 +247,11 @@ class RefereeLevelModelTests(TeamsTestCase):
class RefereeProfileModelTests(TeamsTestCase):
def setUp(self):
super().setUp()
self.level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.level.teams.add(self.team)
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
def test_str(self):
profile = RefereeProfile.objects.create(member=self.member)