first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:52:58 +05:30
commit 34ea15a54d
246 changed files with 18660 additions and 0 deletions

View file

View file

@ -0,0 +1,4 @@
import os
# Tests share one TestClient IP; disable in-process rate limits during pytest.
os.environ.setdefault("DESI_CLANS_DISABLE_RATE_LIMIT", "1")

56
backend/tests/helpers.py Normal file
View file

@ -0,0 +1,56 @@
import uuid
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def register_user() -> tuple[str, str]:
email = f"buyer-{uuid.uuid4().hex}@example.com"
password = "securepass1"
response = client.post(
"/api/auth/register",
json={"email": email, "password": password, "full_name": "Buyer"},
)
assert response.status_code == 201
token = response.json()["access_token"]
return email, token
def auth_headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def first_published_event() -> dict:
response = client.get("/api/events", params={"upcoming": "false"})
assert response.status_code == 200
events = response.json()
assert events, "Seed catalog required for order tests"
return events[0]
def pick_tier(tiers: list[dict], quantity: int = 1) -> dict | None:
return next(
(t for t in tiers if t["status"] == "active" and t["remaining_quantity"] >= quantity),
None,
)
def create_pending_order():
import pytest
_, 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")
response = client.post(
"/api/orders",
headers={**auth_headers(token), "Idempotency-Key": uuid.uuid4().hex},
json={"event_id": event["id"], "items": [{"ticket_type_id": tier["id"], "quantity": 1}]},
)
assert response.status_code == 201
return token, response.json()

View file

