Files
RosterChief/features/tests.py
Bernard Siebens fed24bfee3 Add club-scoped feature flags on django-waffle
Adds a `features` app with a swappable waffle Flag (WAFFLE_FLAG_MODEL) that
gains a m2m to Club, so a feature can be rolled out club by club.

Two things worth calling out:

- `everyone` keeps waffle's contract of overriding *all* other targeting, so
  club targeting is only consulted when `everyone is None`. This keeps
  `everyone = False` usable as a hard kill-switch.
- m2m edits don't call save(), so waffle's per-flag cache would go stale when
  clubs are added or removed. A m2m_changed receiver flushes it from both
  directions, and get_flush_keys() drops the club-set key alongside waffle's own.

Note for future flag tests: waffle's cache is not rolled back with the test
transaction, so tests touching flags must clear it (see features/tests.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:22 +02:00

97 lines
3.4 KiB
Python

from django.core.cache import cache
from django.test import RequestFactory, TestCase
from waffle import flag_is_active, get_waffle_flag_model
from club.models import Club
Flag = get_waffle_flag_model()
class ClubScopedFlagTests(TestCase):
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.
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("/")
request.club = club
request.user = None
return request
def active_for(self, club):
return flag_is_active(self.request_for(club), "shop")
def test_off_for_every_club_by_default(self):
self.assertFalse(self.active_for(self.club))
self.assertFalse(self.active_for(self.other))
def test_on_only_for_the_targeted_club(self):
self.flag.clubs.add(self.club)
self.assertTrue(self.active_for(self.club))
self.assertFalse(self.active_for(self.other))
def test_removing_a_club_turns_it_off_again(self):
self.flag.clubs.add(self.club)
self.flag.clubs.remove(self.club)
self.assertFalse(self.active_for(self.club))
def test_everyone_true_overrides_club_targeting(self):
self.flag.everyone = True
self.flag.save()
self.assertTrue(self.active_for(self.other)) # not targeted, still on
def test_everyone_false_beats_club_targeting(self):
# waffle's contract: `everyone` overrides ALL other settings, so a flag
# switched off for everyone must stay off even for a targeted club.
self.flag.clubs.add(self.club)
self.flag.everyone = False
self.flag.save()
self.assertFalse(self.active_for(self.club))
def test_no_club_on_the_request_is_not_active(self):
# e.g. the base domain / control panel, where there is no tenant.
self.flag.clubs.add(self.club)
self.assertFalse(flag_is_active(self.request_for(None), "shop"))
def test_is_active_for_club_without_a_request(self):
self.flag.clubs.add(self.club)
self.assertTrue(self.flag.is_active_for_club(self.club))
self.assertFalse(self.flag.is_active_for_club(self.other))
def test_is_active_for_club_respects_everyone(self):
self.flag.everyone = False
self.flag.save()
self.assertFalse(self.flag.is_active_for_club(self.club))
self.flag.everyone = True
self.flag.save()
self.assertTrue(self.flag.is_active_for_club(self.club))
def test_cache_is_flushed_when_club_targeting_changes(self):
# The M2M does not call save(), so without the flush signal waffle would
# keep answering from a stale cached set.
self.assertFalse(self.active_for(self.club)) # primes the cache
self.flag.clubs.add(self.club)
self.assertTrue(self.active_for(self.club))
def test_cache_is_flushed_on_reverse_edit(self):
self.assertFalse(self.active_for(self.club))
self.club.flags.add(self.flag)
self.assertTrue(self.active_for(self.club))