From d8803b771965b8cc28c3e81dfea1ed6e8d503fea Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 17:25:55 +0300 Subject: [PATCH 1/6] refactor: add `basename` to router registration and remove unused `queryset` in PaymentsView --- payments/urls.py | 2 +- payments/views.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/payments/urls.py b/payments/urls.py index 369c775..2faaef9 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -12,7 +12,7 @@ app_name = "payments" router = DefaultRouter() -router.register("", PaymentViewSet) +router.register("", PaymentViewSet, basename="payments") urlpatterns = [ path("success/", views.PaymentSuccessView.as_view(), name="success"), path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), diff --git a/payments/views.py b/payments/views.py index 046f3b1..7dc1497 100644 --- a/payments/views.py +++ b/payments/views.py @@ -48,7 +48,6 @@ class PaymentViewSet( viewsets.GenericViewSet, ): - queryset = Payment.objects.select_related("borrowing") permission_classes = (IsAuthenticated,) def get_serializer_class(self) -> type: From 7da07042ea71b38c14339bcbd8a408985c1e8c6a Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 19:22:38 +0300 Subject: [PATCH 2/6] refactor: update healthcheck command in docker-compose, add `basename` to BorrowingViewSet, and remove unused `queryset` in BorrowingsView --- borrowings/urls.py | 2 +- borrowings/views.py | 5 ----- docker-compose.yml | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/borrowings/urls.py b/borrowings/urls.py index d47cf8d..5e6eb6a 100644 --- a/borrowings/urls.py +++ b/borrowings/urls.py @@ -6,7 +6,7 @@ app_name = "borrowings" router = routers.DefaultRouter() -router.register("", BorrowingViewSet) +router.register("", BorrowingViewSet, basename="borrowings") urlpatterns = [ path("", include(router.urls)), diff --git a/borrowings/views.py b/borrowings/views.py index d11c571..bcd2504 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -55,11 +55,6 @@ class BorrowingViewSet( mixins.CreateModelMixin, viewsets.GenericViewSet, ): - queryset = ( - Borrowing.objects.all() - .select_related("book", "user") - .order_by("-borrow_date") - ) permission_classes = (IsAuthenticated,) filter_backends = [DjangoFilterBackend] filterset_fields = ["user", "book", "borrow_date", "actual_return_date"] diff --git a/docker-compose.yml b/docker-compose.yml index b3d5b94..e8113f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - "5432:5432" restart: always healthcheck: - test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 From 3c025a7b351cd8340b491c6ff3be2cfc34b1bba3 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 20:42:32 +0300 Subject: [PATCH 3/6] refactor: remove unused validation methods and tests in PaymentSerializer, add ordering to Borrowing model, improve BorrowingsView queryset, and update router basenames --- borrowings/models.py | 1 + borrowings/urls.py | 2 +- borrowings/views.py | 2 +- payments/serializers.py | 19 ------------------- payments/tests/test_endpoints.py | 12 ------------ payments/tests/test_serializers.py | 10 ---------- payments/urls.py | 2 +- 7 files changed, 4 insertions(+), 44 deletions(-) diff --git a/borrowings/models.py b/borrowings/models.py index 708beb7..8193a9c 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -46,6 +46,7 @@ def validate_actual_return_date( class Meta: db_table = "borrowings" + ordering = ["-borrow_date"] constraints = [ models.CheckConstraint( check=models.Q( diff --git a/borrowings/urls.py b/borrowings/urls.py index 5e6eb6a..177c922 100644 --- a/borrowings/urls.py +++ b/borrowings/urls.py @@ -6,7 +6,7 @@ app_name = "borrowings" router = routers.DefaultRouter() -router.register("", BorrowingViewSet, basename="borrowings") +router.register("", BorrowingViewSet, basename="borrowing") urlpatterns = [ path("", include(router.urls)), diff --git a/borrowings/views.py b/borrowings/views.py index bcd2504..0c0706d 100644 --- a/borrowings/views.py +++ b/borrowings/views.py @@ -87,7 +87,7 @@ def perform_create(self, serializer: BorrowingCreateSerializer) -> None: ) def get_queryset(self) -> Any: - queryset = super().get_queryset() + queryset = Borrowing.objects.all().select_related("book", "user") user = self.request.user if not user.is_staff: queryset = queryset.filter(user=user) diff --git a/payments/serializers.py b/payments/serializers.py index 6a2179d..64962d5 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -25,25 +25,6 @@ class Meta: ] read_only_fields = ["id", "session_url", "session_id"] - def validate_status(self, value: str) -> str: - if value not in dict(PaymentStatus.choices): - raise serializers.ValidationError("Invalid status") - return value - - def validate_payment_type(self, value: str) -> str: - if value not in dict(PaymentType.choices): - raise serializers.ValidationError("Invalid payment_type") - return value - - def validate_money_to_pay(self, value: Decimal) -> Decimal: - if value is None: - raise serializers.ValidationError("money_to_pay is required") - if value < 0: - raise serializers.ValidationError( - "money_to_pay must be non-negative" - ) - return value - class PaymentListSerializer(serializers.ModelSerializer): borrowing = serializers.SlugRelatedField(read_only=True, slug_field="id") diff --git a/payments/tests/test_endpoints.py b/payments/tests/test_endpoints.py index c5d3251..4b3c6e2 100644 --- a/payments/tests/test_endpoints.py +++ b/payments/tests/test_endpoints.py @@ -70,15 +70,3 @@ def test_create_invalid_payment_type_returns_400(self): resp = self.client.post(self.list_url, data=payload, format="json") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("payment_type", resp.data) - - def test_create_negative_amount_returns_400(self): - self.client.force_authenticate(user=self.user) - borrowing = self._create_borrowing() - payload = { - "payment_type": PaymentType.PAYMENT, - "borrowing": borrowing.id, - "money_to_pay": "-0.01", - } - resp = self.client.post(self.list_url, data=payload, format="json") - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("money_to_pay", resp.data) diff --git a/payments/tests/test_serializers.py b/payments/tests/test_serializers.py index ad918a1..f692bd0 100644 --- a/payments/tests/test_serializers.py +++ b/payments/tests/test_serializers.py @@ -77,16 +77,6 @@ def test_invalid_payment_type(self): self.assertFalse(serializer.is_valid()) self.assertIn("payment_type", serializer.errors) - def test_negative_amount(self): - data = { - "payment_type": PaymentType.PAYMENT, - "borrowing_id": 1, - "money_to_pay": "-0.01", - } - serializer = PaymentSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn("money_to_pay", serializer.errors) - def test_missing_money_to_pay(self): data = { "payment_type": PaymentType.PAYMENT, diff --git a/payments/urls.py b/payments/urls.py index 2faaef9..e72568c 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -12,7 +12,7 @@ app_name = "payments" router = DefaultRouter() -router.register("", PaymentViewSet, basename="payments") +router.register("", PaymentViewSet, basename="payment") urlpatterns = [ path("success/", views.PaymentSuccessView.as_view(), name="success"), path("cancel/", views.PaymentCancelView.as_view(), name="cancel"), From 1eb41dfcaea7a98bff21d00b7c51eb2ac671a3f1 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 22:01:33 +0300 Subject: [PATCH 4/6] test: add Stripe webhook and PaymentTestSuccessView tests for payment handling --- payments/tests/test_stripe.py | 175 ++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 payments/tests/test_stripe.py diff --git a/payments/tests/test_stripe.py b/payments/tests/test_stripe.py new file mode 100644 index 0000000..7fe9747 --- /dev/null +++ b/payments/tests/test_stripe.py @@ -0,0 +1,175 @@ +import json +from decimal import Decimal +from datetime import date, timedelta + +from django.urls import reverse +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model +from unittest.mock import patch, Mock + +from payments.models import Payment, PaymentStatus, PaymentType +from books.models import Book +from borrowings.models import Borrowing + + +class StripeIntegrationTests(TestCase): + def setUp(self): + self.client = APIClient() + self.webhook_url = reverse("payments:webhook") + self.test_success_url = reverse("payments:test-success") + + self.user = get_user_model().objects.create_user( + email="user@example.com", password="testpass123" + ) + + self.book = Book.objects.create( + title="Test Book", + author="Test Author", + cover="HARD", + inventory=10, + daily_fee=Decimal("1.50"), + ) + + self.borrowing = Borrowing.objects.create( + user=self.user, + book=self.book, + expected_return_date=date.today() + timedelta(days=5), + ) + + self.payment = Payment.objects.create( + borrowing=self.borrowing, + session_id="cs_test_session_id_123", + session_url="https://checkout.stripe.com/pay/test", + money_to_pay=Decimal("15.00"), + payment_type=PaymentType.PAYMENT, + status="PENDING", + ) + + @patch('payments.views.notify_successful_payment.delay') + @patch('django.db.transaction.on_commit', side_effect=lambda func: func()) + def test_stripe_webhook_successful_payment(self, mock_on_commit, mock_notify): + """Test Stripe webhook handles successful payment correctly""" + webhook_payload = { + "type": "checkout.session.completed", + "data": { + "object": { + "id": "cs_test_session_id_123", + "payment_status": "paid", + "amount_total": 1500, # in cents + "currency": "usd", + } + } + } + + response = self.client.post( + self.webhook_url, + data=json.dumps(webhook_payload), + content_type="application/json", + HTTP_STRIPE_SIGNATURE="fake_signature" + ) + + # Check response + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Check payment status was updated + self.payment.refresh_from_db() + self.assertEqual(self.payment.status, "PAID") + + # Check notification was triggered + mock_notify.assert_called_once_with(self.payment.id) + + def test_stripe_webhook_payment_not_found(self): + """Test webhook gracefully handles non-existent payment""" + webhook_payload = { + "type": "checkout.session.completed", + "data": { + "object": { + "id": "cs_nonexistent_session_id", + "payment_status": "paid", + } + } + } + + response = self.client.post( + self.webhook_url, + data=json.dumps(webhook_payload), + content_type="application/json", + HTTP_STRIPE_SIGNATURE="fake_signature" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @patch('payments.views.notify_successful_payment.delay') + @patch('django.db.transaction.on_commit', side_effect=lambda func: func()) + def test_payment_test_success_view(self, mock_on_commit, mock_notify): + """Test PaymentTestSuccessView updates payment status correctly""" + self.client.force_authenticate(user=self.user) + + payload = { + "session_id": "cs_test_session_id_123" + } + + response = self.client.post( + self.test_success_url, + data=payload, + format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("Payment status updated to PAID (TEST MODE)", response.data["message"]) + self.assertEqual(response.data["payment_id"], self.payment.id) + self.assertEqual(response.data["status"], "PAID") + + # Check payment was updated in database + self.payment.refresh_from_db() + self.assertEqual(self.payment.status, "PAID") + + # Check notification was triggered + mock_notify.assert_called_once_with(self.payment.id) + + def test_payment_test_success_view_requires_authentication(self): + """Test that PaymentTestSuccessView requires authentication""" + payload = { + "session_id": "cs_test_session_id_123" + } + + response = self.client.post( + self.test_success_url, + data=payload, + format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_payment_test_success_view_missing_session_id(self): + """Test PaymentTestSuccessView validates required session_id""" + self.client.force_authenticate(user=self.user) + + # Empty payload + response = self.client.post( + self.test_success_url, + data={}, + format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error"], "session_id is required") + + def test_payment_test_success_view_nonexistent_payment(self): + """Test PaymentTestSuccessView handles non-existent payment""" + self.client.force_authenticate(user=self.user) + + payload = { + "session_id": "cs_nonexistent_session_id" + } + + response = self.client.post( + self.test_success_url, + data=payload, + format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data["error"], "Payment not found") \ No newline at end of file From 2f35830cf2f8a5a66573f8d09bec597bad39328f Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 22:03:11 +0300 Subject: [PATCH 5/6] test: standardize string quotes and formatting in Stripe tests for consistency --- payments/tests/test_stripe.py | 91 ++++++++++++++++------------------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/payments/tests/test_stripe.py b/payments/tests/test_stripe.py index 7fe9747..b75feea 100644 --- a/payments/tests/test_stripe.py +++ b/payments/tests/test_stripe.py @@ -47,9 +47,11 @@ def setUp(self): status="PENDING", ) - @patch('payments.views.notify_successful_payment.delay') - @patch('django.db.transaction.on_commit', side_effect=lambda func: func()) - def test_stripe_webhook_successful_payment(self, mock_on_commit, mock_notify): + @patch("payments.views.notify_successful_payment.delay") + @patch("django.db.transaction.on_commit", side_effect=lambda func: func()) + def test_stripe_webhook_successful_payment( + self, mock_on_commit, mock_notify + ): """Test Stripe webhook handles successful payment correctly""" webhook_payload = { "type": "checkout.session.completed", @@ -60,23 +62,23 @@ def test_stripe_webhook_successful_payment(self, mock_on_commit, mock_notify): "amount_total": 1500, # in cents "currency": "usd", } - } + }, } - + response = self.client.post( self.webhook_url, data=json.dumps(webhook_payload), content_type="application/json", - HTTP_STRIPE_SIGNATURE="fake_signature" + HTTP_STRIPE_SIGNATURE="fake_signature", ) - + # Check response self.assertEqual(response.status_code, status.HTTP_200_OK) - + # Check payment status was updated self.payment.refresh_from_db() self.assertEqual(self.payment.status, "PAID") - + # Check notification was triggered mock_notify.assert_called_once_with(self.payment.id) @@ -89,87 +91,76 @@ def test_stripe_webhook_payment_not_found(self): "id": "cs_nonexistent_session_id", "payment_status": "paid", } - } + }, } - + response = self.client.post( self.webhook_url, data=json.dumps(webhook_payload), content_type="application/json", - HTTP_STRIPE_SIGNATURE="fake_signature" + HTTP_STRIPE_SIGNATURE="fake_signature", ) - + self.assertEqual(response.status_code, status.HTTP_200_OK) - @patch('payments.views.notify_successful_payment.delay') - @patch('django.db.transaction.on_commit', side_effect=lambda func: func()) + @patch("payments.views.notify_successful_payment.delay") + @patch("django.db.transaction.on_commit", side_effect=lambda func: func()) def test_payment_test_success_view(self, mock_on_commit, mock_notify): """Test PaymentTestSuccessView updates payment status correctly""" self.client.force_authenticate(user=self.user) - - payload = { - "session_id": "cs_test_session_id_123" - } - + + payload = {"session_id": "cs_test_session_id_123"} + response = self.client.post( - self.test_success_url, - data=payload, - format="json" + self.test_success_url, data=payload, format="json" ) - + self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("Payment status updated to PAID (TEST MODE)", response.data["message"]) + self.assertIn( + "Payment status updated to PAID (TEST MODE)", + response.data["message"], + ) self.assertEqual(response.data["payment_id"], self.payment.id) self.assertEqual(response.data["status"], "PAID") - + # Check payment was updated in database self.payment.refresh_from_db() self.assertEqual(self.payment.status, "PAID") - + # Check notification was triggered mock_notify.assert_called_once_with(self.payment.id) def test_payment_test_success_view_requires_authentication(self): """Test that PaymentTestSuccessView requires authentication""" - payload = { - "session_id": "cs_test_session_id_123" - } - + payload = {"session_id": "cs_test_session_id_123"} + response = self.client.post( - self.test_success_url, - data=payload, - format="json" + self.test_success_url, data=payload, format="json" ) - + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_payment_test_success_view_missing_session_id(self): """Test PaymentTestSuccessView validates required session_id""" self.client.force_authenticate(user=self.user) - + # Empty payload response = self.client.post( - self.test_success_url, - data={}, - format="json" + self.test_success_url, data={}, format="json" ) - + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data["error"], "session_id is required") def test_payment_test_success_view_nonexistent_payment(self): """Test PaymentTestSuccessView handles non-existent payment""" self.client.force_authenticate(user=self.user) - - payload = { - "session_id": "cs_nonexistent_session_id" - } - + + payload = {"session_id": "cs_nonexistent_session_id"} + response = self.client.post( - self.test_success_url, - data=payload, - format="json" + self.test_success_url, data=payload, format="json" ) - + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(response.data["error"], "Payment not found") \ No newline at end of file + self.assertEqual(response.data["error"], "Payment not found") From dd24c042d65dbad13c8cd9eef1c7c2a396128b73 Mon Sep 17 00:00:00 2001 From: Artcrafterrra Date: Thu, 25 Sep 2025 22:09:25 +0300 Subject: [PATCH 6/6] refactor: replace `check` with `condition` in CheckConstraint for clarity and consistency in borrowing and payment models --- borrowings/models.py | 4 ++-- payments/models.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/borrowings/models.py b/borrowings/models.py index 8193a9c..c9c5ce0 100644 --- a/borrowings/models.py +++ b/borrowings/models.py @@ -49,13 +49,13 @@ class Meta: ordering = ["-borrow_date"] constraints = [ models.CheckConstraint( - check=models.Q( + condition=models.Q( expected_return_date__gte=models.F("borrow_date") ), name="expected_after_borrow", ), models.CheckConstraint( - check=( + condition=( models.Q(actual_return_date__isnull=True) | models.Q(actual_return_date__gte=models.F("borrow_date")) ), diff --git a/payments/models.py b/payments/models.py index de565d6..a09039f 100644 --- a/payments/models.py +++ b/payments/models.py @@ -55,7 +55,7 @@ class Meta: db_table = "payment" constraints = [ models.CheckConstraint( - check=Q(money_to_pay__gte=0), + condition=Q(money_to_pay__gte=0), name="money_to_pay_non_negative", ), models.UniqueConstraint(