@ -0,0 +1,50 @@
import uuid
from tests.helpers import auth_headers, client, register_user
def _admin_token() -> str:
from app.core.config import get_settings
settings = get_settings()
response = client.post(
"/api/admin/auth/login",
json={"email": settings.admin_email, "password": settings.admin_password},
)
if response.status_code != 200:
raise RuntimeError(f"Admin login failed: {response.text} — run scripts/seed_admin.py")
return response.json()["access_token"]
def test_attendee_cannot_access_admin_dashboard() -> None:
_, token = register_user()
response = client.get("/api/admin/dashboard/metrics", headers=auth_headers(token))
assert response.status_code == 401
def test_admin_dashboard_metrics() -> None:
token = _admin_token()
response = client.get("/api/admin/dashboard/metrics", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
body = response.json()
assert "total_revenue_paise" in body
assert "tickets_sold" in body
assert "upcoming_events" in body
def test_admin_lists_catalog() -> None:
token = _admin_token()
headers = {"Authorization": f"Bearer {token}"}
assert client.get("/api/admin/events", headers=headers).status_code == 200
assert client.get("/api/admin/venues", headers=headers).status_code == 200
assert client.get("/api/admin/orders", headers=headers).status_code == 200
assert client.get("/api/admin/payments", headers=headers).status_code == 200
assert client.get("/api/admin/stats/events", headers=headers).status_code == 200
def test_admin_invalid_login() -> None:
response = client.post(
"/api/admin/auth/login",
json={"email": f"nope-{uuid.uuid4().hex}@example.com", "password": "wrongpass"},
)
assert response.status_code == 401

View file

@ -0,0 +1,95 @@
import uuid
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def _unique_email() -> str:
return f"test-{uuid.uuid4().hex}@example.com"
def _register(email: str | None = None, password: str = "securepass1") -> tuple[str, dict]:
address = email or _unique_email()
response = client.post(
"/api/auth/register",
json={"email": address, "password": password, "full_name": "Test User"},
)
return address, response
def test_register_login_and_me() -> None:
email, register_response = _register()
assert register_response.status_code == 201
token = register_response.json()["access_token"]
assert token
login_response = client.post("/api/auth/login", json={"email": email, "password": "securepass1"})
assert login_response.status_code == 200
assert login_response.json()["token_type"] == "bearer"
me_response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
assert me_response.status_code == 200
body = me_response.json()
assert body["email"] == email
assert body["full_name"] == "Test User"
assert body["is_active"] is True
assert "hashed_password" not in body
def test_register_duplicate_email() -> None:
email, first = _register()
assert first.status_code == 201
second = client.post(
"/api/auth/register",
json={"email": email, "password": "securepass1"},
)
assert second.status_code == 409
def test_register_password_too_short() -> None:
response = client.post(
"/api/auth/register",
json={"email": _unique_email(), "password": "short"},
)
assert response.status_code == 422
def test_login_invalid_credentials() -> None:
email, register_response = _register()
assert register_response.status_code == 201
wrong_password = client.post("/api/auth/login", json={"email": email, "password": "wrongpassword"})
assert wrong_password.status_code == 401
unknown = client.post("/api/auth/login", json={"email": "nobody@example.com", "password": "securepass1"})
assert unknown.status_code == 401
def test_me_requires_auth() -> None:
response = client.get("/api/auth/me")
assert response.status_code == 401
def test_me_rejects_invalid_token() -> None:
response = client.get("/api/auth/me", headers={"Authorization": "Bearer not-a-valid-jwt"})
assert response.status_code == 401
def test_passwords_are_hashed_in_database() -> None:
from app.db.session import SessionLocal
from app.services.users import get_user_by_email
email, register_response = _register(password="anotherpass9")
assert register_response.status_code == 201
db = SessionLocal()
try:
user = get_user_by_email(db, email)
assert user is not None
assert user.hashed_password != "anotherpass9"
assert user.hashed_password.startswith("$2")
finally:
db.close()

View file

@ -0,0 +1,64 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_list_events_published() -> None:
response = client.get("/api/events")
assert response.status_code == 200
body = response.json()
assert isinstance(body, list)
if body:
assert "slug" in body[0]
assert "venue" in body[0]
assert body[0]["status"] == "published"
def test_list_events_filter_category() -> None:
response = client.get("/api/events", params={"category": "garba", "upcoming": "false"})
assert response.status_code == 200
for item in response.json():
assert item["category"] == "garba"
def test_get_event_by_slug() -> None:
listing = client.get("/api/events", params={"upcoming": "false"})
assert listing.status_code == 200
if not listing.json():
return
slug = listing.json()[0]["slug"]
detail = client.get(f"/api/events/{slug}")
assert detail.status_code == 200
data = detail.json()
assert data["slug"] == slug
assert "ticket_types" in data
assert "media" in data
def test_get_event_not_found() -> None:
response = client.get("/api/events/does-not-exist-slug")
assert response.status_code == 404
def test_list_venues() -> None:
response = client.get("/api/venues")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_get_venue_by_id() -> None:
listing = client.get("/api/venues")
assert listing.status_code == 200
if not listing.json():
return
venue_id = listing.json()[0]["id"]
response = client.get(f"/api/venues/{venue_id}")
assert response.status_code == 200
assert response.json()["id"] == venue_id
def test_get_venue_not_found() -> None:
response = client.get("/api/venues/999999")
assert response.status_code == 404

View file

@ -0,0 +1,61 @@
import json
from tests.helpers import auth_headers, client, create_pending_order
from app.core.config import get_settings
from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway
def _pay_and_get_ticket():
token, checkout = create_pending_order()
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)
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,
},
)
tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json()
ticket_id = tickets[0]["id"]
return token, ticket_id
def test_ticket_detail_includes_qr() -> None:
token, ticket_id = _pay_and_get_ticket()
response = client.get(f"/api/tickets/{ticket_id}", headers=auth_headers(token))
assert response.status_code == 200
body = response.json()
assert body["qr_image_base64"]
assert body["event_title"]
assert body["venue_name"]
assert body["ticket_type_name"]
assert body["code"].startswith("DC-")
payload = json.loads(body["qr_payload"])
assert payload["token"] == body["qr_token"]
def test_verify_marks_ticket_used_and_prevents_reuse() -> None:
token, ticket_id = _pay_and_get_ticket()
detail = client.get(f"/api/tickets/{ticket_id}", headers=auth_headers(token)).json()
qr_token = detail["qr_token"]
settings = get_settings()
headers = {}
if settings.ticket_verify_api_key:
headers["X-Check-In-Key"] = settings.ticket_verify_api_key
first = client.post("/api/tickets/verify", json={"qr_token": qr_token}, headers=headers)
assert first.status_code == 200
assert first.json()["valid"] is True
assert first.json()["status"] == "used"
second = client.post("/api/tickets/verify", json={"qr_token": qr_token}, headers=headers)
assert second.status_code == 200
assert second.json()["valid"] is False
assert "already used" in second.json()["message"].lower()

View file

@ -0,0 +1,19 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health_endpoint() -> None:
response = client.get("/api/health")
assert response.status_code in (200, 503)
body = response.json()
assert body["service"] == "desi-clans-api"
assert "database" in body
assert body["status"] in ("ok", "degraded")
if response.status_code == 200:
assert body["database"] == "connected"
else:
assert body["database"] == "unavailable"

View file

@ -0,0 +1,174 @@
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()

View file

