From 7ea500ebaaf82eec47ae47d4228cb49512eba32b Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 18 Aug 2025 01:46:52 +0200 Subject: [PATCH] chore(members/tests): various tests Co-authored by: Claude --- jdav_web/members/tests/__init__.py | 3 + jdav_web/members/tests/basic.py | 2 +- jdav_web/members/tests/rules.py | 179 +++++++++++++++++++++++++++++ jdav_web/members/tests/tasks.py | 141 +++++++++++++++++++++++ jdav_web/members/tests/utils.py | 3 +- jdav_web/members/tests/views.py | 110 ++++++++++++++++++ jdav_web/members/views.py | 2 + 7 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 jdav_web/members/tests/rules.py create mode 100644 jdav_web/members/tests/tasks.py create mode 100644 jdav_web/members/tests/views.py diff --git a/jdav_web/members/tests/__init__.py b/jdav_web/members/tests/__init__.py index da0f2b6..35a6867 100644 --- a/jdav_web/members/tests/__init__.py +++ b/jdav_web/members/tests/__init__.py @@ -1 +1,4 @@ from .basic import * +from .views import * +from .tasks import * +from .rules import * diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index b1bc794..409779c 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -416,7 +416,7 @@ class AdminTestCase(TestCase): self.em = EmailAddress.objects.create(name='foobar') self.staff = Group.objects.create(name='Jugendleiter', contact_email=self.em) - cool_kids = Group.objects.create(name='cool kids') + cool_kids = Group.objects.create(name='cool kids', show_website=True) super_kids = Group.objects.create(name='super kids') p1 = PermissionMember.objects.create(member=paul) diff --git a/jdav_web/members/tests/rules.py b/jdav_web/members/tests/rules.py new file mode 100644 index 0000000..9a95289 --- /dev/null +++ b/jdav_web/members/tests/rules.py @@ -0,0 +1,179 @@ +from django.test import TestCase +from django.utils import timezone +from django.contrib.auth.models import User + +from ..models import Member, Group, Freizeit, DIVERSE, GEMEINSCHAFTS_TOUR, MemberTraining, TrainingCategory, LJPProposal +from ..rules import is_oneself, may_view, may_change, may_delete, is_own_training, is_leader_of_excursion, is_leader, statement_not_submitted, _is_leader +from finance.models import Statement +from mailer.models import EmailAddress + + +class RulesTestCase(TestCase): + def setUp(self): + # Create email address for groups + self.email_address = EmailAddress.objects.create(name='test@example.com') + + # Create test users and members + self.user1 = User.objects.create_user(username='user1', email='user1@example.com') + self.member1 = Member.objects.create( + prename='Test', + lastname='Member1', + birth_date=timezone.now().date(), + email='member1@example.com', + gender=DIVERSE + ) + self.user1.member = self.member1 + self.user1.save() + + self.user2 = User.objects.create_user(username='user2', email='user2@example.com') + self.member2 = Member.objects.create( + prename='Test', + lastname='Member2', + birth_date=timezone.now().date(), + email='member2@example.com', + gender=DIVERSE + ) + self.user2.member = self.member2 + self.user2.save() + + self.user3 = User.objects.create_user(username='user3', email='user3@example.com') + self.member3 = Member.objects.create( + prename='Test', + lastname='Member3', + birth_date=timezone.now().date(), + email='member3@example.com', + gender=DIVERSE + ) + self.user3.member = self.member3 + self.user3.save() + + # Create test group + self.group = Group.objects.create(name='Test Group') + self.group.contact_email = self.email_address + self.group.leiters.add(self.member2) + self.group.save() + + # Create test excursion + self.excursion = Freizeit.objects.create( + name='Test Excursion', + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1 + ) + self.excursion.jugendleiter.add(self.member1) + self.excursion.groups.add(self.group) + self.excursion.save() + + # Create training category and training + self.training_category = TrainingCategory.objects.create( + name='Test Training', + permission_needed=False + ) + + self.training = MemberTraining.objects.create( + member=self.member1, + title='Test Training', + category=self.training_category, + participated=True, + passed=True + ) + + # Create LJP proposal + self.ljp_proposal = LJPProposal.objects.create( + title='Test LJP', + excursion=self.excursion + ) + + # Create statement + self.statement_unsubmitted = Statement.objects.create( + short_description='Unsubmitted Statement', + excursion=self.excursion, + submitted=False + ) + + self.statement_submitted = Statement.objects.create( + short_description='Submitted Statement', + submitted=True + ) + + def test_is_oneself(self): + """Test is_oneself rule - member can identify themselves.""" + # Same member + self.assertTrue(is_oneself(self.user1, self.member1)) + + # Different members + self.assertFalse(is_oneself(self.user1, self.member2)) + + def test_may(self): + """Test `may_` rules.""" + self.assertTrue(may_view(self.user1, self.member1)) + self.assertTrue(may_change(self.user1, self.member1)) + self.assertTrue(may_delete(self.user1, self.member1)) + + def test_is_own_training(self): + """Test is_own_training rule - member can access their own training.""" + # Own training + self.assertTrue(is_own_training(self.user1, self.training)) + # Other member's training + self.assertFalse(is_own_training(self.user2, self.training)) + + def test_is_leader_of_excursion(self): + """Test is_leader_of_excursion rule for LJP proposals.""" + # LJP proposal with excursion - member3 is not a leader + self.assertFalse(is_leader_of_excursion(self.user3, self.ljp_proposal)) + # Directly pass an excursion + self.assertTrue(is_leader_of_excursion(self.user1, self.excursion)) + + def test_is_leader(self): + """Test is_leader rule for excursions.""" + # Direct excursion leader + self.assertTrue(is_leader(self.user1, self.excursion)) + + # Group leader (member2 is leader of group that is part of excursion) + self.assertTrue(is_leader(self.user2, self.excursion)) + + # member3 is unrelated + self.assertFalse(is_leader(self.user3, self.excursion)) + + # Test user without member attribute + user_no_member = User.objects.create_user(username='nomember', email='nomember@example.com') + self.assertFalse(is_leader(user_no_member, self.excursion)) + + # Test member without pk attribute + class MemberNoPk: + pass + member_no_pk = MemberNoPk() + self.assertFalse(_is_leader(member_no_pk, self.excursion)) + + # Test member with None pk + class MemberNonePk: + pk = None + member_none_pk = MemberNonePk() + self.assertFalse(_is_leader(member_none_pk, self.excursion)) + + def test_statement_not_submitted(self): + """Test statement_not_submitted rule.""" + # Unsubmitted statement with excursion + self.assertTrue(statement_not_submitted(self.user1, self.excursion)) + + # Submitted statement + self.excursion.statement = self.statement_submitted + self.excursion.save() + self.assertFalse(statement_not_submitted(self.user1, self.excursion)) + + # Excursion without statement + excursion_no_statement = Freizeit.objects.create( + name='No Statement Excursion', + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1 + ) + self.assertFalse(statement_not_submitted(self.user1, excursion_no_statement)) + + # Test the excursion.statement is None case + # Create a special test object to directly trigger + class ExcursionWithNoneStatement: + def __init__(self): + self.statement = None + # if excursion.statement is None: return False + self.assertFalse(statement_not_submitted(self.user1, ExcursionWithNoneStatement())) diff --git a/jdav_web/members/tests/tasks.py b/jdav_web/members/tests/tasks.py new file mode 100644 index 0000000..bade328 --- /dev/null +++ b/jdav_web/members/tests/tasks.py @@ -0,0 +1,141 @@ +from unittest.mock import patch, MagicMock +from django.test import TestCase +from django.utils import timezone +from django.conf import settings + +from ..models import MemberWaitingList, Freizeit, Group, DIVERSE, GEMEINSCHAFTS_TOUR +from ..tasks import ask_for_waiting_confirmation, send_crisis_intervention_list, send_notification_crisis_intervention_list +from mailer.models import EmailAddress + + +class TasksTestCase(TestCase): + def setUp(self): + # Create test email address + self.email_address = EmailAddress.objects.create(name='test@example.com') + + # Create test group + self.group = Group.objects.create(name='Test Group') + self.group.contact_email = self.email_address + self.group.save() + + # Create test waiters + now = timezone.now() + old_confirmation = now - timezone.timedelta(days=settings.WAITING_CONFIRMATION_FREQUENCY + 1) + old_reminder = now - timezone.timedelta(days=settings.CONFIRMATION_REMINDER_FREQUENCY + 1) + + self.waiter1 = MemberWaitingList.objects.create( + prename='Test', + lastname='Waiter1', + birth_date=now.date(), + email='waiter1@example.com', + gender=DIVERSE, + last_wait_confirmation=old_confirmation, + last_reminder=old_reminder, + sent_reminders=0 + ) + + self.waiter2 = MemberWaitingList.objects.create( + prename='Test', + lastname='Waiter2', + birth_date=now.date(), + email='waiter2@example.com', + gender=DIVERSE, + last_wait_confirmation=old_confirmation, + last_reminder=old_reminder, + sent_reminders=settings.MAX_REMINDER_COUNT - 1 + ) + + # Create waiter that shouldn't receive reminder (too recent confirmation) + self.waiter3 = MemberWaitingList.objects.create( + prename='Test', + lastname='Waiter3', + birth_date=now.date(), + email='waiter3@example.com', + gender=DIVERSE, + last_wait_confirmation=now, + last_reminder=old_reminder, + sent_reminders=0 + ) + + # Create waiter that shouldn't receive reminder (max reminders reached) + self.waiter4 = MemberWaitingList.objects.create( + prename='Test', + lastname='Waiter4', + birth_date=now.date(), + email='waiter4@example.com', + gender=DIVERSE, + last_wait_confirmation=old_confirmation, + last_reminder=old_reminder, + sent_reminders=settings.MAX_REMINDER_COUNT + ) + + # Create test excursions + today = timezone.now().date() + tomorrow = today + timezone.timedelta(days=1) + + self.excursion_today_not_sent = Freizeit.objects.create( + name='Today Excursion 1', + date=timezone.now().replace(hour=10, minute=0, second=0, microsecond=0), + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1, + crisis_intervention_list_sent=False, + notification_crisis_intervention_list_sent=False + ) + + self.excursion_today_sent = Freizeit.objects.create( + name='Today Excursion 2', + date=timezone.now().replace(hour=14, minute=0, second=0, microsecond=0), + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1, + crisis_intervention_list_sent=True, + notification_crisis_intervention_list_sent=True + ) + + self.excursion_tomorrow_not_sent = Freizeit.objects.create( + name='Tomorrow Excursion 1', + date=(timezone.now() + timezone.timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0), + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1, + crisis_intervention_list_sent=False, + notification_crisis_intervention_list_sent=False + ) + + self.excursion_tomorrow_sent = Freizeit.objects.create( + name='Tomorrow Excursion 2', + date=(timezone.now() + timezone.timedelta(days=1)).replace(hour=14, minute=0, second=0, microsecond=0), + tour_type=GEMEINSCHAFTS_TOUR, + kilometers_traveled=10, + difficulty=1, + crisis_intervention_list_sent=True, + notification_crisis_intervention_list_sent=True + ) + + @patch.object(MemberWaitingList, 'ask_for_wait_confirmation') + def test_ask_for_waiting_confirmation(self, mock_ask): + """Test ask_for_waiting_confirmation task calls correct waiters.""" + result = ask_for_waiting_confirmation() + + # Should call ask_for_wait_confirmation for waiter1 and waiter2 only + self.assertEqual(result, 2) + self.assertEqual(mock_ask.call_count, 2) + + @patch.object(Freizeit, 'send_crisis_intervention_list') + def test_send_crisis_intervention_list(self, mock_send): + """Test send_crisis_intervention_list task calls correct excursions.""" + result = send_crisis_intervention_list() + + # Should call send_crisis_intervention_list for today's excursions that haven't been sent + self.assertEqual(result, 1) + self.assertEqual(mock_send.call_count, 1) + + @patch.object(Freizeit, 'notify_leaders_crisis_intervention_list') + def test_send_notification_crisis_intervention_list(self, mock_notify): + """Test send_notification_crisis_intervention_list task calls correct excursions.""" + result = send_notification_crisis_intervention_list() + + # Should call notify_leaders_crisis_intervention_list for tomorrow's excursions that haven't been sent + self.assertEqual(result, 1) + self.assertEqual(mock_notify.call_count, 1) diff --git a/jdav_web/members/tests/utils.py b/jdav_web/members/tests/utils.py index 39eaba5..9660aea 100644 --- a/jdav_web/members/tests/utils.py +++ b/jdav_web/members/tests/utils.py @@ -83,7 +83,8 @@ class BasicMemberTestCase(TestCase): """ def setUp(self): self.jl = Group.objects.create(name="Jugendleiter", year_from=0, year_to=0) - self.alp = Group.objects.create(name="Alpenfuechse", year_from=1900, year_to=2000) + self.alp = Group.objects.create(name="Alpenfuechse", year_from=1900, year_to=2000, + show_website=True) self.spiel = Group.objects.create(name="Spielkinder") self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), diff --git a/jdav_web/members/tests/views.py b/jdav_web/members/tests/views.py new file mode 100644 index 0000000..0276424 --- /dev/null +++ b/jdav_web/members/tests/views.py @@ -0,0 +1,110 @@ +from unittest import skip +from http import HTTPStatus +from django.test import TestCase, Client +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ + +from mailer.models import EmailAddress +from ..models import Member, Group, InvitationToGroup, MemberWaitingList, DIVERSE + + +class ConfirmInvitationViewTestCase(TestCase): + def setUp(self): + self.client = Client() + + # Create an email address for the group + self.email_address = EmailAddress.objects.create(name='testmail') + + # Create a test group + self.group = Group.objects.create(name='Test Group') + self.group.contact_email = self.email_address + self.group.save() + + # Create a waiting list entry + self.waiter = MemberWaitingList.objects.create( + prename='Waiter', + lastname='User', + birth_date=timezone.now().date(), + email='waiter@example.com', + gender=DIVERSE, + wait_confirmation_key='test_wait_key', + wait_confirmation_key_expire=timezone.now() + timezone.timedelta(days=1) + ) + + # Create an invitation + self.invitation = InvitationToGroup.objects.create( + waiter=self.waiter, + group=self.group, + key='test_invitation_key', + date=timezone.now().date() + ) + + def test_confirm_invitation_get_valid_key(self): + """Test GET request with valid key shows invitation confirmation page.""" + url = reverse('members:confirm_invitation') + response = self.client.get(url, {'key': 'test_invitation_key'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Confirm trial group meeting invitation')) + self.assertContains(response, self.group.name) + + def test_confirm_invitation_get_invalid_key(self): + """Test GET request with invalid key shows invalid confirmation page.""" + url = reverse('members:confirm_invitation') + + # no key + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + # invalid key + response = self.client.get(url, {'key': 'invalid_key'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + def test_confirm_invitation_get_rejected_invitation(self): + """Test GET request with rejected invitation shows invalid confirmation page.""" + self.invitation.rejected = True + self.invitation.save() + + url = reverse('members:confirm_invitation') + response = self.client.get(url, {'key': self.invitation.key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + def test_confirm_invitation_get_expired_invitation(self): + """Test GET request with expired invitation shows invalid confirmation page.""" + # Set invitation date to more than 30 days ago to make it expired + self.invitation.date = timezone.now().date() - timezone.timedelta(days=31) + self.invitation.save() + + url = reverse('members:confirm_invitation') + response = self.client.get(url, {'key': self.invitation.key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + def test_confirm_invitation_post_invalid_key(self): + """Test POST request with invalid key shows invalid confirmation page.""" + url = reverse('members:confirm_invitation') + + # no key + response = self.client.post(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + # invalid key + response = self.client.post(url, {'key': 'invalid_key'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('This invitation is invalid or expired.')) + + def test_confirm_invitation_post_valid_key(self): + """Test POST request with valid key confirms invitation and shows success page.""" + url = reverse('members:confirm_invitation') + response = self.client.post(url, {'key': self.invitation.key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Invitation confirmed')) + self.assertContains(response, self.group.name) + + # Verify invitation was not marked as rejected (confirm() sets rejected=False) + self.invitation.refresh_from_db() + self.assertFalse(self.invitation.rejected) diff --git a/jdav_web/members/views.py b/jdav_web/members/views.py index a4789d6..14dd53f 100644 --- a/jdav_web/members/views.py +++ b/jdav_web/members/views.py @@ -9,6 +9,7 @@ from members.models import Member, RegistrationPassword, MemberUnconfirmedProxy, from django.urls import reverse from django.utils import timezone from django.conf import settings +from django.views.decorators.cache import never_cache from .pdf import render_tex, media_path @@ -505,6 +506,7 @@ def render_confirm_success(request, invitation): 'timeinfo': invitation.group.get_time_info()}) +@never_cache def confirm_invitation(request): if request.method == 'GET' and 'key' in request.GET: key = request.GET['key']