import re from datetime import datetime, timezone import freezegun import pytest from bs4 import BeautifulSoup from django.conf import settings from django.http import HttpResponse from django.test import TestCase from django.test.client import Client from django.urls import reverse from django.utils.timezone import localdate, now from model_bakery import baker from pytest_django.asserts import assertRedirects import eboutic.models from core.baker_recipes import subscriber_user from core.models import Group, User from counter.baker_recipes import ( price_recipe, product_recipe, refill_recipe, sale_recipe, ) from counter.models import ( Counter, Customer, ProductType, get_eboutic, ) from counter.tests.test_counter import BasketItem from eboutic.models import Basket @pytest.mark.django_db def test_get_eboutic(): assert Counter.objects.get(name="Eboutic") == get_eboutic() baker.make(Counter, type="EBOUTIC") assert Counter.objects.get(name="Eboutic") == get_eboutic() @pytest.mark.django_db def test_eboutic_access_unregistered(client: Client): eboutic_url = reverse("eboutic:main") assertRedirects( client.get(eboutic_url), reverse("core:login", query={"next": eboutic_url}) ) @pytest.mark.django_db def test_eboutic_access_new_customer(client: Client): user = baker.make(User) assert not Customer.objects.filter(user=user).exists() client.force_login(user) assert client.get(reverse("eboutic:main")).status_code == 200 assert Customer.objects.filter(user=user).exists() @pytest.mark.django_db def test_eboutic_access_old_customer(client: Client): user = baker.make(User) customer = Customer.get_or_create(user)[0] client.force_login(user) assert client.get(reverse("eboutic:main")).status_code == 200 assert Customer.objects.filter(user=user).first() == customer @pytest.mark.django_db @pytest.mark.parametrize( ("sellings", "refillings", "expected"), ( ([], [], None), ( [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], [], datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), ), ( [], [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), ), ( [datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc)], [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), ), ( [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], [datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc)], datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), ), ( [ datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc), ], [datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)], datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc), ), ), ) def test_eboutic_basket_expiry( client: Client, sellings: list[datetime], refillings: list[datetime], expected: datetime | None, ): eboutic = get_eboutic() customer = baker.make(Customer) client.force_login(customer.user) if sellings: sale_recipe.make( customer=customer, counter=eboutic, date=iter(sellings), _quantity=len(sellings), _bulk_create=True, ) if refillings: refill_recipe.make( customer=customer, counter=eboutic, date=iter(refillings), _quantity=len(refillings), _bulk_create=True, ) soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml") assert ( # remove any space from the value before asserting re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"]) == f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)" ) class TestEboutic(TestCase): @classmethod def setUpTestData(cls): cls.group_cotiz = baker.make(Group) cls.group_public = baker.make(Group) cls.new_customer = baker.make(User) cls.new_customer_adult = baker.make(User) cls.subscriber = subscriber_user.make() cls.set_age(cls.new_customer, 5) cls.set_age(cls.new_customer_adult, 20) cls.set_age(cls.subscriber, 20) product_type = baker.make(ProductType) cls.snack = price_recipe.make( amount=1.5, product=product_recipe.make(product_type=product_type) ) cls.beer = price_recipe.make( product=product_recipe.make(limit_age=18, product_type=product_type), amount=2.5, ) cls.not_in_counter = price_recipe.make( product=product_recipe.make(product_type=product_type), amount=3.5 ) cls.cotiz = price_recipe.make( amount=10, product=product_recipe.make(product_type=product_type) ) cls.group_public.prices.add(cls.snack, cls.beer, cls.not_in_counter) cls.group_cotiz.prices.add(cls.cotiz) cls.subscriber.groups.add(cls.group_cotiz, cls.group_public) cls.new_customer.groups.add(cls.group_public) cls.new_customer_adult.groups.add(cls.group_public) cls.eboutic = get_eboutic() cls.eboutic.products.add(cls.cotiz.product, cls.beer.product, cls.snack.product) @classmethod def set_age(cls, user: User, age: int): user.date_of_birth = localdate().replace(year=localdate().year - age) user.save() def submit_basket( self, basket: list[BasketItem], client: Client | None = None ) -> HttpResponse: used_client: Client = client if client is not None else self.client data = { "form-TOTAL_FORMS": str(len(basket)), "form-INITIAL_FORMS": "0", } for index, item in enumerate(basket): data.update(item.to_form(index)) return used_client.post(reverse("eboutic:main"), data) def test_submit_empty_basket(self): self.client.force_login(self.subscriber) for basket in [ [], [BasketItem(None, None)], [BasketItem(None, None), BasketItem(None, None)], ]: response = self.submit_basket(basket) assert response.status_code == 200 assert "Votre panier est vide" in response.text def test_submit_invalid_basket(self): self.client.force_login(self.subscriber) for item in [ BasketItem(-1, 2), BasketItem(self.snack.id, -1), BasketItem(None, 1), BasketItem(self.snack.id, None), ]: response = self.submit_basket([item]) assert response.status_code == 200 def test_anonymous(self): assertRedirects( self.client.get(reverse("eboutic:main")), reverse("core:login", query={"next": reverse("eboutic:main")}), ) assertRedirects( self.submit_basket([]), reverse("core:login", query={"next": reverse("eboutic:main")}), ) assertRedirects( self.submit_basket([BasketItem(self.snack.id, 1)]), reverse("core:login", query={"next": reverse("eboutic:main")}), ) def test_add_forbidden_product(self): self.client.force_login(self.new_customer) for product in self.beer, self.cotiz, self.not_in_counter: response = self.submit_basket([BasketItem(product.id, 1)]) assert response.status_code == 200 assert not Basket.objects.exists() def test_sold_out_product(self): sold_out = product_recipe.make( clic_limit=3, counters=[self.eboutic], product_type=baker.make(ProductType) ) price = price_recipe.make(product=sold_out, groups=[self.group_cotiz], amount=0) sale_recipe.make( product=sold_out, customer=self.subscriber.customer, unit_price=0, quantity=1, ) baker.make( eboutic.models.BasketItem, basket=baker.make(Basket), product=sold_out, quantity=2, ) self.client.force_login(self.subscriber) response = self.submit_basket([BasketItem(price.id, 1)]) assert response.status_code == 200 assert Basket.objects.count() == 1 with freezegun.freeze_time( now() + settings.SITH_EBOUTIC_BASKET_TIMEOUT + settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT ): # after a while, unpaid basket items should expire and make the # product available again. response = self.submit_basket([BasketItem(price.id, 1)]) assertRedirects( response, reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}), ) assert Basket.objects.count() == 2 def test_create_basket(self): self.client.force_login(self.new_customer) assertRedirects( self.submit_basket([BasketItem(self.snack.id, 2)]), reverse("eboutic:checkout", kwargs={"basket_id": 1}), ) assert Basket.objects.get(id=1).total == self.snack.amount * 2 self.client.force_login(self.new_customer_adult) assertRedirects( self.submit_basket( [BasketItem(self.snack.id, 2), BasketItem(self.beer.id, 1)] ), reverse("eboutic:checkout", kwargs={"basket_id": 2}), ) assert ( Basket.objects.get(id=2).total == self.snack.amount * 2 + self.beer.amount ) self.client.force_login(self.subscriber) assertRedirects( self.submit_basket( [ BasketItem(self.snack.id, 2), BasketItem(self.beer.id, 1), BasketItem(self.cotiz.id, 1), ] ), reverse("eboutic:checkout", kwargs={"basket_id": 3}), ) assert ( Basket.objects.get(id=3).total == self.snack.amount * 2 + self.beer.amount + self.cotiz.amount )