Skip to content

Commit

Permalink
Upgrade tests to support multiple clients
Browse files Browse the repository at this point in the history
  • Loading branch information
cmin764 committed Dec 3, 2024
1 parent feedf05 commit eecc28d
Show file tree
Hide file tree
Showing 7 changed files with 130 additions and 17 deletions.
1 change: 0 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ DEBUG=false

# Postgres
POSTGRES_SERVER=localhost
POSTGRES_PORT=5432
POSTGRES_USER=deep
POSTGRES_PASSWORD=icecream
POSTGRES_DB=deep_ice
Expand Down
2 changes: 1 addition & 1 deletion deep_ice/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(
# Use the top level .env file (one level above ./deep_ice/).
env_file=".env",
env_ignore_empty=True,
env_ignore_empty=False,
extra="ignore",
)

Expand Down
2 changes: 1 addition & 1 deletion deep_ice/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class BaseIceCream(SQLModel):
class IceCream(BaseIceCream, FetchMixin, table=True):
id: Annotated[int | None, Field(primary_key=True)] = None
stock: int
blocked_quantity: int = 0 # reserved for payments only
blocked_quantity: int = 0 # reserved during payments
is_active: bool = True

cart_items: list["CartItem"] = Relationship(
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dev-dependencies = [
"pytest>=8.3.3",
"ruff>=0.6.9",
"types-passlib>=1.7.7.20240819",
"pytest-env>=1.1.5",
]

[tool.setuptools]
Expand All @@ -46,6 +47,11 @@ filterwarnings = [
"ignore::UserWarning",
]
addopts = "--disable-warnings"
env = [
"SENTRY_DSN = ", # disable Sentry reporting during testing
"LOG_LEVEL = INFO",
"DEBUG = true"
]

[tool.flake8]
# Check that this is aligned with your other tools like Black
Expand Down
77 changes: 64 additions & 13 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
from unittest.mock import AsyncMock

import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlmodel import insert
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.pool import StaticPool

from deep_ice import app
from deep_ice.core.database import get_async_session
from deep_ice.core.security import get_password_hash
from deep_ice.models import Cart, CartItem, IceCream, Order, SQLModel, User
from deep_ice.services.cart import CartService
from deep_ice.services.order import OrderService
from deep_ice.services.stats import stats_service
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlmodel import insert
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.pool import StaticPool


# Run tests with `asyncio` only.
Expand Down Expand Up @@ -71,22 +72,29 @@ async def initial_data(session: AsyncSession) -> dict:
{
"name": "Cosmin Poieana",
"email": "[email protected]",
"password": "cosmin-password",
"hashed_password": get_password_hash("cosmin-password"),
},
{
"name": "John Doe",
"email": "[email protected]",
"password": "john-password",
"hashed_password": get_password_hash("john-password"),
},
{
"name": "Sam Smith",
"email": "[email protected]",
"password": "sam-password",
"hashed_password": get_password_hash("sam-password"),
},
]

await session.exec(insert(IceCream).values(icecream_dump))
await session.exec(insert(User).values(users_dump))
clean_users_dump = [
{key: value for key, value in user_data.items() if key != "password"}
for user_data in users_dump
]
await session.exec(insert(User).values(clean_users_dump))
await session.commit()

return {"icecream": icecream_dump, "users": users_dump}
Expand All @@ -106,10 +114,28 @@ async def get_session_override():
app.dependency_overrides.clear()


async def _get_users(session: AsyncSession, email: str | None = None) -> list[User]:
filters = []
if email:
filters.append(User.email == email)
result = await User.fetch(session, filters=filters)
users = [result.one()] if email else result.all()
return users


@pytest.fixture
async def auth_token(initial_data: dict, client: AsyncClient) -> str:
async def users(session: AsyncSession, initial_data: dict) -> list[User]:
return await _get_users(session)


@pytest.fixture
async def user(users: list[User]) -> User:
return [usr for usr in users if usr.email == "[email protected]"][0]


async def _get_auth_token(client: AsyncClient, *, email: str, password: str) -> str:
# Authenticate and get the token.
form_data = {"username": "[email protected]", "password": "cosmin-password"}
form_data = {"username": email, "password": password}
response = await client.post("/v1/auth/access-token", data=form_data)
assert response.status_code == 200

Expand All @@ -119,18 +145,43 @@ async def auth_token(initial_data: dict, client: AsyncClient) -> str:
return token


@pytest.fixture
async def auth_tokens(
initial_data: dict, users: list[User], client: AsyncClient
) -> list[dict[str, str]]:
users_dump = initial_data["users"]
tokens = []
for user in users:
user_dump = [item for item in users_dump if item["email"] == user.email][0]
token = await _get_auth_token(
client, email=user.email, password=user_dump["password"]
)
tokens.append({"email": user.email, "token": token})
return tokens


@pytest.fixture
async def auth_token(initial_data: dict, user: User, client: AsyncClient) -> str:
users_dump = initial_data["users"]
user_dump = [item for item in users_dump if item["email"] == user.email][0]
return await _get_auth_token(
client, email=user.email, password=user_dump["password"]
)


@pytest.fixture
async def auth_client(client: AsyncClient, auth_token: str):
client.headers.update({"Authorization": f"Bearer {auth_token}"})
return client


@pytest.fixture
async def user(session: AsyncSession) -> User:
user = (
await User.fetch(session, filters=[User.email == "[email protected]"])
).one()
return user
async def secondary_auth_client(
user: User, client: AsyncClient, auth_tokens: list[dict[str, str]]
):
token = [tkn for tkn in auth_tokens if tkn["email"] != user.email][0]
client.headers.update({"Authorization": f"Bearer {token}"})
return client


@pytest.fixture
Expand Down
45 changes: 44 additions & 1 deletion tests/test_payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import pytest

from deep_ice import app
from deep_ice.models import Order, OrderItem, OrderStatus, PaymentMethod, PaymentStatus
from deep_ice.models import (
Order,
OrderItem,
OrderStatus,
PaymentMethod,
PaymentStatus,
)


async def _check_order_creation(session, order_id, *, status, amount):
Expand Down Expand Up @@ -85,3 +91,40 @@ async def test_make_successful_payment(
data = response.json()
assert len(data) == 1
assert data[0]["method"] == method.value


@pytest.mark.anyio
async def test_payment_redirect_insufficient_stock(
redis_client, session, auth_client, cart_items
):
# Simulate purchase of some icecream in the meantime, leaving the current cart on
# insufficient stock.
first_item = cart_items[0]
icecream = await first_item.awaitable_attrs.icecream
initial_quantity = first_item.quantity
max_quantity = initial_quantity - 1
icecream.stock = max_quantity
session.add(icecream)
await session.commit()

# Simulate payment and check if the redirect happened including the cart item
# quantity update to the new maximum available quantity for that ice cream flavor.
response = await auth_client.post(
"/v1/payments", json={"method": PaymentMethod.CASH.value}
)
assert response.status_code == 307
redirect_url = response.headers.get("Location")
assert redirect_url.endswith("/v1/cart")
await session.refresh(first_item)
assert first_item.quantity != initial_quantity
assert first_item.quantity == max_quantity


@pytest.mark.anyio
async def test_concurrent_card_payments(
redis_client, session, auth_client, secondary_auth_client, cart_items
):
# Two different customers triggering a card payment simultaneously.
args, kwargs = ["/v1/payments"], {"json": {"method": PaymentMethod.CARD.value}}
main_response = await auth_client.post(*args, **kwargs)
secondary_response = await secondary_auth_client.post(*args, **kwargs)
14 changes: 14 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit eecc28d

Please sign in to comment.