Files
RosterChief/mobile/tests.py
Bernard Siebens 09df5d25b8 Scaffold the mobile member app: PWA shell, push, and app-shell tokens
New `mobile` Django app mounted at /app/ -- the installed PWA for Member
mode (M1-M7, see design_handoff_rosterchief_platform/README.md). Coach
mode (C1-C6) is a later phase and has no routes yet.

Foundation pieces:
- assets/mobile.css: Tailwind v4 theme reusing management.css's design
  tokens (same per-club --tenant-* theming pattern), plus the ice/coach
  accent and mobile's 14px card radius.
- PushSubscription model + pywebpush-based sender (mobile/services/push.py),
  wired to notifications.Notification via a post_save signal so the
  existing notification system gains a push channel without knowing about
  PWAs itself.
- Per-club manifest.webmanifest + service worker (served at /app/sw.js)
  + a server-rendered fallback home-screen icon (club initials on
  secondary_color) for clubs without an uploaded logo -- confirmed with
  the user as the fallback, never a generic RosterChief mark.
- App shell (base.html): navy header, person switcher (every child a
  signed-in parent manages, plus "Me"), bottom tab bar, safe-area insets.
  Vendored htmx + Alpine for the screens built on top of it.
- Placeholder views/routes for all seven M1-M7 screens so the shell is
  fully wired end-to-end before each screen is built out individually.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 10:27:13 +02:00

150 lines
6.5 KiB
Python

import datetime
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from club.models import Club, ClubMembership, Season
from members.models import Family, FamilyMembership, Member
from .models import PushSubscription
from .services.icons import render_fallback_icon
User = get_user_model()
def make_club(**kwargs):
return Club.objects.create(name="Ajax United", slug="ajax-united", secondary_color="#e4002b", **kwargs)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class MobileShellTests(TestCase):
"""The app shell (base.html) plus the PWA plumbing every screen sits on."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
Season.objects.create(club=cls.club, start_date=datetime.date(2025, 8, 1), end_date=datetime.date(2026, 5, 31))
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
ClubMembership.objects.create(club=cls.club, member=cls.member, season=Season.objects.first())
def _get(self, url_name, **kwargs):
return self.client.get(reverse(f"mobile:{url_name}", kwargs=kwargs), HTTP_HOST="ajax-united.rosterchief.app")
def test_home_requires_login(self):
response = self._get("home")
self.assertEqual(response.status_code, 302)
def test_home_renders_the_app_shell_when_signed_in(self):
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.club.name)
self.assertContains(response, 'rel="manifest"')
def test_manifest_names_the_club_and_points_at_its_icon(self):
response = self._get("manifest")
self.assertEqual(response["Content-Type"], "application/manifest+json")
self.assertEqual(response.json()["name"], self.club.name)
self.assertIn("icon", response.json()["icons"][0]["src"])
def test_icon_falls_back_to_a_rendered_png_without_a_logo(self):
response = self._get("icon", size=192)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "image/png")
def test_service_worker_is_served_at_the_app_scope(self):
response = self.client.get("/app/sw.js", HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertIn("javascript", response["Content-Type"])
self.assertIn("addEventListener", response.content.decode())
def test_person_switcher_lists_managed_children_alongside_me(self):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=Season.objects.first())
self.client.force_login(self.user)
response = self._get("home")
self.assertContains(response, "Noor")
def test_scope_person_switches_via_the_as_query_param(self):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=Season.objects.first())
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:home") + f"?as={child.pk}", HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.context["scope_person"], child)
@override_settings(
VAPID_PRIVATE_KEY="",
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
)
class PushSubscribeViewTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = make_club()
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", user=cls.user)
def test_subscribing_creates_a_push_subscription_row(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("mobile:push_subscribe"),
data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "key1", "auth": "key2"}},
content_type="application/json",
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(PushSubscription.objects.filter(member=self.member, endpoint="https://push.example.com/abc").exists())
def test_resubscribing_the_same_endpoint_updates_rather_than_duplicates(self):
self.client.force_login(self.user)
PushSubscription.objects.create(club=self.club, member=self.member, endpoint="https://push.example.com/abc", p256dh="old", auth="old")
self.client.post(
reverse("mobile:push_subscribe"),
data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "new", "auth": "new"}},
content_type="application/json",
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(PushSubscription.objects.filter(endpoint="https://push.example.com/abc").count(), 1)
self.assertEqual(PushSubscription.objects.get(endpoint="https://push.example.com/abc").p256dh, "new")
def test_anonymous_cannot_subscribe(self):
response = self.client.post(
reverse("mobile:push_subscribe"),
data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "key1", "auth": "key2"}},
content_type="application/json",
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(response.status_code, 302)
class RenderFallbackIconTests(TestCase):
def test_renders_a_png_without_a_logo(self):
club = make_club()
png_bytes = render_fallback_icon(club, size=64)
self.assertTrue(png_bytes.startswith(b"\x89PNG"))