Skip to content

Commit

Permalink
Ticketing backend tests (#666)
Browse files Browse the repository at this point in the history
* Add test cases for backend ticketing APIs

Long overdue addition of tests to the ticketing backend.

Tests and fixes all the APIs under the Event and Ticket models.

There are more complex workflows with race conditions etc that are not
tested, but should be at some point. Unmerged functionality is also
not tested yet.

* Don't use locked rows to groupby

* Set cybersource settings in CI

* Address feedback
  • Loading branch information
rohangpta authored Apr 18, 2024
1 parent 27ac7c6 commit 40fb7f7
Show file tree
Hide file tree
Showing 3 changed files with 940 additions and 36 deletions.
104 changes: 68 additions & 36 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2396,6 +2396,11 @@ def add_to_cart(self, request, *args, **kwargs):
cart, _ = Cart.objects.get_or_create(owner=self.request.user)

quantities = request.data.get("quantities")
if not quantities:
return Response(

Check warning on line 2400 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L2400

Added line #L2400 was not covered by tests
{"detail": "Quantities must be specified"},
status=status.HTTP_400_BAD_REQUEST,
)

num_requested = sum(item["count"] for item in quantities)
num_carted = cart.tickets.filter(event=event).count()
Expand Down Expand Up @@ -2468,6 +2473,11 @@ def remove_from_cart(self, request, *args, **kwargs):
"""
event = self.get_object()
quantities = request.data.get("quantities")
if not quantities:
return Response(

Check warning on line 2477 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L2477

Added line #L2477 was not covered by tests
{"detail": "Quantities must be specified"},
status=status.HTTP_400_BAD_REQUEST,
)
cart = get_object_or_404(Cart, owner=self.request.user)

for item in quantities:
Expand Down Expand Up @@ -2515,7 +2525,7 @@ def buyers(self, request, *args, **kwargs):
)

buyers = tickets.filter(owner__isnull=False).values(
"fullname", "id", "owner_id", "type"
"fullname", "id", "owner_id", "type", "owner__email"
)

return Response({"buyers": buyers})
Expand Down Expand Up @@ -2629,9 +2639,20 @@ def create_tickets(self, request, *args, **kwargs):
"""
event = self.get_object()

quantities = request.data.get("quantities")
quantities = request.data.get("quantities", [])
if not quantities:
return Response(
{"detail": "Quantities must be specified"},
status=status.HTTP_400_BAD_REQUEST,
)

for item in quantities:
if not item.get("type") or not item.get("count"):
return Response(
{"detail": "Specify type and count to create some tickets."},
status=status.HTTP_400_BAD_REQUEST,
)

# Ticket prices must be non-negative
if item.get("price", 0) < 0:
return Response(
Expand Down Expand Up @@ -2671,8 +2692,8 @@ def create_tickets(self, request, *args, **kwargs):
Ticket(
event=event,
type=item["type"],
price=item["price"],
group_discount=item.get("group_discount", None),
price=item.get("price", 0),
group_discount=item.get("group_discount", 0),
group_size=item.get("group_size", None),
)
for item in quantities
Expand Down Expand Up @@ -4720,19 +4741,19 @@ def get_object(self):
class TicketViewSet(viewsets.ModelViewSet):
"""
get:
List all tickets owned by user
Get a specific ticket owned by a user
list:
List all tickets owned by a user
cart:
List all unowned/unheld tickets currently in user's cart
List all unowned/unheld tickets currently in a user's cart
initiate_checkout:
Initiate a hold on the tickets in a user's cart and create a capture context
complete_checkout:
Complete the checkout process after we have obtained an auth on the user's card
buy:
Buy the tickets in a user's cart
Complete the checkout process after we have obtained an auth on a user's card
qr:
Get a ticket's QR code
Expand Down Expand Up @@ -4767,15 +4788,17 @@ def cart(self, request, *args, **kwargs):
---
"""

cart, _ = Cart.objects.get_or_create(owner=self.request.user)
cart, _ = Cart.objects.prefetch_related("tickets").get_or_create(
owner=self.request.user
)

# Replace in-cart tickets that have been bought/held by someone else
tickets_to_replace = cart.tickets.filter(
Q(owner__isnull=False) | Q(holder__isnull=False)
).exclude(holder=self.request.user)

# In most cases, we won't need to replace, so exit early
if not tickets_to_replace:
if not tickets_to_replace.exists():
return Response(
{
"tickets": TicketSerializer(cart.tickets.all(), many=True).data,
Expand All @@ -4786,19 +4809,20 @@ def cart(self, request, *args, **kwargs):
sold_out_count = 0

replacement_tickets = []
for gone_ticket in tickets_to_replace:
tickets_in_cart = cart.tickets.values_list("id", flat=True)
for ticket_class in tickets_to_replace.values("type", "event").annotate(
count=Count("id")
):
# We don't need to lock since we aren't updating holder/owner
ticket = Ticket.objects.filter(
event=gone_ticket.event,
type=gone_ticket.type,
tickets = Ticket.objects.filter(
event=ticket_class["event"],
type=ticket_class["type"],
owner__isnull=True,
holder__isnull=True,
).first()
).exclude(id__in=tickets_in_cart)[: ticket_class["count"]]

if ticket is not None:
replacement_tickets.append(ticket)
else:
sold_out_count += 1
sold_out_count += ticket_class["count"] - tickets.count()
replacement_tickets.extend(list(tickets))

cart.tickets.remove(*tickets_to_replace)
if replacement_tickets:
Expand All @@ -4824,6 +4848,8 @@ def initiate_checkout(self, request, *args, **kwargs):
Once the user has entered their payment details and submitted the form
the request will be routed to complete_checkout
403 implies a stale cart.
---
requestBody: {}
responses:
Expand Down Expand Up @@ -4874,17 +4900,13 @@ def initiate_checkout(self, request, *args, **kwargs):
"success": False,
"detail": "Cart is stale, invoke /api/tickets/cart to refresh",
},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)

# Place hold on tickets for 10 mins
holding_expiration = timezone.now() + datetime.timedelta(minutes=10)
tickets.update(holder=self.request.user, holding_expiration=holding_expiration)

# Calculate cart total, applying group discounts where appropriate
ticket_type_counts = {
item["type"]: item["count"]
for item in tickets.values("type").annotate(count=Count("type"))
for item in cart.tickets.values("type").annotate(count=Count("type"))
}

cart_total = sum(
Expand Down Expand Up @@ -4939,9 +4961,15 @@ def initiate_checkout(self, request, *args, **kwargs):
)
if not context or http_status >= 400:
raise ApiException(

Check warning on line 4963 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L4963

Added line #L4963 was not covered by tests
reason=f"Received {context} with HTTP status {status}",
reason=f"Received {context} with HTTP status {http_status}",
)

# Place hold on tickets for 10 mins
holding_expiration = timezone.now() + datetime.timedelta(minutes=10)
tickets.update(
holder=self.request.user, holding_expiration=holding_expiration
)

return Response({"success": True, "detail": context})
except ApiException as e:
return Response(

Check warning on line 4975 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L4974-L4975

Added lines #L4974 - L4975 were not covered by tests
Expand All @@ -4959,6 +4987,8 @@ def complete_checkout(self, request, *args, **kwargs):
"""
Complete the checkout after the user has entered their payment details
and obtained a transient token on the frontend.
403 implies a stale cart.
---
requestBody:
content:
Expand All @@ -4982,17 +5012,19 @@ def complete_checkout(self, request, *args, **kwargs):
---
"""
tt = request.data.get("transient_token")
cart = get_object_or_404(
Cart.objects.prefetch_related("tickets"), owner=self.request.user
)

ok, message = validate_transient_token(tt)
if not ok:
# Cleanup state since the purchase failed
cart.tickets.update(holder=None, owner=None)
return Response(
{"success": False, "detail": message},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

cart = get_object_or_404(
Cart.objects.prefetch_related("tickets"), owner=self.request.user
)

# Guard against holds expiring before the capture context
tickets = cart.tickets.filter(holder=self.request.user, owner__isnull=True)
if tickets.count() != cart.tickets.count():
Expand All @@ -5001,7 +5033,7 @@ def complete_checkout(self, request, *args, **kwargs):
"success": False,
"detail": "Cart is stale, invoke /api/tickets/cart to refresh",
},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)

try:
Expand All @@ -5011,7 +5043,7 @@ def complete_checkout(self, request, *args, **kwargs):

if not transaction_data or http_status >= 400:
raise ApiException(
reason=f"Received {transaction_data} with HTTP status {status}"
reason=f"Received {transaction_data} with HTTP status {http_status}"
)
transaction_data = json.loads(transaction_data)
except ApiException as e:
Expand Down Expand Up @@ -5039,7 +5071,7 @@ def complete_checkout(self, request, *args, **kwargs):

if not payment_response or http_status >= 400:
raise ApiException(

Check warning on line 5073 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L5073

Added line #L5073 was not covered by tests
reason=f"Received {payment_response} with HTTP status {status}"
reason=f"Received {payment_response} with HTTP status {http_status}"
)
except ApiException as e:

Check warning on line 5076 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L5076

Added line #L5076 was not covered by tests
# Cleanup state since the purchase failed
Expand All @@ -5057,7 +5089,7 @@ def complete_checkout(self, request, *args, **kwargs):
# We're explicitly using the response data over what's in self.request.user
orderInfo = transaction_data["orderInformation"]
transaction_record = TicketTransactionRecord.objects.create(
reconciliation_id=reconciliation_id,
reconciliation_id=str(reconciliation_id),
total_amount=float(orderInfo["amountDetails"]["totalAmount"]),
buyer_first_name=orderInfo["billTo"]["firstName"],
buyer_last_name=orderInfo["billTo"]["lastName"],
Expand Down
10 changes: 10 additions & 0 deletions backend/pennclubs/settings/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@

# Allow http callback for DLA
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

# Cybersource settings
CYBERSOURCE_CONFIG = {
"authentication_type": "http_signature",
"merchantid": "testrest",
"merchant_keyid": "08c94330-f618-42a3-b09d-e1e43be5efda",
"merchant_secretkey": "yBJxy6LjM2TmcPGu+GaJrHtkke25fPpUX+UY6/L/1tE=",
"run_environment": "apitest.cybersource.com",
}
CYBERSOURCE_TARGET_ORIGIN = "https://localhost:3001"
Loading

0 comments on commit 40fb7f7

Please sign in to comment.