diff --git a/.env.template b/.env.template index d48fcfc..ca866e7 100644 --- a/.env.template +++ b/.env.template @@ -3,7 +3,6 @@ DEBUG=false # Postgres POSTGRES_SERVER=localhost -POSTGRES_PORT=5432 POSTGRES_USER=deep POSTGRES_PASSWORD=icecream POSTGRES_DB=deep_ice diff --git a/deep_ice/core/config.py b/deep_ice/core/config.py index 52eb24d..02e28c7 100644 --- a/deep_ice/core/config.py +++ b/deep_ice/core/config.py @@ -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", ) diff --git a/deep_ice/models.py b/deep_ice/models.py index 2a2f01a..97e51ee 100644 --- a/deep_ice/models.py +++ b/deep_ice/models.py @@ -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( diff --git a/pyproject.toml b/pyproject.toml index 7b81754..ab55f46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index d231710..719d5be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,12 @@ 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 @@ -8,11 +14,6 @@ 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. @@ -71,22 +72,29 @@ async def initial_data(session: AsyncSession) -> dict: { "name": "Cosmin Poieana", "email": "cmin764@gmail.com", + "password": "cosmin-password", "hashed_password": get_password_hash("cosmin-password"), }, { "name": "John Doe", "email": "john.doe@deepicecream.ai", + "password": "john-password", "hashed_password": get_password_hash("john-password"), }, { "name": "Sam Smith", "email": "sam.smith@deepicecream.ai", + "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} @@ -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 == "cmin764@gmail.com"][0] + + +async def _get_auth_token(client: AsyncClient, *, email: str, password: str) -> str: # Authenticate and get the token. - form_data = {"username": "cmin764@gmail.com", "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 @@ -119,6 +145,30 @@ 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}"}) @@ -126,11 +176,12 @@ async def auth_client(client: AsyncClient, auth_token: str): @pytest.fixture -async def user(session: AsyncSession) -> User: - user = ( - await User.fetch(session, filters=[User.email == "cmin764@gmail.com"]) - ).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 diff --git a/tests/test_payments.py b/tests/test_payments.py index 4ad8381..2931b51 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -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): @@ -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) diff --git a/uv.lock b/uv.lock index 7e119d8..29ededb 100644 --- a/uv.lock +++ b/uv.lock @@ -195,6 +195,7 @@ dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-env" }, { name = "pytest-mock" }, { name = "ruff" }, { name = "types-passlib" }, @@ -226,6 +227,7 @@ dev = [ { name = "mypy", specifier = ">=1.12.0" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-env", specifier = ">=1.1.5" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "ruff", specifier = ">=0.6.9" }, { name = "types-passlib", specifier = ">=1.7.7.20240819" }, @@ -762,6 +764,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, ] +[[package]] +name = "pytest-env" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141 }, +] + [[package]] name = "pytest-mock" version = "3.14.0"