import datetime import uuid from contextlib import contextmanager from decimal import Decimal from io import StringIO from allauth.mfa.models import Authenticator from dateutil.relativedelta import relativedelta from django.contrib import admin as django_admin from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.management import call_command from django.db import IntegrityError from django.db.models import ProtectedError from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse from django.utils import timezone from events.models import Event from members.models import Family, FamilyMembership, Member from teams.models import Position, StaffAssignment, Team, TeamMembership from teams.services import eligible_roster_members from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor, club_logo_path from .services.access import ( COACH_MANAGER, can_edit_event, can_manage_shop, has_club_role, members_visible_to, roles_in_club, teams_managed_by, teams_staffed_by, ) from .services.fees import mark_as_paid, record_payment, remaining_balance from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons from .tenancy import ( ClubTenantMiddleware, get_current_club, require_current_club, reset_current_club, set_current_club, ) @contextmanager def with_club(club): """Bind ``club`` as the active tenant for the duration of the block.""" token = set_current_club(club) try: yield club finally: reset_current_club(token) class ClubModelTests(TestCase): def test_str_returns_name(self): club = Club.objects.create(name="City Swim Club") self.assertEqual(str(club), "City Swim Club") def test_pk_is_uuid(self): club = Club.objects.create(name="City Swim Club") self.assertIsInstance(club.pk, uuid.UUID) def test_clubs_are_ordered_by_name(self): Club.objects.create(name="Zulu Club") Club.objects.create(name="Alpha Club") Club.objects.create(name="Middle Club") self.assertEqual( list(Club.objects.values_list("name", flat=True)), ["Alpha Club", "Middle Club", "Zulu Club"], ) def test_verbose_names(self): self.assertEqual(Club._meta.verbose_name, "club") self.assertEqual(Club._meta.verbose_name_plural, "clubs") def make_season(club, start_year=2026): return Season.objects.create( club=club, start_date=datetime.date(start_year, 8, 1), end_date=datetime.date(start_year + 1, 5, 31), ) class ClubMembershipModelTests(TestCase): # 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", ) def test_str_returns_club_and_member(self): membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member, license="LIC-001") self.assertEqual(str(membership), "City Swim Club - Jane Doe") def test_defaults_are_pending_and_unpaid(self): membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) self.assertEqual(membership.status, ClubMembership.StatusChoices.PENDING) self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.UNPAID) self.assertEqual(membership.license, "") self.assertIsNone(membership.signed_up_at) def test_pk_is_uuid(self): membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) self.assertIsInstance(membership.pk, uuid.UUID) def test_club_is_filled_from_active_tenant(self): with with_club(self.club): membership = ClubMembership.objects.create(season=self.season, member=self.member) self.assertEqual(membership.club, self.club) def test_member_is_unique_per_club_and_season(self): ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) with self.assertRaises(IntegrityError): ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) def test_same_member_can_join_consecutive_seasons(self): next_season = make_season(self.club, start_year=2027) first = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) second = ClubMembership.objects.create(club=self.club, season=next_season, member=self.member) self.assertEqual(self.member.member_of.count(), 2) self.assertNotEqual(first.season, second.season) def test_same_member_can_join_different_clubs(self): other_club = Club.objects.create(name="Other Swim Club") other_season = make_season(other_club) ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) ClubMembership.objects.create(club=other_club, season=other_season, member=self.member) self.assertEqual(self.member.member_of.count(), 2) def test_deleting_club_is_blocked_while_a_season_has_memberships(self): # Club -> Season is CASCADE, but ClubMembership -> Season is PROTECT, so # the club can't be deleted while one of its seasons is still referenced. ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) with self.assertRaises(ProtectedError): self.club.delete() self.assertTrue(ClubMembership.objects.exists()) def test_deleting_empty_club_cascades_to_its_seasons(self): self.club.delete() self.assertFalse(Club.objects.filter(pk=self.club.pk).exists()) self.assertFalse(Season.objects.filter(pk=self.season.pk).exists()) def test_deleting_member_deletes_membership_but_keeps_club(self): ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) self.member.delete() self.assertFalse(ClubMembership.objects.exists()) self.assertTrue(Club.objects.filter(pk=self.club.pk).exists()) def test_season_is_protected_while_referenced(self): ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) with self.assertRaises(ProtectedError): self.season.delete() def test_memberships_are_ordered_by_club_then_member_name(self): alpha_club = Club.objects.create(name="Alpha Club") zulu_club = Club.objects.create(name="Zulu Club") alpha_season = make_season(alpha_club) zulu_season = make_season(zulu_club) jane = Member.objects.create(first_name="Jane", last_name="Doe") alice = Member.objects.create(first_name="Alice", last_name="Smith") bob = Member.objects.create(first_name="Bob", last_name="Smith") ClubMembership.objects.create(club=zulu_club, season=zulu_season, member=bob) ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=bob) ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=alice) ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=jane) self.assertEqual( [(membership.club.name, membership.member.last_name, membership.member.first_name) for membership in ClubMembership.objects.all()], [ ("Alpha Club", "Doe", "Jane"), ("Alpha Club", "Smith", "Alice"), ("Alpha Club", "Smith", "Bob"), ("Zulu Club", "Smith", "Bob"), ], ) def test_reverse_relations(self): membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member) self.assertEqual(list(self.club.clubmemberships.all()), [membership]) self.assertEqual(list(self.member.member_of.all()), [membership]) self.assertEqual(list(self.season.memberships.all()), [membership]) def test_verbose_names(self): self.assertEqual(ClubMembership._meta.verbose_name, "club membership") self.assertEqual(ClubMembership._meta.verbose_name_plural, "club memberships") class ClubSlugTests(TestCase): def test_slug_is_derived_from_name(self): club = Club.objects.create(name="City Swim Club") self.assertEqual(club.slug, "city-swim-club") def test_explicit_slug_is_kept(self): club = Club.objects.create(name="City Swim Club", slug="ajax-united") self.assertEqual(club.slug, "ajax-united") def test_derived_slugs_are_made_unique(self): first = Club.objects.create(name="City Swim Club") second = Club.objects.create(name="City Swim Club") self.assertEqual(first.slug, "city-swim-club") self.assertEqual(second.slug, "city-swim-club-2") def test_slug_is_unique(self): Club.objects.create(name="First", slug="shared") with self.assertRaises(IntegrityError): Club.objects.create(name="Second", slug="shared") @override_settings( ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", 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() # Bound to this instance's _capture, so it cannot be shared across tests. self.captured = {} self.middleware = ClubTenantMiddleware(self._capture) def _capture(self, request): # Runs inside the middleware, while the context var is set. self.captured["context_club"] = get_current_club() return "response" def _run(self, host): request = self.factory.get("/", HTTP_HOST=host) response = self.middleware(request) return request, response def test_subdomain_resolves_to_club(self): request, response = self._run("ajax-united.rosterchief.app") self.assertEqual(response, "response") self.assertEqual(request.club, self.club) self.assertEqual(self.captured["context_club"], self.club) def test_subdomain_resolution_ignores_port(self): request, _ = self._run("ajax-united.rosterchief.app:8000") self.assertEqual(request.club, self.club) def test_unknown_subdomain_sets_none(self): request, _ = self._run("unknown-club.rosterchief.app") self.assertIsNone(request.club) def test_bare_base_domain_has_no_club(self): request, _ = self._run("rosterchief.app") self.assertIsNone(request.club) def test_www_is_treated_as_no_club(self): request, _ = self._run("www.rosterchief.app") self.assertIsNone(request.club) def test_foreign_domain_has_no_club(self): request, _ = self._run("ajax-united.example.org") self.assertIsNone(request.club) def test_context_var_is_reset_after_request(self): self._run("ajax-united.rosterchief.app") self.assertIsNone(get_current_club()) @override_settings(ROSTERCHIEF_BASE_DOMAIN="") def test_generic_host_resolution_without_base_domain(self): request, _ = self._run("ajax-united.example.com") self.assertEqual(request.club, self.club) @override_settings(ROSTERCHIEF_BASE_DOMAIN="") def test_two_label_host_has_no_club_without_base_domain(self): request, _ = self._run("example.com") self.assertIsNone(request.club) class _FakeRequest: def __init__(self, host): self._host = host def get_host(self): return self._host @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app") class GetSubdomainTests(TestCase): def subdomain(self, host): return ClubTenantMiddleware.get_subdomain(_FakeRequest(host)) def test_empty_host_returns_none(self): self.assertIsNone(self.subdomain("")) def test_trailing_dot_is_stripped(self): self.assertEqual(self.subdomain("ajax-united.rosterchief.app."), "ajax-united") def test_nested_subdomain_uses_leftmost_label(self): self.assertEqual(self.subdomain("a.b.rosterchief.app"), "a") class TenantContextTests(TestCase): @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): self.assertEqual(require_current_club(), self.club) def test_require_current_club_raises_without_context(self): with self.assertRaises(RuntimeError): require_current_club() def test_club_manager_current_returns_active_club(self): with with_club(self.club): self.assertEqual(Club.objects.current(), self.club) def test_club_manager_current_is_none_without_context(self): self.assertIsNone(Club.objects.current()) class TenantScopedModelTests(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.dates = { "start_date": datetime.date(2026, 8, 1), "end_date": datetime.date(2027, 5, 31), } def test_save_keeps_explicit_club(self): season = Season.objects.create(club=self.club, **self.dates) self.assertEqual(season.club, self.club) def test_save_fills_club_from_context(self): with with_club(self.club): season = Season.objects.create(**self.dates) self.assertEqual(season.club, self.club) def test_save_without_club_or_context_raises(self): with self.assertRaises(RuntimeError): Season.objects.create(**self.dates) def test_for_club_filters_by_club(self): mine = Season.objects.create(club=self.club, **self.dates) Season.objects.create(club=self.other, **self.dates) self.assertEqual(list(Season.objects.for_club(self.club)), [mine]) def test_current_club_filters_by_active_club(self): mine = Season.objects.create(club=self.club, **self.dates) Season.objects.create(club=self.other, **self.dates) with with_club(self.club): self.assertEqual(list(Season.objects.current_club()), [mine]) def test_name_is_two_digit_year_range(self): season = Season.objects.create(club=self.club, **self.dates) self.assertEqual(season.name, "26-27") def test_name_zero_pads_years(self): season = Season.objects.create( club=self.club, start_date=datetime.date(2008, 8, 1), end_date=datetime.date(2009, 5, 31), ) self.assertEqual(season.name, "08-09") def test_str_is_the_name(self): season = Season.objects.create(club=self.club, **self.dates) self.assertEqual(str(season), "26-27") class SeasonGetCurrentTests(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.season = Season.objects.create( club=cls.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31), ) def test_returns_season_covering_the_given_date(self): with with_club(self.club): found = Season.get_current(datetime.date(2026, 12, 25)) self.assertEqual(found, self.season) def test_includes_boundary_dates(self): with with_club(self.club): self.assertEqual(Season.get_current(datetime.date(2026, 8, 1)), self.season) self.assertEqual(Season.get_current(datetime.date(2027, 5, 31)), self.season) def test_returns_none_when_no_season_covers_the_date(self): with with_club(self.club): self.assertIsNone(Season.get_current(datetime.date(2027, 7, 1))) def test_is_scoped_to_the_active_club(self): # The other club's season covers the same date but must not leak. Season.objects.create( club=self.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31), ) with with_club(self.other): found = Season.get_current(datetime.date(2026, 12, 25)) self.assertEqual(found.club, self.other) def test_defaults_to_today(self): # self.other, not self.club -- setUp's self.season (2026-08-01 to 2027-05-31) # would otherwise also cover "today" once real dates reach that window, # colliding with the one created here. today = timezone.now().date() current = Season.objects.create( club=self.other, start_date=today - datetime.timedelta(days=10), end_date=today + datetime.timedelta(days=10), ) with with_club(self.other): self.assertEqual(Season.get_current(), current) def test_requires_an_active_club(self): with self.assertRaises(RuntimeError): Season.get_current(datetime.date(2026, 12, 25)) class SeasonNextAfterTests(TestCase): @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) def test_returns_none_when_there_is_no_later_season(self): self.assertIsNone(Season.next_after(self.club, datetime.date(2027, 12, 25))) def test_is_scoped_to_the_given_club(self): other = Club.objects.create(name="Rival FC", slug="rival-fc") Season.objects.create(club=other, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31)) self.assertEqual(Season.next_after(other, datetime.date(2026, 12, 25)).club, other) class SponsorModelTests(TestCase): @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)) self.assertEqual(str(sponsor), "Acme Corp") def test_a_blank_end_date_is_valid(self): sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) sponsor.full_clean() def test_an_end_date_on_the_same_day_as_start_is_valid(self): sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1), end_date=datetime.date(2026, 1, 1)) sponsor.full_clean() def test_an_end_date_before_start_is_rejected(self): sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 6, 1), end_date=datetime.date(2026, 1, 1)) with self.assertRaises(ValidationError) as ctx: sponsor.full_clean() self.assertIn("end_date", ctx.exception.error_dict) def test_sponsors_are_ordered_by_name(self): Sponsor.objects.create(club=self.club, name="Zulu Corp", start_date=datetime.date(2026, 1, 1)) Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) self.assertEqual(list(Sponsor.objects.values_list("name", flat=True)), ["Acme Corp", "Zulu Corp"]) class AdminRegistrationSmokeTests(TestCase): """Every registered model across all apps must have a working admin: load each changelist and add page to catch bad list_display / search_fields / fieldsets / autocomplete targets in any app's admin config.""" @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=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): from django.apps import apps registered = set(django_admin.site._registry) # Concrete, non-auto-created models in these apps should all be registered. project_apps = {"authentication", "club", "members", "teams", "events", "formbuilder", "shop"} for model in apps.get_models(): if model._meta.app_label not in project_apps or model._meta.auto_created: continue with self.subTest(model=model.__name__): self.assertIn(model, registered, f"{model.__name__} is not registered in the admin") def test_all_changelists_load(self): for model in django_admin.site._registry: url = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_changelist") with self.subTest(model=model.__name__): self.assertEqual(self.client.get(url).status_code, 200) def test_all_add_pages_load(self): for model in django_admin.site._registry: url = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_add") with self.subTest(model=model.__name__): self.assertEqual(self.client.get(url).status_code, 200) class ClubArchivingTests(TestCase): @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) self.assertIn(self.club, Club.objects.active()) self.assertNotIn(self.club, Club.objects.archived()) def test_archive_and_restore(self): self.club.archive() self.assertTrue(self.club.is_archived) self.assertIn(self.club, Club.objects.archived()) self.assertNotIn(self.club, Club.objects.active()) self.club.restore() self.assertFalse(self.club.is_archived) self.assertIn(self.club, Club.objects.active()) def test_archiving_twice_keeps_the_original_timestamp(self): self.club.archive() first = self.club.archived_at self.club.archive() self.assertEqual(self.club.archived_at, first) def test_restoring_an_active_club_is_a_no_op(self): self.club.restore() self.assertFalse(self.club.is_archived) def test_archiving_destroys_nothing(self): season = make_season(self.club) member = Member.objects.create(first_name="Jane", last_name="Doe") ClubMembership.objects.create(club=self.club, member=member, season=season) self.club.archive() self.assertTrue(ClubMembership.objects.filter(club=self.club).exists()) self.assertTrue(Season.objects.filter(club=self.club).exists()) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"]) 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.middleware = ClubTenantMiddleware(lambda request: "response") def resolve(self): request = RequestFactory().get("/", HTTP_HOST="ajax-united.rosterchief.app") self.middleware(request) return request.club def test_active_club_resolves(self): self.assertEqual(self.resolve(), self.club) def test_archived_club_stops_resolving(self): self.club.archive() self.assertIsNone(self.resolve()) def test_restored_club_resolves_again(self): self.club.archive() self.club.restore() self.assertEqual(self.resolve(), self.club) class ClubRoleTests(TestCase): def test_str(self): club = Club.objects.create(name="Ajax United", slug="ajax-united") member = Member.objects.create(first_name="Jane", last_name="Doe") role = ClubRole.objects.create(club=club, member=member) self.assertEqual(str(role), f"{club} - {member}") class ClubMembershipCleanTests(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") today = timezone.localdate() 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) with self.assertRaises(ValidationError) as ctx: membership.full_clean() self.assertIn("season", ctx.exception.error_dict) def test_accepts_same_club_season(self): ClubMembership(club=self.club, member=self.member, season=self.season).full_clean() class GuardianMembershipTests(TestCase): """A parent attached to the club only through their child -- see ClubMembership.Kind. They hold the login and can be reached, but they are not a member: no fee, absent from every member list and count, and not eligible for a roster or staff spot.""" @classmethod def setUpTestData(cls): cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") today = timezone.localdate() cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300)) cls.child = Member.objects.create(first_name="Jamie", last_name="Doe") cls.parent = Member.objects.create(first_name="Taylor", last_name="Doe") ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) cls.guardianship = ClubMembership.objects.create(club=cls.club, member=cls.parent, season=cls.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE) cls.admin_user = get_user_model().objects.create_user(email="guardian-admin@example.com", password="pw") admin_member = Member.objects.create(user=cls.admin_user, first_name="Ada", last_name="Admin") ClubRole.objects.create(club=cls.club, member=admin_member, role=ClubRole.Roles.ADMIN) def test_a_guardian_is_not_a_visible_member(self): visible = members_visible_to(self.admin_user, self.club) self.assertIn(self.child, visible) self.assertNotIn(self.parent, visible) def test_a_guardian_is_visible_when_explicitly_asked_for(self): # The group pickers and the person's own detail page ask for this: they're # about a person, not about the member list. visible = members_visible_to(self.admin_user, self.club, include_guardians=True) self.assertIn(self.parent, visible) def test_the_derived_member_role_does_not_leak_a_guardian_back_in(self): # An active membership of any kind grants a MEMBER ClubRole (club/signals.py), # so matching on "has any role in this club" would undo the exclusion. self.assertTrue(ClubRole.objects.filter(club=self.club, member=self.parent, role=ClubRole.Roles.MEMBER).exists()) self.assertNotIn(self.parent, members_visible_to(self.admin_user, self.club)) def test_a_parent_who_also_plays_stays_a_member(self): # Being a parent and being a member are independent; the family graph # records the first, `kind` the second. self.guardianship.kind = ClubMembership.Kind.MEMBER self.guardianship.save(update_fields=["kind"]) self.assertIn(self.parent, members_visible_to(self.admin_user, self.club)) def test_a_guardian_who_is_also_on_a_roster_stays_visible(self): # The guardian row alone would hide them; playing for a team must not be # undone by their also being someone's parent. team = Team.objects.create(club=self.club, name="First Team", short_name="1st") position = Position.objects.create(club=self.club, name="Forward", short_name="FW") TeamMembership.objects.create(team=team, member=self.parent, season=self.season, position=position) self.assertIn(self.parent, members_visible_to(self.admin_user, self.club)) def test_a_guardian_is_not_eligible_for_a_roster_or_staff_spot(self): self.assertIn(self.child, eligible_roster_members(self.club)) self.assertNotIn(self.parent, eligible_roster_members(self.club)) def test_a_guardian_cannot_owe_a_fee(self): self.guardianship.fee_amount = Decimal("250.00") with self.assertRaises(ValidationError) as ctx: self.guardianship.full_clean() self.assertIn("fee_amount", ctx.exception.error_dict) def test_a_new_season_carries_guardians_forward(self): # Their tie to the club isn't seasonal -- without this a parent silently # drops off at the season boundary while their child stays enrolled. generate_seasons(self.club, until=self.season.end_date + datetime.timedelta(days=400)) next_season = Season.objects.filter(club=self.club, start_date__gt=self.season.start_date).order_by("start_date").first() self.assertIsNotNone(next_season) self.assertTrue(ClubMembership.objects.filter(club=self.club, member=self.parent, season=next_season, kind=ClubMembership.Kind.GUARDIAN).exists()) def test_a_guardian_removed_from_the_latest_season_is_not_resurrected(self): # Copied from the season immediately before, not from "any season ever", # so a deliberate removal stays removed. self.guardianship.delete() generate_seasons(self.club, until=self.season.end_date + datetime.timedelta(days=400)) next_season = Season.objects.filter(club=self.club, start_date__gt=self.season.start_date).order_by("start_date").first() self.assertFalse(ClubMembership.objects.filter(club=self.club, member=self.parent, season=next_season).exists()) class AccessServiceTests(TestCase): # 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() 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). 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") member = Member.objects.create(user=user, first_name=email.split("@")[0].title(), last_name="Doe") return user, member def grant(self, member, role): return ClubRole.objects.create(club=self.club, member=member, role=role) def make_coach(self, member, team=None): return StaffAssignment.objects.create(team=team or self.team, member=member, season=self.season, position=self.coach_position) def make_support_staff(self, member, team=None): """Staff on the team, but in a non-management position.""" return StaffAssignment.objects.create(team=team or self.team, member=member, season=self.season, position=self.physio_position) def make_event(self, **kwargs): kwargs.setdefault("club", self.club) kwargs.setdefault("title", "Match") kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1)) return Event.objects.create(**kwargs) # --- has_club_role / roles_in_club --- def test_has_club_role_is_scoped_to_role_and_club(self): user, member = self.make_user_member("admin@example.com") self.grant(member, ClubRole.Roles.ADMIN) self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN)) self.assertFalse(has_club_role(user, self.club, ClubRole.Roles.EDITOR)) self.assertFalse(has_club_role(user, self.other_club, ClubRole.Roles.ADMIN)) def test_roles_in_club_includes_derived_coach_manager(self): user, member = self.make_user_member("editor@example.com") self.grant(member, ClubRole.Roles.EDITOR) self.make_coach(member) self.assertEqual(roles_in_club(user, self.club), {ClubRole.Roles.EDITOR, COACH_MANAGER}) def test_non_management_staff_gets_no_derived_role(self): user, member = self.make_user_member("physio@example.com") self.make_support_staff(member) self.assertEqual(roles_in_club(user, self.club), set()) def test_roles_in_club_is_empty_for_outsider(self): user, _ = self.make_user_member("nobody@example.com") self.assertEqual(roles_in_club(user, self.club), set()) # --- teams_managed_by --- def test_admin_manages_every_team(self): user, member = self.make_user_member("admin@example.com") self.grant(member, ClubRole.Roles.ADMIN) self.assertEqual(set(teams_managed_by(user, self.club)), {self.team, self.second_team}) def test_coach_manages_only_their_team(self): user, member = self.make_user_member("coach@example.com") self.make_coach(member) self.assertEqual(list(teams_managed_by(user, self.club)), [self.team]) def test_plain_member_manages_no_teams(self): user, _ = self.make_user_member("plain@example.com") self.assertEqual(list(teams_managed_by(user, self.club)), []) def test_non_management_staff_manages_no_teams(self): user, member = self.make_user_member("physio@example.com") self.make_support_staff(member) self.assertEqual(list(teams_managed_by(user, self.club)), []) def test_non_management_staff_does_not_inherit_a_managers_team(self): # The team has BOTH a manager and a non-management staffer. The staffer # must not pick up the team just because *someone else* manages it. _, manager = self.make_user_member("coach@example.com") self.make_coach(manager) physio_user, physio = self.make_user_member("physio@example.com") self.make_support_staff(physio) self.assertEqual(list(teams_managed_by(physio_user, self.club)), []) def make_past_season(self): return Season.objects.create( club=self.club, start_date=self.season.start_date - datetime.timedelta(days=400), end_date=self.season.start_date - datetime.timedelta(days=1), ) def test_a_former_seasons_coach_no_longer_manages_the_team(self): # StaffAssignment is per-season: authority expires with it. user, member = self.make_user_member("coach@example.com") StaffAssignment.objects.create(team=self.team, member=member, season=self.make_past_season(), position=self.coach_position) self.assertEqual(list(teams_managed_by(user, self.club)), []) self.assertEqual(list(teams_staffed_by(user, self.club)), []) self.assertFalse(roles_in_club(user, self.club)) def test_a_former_seasons_coach_cannot_edit_a_current_event(self): user, member = self.make_user_member("coach@example.com") StaffAssignment.objects.create(team=self.team, member=member, season=self.make_past_season(), position=self.coach_position) event = self.make_event() event.teams.add(self.team) self.assertFalse(can_edit_event(user, event)) # --- members_visible_to --- def test_admin_sees_all_club_members(self): user, member = self.make_user_member("admin@example.com") self.grant(member, ClubRole.Roles.ADMIN) other = Member.objects.create(first_name="Other", last_name="Member") ClubMembership.objects.create(club=self.club, member=member, season=self.season) ClubMembership.objects.create(club=self.club, member=other, season=self.season) self.assertEqual(set(members_visible_to(user, self.club)), {member, other}) def test_coach_sees_self_and_managed_roster(self): user, member = self.make_user_member("coach@example.com") self.make_coach(member) player = Member.objects.create(first_name="Player", last_name="One") TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=self.forward) unrelated = Member.objects.create(first_name="Un", last_name="Related") visible = set(members_visible_to(user, self.club)) self.assertEqual(visible, {member, player}) self.assertNotIn(unrelated, visible) def test_parent_sees_self_and_children(self): user, parent = self.make_user_member("parent@example.com") child = Member.objects.create(first_name="Kid", last_name="Doe") family = Family.objects.create(name="Doe") FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT) FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD) self.assertEqual(set(members_visible_to(user, self.club)), {parent, child}) def test_user_without_a_member_sees_nobody(self): user = get_user_model().objects.create_user(email="ghost@example.com", password="pw") self.assertEqual(list(members_visible_to(user, self.club)), []) def test_non_management_staff_sees_the_roster_but_holds_no_authority(self): # A physio can see the team they work with, but manages nothing. user, member = self.make_user_member("physio@example.com") self.make_support_staff(member) player = Member.objects.create(first_name="Player", last_name="One") TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=self.forward) self.assertEqual(set(members_visible_to(user, self.club)), {member, player}) self.assertEqual(list(teams_managed_by(user, self.club)), []) self.assertEqual(list(teams_staffed_by(user, self.club)), [self.team]) def test_manager_also_sees_the_teams_other_staff(self): user, manager = self.make_user_member("coach@example.com") self.make_coach(manager) _, physio = self.make_user_member("physio@example.com") self.make_support_staff(physio) self.assertIn(physio, set(members_visible_to(user, self.club))) def test_admin_sees_members_without_a_club_membership(self): user, admin = self.make_user_member("admin@example.com") self.grant(admin, ClubRole.Roles.ADMIN) _, coach = self.make_user_member("coach@example.com") self.make_coach(coach) # staff, but no ClubMembership visible = set(members_visible_to(user, self.club)) self.assertIn(coach, visible) self.assertIn(admin, visible) # the admin sees themselves via their ClubRole def test_roster_visibility_is_scoped_to_the_current_season(self): user, member = self.make_user_member("coach@example.com") self.make_coach(member) old_season = Season.objects.create( club=self.club, start_date=self.season.start_date - datetime.timedelta(days=400), end_date=self.season.start_date - datetime.timedelta(days=1), ) former_player = Member.objects.create(first_name="Former", last_name="Player") TeamMembership.objects.create(team=self.team, member=former_player, season=old_season, position=self.forward) self.assertNotIn(former_player, set(members_visible_to(user, self.club))) # --- can_edit_event --- def test_admin_can_edit_event(self): user, member = self.make_user_member("admin@example.com") self.grant(member, ClubRole.Roles.ADMIN) self.assertTrue(can_edit_event(user, self.make_event())) def test_editor_can_edit_event(self): user, member = self.make_user_member("editor@example.com") self.grant(member, ClubRole.Roles.EDITOR) self.assertTrue(can_edit_event(user, self.make_event())) def test_owner_can_edit_their_event(self): user, member = self.make_user_member("owner@example.com") self.assertTrue(can_edit_event(user, self.make_event(created_by=member))) def test_coach_can_edit_their_teams_event(self): user, member = self.make_user_member("coach@example.com") self.make_coach(member) event = self.make_event() event.teams.add(self.team) self.assertTrue(can_edit_event(user, event)) def test_coach_cannot_edit_another_teams_event(self): user, member = self.make_user_member("coach@example.com") self.make_coach(member) event = self.make_event() event.teams.add(self.second_team) self.assertFalse(can_edit_event(user, event)) def test_plain_member_cannot_edit_event(self): user, _ = self.make_user_member("plain@example.com") self.assertFalse(can_edit_event(user, self.make_event())) def test_non_management_staff_cannot_edit_their_teams_event(self): user, member = self.make_user_member("physio@example.com") self.make_support_staff(member) event = self.make_event() event.teams.add(self.team) self.assertFalse(can_edit_event(user, event)) def test_non_management_staff_on_a_managed_team_cannot_edit_its_event(self): # Same escalation shape as teams_managed_by: a manager exists on the team, # but the physio must not inherit edit rights from them. _, manager = self.make_user_member("coach@example.com") self.make_coach(manager) physio_user, physio = self.make_user_member("physio@example.com") self.make_support_staff(physio) event = self.make_event() event.teams.add(self.team) self.assertFalse(can_edit_event(physio_user, event)) # --- can_manage_shop --- def test_only_admin_can_manage_shop(self): admin_user, admin_member = self.make_user_member("admin@example.com") self.grant(admin_member, ClubRole.Roles.ADMIN) editor_user, editor_member = self.make_user_member("editor@example.com") self.grant(editor_member, ClubRole.Roles.EDITOR) self.assertTrue(can_manage_shop(admin_user, self.club)) self.assertFalse(can_manage_shop(editor_user, self.club)) class ClubRoleStatusSyncTests(TestCase): @classmethod def setUpTestData(cls): cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") today = timezone.localdate() 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) def make_membership(self, status=ClubMembership.StatusChoices.ACTIVE): return ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=status) def test_active_membership_grants_member_role(self): self.make_membership() self.assertEqual(self.roles().get().role, ClubRole.Roles.MEMBER) def test_pending_membership_grants_no_role(self): self.make_membership(status=ClubMembership.StatusChoices.PENDING) self.assertFalse(self.roles().exists()) def test_deactivating_membership_withdraws_member_role(self): membership = self.make_membership() self.assertTrue(self.roles().exists()) membership.status = ClubMembership.StatusChoices.LAPSED membership.save() self.assertFalse(self.roles().exists()) def test_deleting_membership_withdraws_member_role(self): membership = self.make_membership() membership.delete() self.assertFalse(self.roles().exists()) def test_elevated_role_is_never_downgraded_or_removed(self): ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN) membership = self.make_membership() self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN) membership.status = ClubMembership.StatusChoices.CANCELLED membership.save() self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN) def test_editor_role_survives_a_lapsed_membership(self): ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.EDITOR) membership = self.make_membership() membership.status = ClubMembership.StatusChoices.LAPSED membership.save() self.assertEqual(self.roles().get().role, ClubRole.Roles.EDITOR) def test_elevated_role_survives_membership_deletion(self): ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN) membership = self.make_membership() membership.delete() self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN) def test_elevated_role_survives_a_season_rollover(self): # Last season's membership lapses and the new season's is still pending: # the admin must not lose their role in the gap. ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN) last_season = self.make_membership() next_season = Season.objects.create( club=self.club, start_date=self.season.end_date + datetime.timedelta(days=1), end_date=self.season.end_date + datetime.timedelta(days=300), ) last_season.status = ClubMembership.StatusChoices.LAPSED last_season.save() ClubMembership.objects.create(club=self.club, member=self.member, season=next_season, status=ClubMembership.StatusChoices.PENDING) self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN) def test_elevated_access_and_login_survive_a_lapsed_membership(self): # The whole point: a lapsed membership must not lock an admin out. user = get_user_model().objects.create_user(email="admin@example.com", password="pw") admin = Member.objects.create(user=user, first_name="Ada", last_name="Min") ClubRole.objects.create(club=self.club, member=admin, role=ClubRole.Roles.ADMIN) membership = ClubMembership.objects.create(club=self.club, member=admin, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) membership.status = ClubMembership.StatusChoices.CANCELLED membership.save() self.assertTrue(user.is_active) # can still log in self.assertIn(ClubRole.Roles.ADMIN, roles_in_club(user, self.club)) self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN)) self.assertTrue(can_manage_shop(user, self.club)) @override_settings( ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], ) class BrandingTests(TestCase): """The auth screens are shared; only the skin they inherit differs per tenant.""" @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) def test_the_base_domain_gets_the_platform_skin(self): response = self.login_page("rosterchief.app") self.assertTemplateUsed(response, "_platform_base.html") self.assertTemplateNotUsed(response, "_club_base.html") self.assertContains(response, "Club & Team Management") self.assertIsNone(response.context["club"]) def test_a_club_subdomain_gets_the_club_skin(self): response = self.login_page("ajax-united.rosterchief.app") self.assertTemplateUsed(response, "_club_base.html") self.assertTemplateNotUsed(response, "_platform_base.html") self.assertContains(response, "Ajax United") self.assertEqual(response.context["club"], self.club) def test_an_archived_club_falls_back_to_the_platform_skin(self): # The subdomain stops resolving, so there is no club to brand with. self.club.archive() self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "_platform_base.html") def test_a_club_without_a_logo_shows_its_initials_not_our_mark(self): response = self.login_page("ajax-united.rosterchief.app") self.assertContains(response, "AU") self.assertNotContains(response, "rosterchief-dark.svg") def test_a_club_logo_is_rendered_when_set(self): self.club.logo = "clubs/ajax-united/crest.png" self.club.save() self.assertContains(self.login_page("ajax-united.rosterchief.app"), "clubs/ajax-united/crest.png") def test_a_club_logo_gets_a_primary_coloured_ring(self): self.club.logo = "clubs/ajax-united/crest.png" self.club.save() self.assertContains(self.login_page("ajax-united.rosterchief.app"), "ring-primary") def test_a_club_colour_overrides_the_theme(self): self.club.primary_color = "#1e40af" self.club.save() self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary: #1e40af") def test_no_colour_means_no_override(self): self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary") def test_a_club_secondary_colour_overrides_the_theme(self): self.club.secondary_color = "#be185d" self.club.save() self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary: #be185d") def test_no_secondary_colour_means_no_override(self): self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary") @override_settings( ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], ) class Custom403PageTests(TestCase): """Django's default 403 handler picks up templates/403.html automatically -- branded per tenant (base_template, same as maintenance.html) so a permission error still looks like the app, not a bare Django error page, and the navbar (sign out, theme toggle, home link) stays reachable.""" @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") self.client.force_login(member) response = self.client.get(reverse("management:position_list"), HTTP_HOST="ajax-united.rosterchief.app") self.assertEqual(response.status_code, 403) self.assertContains(response, "Access denied", status_code=403) self.assertContains(response, "Ajax United", status_code=403) self.assertContains(response, "Sign out", status_code=403) def test_the_base_domain_403_gets_the_platform_skin(self): self.client.force_login(get_user_model().objects.create_user(email="platform-403@example.com", password="pw-secret-123")) response = self.client.get(reverse("controlpanel:dashboard")) self.assertEqual(response.status_code, 403) self.assertContains(response, "Access denied", status_code=403) self.assertContains(response, "Club & Team Management", status_code=403) class ClubBrandingModelTests(TestCase): def test_initials_use_the_first_two_words(self): self.assertEqual(Club(name="Ajax United Football Club").initials, "AU") self.assertEqual(Club(name="Ajax").initials, "A") def test_text_on_a_pale_colour_is_black_and_on_a_dark_one_white(self): # A club picking pale yellow must not get white-on-yellow buttons. self.assertEqual(Club(primary_color="#fef08a").primary_content_color, "#000000") self.assertEqual(Club(primary_color="#1e40af").primary_content_color, "#ffffff") def test_no_colour_means_no_contrast_colour(self): self.assertEqual(Club(primary_color="").primary_content_color, "") def test_contrast_color_filter_matches_the_same_algorithm(self): # HTML emails apply this to a literal fallback background (e.g. # club.secondary_color|default:"#ec4899") rather than a club's own # colour, so it can't be exercised through primary_content_color -- # but it must still agree with it for a colour a club actually set. from club.templatetags.club_email import contrast_color self.assertEqual(contrast_color("#ec4899"), Club(secondary_color="#ec4899").secondary_content_color) self.assertEqual(contrast_color("#fef08a"), "#000000") self.assertEqual(contrast_color("#1e40af"), "#ffffff") def test_a_colour_must_be_a_hex_value(self): club = Club(name="Ajax United", primary_color="blue") with self.assertRaises(ValidationError): club.full_clean() def test_logos_are_stored_per_club(self): self.assertEqual(club_logo_path(Club(slug="ajax-united"), "crest.png"), "clubs/ajax-united/crest.png") @override_settings( ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], ) class RootViewTests(TestCase): @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") self.assertRedirects(response, reverse("controlpanel:dashboard"), fetch_redirect_response=False) def test_a_club_subdomain_lands_on_the_club_home(self): self.client.force_login(self.user) response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app") self.assertTemplateUsed(response, "club/home.html") self.assertContains(response, "Ajax United") def test_the_club_home_requires_a_login(self): response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app") self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False) 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.""" @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): return ClubRole.objects.filter(club=self.club, member=self.member) def test_remaining_balance_starts_at_the_full_fee(self): self.assertEqual(remaining_balance(self.membership), Decimal("150.00")) def test_remaining_balance_is_never_negative(self): record_payment(self.membership, amount=Decimal("200.00")) self.assertEqual(remaining_balance(self.membership), Decimal("0.00")) def test_a_partial_payment_creates_a_record_and_updates_the_running_total(self): payment = record_payment(self.membership, amount=Decimal("50.00"), method=FeePayment.Method.CASH, reference="R1", note="first installment") self.assertEqual(payment.membership, self.membership) self.assertEqual(payment.amount, Decimal("50.00")) self.membership.refresh_from_db() self.assertEqual(self.membership.amount_paid, Decimal("50.00")) self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) # Not yet settled -- status doesn't change on a partial payment. self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) def test_multiple_partial_payments_accumulate(self): record_payment(self.membership, amount=Decimal("50.00")) record_payment(self.membership, amount=Decimal("60.00")) self.membership.refresh_from_db() self.assertEqual(self.membership.amount_paid, Decimal("110.00")) self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2) def test_reaching_the_full_amount_settles_and_activates(self): record_payment(self.membership, amount=Decimal("100.00")) record_payment(self.membership, amount=Decimal("50.00")) self.membership.refresh_from_db() self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) self.assertEqual(self.membership.activated_at, timezone.localdate()) self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists()) def test_settling_in_full_does_not_overwrite_an_earlier_activated_at(self): earlier = datetime.date(2026, 1, 1) self.membership.activated_at = earlier self.membership.save() record_payment(self.membership, amount=Decimal("150.00")) self.membership.refresh_from_db() self.assertEqual(self.membership.activated_at, earlier) def test_a_waived_membership_is_untouched_by_a_payment(self): self.membership.fee_status = ClubMembership.FeeStatus.WAIVED self.membership.save() record_payment(self.membership, amount=Decimal("50.00")) self.membership.refresh_from_db() self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.WAIVED) def test_mark_as_paid_records_the_exact_remaining_balance(self): record_payment(self.membership, amount=Decimal("100.00")) mark_as_paid(self.membership) self.membership.refresh_from_db() self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) payment = FeePayment.objects.get(membership=self.membership, amount=Decimal("50.00")) self.assertEqual(payment.note, "Marked as paid") def test_mark_as_paid_with_no_fee_amount_set_skips_creating_a_zero_payment(self): # FeePayment.amount has a MinValueValidator(0.01) -- a $0 "payment" isn't a # real transaction, so this must flip the flags directly instead. unpriced = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="No", last_name="Price"), season=self.season, status=ClubMembership.StatusChoices.PENDING) mark_as_paid(unpriced) unpriced.refresh_from_db() self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID) self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE) self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists()) def test_recorded_by_is_stored_on_the_payment(self): user = get_user_model().objects.create_user(email="admin-fees@example.com", password="pw-secret-123") payment = record_payment(self.membership, amount=Decimal("50.00"), recorded_by=user) self.assertEqual(payment.recorded_by, user) class SeasonStartEndTests(TestCase): """club.services.seasons._initial_season_start / _season_end -- the per-club rules generate_seasons chains off, now that a club's own season_start/season_duration_months drive them instead of a fixed Aug-May window.""" # 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) start = _initial_season_start(self.club, datetime.date(2026, 8, 15)) self.assertEqual(start, datetime.date(2026, 8, 1)) def test_a_date_before_this_years_anchor_uses_last_year(self): self.club.season_start = datetime.date(2000, 8, 1) start = _initial_season_start(self.club, datetime.date(2027, 2, 1)) self.assertEqual(start, datetime.date(2026, 8, 1)) def test_the_anchor_date_itself_uses_this_year(self): self.club.season_start = datetime.date(2000, 8, 1) start = _initial_season_start(self.club, datetime.date(2026, 8, 1)) self.assertEqual(start, datetime.date(2026, 8, 1)) def test_season_end_is_the_day_before_the_start_plus_the_duration(self): self.club.season_duration_months = 12 end = _season_end(datetime.date(2026, 8, 1), self.club) self.assertEqual(end, datetime.date(2027, 7, 31)) def test_a_shorter_duration_produces_a_shorter_season(self): self.club.season_duration_months = 6 end = _season_end(datetime.date(2026, 8, 1), self.club) self.assertEqual(end, datetime.date(2027, 1, 31)) class GenerateSeasonsTests(TestCase): """club.services.seasons.generate_seasons -- the service behind the generate_seasons management command.""" @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() generate_seasons(self.club, today) self.assertTrue(Season.objects.filter(club=self.club, start_date__lte=today, end_date__gte=today).exists()) def test_generates_every_window_through_the_horizon(self): today = timezone.localdate() until = today + relativedelta(years=2) created = generate_seasons(self.club, until) self.assertGreaterEqual(len(created), 2) for season in created: self.assertLessEqual(season.start_date, until) def test_is_idempotent_on_a_second_run(self): until = timezone.localdate() + relativedelta(years=2) generate_seasons(self.club, until) count_after_first = Season.objects.filter(club=self.club).count() second_run = generate_seasons(self.club, until) self.assertEqual(second_run, []) self.assertEqual(Season.objects.filter(club=self.club).count(), count_after_first) def test_a_new_season_starts_the_day_after_the_last_one_ends(self): today = timezone.localdate() generate_seasons(self.club, today) latest = Season.objects.filter(club=self.club).order_by("-end_date").first() generate_seasons(self.club, latest.end_date + relativedelta(months=self.club.season_duration_months)) next_season = Season.objects.filter(club=self.club, start_date=latest.end_date + datetime.timedelta(days=1)).first() self.assertIsNotNone(next_season) def test_changing_the_duration_only_affects_the_next_generated_season(self): today = timezone.localdate() generate_seasons(self.club, today) first = Season.objects.filter(club=self.club).order_by("-end_date").first() self.club.season_duration_months = 6 self.club.save() generate_seasons(self.club, first.end_date + relativedelta(months=6)) first_end_before = first.end_date first.refresh_from_db() self.assertEqual(first.end_date, first_end_before) # existing season untouched second = Season.objects.get(club=self.club, start_date=first.end_date + datetime.timedelta(days=1)) self.assertEqual(second.end_date, second.start_date + relativedelta(months=6) - datetime.timedelta(days=1)) def test_does_not_disturb_a_pre_existing_irregular_season(self): odd = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) generate_seasons(self.club, timezone.localdate()) odd.refresh_from_db() self.assertEqual(odd.start_date, datetime.date(2020, 3, 1)) self.assertEqual(odd.end_date, datetime.date(2020, 9, 1)) class ResyncSeasonsTests(TestCase): """club.services.seasons.resync_seasons -- cleaning up seasons that don't match a club's current settings (e.g. left over from a since-changed season_start/season_duration_months).""" @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)) removed, kept = resync_seasons(self.club, self.until) self.assertIn(wrong, removed) self.assertEqual(kept, []) def test_commit_actually_deletes_a_wrong_unreferenced_season(self): wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) resync_seasons(self.club, self.until, commit=True) self.assertFalse(Season.objects.filter(pk=wrong.pk).exists()) def test_without_commit_nothing_is_actually_deleted(self): wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) resync_seasons(self.club, self.until, commit=False) self.assertTrue(Season.objects.filter(pk=wrong.pk).exists()) def test_a_wrong_but_referenced_season_is_kept_not_removed(self): wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) member = Member.objects.create(first_name="Jane", last_name="Doe") ClubMembership.objects.create(club=self.club, member=member, season=wrong) removed, kept = resync_seasons(self.club, self.until, commit=True) self.assertEqual(removed, []) self.assertIn(wrong, kept) self.assertTrue(Season.objects.filter(pk=wrong.pk).exists()) def test_a_season_matching_current_settings_is_left_alone(self): generate_seasons(self.club, timezone.localdate()) removed, kept = resync_seasons(self.club, self.until) self.assertEqual(removed, []) self.assertEqual(kept, []) def test_a_season_referenced_only_via_staff_assignment_is_kept_not_removed(self): # Season is PROTECTed by more than just ClubMembership -- a season kept # alive only through a StaffAssignment must not be silently deleted either. wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) team = Team.objects.create(club=self.club, name="First Team", short_name="1st") position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True) member = Member.objects.create(first_name="Jane", last_name="Doe") StaffAssignment.objects.create(team=team, member=member, season=wrong, position=position) removed, kept = resync_seasons(self.club, self.until, commit=True) self.assertEqual(removed, []) self.assertIn(wrong, kept) self.assertTrue(Season.objects.filter(pk=wrong.pk).exists()) class GenerateSeasonsCommandTests(TestCase): @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()) today = timezone.localdate() until = today + relativedelta(years=2) seasons = Season.objects.filter(club=self.club).order_by("start_date") self.assertTrue(seasons.exists()) for season in seasons: self.assertLessEqual(season.start_date, until) self.assertTrue(seasons.filter(start_date__lte=today, end_date__gte=today).exists()) def test_years_argument_controls_the_horizon(self): call_command("generate_seasons", "--years", "1", stdout=StringIO()) until = timezone.localdate() + relativedelta(years=1) for season in Season.objects.filter(club=self.club): self.assertLessEqual(season.start_date, until) def test_archived_clubs_are_skipped(self): self.club.archive() call_command("generate_seasons", stdout=StringIO()) self.assertFalse(Season.objects.filter(club=self.club).exists()) def test_second_run_creates_nothing_new(self): call_command("generate_seasons", stdout=StringIO()) count_after_first = Season.objects.filter(club=self.club).count() out = StringIO() call_command("generate_seasons", stdout=out) self.assertEqual(Season.objects.filter(club=self.club).count(), count_after_first) self.assertIn("Generated 0 season", out.getvalue()) def test_resync_without_commit_reports_but_does_not_delete(self): wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) out = StringIO() call_command("generate_seasons", "--resync", stdout=out) self.assertTrue(Season.objects.filter(pk=wrong.pk).exists()) self.assertIn("Would remove", out.getvalue()) def test_resync_with_commit_deletes_the_wrong_season(self): wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1)) call_command("generate_seasons", "--resync", "--commit", stdout=StringIO()) self.assertFalse(Season.objects.filter(pk=wrong.pk).exists())