Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions borrowings/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,16 @@ def validate_actual_return_date(

class Meta:
db_table = "borrowings"
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"))
),
Expand Down
2 changes: 1 addition & 1 deletion borrowings/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
app_name = "borrowings"

router = routers.DefaultRouter()
router.register("", BorrowingViewSet)
router.register("", BorrowingViewSet, basename="borrowing")

urlpatterns = [
path("", include(router.urls)),
Expand Down
7 changes: 1 addition & 6 deletions borrowings/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -92,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)
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 0 additions & 19 deletions payments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 0 additions & 12 deletions payments/tests/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
10 changes: 0 additions & 10 deletions payments/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
166 changes: 166 additions & 0 deletions payments/tests/test_stripe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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")
2 changes: 1 addition & 1 deletion payments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
app_name = "payments"

router = DefaultRouter()
router.register("", PaymentViewSet)
router.register("", PaymentViewSet, basename="payment")
urlpatterns = [
path("success/", views.PaymentSuccessView.as_view(), name="success"),
path("cancel/", views.PaymentCancelView.as_view(), name="cancel"),
Expand Down
1 change: 0 additions & 1 deletion payments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ class PaymentViewSet(
viewsets.GenericViewSet,
):

queryset = Payment.objects.select_related("borrowing")
permission_classes = (IsAuthenticated,)

def get_serializer_class(self) -> type:
Expand Down
Loading