From b67c89746b7be17f999d5c0d09cae747579e70e2 Mon Sep 17 00:00:00 2001 From: Code With Me Date: Mon, 9 Mar 2026 21:21:41 +0300 Subject: [PATCH] test: Added integration tests for flow authorization, reservation and order creation --- .github/workflows/ci.yaml | 11 ++++- pyproject.toml | 8 +++- tests/conftest.py | 32 ++++++++++++++ tests/test_integration_auth.py | 46 +++++++++++++++++++ tests/test_integration_orders.py | 57 ++++++++++++++++++++++++ tests/test_integration_reserve.py | 73 +++++++++++++++++++++++++++++++ uv.lock | 16 +++++++ 7 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 tests/test_integration_auth.py create mode 100644 tests/test_integration_orders.py create mode 100644 tests/test_integration_reserve.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9bfd3ba..6b102d9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,8 +27,15 @@ jobs: --health-timeout 5s --health-retries 5 - env: - DB_HOST: localhost # override docker container name from .env + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd 'redis-cli ping' + --health-interval 10s + --health-timeout 5s + --health-retries 5 # ===== # Hardcode will be replaced with secrets GitHub Repo # ===== diff --git a/pyproject.toml b/pyproject.toml index aac2b84..c3d7309 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dev = [ 'pytest>=9.0.2', 'pytest-asyncio>=1.3.0', 'pytest-cov>=7.0.0', + 'pytest-env>=1.5.0', 'ruff>=0.15.0', ] @@ -56,7 +57,12 @@ python_classes = ['Test*'] python_functions = ['test_*'] pythonpath = ['.'] asyncio_mode = 'auto' -asyncio_default_fixture_loop_scope = 'function' +asyncio_default_fixture_loop_scope = 'session' +asyncio_default_test_loop_scope = 'session' +env = [ + 'DB_HOST=localhost', + 'REDIS_HOST=localhost' +] [tool.mypy] python_version = '3.11' diff --git a/tests/conftest.py b/tests/conftest.py index 09000f5..d04e401 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ from collections.abc import AsyncGenerator import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from redis.asyncio import Redis from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -15,6 +17,7 @@ import app.services.user.models # noqa: F401 from app.core.config import settings from app.core.database import Base +from app.main import app as main_app def _test_db_url() -> str: @@ -54,3 +57,32 @@ async def db_session( ) -> AsyncGenerator[AsyncSession, None]: async with db_session_factory() as session: yield session + + +def _test_redis_url() -> str: + host = os.environ.get('REDIS_HOST', 'localhost') + return f'redis://{host}:{settings.redis_port}' + + +@pytest_asyncio.fixture +async def redis_client() -> AsyncGenerator[Redis, None]: + redis = Redis.from_url(_test_redis_url(), decode_responses=True) + await redis.flushdb() + yield redis + await redis.flushdb() + await redis.aclose() + + +@pytest_asyncio.fixture +async def async_client( + db_session_factory: async_sessionmaker[AsyncSession], redis_client: Redis +) -> AsyncGenerator[AsyncClient, None]: + from app.core.lua_scripts import RATE_LIMIT_LUA_SCRIPT + + main_app.state.redis = redis_client + main_app.state.rate_limit_script = redis_client.register_script( + RATE_LIMIT_LUA_SCRIPT + ) + transport = ASGITransport(app=main_app) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + yield client diff --git a/tests/test_integration_auth.py b/tests/test_integration_auth.py new file mode 100644 index 0000000..5421881 --- /dev/null +++ b/tests/test_integration_auth.py @@ -0,0 +1,46 @@ +from http import HTTPStatus +from uuid import uuid4 + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio(loop_scope='session') +async def test_auth_flow(async_client: AsyncClient) -> None: + test_email = f'{uuid4().hex[:8]}@example.com' + test_password = 'super_secret_password' + response = await async_client.post( + '/api/v1/users', json={'email': test_email, 'password': test_password} + ) + assert response.status_code == HTTPStatus.CREATED + response_data = response.json() + assert response_data['email'] == test_email + + response = await async_client.post( + '/api/v1/users', json={'email': test_email, 'password': test_password} + ) + assert response.status_code == HTTPStatus.BAD_REQUEST + response_data = response.json() + assert 'already' in response_data['detail'] + + response = await async_client.post( + '/api/v1/auth/token', data={'username': test_email, 'password': test_password} + ) + assert response.status_code == HTTPStatus.OK + response_data = response.json() + access_token = response_data['access_token'] + refresh_token = response_data['refresh_token'] + + response = await async_client.post( + '/api/v1/auth/refresh', json={'refresh_token': refresh_token} + ) + assert response.status_code == HTTPStatus.OK + response_data = response.json() + access_token = response_data['access_token'] + + response = await async_client.get( + '/api/v1/users/me', headers={'Authorization': f'Bearer {access_token}'} + ) + assert response.status_code == HTTPStatus.OK + response_data = response.json() + assert response_data['email'] == test_email diff --git a/tests/test_integration_orders.py b/tests/test_integration_orders.py new file mode 100644 index 0000000..fa8cda6 --- /dev/null +++ b/tests/test_integration_orders.py @@ -0,0 +1,57 @@ +from http import HTTPStatus +from uuid import uuid4 + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.inventory.models import Product + + +async def test_reserve_order_flow( + async_client: AsyncClient, db_session: AsyncSession +) -> None: + test_email = f'{uuid4().hex[:8]}@example.com' + test_password = 'super_secret_password' + test_product_id = uuid4() + idempotency_key = uuid4().hex + test_product = Product( + id=test_product_id, + name='Hatchback 02 blue', + description='dayz car such as golf II blue', + price='150', + qty_available=5, + ) + db_session.add(test_product) + await db_session.commit() + test_user = await async_client.post( + '/api/v1/users', json={'email': test_email, 'password': test_password} + ) + assert test_user.status_code == HTTPStatus.CREATED + test_user_login = await async_client.post( + '/api/v1/auth/token', data={'username': test_email, 'password': test_password} + ) + assert test_user_login.status_code == HTTPStatus.OK + test_user_access_token = test_user_login.json()['access_token'] + headers = { + 'Authorization': f'Bearer {test_user_access_token}', + 'X-Idempotency-Key': idempotency_key, + } + test_reserve = await async_client.post( + '/api/v1/inventory/reserve', + json={'product_id': str(test_product_id), 'quantity': 1}, + headers=headers, + ) + assert test_reserve.status_code == HTTPStatus.OK + test_reserve_data = test_reserve.json() + assert 'id' in test_reserve_data + reservation_id = test_reserve_data['id'] + order_idempotency_key = uuid4().hex + headers['X-Idempotency-Key'] = order_idempotency_key + test_order = await async_client.post( + '/api/v1/orders/', json={'reservation_id': reservation_id}, headers=headers + ) + assert test_order.status_code == HTTPStatus.OK + test_order_data = test_order.json() + assert test_order_data['status'] == 'pending' + assert test_order_data['total_amount'] == '150.00' + assert 'id' in test_order_data diff --git a/tests/test_integration_reserve.py b/tests/test_integration_reserve.py new file mode 100644 index 0000000..01d53d3 --- /dev/null +++ b/tests/test_integration_reserve.py @@ -0,0 +1,73 @@ +from http import HTTPStatus +from uuid import uuid4 + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.inventory.models import Product + + +async def test_reserve_flow( + async_client: AsyncClient, + db_session: AsyncSession, +) -> None: + test_email = f'{uuid4().hex[:8]}@example.com' + test_password = 'super_secret_password' + test_product_id = uuid4() + idempotency_key = uuid4().hex + test_product = Product( + id=test_product_id, + name='Hatchback 02 red', + description='dayz car such as golf II', + price='100', + qty_available=5, + ) + db_session.add(test_product) + await db_session.commit() + test_user = await async_client.post( + '/api/v1/users', json={'email': test_email, 'password': test_password} + ) + assert test_user.status_code == HTTPStatus.CREATED + test_user_login = await async_client.post( + '/api/v1/auth/token', data={'username': test_email, 'password': test_password} + ) + assert test_user_login.status_code == HTTPStatus.OK + test_user_access_token = test_user_login.json()['access_token'] + headers = { + 'Authorization': f'Bearer {test_user_access_token}', + 'X-Idempotency-Key': idempotency_key, + } + test_reserve = await async_client.post( + '/api/v1/inventory/reserve', + json={'product_id': str(test_product_id), 'quantity': 1}, + headers=headers, + ) + assert test_reserve.status_code == HTTPStatus.OK + test_reserve_data = test_reserve.json() + assert 'id' in test_reserve_data + assert test_reserve_data['product_id'] == str(test_product_id) + assert test_reserve_data['status'] == 'pending' + + test_reserve = await async_client.post( + '/api/v1/inventory/reserve', + json={'product_id': str(test_product_id), 'quantity': 1}, + headers=headers, + ) + assert test_reserve.status_code == HTTPStatus.OK + test_reserve_data = test_reserve.json() + assert 'id' in test_reserve_data + assert test_reserve_data['product_id'] == str(test_product_id) + + status_codes = [] + for _ in range(20): + response = await async_client.post( + '/api/v1/inventory/reserve', + json={'product_id': str(test_product_id), 'quantity': 1}, + headers={ + 'Authorization': f'Bearer {test_user_access_token}', + 'X-Idempotency-Key': uuid4().hex, + }, + ) + status_codes.append(response.status_code) + + assert HTTPStatus.TOO_MANY_REQUESTS in status_codes diff --git a/uv.lock b/uv.lock index aca887f..5b9a919 100644 --- a/uv.lock +++ b/uv.lock @@ -994,6 +994,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-env" }, { name = "ruff" }, ] @@ -1027,6 +1028,7 @@ dev = [ { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-env", specifier = ">=1.5.0" }, { name = "ruff", specifier = ">=0.15.0" }, ] @@ -2650,6 +2652,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-env" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/56/a931c6f6194917ff44be41b8586e2ffd13a18fa70fb28d9800a4695befa5/pytest_env-1.5.0.tar.gz", hash = "sha256:db8994b9ce170f135a37acc09ac753a6fc697d15e691b576ed8d8ca261c40246", size = 15271, upload-time = "2026-02-17T18:31:39.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/af/99b52a8524983bfece35e51e65a0b517b22920c023e57855c95e744e19e4/pytest_env-1.5.0-py3-none-any.whl", hash = "sha256:89a15686ac837c9cd009a8a2d52bd55865e2f23c82094247915dae4540c87161", size = 10122, upload-time = "2026-02-17T18:31:37.496Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"