174 lines
5.7 KiB
Python
174 lines
5.7 KiB
Python
import uuid
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.db.session import SessionLocal
|
|
from app.main import app
|
|
from app.models.catalog import TicketType
|
|
from app.models.enums import OrderStatus, TicketTypeStatus
|
|
from app.models.user import User
|
|
from app.schemas.orders import OrderCreate, OrderLineCreate
|
|
from app.services.orders import InsufficientInventoryError, create_order
|
|
from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway
|
|
from tests.helpers import (
|
|
auth_headers,
|
|
client,
|
|
create_pending_order,
|
|
first_published_event,
|
|
pick_tier,
|
|
register_user,
|
|
)
|
|
|
|
|
|
def _complete_mock_payment(token: str, checkout: dict) -> None:
|
|
gateway = get_payment_gateway()
|
|
assert isinstance(gateway, MockPaymentGateway)
|
|
payment = checkout["payment"]
|
|
payment_id = f"mock_pay_{checkout['id']}"
|
|
signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id)
|
|
response = client.post(
|
|
f"/api/orders/{checkout['id']}/payment/verify",
|
|
headers=auth_headers(token),
|
|
json={
|
|
"provider_order_id": payment["provider_order_id"],
|
|
"provider_payment_id": payment_id,
|
|
"signature": signature,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_list_event_tickets() -> None:
|
|
event = first_published_event()
|
|
response = client.get(f"/api/events/{event['id']}/tickets")
|
|
assert response.status_code == 200
|
|
tiers = response.json()
|
|
assert tiers
|
|
assert tiers[0]["event_id"] == event["id"]
|
|
assert "remaining_quantity" in tiers[0]
|
|
assert "price_paise" in tiers[0]
|
|
|
|
|
|
def test_create_order_pending_until_verified() -> None:
|
|
token, checkout = create_pending_order()
|
|
assert checkout["status"] == OrderStatus.pending.value
|
|
assert checkout["payment"]["amount_paise"] == checkout["total_paise"]
|
|
assert checkout["payment"]["provider_order_id"]
|
|
|
|
tickets_before = client.get("/api/tickets/my", headers=auth_headers(token)).json()
|
|
assert not any(t["order_id"] == checkout["id"] for t in tickets_before)
|
|
|
|
_complete_mock_payment(token, checkout)
|
|
|
|
my_tickets = client.get("/api/tickets/my", headers=auth_headers(token))
|
|
assert my_tickets.status_code == 200
|
|
tickets = my_tickets.json()
|
|
assert any(t["order_id"] == checkout["id"] for t in tickets)
|
|
assert all(t["code"].startswith("DC-") for t in tickets)
|
|
|
|
|
|
def test_order_requires_authentication() -> None:
|
|
event = first_published_event()
|
|
response = client.post(
|
|
"/api/orders",
|
|
json={"event_id": event["id"], "items": [{"ticket_type_id": 1, "quantity": 1}]},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_cannot_oversell_inventory() -> None:
|
|
_, token = register_user()
|
|
event = first_published_event()
|
|
tiers = client.get(f"/api/events/{event['id']}/tickets").json()
|
|
tier = pick_tier(tiers, 1)
|
|
if not tier:
|
|
pytest.skip("No ticket inventory available for test")
|
|
if tier["remaining_quantity"] >= 20:
|
|
pytest.skip("Quantity cap prevents oversell test for this tier")
|
|
|
|
response = client.post(
|
|
"/api/orders",
|
|
headers=auth_headers(token),
|
|
json={
|
|
"event_id": event["id"],
|
|
"items": [{"ticket_type_id": tier["id"], "quantity": tier["remaining_quantity"] + 1}],
|
|
},
|
|
)
|
|
assert response.status_code == 409
|
|
|
|
|
|
def test_server_calculates_total_not_client() -> None:
|
|
token, checkout = create_pending_order()
|
|
tiers = client.get(f"/api/events/{checkout['event_id']}/tickets").json()
|
|
tier = next(t for t in tiers if t["id"] == checkout["items"][0]["ticket_type_id"])
|
|
qty = checkout["items"][0]["quantity"]
|
|
assert checkout["total_paise"] == tier["price_paise"] * qty
|
|
|
|
|
|
def test_concurrent_purchases_do_not_oversell() -> None:
|
|
event = first_published_event()
|
|
db = SessionLocal()
|
|
try:
|
|
tier = db.scalar(
|
|
select(TicketType).where(TicketType.event_id == event["id"]).order_by(TicketType.id.asc())
|
|
)
|
|
assert tier is not None
|
|
tier.quantity_sold = max(tier.quantity_total - 1, 0)
|
|
tier.quantity_total = tier.quantity_sold + 1
|
|
tier.status = TicketTypeStatus.active
|
|
db.commit()
|
|
ticket_type_id = tier.id
|
|
event_id = event["id"]
|
|
finally:
|
|
db.close()
|
|
|
|
user_ids: list[int] = []
|
|
for _ in range(2):
|
|
email = f"race-{uuid.uuid4().hex}@example.com"
|
|
reg = client.post(
|
|
"/api/auth/register",
|
|
json={"email": email, "password": "securepass1"},
|
|
)
|
|
assert reg.status_code == 201
|
|
db = SessionLocal()
|
|
try:
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
assert user is not None
|
|
user_ids.append(user.id)
|
|
finally:
|
|
db.close()
|
|
|
|
def _buy(user_id: int) -> str:
|
|
session = SessionLocal()
|
|
try:
|
|
user = session.get(User, user_id)
|
|
create_order(
|
|
session,
|
|
user,
|
|
OrderCreate(
|
|
event_id=event_id,
|
|
items=[OrderLineCreate(ticket_type_id=ticket_type_id, quantity=1)],
|
|
),
|
|
)
|
|
return "success"
|
|
except InsufficientInventoryError:
|
|
session.rollback()
|
|
return "conflict"
|
|
finally:
|
|
session.close()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
results = list(pool.map(_buy, user_ids))
|
|
|
|
assert results.count("success") == 1
|
|
assert results.count("conflict") == 1
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
refreshed = db.get(TicketType, ticket_type_id)
|
|
assert refreshed is not None
|
|
assert refreshed.quantity_sold == refreshed.quantity_total
|
|
finally:
|
|
db.close()
|