@ -0,0 +1,97 @@
import hashlib
import hmac
import json
from app.core.config import get_settings
from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway
from tests.helpers import auth_headers, client, create_pending_order
def _mock_signature(body: bytes) -> str:
secret = get_settings().payment_mock_secret
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
def test_verify_payment_issues_tickets() -> None:
token, checkout = create_pending_order()
assert checkout["status"] == "pending"
payment = checkout["payment"]
assert payment["provider"] == "mock"
gateway = get_payment_gateway()
assert isinstance(gateway, MockPaymentGateway)
payment_id = f"mock_pay_{checkout['id']}"
signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id)
verify = 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 verify.status_code == 200
assert verify.json()["status"] == "paid"
tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json()
assert any(t["order_id"] == checkout["id"] for t in tickets)
def test_rejects_invalid_signature() -> None:
token, checkout = create_pending_order()
payment = checkout["payment"]
verify = client.post(
f"/api/orders/{checkout['id']}/payment/verify",
headers=auth_headers(token),
json={
"provider_order_id": payment["provider_order_id"],
"provider_payment_id": "mock_pay_bad",
"signature": "not-valid",
},
)
assert verify.status_code == 400
def test_webhook_idempotent() -> None:
token, checkout = create_pending_order()
payment = checkout["payment"]
payment_id = f"mock_pay_wh_{checkout['id']}"
import uuid
event_id = f"evt_duplicate_{uuid.uuid4().hex}"
payload = {
"id": event_id,
"event": "payment.captured",
"payload": {
"payment": {
"entity": {
"id": payment_id,
"order_id": payment["provider_order_id"],
"amount": checkout["total_paise"],
"currency": "INR",
}
}
},
}
body = json.dumps(payload).encode()
signature = _mock_signature(body)
headers = {"X-Mock-Signature": signature}
first = client.post("/api/webhooks/mock", content=body, headers=headers)
assert first.status_code == 200
assert first.json()["status"] == "paid"
second = client.post("/api/webhooks/mock", content=body, headers=headers)
assert second.status_code == 200
assert second.json()["status"] == "duplicate_ignored"
tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json()
assert len([t for t in tickets if t["order_id"] == checkout["id"]]) == 1
def test_cancel_pending_order_releases_inventory() -> None:
token, checkout = create_pending_order()
cancel = client.post(f"/api/orders/{checkout['id']}/cancel", headers=auth_headers(token))
assert cancel.status_code == 200
assert cancel.json()["status"] == "cancelled"

View file

@ -0,0 +1,55 @@
from datetime import timedelta
from jose import jwt
from app.core.config import Settings
from app.core.security import (
ALGORITHM,
TOKEN_TYPE_USER,
create_user_access_token,
decode_user_access_token,
hash_password,
verify_password,
)
def test_password_hashing_roundtrip() -> None:
hashed = hash_password("my-secret-password")
assert hashed != "my-secret-password"
assert verify_password("my-secret-password", hashed)
assert not verify_password("wrong", hashed)
def test_jwt_expiration_claim() -> None:
settings = Settings(
environment="development",
database_url="postgresql://localhost/test",
secret_key="unit-test-secret-key-at-least-32-chars",
access_token_expire_minutes=30,
)
token = create_user_access_token(42, settings)
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
assert payload["sub"] == "42"
assert payload["type"] == TOKEN_TYPE_USER
assert "exp" in payload
user_id = decode_user_access_token(token, settings)
assert user_id == 42
def test_jwt_rejects_wrong_type() -> None:
settings = Settings(
environment="development",
database_url="postgresql://localhost/test",
secret_key="unit-test-secret-key-at-least-32-chars",
access_token_expire_minutes=30,
)
from datetime import datetime, timezone
expire = datetime.now(timezone.utc) + timedelta(minutes=5)
admin_style = jwt.encode(
{"sub": "admin@example.com", "exp": expire, "type": "admin"},
settings.secret_key,
algorithm=ALGORITHM,
)
assert decode_user_access_token(admin_style, settings) is None

View file

@ -0,0 +1,50 @@
from unittest.mock import patch
import pytest
from pydantic import ValidationError
from app.core.config import Settings
from tests.helpers import client
def _base_settings_kwargs(**overrides):
return {
"environment": "development",
"database_url": "postgresql://localhost/test",
"secret_key": "unit-test-secret-key-at-least-32-chars",
**overrides,
}
def test_production_requires_gate_key_and_razorpay() -> None:
with pytest.raises(ValidationError):
Settings(
**_base_settings_kwargs(
environment="production",
payment_provider="mock",
ticket_verify_api_key="short",
)
)
settings = Settings(
**_base_settings_kwargs(
environment="production",
payment_provider="razorpay",
ticket_verify_api_key="gate-key-min-16-chars",
razorpay_key_id="rzp_test",
razorpay_key_secret="secret",
razorpay_webhook_secret="whsec",
admin_password="not-the-default",
cors_origins="https://desiclans.in",
)
)
assert settings.environment == "production"
@patch("app.api.routes.webhooks.get_settings")
def test_mock_webhook_disabled_outside_development(mock_get_settings) -> None:
mock_get_settings.return_value = Settings(
**_base_settings_kwargs(environment="staging"),
)
response = client.post("/api/webhooks/mock", content=b"{}", headers={"X-Mock-Signature": "x"})
assert response.status_code == 404