diff --git a/.coveragerc b/.coveragerc index 168d0f0c..bdcf9412 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,9 @@ omit=venv/*,\ booking/management/commands/update_categories.py,\ booking/management/commands/check_credits.py,\ booking/management/commands/reactivate_blocks.py,\ - ../*migrations*,../*tests*,../*wsgi*,../*__init__*,\ - timetable/management/commands/create_pip_hire_sessions.py,manage.py + */migrations/*,\ + */tests/*,\ + pipsevents/wsgi.py,\ + timetable/management/commands/create_pip_hire_sessions.py,\ + manage.py relative_files = True diff --git a/accounts/tests/test_migrations.py b/accounts/tests/test_migrations.py index 7f6ecc38..d9d77bb8 100644 --- a/accounts/tests/test_migrations.py +++ b/accounts/tests/test_migrations.py @@ -1,3 +1,5 @@ +import pytest + from datetime import datetime, date from datetime import timezone as dt_timezone @@ -29,6 +31,7 @@ OVER_18_STATEMENT_AT_2020_05_01 = "I confirm that I am aged 18 or over" +@pytest.mark.skip(reason="Old migration tests") class DisclaimerVersioningMingrationTests(MigrationTest): before = [ diff --git a/accounts/tests/test_models.py b/accounts/tests/test_models.py index d5347c6a..2436c870 100644 --- a/accounts/tests/test_models.py +++ b/accounts/tests/test_models.py @@ -108,13 +108,12 @@ def test_new_version_must_have_new_terms(self): disclaimer_terms="foo", over_18_statement="bar", medical_treatment_terms="foobar", version=None ) - with pytest.raises(ValidationError) as e: + with pytest.raises(ValidationError, match="No changes made to content; not saved") as e: baker.make( DisclaimerContent, disclaimer_terms="foo", over_18_statement="bar", medical_treatment_terms="foobar", version=None ) - assert str(e) == "No changes made to content; not saved" class DisclaimerModelTests(TestCase): @@ -199,6 +198,27 @@ def test_cannot_create_new_active_disclaimer(self): with self.assertRaises(ValidationError): baker.make(OnlineDisclaimer, user=user, version=disclaimer_content.version) + def test_expired_disclaimer(self): + user = baker.make_recipe('booking.user', username='testuser') + disclaimer_content = baker.make( + DisclaimerContent, + disclaimer_terms="foo", over_18_statement="bar", medical_treatment_terms="foobar", + version=None # ensure version is incremented from any existing ones + ) + # disclaimer is out of date, so inactive + disclaimer = baker.make( + OnlineDisclaimer, user=user, + version=disclaimer_content.version + ) + + assert disclaimer.is_active + + # manually mark as expired, even though stil in date + disclaimer.expired = True + disclaimer.save() + + assert not disclaimer.is_active + def test_delete_online_disclaimer(self): self.assertFalse(ArchivedDisclaimer.objects.exists()) disclaimer = baker.make(OnlineDisclaimer, name='Test 1') @@ -243,6 +263,19 @@ def test_nonregistered_disclaimer_is_active(self): ) self.assertFalse(old_disclaimer.is_active) + def test_nonregistered_disclaimer_expired(self): + disclaimer_content = baker.make( + DisclaimerContent, version=None # ensure version is incremented from any existing ones + ) + disclaimer = baker.make( + NonRegisteredDisclaimer, first_name='Test', last_name='User', version=disclaimer_content.version + ) + assert disclaimer.is_active + + disclaimer.expired = True + disclaimer.save() + assert not disclaimer.is_active + def test_delete_nonregistered_disclaimer(self): self.assertFalse(ArchivedDisclaimer.objects.exists()) disclaimer = baker.make(NonRegisteredDisclaimer, first_name='Test', last_name='User') diff --git a/booking/models/booking_models.py b/booking/models/booking_models.py index c97816f3..5a0a846d 100644 --- a/booking/models/booking_models.py +++ b/booking/models/booking_models.py @@ -762,9 +762,7 @@ def can_cancel(self): def has_available_block(self): available_blocks = [ block for block in - Block.objects.select_related("user", "block_type").filter( - user=self.user, block_type__event_type=self.event.event_type - ) + self.user.blocks.filter(block_type__event_type=self.event.event_type) if block.active_block() ] return bool(available_blocks) @@ -773,10 +771,8 @@ def has_available_block(self): def has_unpaid_block(self): available_blocks = [ block for block in - Block.objects.select_related("user", "block_type").filter( - user=self.user, block_type__event_type=self.event.event_type - ) - if not block.full and not block.expired and not block.paid + self.user.blocks.filter(block_type__event_type=self.event.event_type, paid=False) + if not block.full and not block.expired ] return bool(available_blocks) diff --git a/booking/models/membership_models.py b/booking/models/membership_models.py index f9ae9a78..af2e5a10 100644 --- a/booking/models/membership_models.py +++ b/booking/models/membership_models.py @@ -238,21 +238,25 @@ def valid_for_event(self, event): if self.end_date and self.end_date < event.date: return False - - # check quantities of classes already booked with this membership for this event type + + # check quantities of classes already booked with this membership for this event type in the same month allowed_numbers = membership_item.quantity - open_booking_count = self.bookings.filter(event__event_type=event.event_type, status="OPEN").count() + open_booking_count = self.bookings.filter( + event__event_type=event.event_type, event__date__month=event.date.month, event__date__year=event.date.year, + status="OPEN" + ).count() return open_booking_count < allowed_numbers def hr_status(self): return self.HR_STATUS.get(self.subscription_status, self.subscription_status.title()) def bookings_this_month(self): - return self.bookings.filter(status="OPEN", event__date__month=datetime.now().month) + return self.bookings.filter(status="OPEN", event__date__month=datetime.now().month, event__date__year=datetime.now().year) def bookings_next_month(self): - next_month = (datetime.now().month - 12) % 12 - return self.bookings.filter(status="OPEN", event__date__month=next_month) + next_month = (datetime.now().month + 1 - 12) % 12 + year = datetime.now().year + 1 if next_month == 1 else datetime.now().year + return self.bookings.filter(status="OPEN", event__date__month=next_month, event__date__year=year) @classmethod def calculate_membership_end_date(cls, end_date=None): diff --git a/booking/tests/test_gift_voucher_views.py b/booking/tests/test_gift_voucher_views.py index 84a90886..03f411e4 100644 --- a/booking/tests/test_gift_voucher_views.py +++ b/booking/tests/test_gift_voucher_views.py @@ -102,6 +102,20 @@ def test_purchase_gift_voucher_block(self): assert "paypal_form" in resp.context_data + def test_purchase_gift_voucher_invalid_email(self): + data = { + 'voucher_type': self.block_voucher_type1.id, + 'user_email': "test@test.com", + 'user_email1': "test1@test.com", + 'recipient_name': 'Donald Duck', + 'message': 'Quack' + } + resp = self.client.post(self.url, data) + assert resp.status_code == 200 + assert resp.context_data["form"].errors == { + "user_email1": ["Email addresses do not match"] + } + class TestGiftVoucherUpdateView(GiftVoucherTestMixin, TestCase): diff --git a/booking/tests/test_management.py b/booking/tests/test_management.py index 794f115a..d6311324 100644 --- a/booking/tests/test_management.py +++ b/booking/tests/test_management.py @@ -3179,3 +3179,67 @@ def test_find_no_shows_defaults(self): assert len(mail.outbox) == 2 assert f"{3}: {user.first_name} {user.last_name} - (id {user.id})" in mail.outbox[1].body assert f"{user1.first_name} {user1.last_name} - (id {user1.id})" not in mail.outbox[1].body + + +import json +import pytest +@pytest.mark.django_db +def test_update_prices(tmp_path): + blocktype = baker.make_recipe("booking.blocktype5", size=3, identifier="standard", cost=20) + blocktype1 = baker.make_recipe("booking.blocktype5", size=3, identifier="not-standard", cost=20) + event_type = baker.make("booking.EventType", event_type="CL", subtype="Test class") + event_type1 = baker.make("booking.EventType", event_type="CL", subtype="Test class 1") + event = baker.make_recipe("booking.future_PC", event_type=event_type, cost=10) + event1 = baker.make_recipe("booking.future_PC", event_type=event_type1, cost=10) + tt_session = baker.make("timetable.Session", event_type=event_type, cost=10) + tt_session1 = baker.make("timetable.Session", event_type=event_type1, cost=10) + + new_prices = { + "blocktype": [ + { + "identifier": "standard", + "size": 3, + "price": 36.00 + }, + ], + "event": [ + { + "event_type": "CL", + "subtype": "Test class", + "price": 13.00 + }, + ], + "session": [ + { + "event_type": "CL", + "subtype": "Test class", + "price": 12.00 + }, + ] + } + new_prices_filepath = tmp_path / "prices.json" + new_prices_filepath.write_text(json.dumps(new_prices)) + + # dry run + management.call_command("update_prices", new_prices_filepath) + for obj in [blocktype, blocktype1, event_type, event_type1, event, event1, tt_session, tt_session1]: + obj.refresh_from_db() + assert blocktype.cost == 20 + assert blocktype1.cost == 20 + assert event.cost == 10 + assert event1.cost == 10 + assert tt_session.cost == 10 + assert tt_session1.cost == 10 + + # live run + management.call_command("update_prices", new_prices_filepath, live_run=True) + for obj in [blocktype, blocktype1, event_type, event_type1, event, event1, tt_session, tt_session1]: + obj.refresh_from_db() + assert blocktype.cost == 36 + assert blocktype1.cost == 20 + assert event.cost == 13 + assert event1.cost == 10 + assert tt_session.cost == 12 + assert tt_session1.cost == 10 + + \ No newline at end of file diff --git a/booking/tests/test_membership_models.py b/booking/tests/test_membership_models.py index a24d76a5..9fce4565 100644 --- a/booking/tests/test_membership_models.py +++ b/booking/tests/test_membership_models.py @@ -1,6 +1,7 @@ import json from datetime import datetime +from datetime import timezone as dt_tz from unittest.mock import patch @@ -24,13 +25,15 @@ def test_membership_create(mocked_responses, seller): "id": "memb-1", "name": "memb 1", "description": "a membership", - "default_price": "price_1" + "default_price": "price_1", } ), status=200, content_type="application/json", ) - membership = baker.make(Membership, name="memb 1", description="a membership", price=10) + membership = baker.make( + Membership, name="memb 1", description="a membership", price=10 + ) assert membership.stripe_product_id == "memb-1" assert membership.stripe_price_id == "price_1" @@ -57,7 +60,7 @@ def test_membership_change_price(mocked_responses, seller): "id": "memb-1", "name": "membership 1", "description": "a membership", - "default_price": "price_1" + "default_price": "price_1", } ), status=200, @@ -99,14 +102,16 @@ def test_membership_change_price(mocked_responses, seller): "id": "memb-1", "name": "membership 1", "description": "a membership", - "default_price": "price_2" + "default_price": "price_2", } ), status=200, content_type="application/json", ) - membership = baker.make(Membership, name="memb 1", description="a membership", price=10) + membership = baker.make( + Membership, name="memb 1", description="a membership", price=10 + ) assert membership.stripe_product_id == "memb-1" assert membership.stripe_price_id == "price_1" @@ -128,7 +133,7 @@ def test_membership_change_price_with_user_memberships(mocked_responses, seller) "id": "memb-1", "name": "membership 1", "description": "a membership", - "default_price": "price_1" + "default_price": "price_1", } ), status=200, @@ -170,7 +175,7 @@ def test_membership_change_price_with_user_memberships(mocked_responses, seller) "id": "memb-1", "name": "membership 1", "description": "a membership", - "default_price": "price_2" + "default_price": "price_2", } ), status=200, @@ -204,12 +209,12 @@ def test_membership_change_price_with_user_memberships(mocked_responses, seller) "subscription": "subsc-1", "end_behavior": "release", "phases": [ - { - "start_date": datetime(2024, 6, 25).timestamp(), - "end_date": datetime(2024, 7, 25).timestamp(), - "items": [{"price": 2000, "quantity": 1}] - } - ] + { + "start_date": datetime(2024, 6, 25).timestamp(), + "end_date": datetime(2024, 7, 25).timestamp(), + "items": [{"price": 2000, "quantity": 1}], + } + ], } ), status=200, @@ -223,19 +228,25 @@ def test_membership_change_price_with_user_memberships(mocked_responses, seller) "object": "subscription_schedule", "url": "/v1/subscription_schedules", "id": "sub_sched-1", - "subscription": "subsc-1" + "subscription": "subsc-1", } ), status=200, content_type="application/json", ) - - membership = baker.make(Membership, name="memb 1", description="a membership", price=10) + membership = baker.make( + Membership, name="memb 1", description="a membership", price=10 + ) assert membership.stripe_product_id == "memb-1" assert membership.stripe_price_id == "price_1" - baker.make(UserMembership, membership=membership, subscription_status="active", subscription_id="subsc-1") + baker.make( + UserMembership, + membership=membership, + subscription_status="active", + subscription_id="subsc-1", + ) membership.price = 20 membership.save() @@ -254,7 +265,7 @@ def test_membership_change_name(mocked_responses, seller): "id": "memb-1", "name": "memb 1", "description": "a membership", - "default_price": "price_1" + "default_price": "price_1", } ), status=200, @@ -270,14 +281,16 @@ def test_membership_change_name(mocked_responses, seller): "id": "memb-1", "name": "memb 2", "description": "a membership", - "default_price": "price_1" + "default_price": "price_1", } ), status=200, content_type="application/json", ) - membership = baker.make(Membership, name="memb 1", description="a membership", price=10) + membership = baker.make( + Membership, name="memb 1", description="a membership", price=10 + ) assert membership.stripe_product_id == "memb-1" assert membership.stripe_price_id == "price_1" @@ -291,16 +304,26 @@ def test_membership_change_name(mocked_responses, seller): @patch("booking.models.membership_models.StripeConnector", MockConnector) def test_membership_item_str(): - membership = baker.make(Membership, name="Test membership", description="a membership", price=10) + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) event_type = baker.make_recipe("booking.event_type_PC", subtype="Level class") - mitem = baker.make(MembershipItem, event_type=event_type, membership=membership, quantity=4) + mitem = baker.make( + MembershipItem, event_type=event_type, membership=membership, quantity=4 + ) assert str(mitem) == "Test membership - Level class - 4" @patch("booking.models.membership_models.StripeConnector", MockConnector) def test_user_membership_str(seller): - membership = baker.make(Membership, name="Test membership", description="a membership", price=10) - user_membership = baker.make(UserMembership, membership=membership, user__username="test", ) + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + user_membership = baker.make( + UserMembership, + membership=membership, + user__username="test", + ) assert str(user_membership) == "test - Test membership" @@ -308,64 +331,153 @@ def test_user_membership_str(seller): "start_date,end_date,status,is_active", [ # started, no end, status active - (datetime(2020, 5, 20), None, "active", True), + (datetime(2020, 5, 20, tzinfo=dt_tz.utc), None, "active", True), # started, end, status active - (datetime(2020, 2, 20), datetime(2020, 5, 20), "active", True), + (datetime(2020, 2, 20, tzinfo=dt_tz.utc), datetime(2020, 5, 20, tzinfo=dt_tz.utc), "active", True), # started, no end, status cancelled - (datetime(2020, 5, 20), None, "canceled", False), - ] + (datetime(2020, 5, 20, tzinfo=dt_tz.utc), None, "canceled", False), + # started, no end, status inactive + (datetime(2020, 5, 20, tzinfo=dt_tz.utc), None, "inactive", False), + ], ) -@pytest.mark.freeze_time('2020-05-21') +@pytest.mark.freeze_time("2020-05-21") @patch("booking.models.membership_models.StripeConnector", MockConnector) def test_user_membership_is_active(seller, start_date, end_date, status, is_active): - membership = baker.make(Membership, name="Test membership", description="a membership", price=10) - user_membership = baker.make(UserMembership, membership=membership, start_date=start_date, end_date=end_date, subscription_status=status) + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + user_membership = baker.make( + UserMembership, + membership=membership, + start_date=start_date, + end_date=end_date, + subscription_status=status, + ) assert user_membership.is_active() == is_active @pytest.mark.parametrize( "now,is_active", [ - (datetime(2020, 5, 25), True), - (datetime(2020, 5, 28), True), - (datetime(2020, 5, 30), False), - ] + (datetime(2020, 5, 25, tzinfo=dt_tz.utc), True), + (datetime(2020, 5, 28, tzinfo=dt_tz.utc), True), + (datetime(2020, 5, 30, tzinfo=dt_tz.utc), False), + ], ) -@pytest.mark.freeze_time('2020-05-21') @patch("booking.models.membership_models.StripeConnector", MockConnector) def test_user_membership_past_due_is_active(freezer, seller, now, is_active): freezer.move_to(now) - membership = baker.make(Membership, name="Test membership", description="a membership", price=10) - user_membership = baker.make(UserMembership, membership=membership, start_date=datetime(2020, 4, 20), subscription_status="past_due") + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + user_membership = baker.make( + UserMembership, + membership=membership, + start_date=datetime(2020, 4, 20, tzinfo=dt_tz.utc), + subscription_status="past_due", + ) + assert user_membership.is_active() == is_active + + +@pytest.mark.parametrize( + "now,start_date,end_date,is_active", + [ + # started before, ends at end of month + # membership is still active if we're before the end date + ( + datetime(2020, 5, 25, tzinfo=dt_tz.utc), + datetime(2020, 4, 20, tzinfo=dt_tz.utc), + datetime(2020, 6, 1, tzinfo=dt_tz.utc), + True, + ), + ( + datetime(2020, 5, 28, tzinfo=dt_tz.utc), + datetime(2020, 4, 20, tzinfo=dt_tz.utc), + datetime(2020, 6, 1, tzinfo=dt_tz.utc), + True, + ), + ( + datetime(2020, 5, 30, tzinfo=dt_tz.utc), + datetime(2020, 4, 20, tzinfo=dt_tz.utc), + datetime(2020, 6, 1, tzinfo=dt_tz.utc), + True, + ), + # After end date + ( + datetime(2020, 6, 1, tzinfo=dt_tz.utc), + datetime(2020, 4, 20, tzinfo=dt_tz.utc), + datetime(2020, 6, 1, tzinfo=dt_tz.utc), + False, + ), + # Starts in future + ( + datetime(2020, 5, 25, tzinfo=dt_tz.utc), + datetime(2020, 6, 1, tzinfo=dt_tz.utc), + datetime(2020, 6, 1, tzinfo=dt_tz.utc), + False, + ), + ], +) +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_user_membership_cancelled_is_active( + freezer, seller, now, start_date, end_date, is_active +): + freezer.move_to(now) + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + user_membership = baker.make( + UserMembership, + membership=membership, + start_date=start_date, + end_date=end_date, + subscription_status="canceled", + ) assert user_membership.is_active() == is_active -@pytest.mark.freeze_time('2020-05-21') +@pytest.mark.freeze_time("2020-05-21") @patch("booking.models.membership_models.StripeConnector", MockConnector) def test_user_membership_valid_for_event(seller): - membership = baker.make(Membership, name="Test membership", description="a membership", price=10) + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) pc_event_type = baker.make_recipe("booking.event_type_PC", subtype="Level class") pp_event_type = baker.make_recipe("booking.event_type_PP", subtype="Pole practice") - baker.make(MembershipItem, event_type=pc_event_type, membership=membership, quantity=4) + baker.make( + MembershipItem, event_type=pc_event_type, membership=membership, quantity=4 + ) - user_membership = baker.make(UserMembership, membership=membership, start_date=datetime(2020, 5, 1), subscription_status="active") + user_membership = baker.make( + UserMembership, + membership=membership, + start_date=datetime(2020, 5, 1, tzinfo=dt_tz.utc), + subscription_status="active", + ) - # only valid for correct event type - pc_event = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 5, 28)) - pp_event = baker.make(Event, event_type=pp_event_type, date=datetime(2020, 5, 28)) + # only valid for correct event type + pc_event = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 5, 28, tzinfo=dt_tz.utc)) + pp_event = baker.make(Event, event_type=pp_event_type, date=datetime(2020, 5, 28, tzinfo=dt_tz.utc)) assert user_membership.valid_for_event(pc_event) assert not user_membership.valid_for_event(pp_event) # already booked for this event type this month baker.make( - "booking.booking", user=user_membership.user, event__event_type=pc_event_type, event__date=datetime(2020, 5, 20), - membership=user_membership, _quantity=3 + "booking.booking", + user=user_membership.user, + event__event_type=pc_event_type, + event__date=datetime(2020, 5, 20, tzinfo=dt_tz.utc), + membership=user_membership, + _quantity=3, ) assert user_membership.valid_for_event(pc_event) baker.make( - "booking.booking", user=user_membership.user, event__event_type=pc_event_type, event__date=datetime(2020, 5, 15), - membership=user_membership + "booking.booking", + user=user_membership.user, + event__event_type=pc_event_type, + event__date=datetime(2020, 5, 15, tzinfo=dt_tz.utc), + membership=user_membership, ) assert not user_membership.valid_for_event(pc_event) @@ -373,23 +485,416 @@ def test_user_membership_valid_for_event(seller): @pytest.mark.parametrize( "event_date,end_date,is_valid", [ - (datetime(2020, 5, 1, 10, 0), None, True), - (datetime(2020, 5, 31, 10, 0), None, True), - (datetime(2020, 6, 1, 10, 0), None, True), # event date is after this month, but still valid because no end date - (datetime(2020, 6, 1, 10, 0), datetime(2020, 6, 1), False), - (datetime(2020, 4, 30, 10, 0), None, False), - ] + # valid, same month + (datetime(2020, 5, 1, 10, 0, tzinfo=dt_tz.utc), None, True), + (datetime(2020, 5, 31, 10, 0, tzinfo=dt_tz.utc), None, True), + # same day/month later year, still valid because no end date + (datetime(2021, 5, 31, 10, 0, tzinfo=dt_tz.utc), None, True), + # same day/month later year, not valid because end date + (datetime(2021, 5, 31, 10, 0, tzinfo=dt_tz.utc), datetime(2020, 6, 1, tzinfo=dt_tz.utc), False), + # same day/month earlier year, not valid + (datetime(2019, 5, 31, 10, 0, tzinfo=dt_tz.utc), None, False), + ( + datetime(2020, 6, 1, 10, 0, tzinfo=dt_tz.utc), + None, + True, + ), # event date is after this month, but still valid because no end date + (datetime(2020, 6, 1, 10, 0, tzinfo=dt_tz.utc), datetime(2020, 6, 1, tzinfo=dt_tz.utc), False), + (datetime(2020, 4, 30, 10, 0, tzinfo=dt_tz.utc), None, False), + # membership has already ended, not active + (datetime(2020, 5, 1, 10, 0, tzinfo=dt_tz.utc), datetime(2020, 4, 1, tzinfo=dt_tz.utc), False), + ], ) @patch("booking.models.membership_models.StripeConnector", MockConnector) def test_user_membership_valid_for_event_dates(seller, event_date, end_date, is_valid): - membership = baker.make(Membership, name="Test membership", description="a membership", price=10) + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) pc_event_type = baker.make_recipe("booking.event_type_PC", subtype="Level class") - baker.make(MembershipItem, event_type=pc_event_type, membership=membership, quantity=4) + baker.make( + MembershipItem, event_type=pc_event_type, membership=membership, quantity=4 + ) user_membership = baker.make( - UserMembership, membership=membership, start_date=datetime(2020, 5, 1), end_date=end_date, subscription_status="active" + UserMembership, + membership=membership, + start_date=datetime(2020, 5, 1, tzinfo=dt_tz.utc), + end_date=end_date, + subscription_status="active", ) - # only valid for correct event type + # only valid for correct event type pc_event = baker.make(Event, event_type=pc_event_type, date=event_date) - assert user_membership.valid_for_event(pc_event) == is_valid \ No newline at end of file + assert user_membership.valid_for_event(pc_event) == is_valid + + +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_user_membership_valid_for_event_inactive_membership(seller): + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + pc_event_type = baker.make_recipe("booking.event_type_PC", subtype="Level class") + baker.make( + MembershipItem, event_type=pc_event_type, membership=membership, quantity=4 + ) + + inactive_user_membership = baker.make( + UserMembership, + membership=membership, + start_date=datetime(2020, 5, 1, tzinfo=dt_tz.utc), + subscription_status="incomplete", + ) + + active_user_membership = baker.make( + UserMembership, + membership=membership, + start_date=datetime(2020, 5, 1, tzinfo=dt_tz.utc), + subscription_status="active", + ) + + # not valid for correct event type + pc_event = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 5, 10, tzinfo=dt_tz.utc)) + assert not inactive_user_membership.valid_for_event(pc_event) + assert active_user_membership.valid_for_event(pc_event) + + +@pytest.mark.parametrize( + "status,hr_status", + [ + *[(k, v) for k, v in UserMembership.HR_STATUS.items()], + ("unknown status", "Unknown Status") + ] +) +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_user_membership_hr_status(seller, status, hr_status): + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + user_membership = baker.make( + UserMembership, + membership=membership, + start_date=datetime(2020, 5, 1, tzinfo=dt_tz.utc), + subscription_status=status, + ) + assert user_membership.hr_status() == hr_status + + +@pytest.mark.freeze_time("2020-05-21") +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_user_membership_bookings_by_month(seller): + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + # 2 valid event types + pc_event_type = baker.make_recipe("booking.event_type_PC", subtype="Level class") + baker.make( + MembershipItem, event_type=pc_event_type, membership=membership, quantity=4 + ) + pc_event_type1 = baker.make_recipe("booking.event_type_PC", subtype="Level class1") + baker.make( + MembershipItem, event_type=pc_event_type1, membership=membership, quantity=4 + ) + # 1 non-valid event type + other_event_type = baker.make_recipe("booking.event_type_PC", subtype="Other class") + + # started membership in Jan last year + user_membership = baker.make( + UserMembership, + membership=membership, + start_date=datetime(2019, 1, 1, tzinfo=dt_tz.utc), + subscription_status="active", + ) + + # Events this month for each event type + pc_event = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 5, 15, tzinfo=dt_tz.utc)) + pc_event1 = baker.make(Event, event_type=pc_event_type1, date=datetime(2020, 5, 10, tzinfo=dt_tz.utc)) + oc_event = baker.make(Event, event_type=other_event_type, date=datetime(2020, 5, 12, tzinfo=dt_tz.utc)) + + # Events next month for each event type + pc_event_next = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 6, 15, tzinfo=dt_tz.utc)) + pc_event1_next = baker.make(Event, event_type=pc_event_type1, date=datetime(2020, 6, 10, tzinfo=dt_tz.utc)) + oc_event_next = baker.make(Event, event_type=other_event_type, date=datetime(2020, 6, 12, tzinfo=dt_tz.utc)) + + # Events same month last year for each event type + pc_event_old = baker.make(Event, event_type=pc_event_type, date=datetime(2019, 5, 15, tzinfo=dt_tz.utc)) + pc_event1_old = baker.make(Event, event_type=pc_event_type1, date=datetime(2019, 5, 10, tzinfo=dt_tz.utc)) + oc_event_old = baker.make(Event, event_type=other_event_type, date=datetime(2019, 5, 12, tzinfo=dt_tz.utc)) + + # Valid events for non-open booking status + # no-shows are included in count, cancelled are not + no_show_pc = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 5, 16, tzinfo=dt_tz.utc)) + cancelled_pc = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 5, 17, tzinfo=dt_tz.utc)) + + # make a booking with membership for each valid event type + for ev in [pc_event, pc_event1, pc_event_next, pc_event_old, pc_event1_old, pc_event1_next]: + baker.make_recipe( + "booking.booking", + user=user_membership.user, + event=ev, + membership=user_membership, + ) + + # make a booking without membership for each invalid event type + for ev in [oc_event, oc_event_old, oc_event_next]: + baker.make_recipe( + "booking.booking", + user=user_membership.user, + event=ev, + paid=True, + ) + + # no-show booking + baker.make_recipe( + "booking.booking", + user=user_membership.user, + event=no_show_pc, + membership=user_membership, + status="OPEN", + no_show=True, + ) + + # cancelled booking + baker.make_recipe( + "booking.booking", + user=user_membership.user, + event=cancelled_pc, + membership=user_membership, + status="CANCELLED", + ) + + # Bookings for these events are counted + booked_events = [pc_event.id, pc_event1.id, no_show_pc.id] + assert set(user_membership.bookings_this_month()) == {booking for booking in user_membership.user.bookings.filter(event_id__in=booked_events)} + booked_events_next = [pc_event1_next.id, pc_event_next.id] + assert set(user_membership.bookings_next_month()) == {booking for booking in user_membership.user.bookings.filter(event_id__in=booked_events_next)} + + +@pytest.mark.parametrize( + "now,event_date", + [ + (datetime(2020, 4, 15, tzinfo=dt_tz.utc), datetime(2020, 5, 15, tzinfo=dt_tz.utc)), + (datetime(2020, 5, 31, tzinfo=dt_tz.utc), datetime(2020, 6, 1, tzinfo=dt_tz.utc)), + (datetime(2020, 12, 30, tzinfo=dt_tz.utc), datetime(2021, 1, 15, tzinfo=dt_tz.utc)), + ] +) +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_user_membership_bookings_next_month(freezer, seller, now, event_date): + freezer.move_to(now) + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + pc_event_type = baker.make_recipe("booking.event_type_PC", subtype="Level class") + baker.make( + MembershipItem, event_type=pc_event_type, membership=membership, quantity=4 + ) + event = baker.make(Event, event_type=pc_event_type, date=event_date) + # started membership in Jan last year + user_membership = baker.make( + UserMembership, + membership=membership, + start_date=datetime(2019, 1, 1, tzinfo=dt_tz.utc), + subscription_status="active", + ) + + booking = baker.make_recipe( + "booking.booking", + user=user_membership.user, + event=event, + membership=user_membership, + ) + + assert list(user_membership.bookings_next_month()) == [booking] + + +@pytest.mark.parametrize( + "end_date,membership_end_date", + [ + # no date + (None, None), + # non DST + (datetime(2022, 2, 5, tzinfo=dt_tz.utc), datetime(2022, 3, 1, tzinfo=dt_tz.utc)), + # DST + (datetime(2022, 7, 5, tzinfo=dt_tz.utc), datetime(2022, 8, 1, tzinfo=dt_tz.utc)), + # end of year + (datetime(2022, 12, 5, tzinfo=dt_tz.utc), datetime(2023, 1, 1, tzinfo=dt_tz.utc)), + ] +) +def test_calculate_membership_end_date(end_date, membership_end_date): + assert UserMembership.calculate_membership_end_date(end_date) == membership_end_date + + +def _setup_user_membership_and_bookings(): + membership = baker.make( + Membership, name="Test membership", description="a membership", price=10 + ) + pc_event_type = baker.make_recipe("booking.event_type_PC", subtype="Level class") + baker.make( + MembershipItem, event_type=pc_event_type, membership=membership, quantity=4 + ) + + event = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 3, 5, tzinfo=dt_tz.utc)) + end_of_month_event = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 3, 29, tzinfo=dt_tz.utc)) + next_month_event = baker.make(Event, event_type=pc_event_type, date=datetime(2020, 4, 5, tzinfo=dt_tz.utc)) + + # active membership, no end date + user_membership = baker.make( + UserMembership, + membership=membership, + start_date=datetime(2019, 1, 1, tzinfo=dt_tz.utc), + subscription_status="active", + ) + + booking = baker.make_recipe( + "booking.booking", + user=user_membership.user, + event=event, + membership=user_membership, + ) + booking_end_of_month = baker.make_recipe( + "booking.booking", + user=user_membership.user, + event=end_of_month_event, + membership=user_membership, + ) + booking_next = baker.make_recipe( + "booking.booking", + user=user_membership.user, + event=next_month_event, + membership=user_membership, + ) + return user_membership, booking, booking_end_of_month, booking_next + + +@pytest.mark.freeze_time("2020-03-21") +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_reallocate_bookings(seller): + user_membership, booking, booking_end_of_month, booking_next = _setup_user_membership_and_bookings() + booking_set = {booking, booking_end_of_month, booking_next} + + # active membership, reallocating does nothing + user_membership.reallocate_bookings() + + for bk in booking_set: + bk.refresh_from_db() + + assert set(user_membership.bookings.all()) == booking_set + + +@pytest.mark.freeze_time("2020-03-21") +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_reallocate_bookings_after_cancel(seller): + user_membership, booking, booking_end_of_month, booking_next = _setup_user_membership_and_bookings() + booking_set = {booking, booking_end_of_month, booking_next} + + # change status to cancelled + user_membership.subscription_status = "canceled" + user_membership.end_date = datetime(2020, 4, 1, tzinfo=dt_tz.utc) + user_membership.save() + + user_membership.reallocate_bookings() + + for bk in booking_set: + bk.refresh_from_db() + + # next months booking has been removed + assert set(user_membership.bookings.all()) == {booking, booking_end_of_month} + assert booking_next.membership == None + assert not booking_next.paid + + +@pytest.mark.freeze_time("2020-03-21") +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_reallocate_bookings_after_cancel_with_other_membership_inactive(seller): + user_membership, booking, booking_end_of_month, booking_next = _setup_user_membership_and_bookings() + booking_set = {booking, booking_end_of_month, booking_next} + + # change status to cancelled + user_membership.subscription_status = "canceled" + user_membership.end_date = datetime(2020, 4, 1, tzinfo=dt_tz.utc) + user_membership.save() + + # New membership starts next month, not yet active + next_user_membership = baker.make( + UserMembership, + membership=user_membership.membership, + start_date=datetime(2020, 4, 1, tzinfo=dt_tz.utc), + subscription_status="inactive", + ) + + user_membership.reallocate_bookings() + + for bk in booking_set: + bk.refresh_from_db() + + # next months booking has been removed + assert set(user_membership.bookings.all()) == {booking, booking_end_of_month} + # not allocated to next membership + assert set(next_user_membership.bookings.all()) == set() + assert booking_next.membership == None + assert not booking_next.paid + + +@pytest.mark.freeze_time("2020-03-21") +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_reallocate_bookings_after_cancel_with_other_membership_active(seller): + user_membership, booking, booking_end_of_month, booking_next = _setup_user_membership_and_bookings() + booking_set = {booking, booking_end_of_month, booking_next} + + # change status to cancelled + user_membership.subscription_status = "canceled" + user_membership.end_date = datetime(2020, 4, 1, tzinfo=dt_tz.utc) + user_membership.save() + + # New membership starts next month, not yet active + next_user_membership = baker.make( + UserMembership, + membership=user_membership.membership, + start_date=datetime(2020, 4, 1, tzinfo=dt_tz.utc), + subscription_status="active", + ) + + user_membership.reallocate_bookings() + + for bk in booking_set: + bk.refresh_from_db() + + # next months booking has been removed + assert set(user_membership.bookings.all()) == {booking, booking_end_of_month} + # allocated to next membership + assert set(next_user_membership.bookings.all()) == {booking_next} + assert booking_next.membership == next_user_membership + assert booking_next.paid + + +@pytest.mark.xfail +@pytest.mark.freeze_time("2020-03-21") +@patch("booking.models.membership_models.StripeConnector", MockConnector) +def test_reallocate_bookings_after_cancel_with_active_block(seller): + user_membership, booking, booking_end_of_month, booking_next = _setup_user_membership_and_bookings() + booking_set = {booking, booking_end_of_month, booking_next} + + # make a valid block + block = baker.make_recipe( + "booking.block", block_type__size=4, start_date=datetime(2020, 3, 1, tzinfo=dt_tz.utc), + block_type__event_type=booking_next.event.event_type, paid=True, + user=user_membership.user, + + ) + assert block.active_block() + assert booking_next.has_available_block + + # change status to cancelled + user_membership.subscription_status == "canceled" + user_membership.save() + + user_membership.reallocate_bookings() + + for bk in booking_set: + bk.refresh_from_db() + + # next months booking has been removed + assert set(user_membership.bookings.all()) == {booking, booking_end_of_month} + # allocated to next membership + assert booking_next.membership is None + assert booking_next.block == block + assert booking_next.paid \ No newline at end of file diff --git a/booking/tests/test_migrations.py b/booking/tests/test_migrations.py index eddc1b48..1c256803 100644 --- a/booking/tests/test_migrations.py +++ b/booking/tests/test_migrations.py @@ -1,11 +1,14 @@ from datetime import datetime, timedelta from datetime import timezone as dt_timezone +import pytest + from django.utils import timezone from django_migration_testcase import MigrationTest +@pytest.mark.skip(reason="Old migration tests") class VoucherMigrationTests(MigrationTest): before = [ @@ -143,6 +146,7 @@ def test_data_migration_voucher_to_event_voucher(self): self.assertEqual(BlockVoucher.objects.count(), 0) +@pytest.mark.skip(reason="Old migration tests") class DateWarningSentMigrationTests(MigrationTest): before = [ diff --git a/conftest.py b/conftest.py index 69e163c4..01394ae2 100644 --- a/conftest.py +++ b/conftest.py @@ -118,6 +118,8 @@ def get_mock_subscription(webhook_event_type, **params): "object": "subscription", "id": "id", "status": "active", + "default_payment_method": None, + "pending_setup_intent": None, "items": Mock(data=[Mock(price=Mock(id="price_1234"))]), # matches the id returned by the MockStripeConnector "customer": "cus-1", "start_date": datetime(2024, 6, 25).timestamp(), diff --git a/pytest.ini b/pytest.ini index 2a1b102e..84597850 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -addopts = --reuse-db +; addopts = --reuse-db python_files = test*.py diff --git a/stripe_payments/tests/test_stripe_views.py b/stripe_payments/tests/test_stripe_views.py index 3ea8be75..36303e9b 100644 --- a/stripe_payments/tests/test_stripe_views.py +++ b/stripe_payments/tests/test_stripe_views.py @@ -689,8 +689,8 @@ def test_webhook_subscription_created( ) resp = client.post(webhook_url, data={}, HTTP_STRIPE_SIGNATURE="foo") assert resp.status_code == 200 - # email sent to user - assert len(mail.outbox) == 1 + # no emails sent for initial creation with no default payment method (i.e. setup but not paid/confirmed yet) + assert len(mail.outbox) == 0 # membership created, with start date as first of next month assert membership.user_memberships.count() == 1 assert membership.user_memberships.first().start_date.date() == datetime(2024, 7, 1).date()