diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..620ac3b4 --- /dev/null +++ b/.env.test @@ -0,0 +1,7 @@ +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=shop +POSTGRES_USER=user +POSTGRES_PASSWORD=password +REDIS_HOST=localhost +REDIS_PORT=6379 \ No newline at end of file diff --git a/.github/workflows/hw1-tests.yml b/.github/workflows/hw1-tests.yml index 95fe89f7..b7d2c54b 100644 --- a/.github/workflows/hw1-tests.yml +++ b/.github/workflows/hw1-tests.yml @@ -1,37 +1,37 @@ -name: "HW1 Tests" - -# Запускаем тесты при изменении файлов в hw1/ -on: - pull_request: - branches: [ main ] - paths: [ 'hw1/**' ] - push: - branches: [ main ] - paths: [ 'hw1/**' ] - -jobs: - test-hw1: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - working-directory: hw1 - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run tests - working-directory: hw1 - run: | - pytest test_app.py -v +#name: "HW1 Tests" +# +## Запускаем тесты при изменении файлов в hw1/ +#on: +# pull_request: +# branches: [ main ] +# paths: [ 'hw1/**' ] +# push: +# branches: [ main ] +# paths: [ 'hw1/**' ] +# +#jobs: +# test-hw1: +# runs-on: ubuntu-latest +# strategy: +# matrix: +# python-version: ["3.12", "3.13"] +# +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 +# +# - name: Set up Python ${{ matrix.python-version }} +# uses: actions/setup-python@v4 +# with: +# python-version: ${{ matrix.python-version }} +# +# - name: Install dependencies +# working-directory: hw1 +# run: | +# python -m pip install --upgrade pip +# pip install -r requirements.txt +# +# - name: Run tests +# working-directory: hw1 +# run: | +# pytest test_app.py -v diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index be7fc297..20950446 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -1,39 +1,39 @@ -name: "HW2 Tests" - -# Запускаем тесты при изменении файлов в hw2/hw/ -on: - pull_request: - branches: [ main ] - paths: [ 'hw2/hw/**' ] - push: - branches: [ main ] - paths: [ 'hw2/hw/**' ] - -jobs: - test-hw2: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - working-directory: hw2/hw - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run tests - working-directory: hw2/hw - env: - PYTHONPATH: ${{ github.workspace }}/hw2/hw - run: | - pytest test_homework2.py -v +#name: "HW2 Tests" +# +## Запускаем тесты при изменении файлов в hw2/hw/ +#on: +# pull_request: +# branches: [ main ] +# paths: [ 'hw2/hw/**' ] +# push: +# branches: [ main ] +# paths: [ 'hw2/hw/**' ] +# +#jobs: +# test-hw2: +# runs-on: ubuntu-latest +# strategy: +# matrix: +# python-version: ["3.12", "3.13"] +# +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 +# +# - name: Set up Python ${{ matrix.python-version }} +# uses: actions/setup-python@v4 +# with: +# python-version: ${{ matrix.python-version }} +# +# - name: Install dependencies +# working-directory: hw2/hw +# run: | +# python -m pip install --upgrade pip +# pip install -r requirements.txt +# +# - name: Run tests +# working-directory: hw2/hw +# env: +# PYTHONPATH: ${{ github.workspace }}/hw2/hw +# run: | +# pytest test_homework2.py -v diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml new file mode 100644 index 00000000..3f46f4ba --- /dev/null +++ b/.github/workflows/hw5_tests.yaml @@ -0,0 +1,111 @@ +name: Vlad's tests + +on: + push: + branches: [ main, develop, hw2 ] + paths: [ 'hw2/hw/**' ] + pull_request: + branches: [ main, develop, hw2 ] + paths: [ 'hw2/hw/**' ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: shop + POSTGRES_USER: user + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + prometheus: + image: prom/prometheus + ports: + - 9090:9090 + options: >- + --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + grafana: + image: grafana/grafana + ports: + - 3000:3000 + options: >- + --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + cd hw2/hw + pip install -r requirements.txt + pip install pytest pytest-cov pytest-asyncio requests uvicorn asyncpg redis + + - name: Install redis-cli + run: | + sudo apt-get update + sudo apt-get install -y redis-tools + + - name: Wait for databases to be ready + run: | + echo "Waiting for PostgreSQL..." + until pg_isready -h localhost -p 5432; do sleep 1; done + echo "Waiting for Redis..." + until redis-cli -h localhost ping | grep -q PONG; do sleep 1; done + echo "✅ All databases are ready" + + - name: Start FastAPI app in background + run: | + cd hw2/hw + python -m uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 & + echo "FastAPI app starting..." + sleep 15 + + - name: Check app health + run: | + echo "Checking if FastAPI app is healthy..." + curl -f http://localhost:8080/ || (echo "❌ FastAPI app not healthy"; exit 1) + echo "✅ FastAPI app is healthy" + + - name: Run homework tests + run: | + cd hw2/hw + pytest test_my_test.py -v --cov=. --cov-report=term-missing + + - name: Stop FastAPI app + if: always() + run: | + echo "Stopping FastAPI app..." + pkill -f "uvicorn" || true \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..1b5100d6 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,7 @@ from typing import Any, Awaitable, Callable - +from http import HTTPStatus +import json +from math import factorial async def application( scope: dict[str, Any], @@ -13,6 +15,155 @@ async def application( send: Корутина для отправки сообщений клиенту """ # TODO: Ваша реализация здесь + # Обрабатываем lifespan-запросы (startup/shutdown) + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + await handle_http_request(scope, receive, send) + + +async def handle_http_request(scope, receive, send): + method = scope["method"] + path = scope["path"] + + if method == "GET": + + # Ручка для fibonacci + if path.startswith("/fibonacci/"): + number_str = path.split("/fibonacci/")[1] + try: + n = int(number_str) + except: + await send_json_response(send, {"error": "Invalid parameter"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + if n < 0: + await send_json_response(send, {"error": "Invalid parameter"}, HTTPStatus.BAD_REQUEST) + return + + result = await fibonacci(n) + await send_json_response(send, {"result": result}, HTTPStatus.OK) + return + + # Ручка для factorial + if path.startswith("/factorial"): + query_string = scope.get("query_string", b"").decode() + params = {} + if query_string: + for param in query_string.split("&"): + if "=" in param: + key, value = param.split("=", 1) + params[key] = value + + if "n" not in params: + await send_json_response(send, {"error": "Missing parameter 'n'"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + n_str = params["n"] + + # Проверяем что параметр не пустой + if n_str == "": + await send_json_response(send, {"error": "Invalid parameter"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + try: + n = int(n_str) + except : + await send_json_response(send, {"error": "Invalid number parameter"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + # Проверяем что число не отрицательное + if n < 0: + await send_json_response(send, {"error": "Number must be non-negative"}, HTTPStatus.BAD_REQUEST) + return + + result = factorial(n) + await send_json_response(send, {"result": result}, HTTPStatus.OK) + return + + # Ручка для mean + if path.startswith("/mean"): + + body = await receive_body(receive) + if body is None: + await send_json_response(send, {"error": "No JSON data"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + try: + data = json.loads(body) + except json.JSONDecodeError: + await send_json_response(send, {"error": "Invalid JSON"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + # Проверяем что данные - это список + if not isinstance(data, list): + await send_json_response(send, {"error": "Data must be a list"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + # Проверяем что список не пустой + if len(data) == 0: + await send_json_response(send, {"error": "List cannot be empty"}, HTTPStatus.BAD_REQUEST) + return + + # Проверяем что все элементы - числа + if not all(isinstance(x, (int, float)) for x in data): + await send_json_response(send, {"error": "All elements must be numbers"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + # Вычисляем среднее значение + result = sum(data) / len(data) + await send_json_response(send, {"result": result}, HTTPStatus.OK) + return + + await send_json_response(send, {"error": "Not available"}, HTTPStatus.NOT_FOUND) + return + +async def receive_body(receive): + """Получает тело запроса""" + body = b"" + more_body = True + + while more_body: + message = await receive() + body += message.get("body", b"") + more_body = message.get("more_body", False) + + return body.decode('utf-8') if body else None + + +async def send_json_response(send, data: dict, status_code: HTTPStatus): + """Универсальная функция для отправки JSON ответов""" + body = json.dumps(data).encode() + + await send({ + "type": "http.response.start", + "status": status_code, + "headers": [[b"content-type", b"application/json"]] + }) + await send({ + "type": "http.response.body", + "body": body, + }) + return + +async def fibonacci(n: int) -> int: + """Вычисляет n-ное число Фибоначчи""" + if n < 0: + raise ValueError("Number must be non-negative") + if n <= 1: + return n + + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + + + if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..21f49a4e --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.10.18-alpine3.22 +LABEL service="processing" +EXPOSE 8080 +WORKDIR /app +COPY shop_api/requiremets.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY shop_api . +CMD ["python", "main.py"] \ No newline at end of file diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..394f24bd --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,71 @@ +import os +import pytest +import requests +import time +from http import HTTPStatus +from fastapi.testclient import TestClient +from typing import Generator, List, Dict, Any + +# Загружаем тестовые переменные окружения +os.environ['POSTGRES_HOST'] = 'localhost' +os.environ['POSTGRES_PORT'] = '5432' +os.environ['POSTGRES_DB'] = 'shop' +os.environ['POSTGRES_USER'] = 'user' +os.environ['POSTGRES_PASSWORD'] = 'password' +os.environ['REDIS_HOST'] = 'localhost' +os.environ['REDIS_PORT'] = '6379' + +from .shop_api.main import app + +BASE_URL = "http://localhost:8080" + + +@pytest.fixture(scope="session", autouse=True) +def wait_for_services(): + """Ожидание готовности сервисов перед запуском тестов""" + max_retries = 30 + retry_delay = 2 + + for i in range(max_retries): + try: + response = requests.get(f"{BASE_URL}/docs", timeout=5) + if response.status_code == HTTPStatus.OK: + print("✅ Все сервисы готовы!") + break + except Exception as e: + if i < max_retries - 1: + print(f"⏳ Ожидаем готовности сервисов... ({i + 1}/{max_retries})") + time.sleep(retry_delay) + else: + pytest.fail(f"❌ Сервисы не запустились за отведенное время: {e}") + + +@pytest.fixture(scope="session") +def client(): + """Тестовый клиент для работы с развернутым приложением""" + return TestClient(app) + + +@pytest.fixture +def sample_item_data() -> Dict[str, Any]: + """Фикстура с данными тестового товара""" + return { + "name": "Test Product", + "price": 99.99 + } + + +@pytest.fixture +def created_item(client: TestClient, sample_item_data: Dict[str, Any]) -> Dict[str, Any]: + """Фикстура создает товар и возвращает его данные""" + response = client.post("/item", json=sample_item_data) + assert response.status_code == HTTPStatus.CREATED + return response.json() + + +@pytest.fixture +def created_cart(client: TestClient) -> Dict[str, Any]: + """Фикстура создает корзину и возвращает ее данные""" + response = client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + return response.json() \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..022d271c --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,57 @@ +version: "3" + +services: + local: + build: + context: . + dockerfile: ./Dockerfile + restart: always + ports: + - 8080:8080 + depends_on: + - postgres + - redis + environment: + - DATABASE_URL=postgresql://user:password@postgres:5432/shop + - REDIS_URL=redis://redis:6379 + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: shop + POSTGRES_USER: user + POSTGRES_PASSWORD: password + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init-db:/docker-entrypoint-initdb.d + ports: + - 5432:5432 + restart: always + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + restart: always + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw2/hw/init-db/init.sql b/hw2/hw/init-db/init.sql new file mode 100644 index 00000000..a55076d9 --- /dev/null +++ b/hw2/hw/init-db/init.sql @@ -0,0 +1,34 @@ +-- Создание таблицы корзин +CREATE TABLE IF NOT EXISTS carts ( + id SERIAL PRIMARY KEY, + total_price DECIMAL(10,2) DEFAULT 0.00, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Создание таблицы элементов корзины +CREATE TABLE IF NOT EXISTS cart_items ( + id SERIAL PRIMARY KEY, + cart_id INTEGER REFERENCES carts(id) ON DELETE CASCADE, + product_id INTEGER NOT NULL, + quantity INTEGER DEFAULT 1, + price DECIMAL(10,2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Создание таблицы продуктов +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price DECIMAL(10,2) NOT NULL, + deleted BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для улучшения производительности +CREATE INDEX IF NOT EXISTS idx_cart_items_cart_id ON cart_items(cart_id); +CREATE INDEX IF NOT EXISTS idx_cart_items_product_id ON cart_items(product_id); +CREATE INDEX IF NOT EXISTS idx_carts_created_at ON carts(created_at); +CREATE INDEX IF NOT EXISTS idx_products_deleted ON products(deleted); +CREATE INDEX IF NOT EXISTS idx_products_price ON products(price); + diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..a99227f0 100644 Binary files a/hw2/hw/requirements.txt and b/hw2/hw/requirements.txt differ diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..f134aeb9 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 1s + evaluation_interval: 1s + +scrape_configs: + - job_name: demo-service-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..6a99dc2f 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,692 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Response, Query, HTTPException +from typing import Optional, List +import uvicorn +import json +import os +from http import HTTPStatus + +from prometheus_fastapi_instrumentator import Instrumentator +import asyncpg +import redis.asyncio as redis +from datetime import datetime +from pydantic import BaseModel +from fastapi.encoders import jsonable_encoder +from decimal import Decimal app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): + return float(obj) + #return super(DecimalEncoder, self).default(obj) + + +# Модели данных +class CartItem(BaseModel): + product_id: int + quantity: int + price: float + + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + created_at: Optional[datetime] = None + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + created_at: Optional[datetime] = None + + +# Подключение к БД +async def get_db_connection(): + return await asyncpg.connect( + database=os.getenv('POSTGRES_DB', 'shop'), + user=os.getenv('POSTGRES_USER', 'user'), + password=os.getenv('POSTGRES_PASSWORD', 'password'), + host=os.getenv('POSTGRES_HOST', 'postgres'), + port=os.getenv('POSTGRES_PORT', '5432') + ) + + +# Redis подключение +async def get_redis_connection(): + return redis.Redis( + host=os.getenv('REDIS_HOST', 'redis'), + port=os.getenv('REDIS_PORT', 6379), + decode_responses=True + ) + + +# Вспомогательные функции +async def get_cart_from_db(cart_id: int) -> Optional[dict]: + """Получить корзину из БД""" + db_conn = await get_db_connection() + try: + # Получаем основную информацию о корзине + cart = await db_conn.fetchrow( + "SELECT id, total_price as price, created_at FROM carts WHERE id = $1", + cart_id + ) + + if not cart: + return None + + # Получаем товары в корзине + items = await db_conn.fetch( + """SELECT product_id, quantity, price + FROM cart_items WHERE cart_id = $1""", + cart_id + ) + + # Конвертируем Decimal в float сразу + cart_data = { + 'id': cart['id'], + 'items': [{ + 'id': item['product_id'], + 'quantity': item['quantity'], + 'price': float(item['price']) # Конвертируем здесь + } for item in items], + 'price': float(cart['price']), # Конвертируем здесь + 'created_at': cart['created_at'].isoformat() if cart['created_at'] else None + } + + return cart_data + finally: + await db_conn.close() + +async def get_cart_with_cache(cart_id: int) -> Optional[dict]: + """Получить корзину с кешированием""" + redis_conn = await get_redis_connection() + + try: + # Пробуем получить из кеша + cached_cart = await redis_conn.get(f"cart:{cart_id}") + if cached_cart: + cart_data = json.loads(cached_cart) + # Конвертируем Decimal при получении из кеша + if cart_data and 'price' in cart_data: + cart_data['price'] = float(cart_data['price']) + for item in cart_data.get('items', []): + if 'price' in item: + item['price'] = float(item['price']) + return cart_data + + # Если нет в кеше, получаем из БД + cart_data = await get_cart_from_db(cart_id) + + if cart_data: + # Конвертируем Decimal перед сохранением в кеш + cart_data_for_cache = cart_data.copy() + cart_data_for_cache['price'] = float(cart_data_for_cache['price']) + for item in cart_data_for_cache.get('items', []): + item['price'] = float(item['price']) + + # Сохраняем в кеш на 5 минут + await redis_conn.setex(f"cart:{cart_id}", 300, json.dumps(cart_data_for_cache, cls=DecimalEncoder)) + + return cart_data + finally: + await redis_conn.aclose() + + +async def invalidate_cart_cache(cart_id: int): + """Инвалидировать кеш корзины""" + redis_conn = await get_redis_connection() + try: + await redis_conn.delete(f"cart:{cart_id}") + finally: + await redis_conn.aclose() + + +async def get_item_from_db(item_id: int) -> Optional[dict]: + """Получить товар из БД""" + db_conn = await get_db_connection() + try: + item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", # Исключаем created_at + item_id + ) + + if not item: + return None + + return { + 'id': item['id'], + 'name': item['name'], + 'price': float(item['price']), + 'deleted': item['deleted'] + } + finally: + await db_conn.close() + + +@app.get("/") +async def root(): + return {"status": "ok"} + + +### CART ENDPOINTS + +@app.post("/cart") +async def create_cart(): + db_conn = await get_db_connection() + + try: + # Создаем новую корзину + cart_id = await db_conn.fetchval( + "INSERT INTO carts (total_price) VALUES (0.00) RETURNING id" + ) + + # Создаем ответ + cart_data = { + 'id': cart_id, + 'items': [], + 'price': 0.0, + 'created_at': datetime.now().isoformat() + } + + response = Response( + content=json.dumps(cart_data), + status_code=HTTPStatus.CREATED, + media_type="application/json", + headers={} + ) + response.headers["Location"] = f"/cart/{cart_id}" + return response + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error creating cart: {str(e)}") + finally: + await db_conn.close() + + +@app.get("/cart/{cart_id}") +async def get_cart(cart_id: int): + cart_data = await get_cart_with_cache(cart_id) + + if not cart_data: + raise HTTPException(status_code=404, detail="Cart not found") + + return cart_data + + +@app.get("/cart") +async def get_carts( + offset: int = Query(0, ge=0, description="Смещение по списку"), + limit: int = Query(10, gt=0, description="Ограничение на количество"), + min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена"), + max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена"), + min_quantity: Optional[int] = Query(None, ge=0, description="Минимальное количество товаров"), + max_quantity: Optional[int] = Query(None, ge=0, description="Максимальное количество товаров") +): + db_conn = await get_db_connection() + + try: + # Базовый запрос + query = """ + SELECT c.id, c.total_price as price, c.created_at, + COALESCE(SUM(ci.quantity), 0) as total_quantity + FROM carts c + LEFT JOIN cart_items ci ON c.id = ci.cart_id + """ + + where_conditions = [] + params = [] + param_count = 0 + + # Фильтры по цене + if min_price is not None: + param_count += 1 + where_conditions.append(f"c.total_price >= ${param_count}") + params.append(min_price) + + if max_price is not None: + param_count += 1 + where_conditions.append(f"c.total_price <= ${param_count}") + params.append(max_price) + + # Добавляем условия WHERE если есть фильтры + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + # Группировка для подсчета общего количества товаров + query += " GROUP BY c.id, c.total_price, c.created_at" + + # Получаем все корзины + carts = await db_conn.fetch(query, *params) + + # Преобразуем в нужный формат и фильтруем по количеству товаров + result_carts = [] + for cart in carts: + # Получаем товары для каждой корзины + items = await db_conn.fetch( + "SELECT product_id, quantity, price FROM cart_items WHERE cart_id = $1", + cart['id'] + ) + + cart_data = { + 'id': cart['id'], + 'items': [dict(item) for item in items], + 'price': float(cart['price']), + 'created_at': cart['created_at'].isoformat() if cart['created_at'] else None + } + + result_carts.append(cart_data) + + # Фильтрация по общему количеству товаров (min_quantity/max_quantity) + if min_quantity is not None or max_quantity is not None: + filtered_carts = [] + + for cart_data in result_carts: + total_items_quantity = sum(item['quantity'] for item in cart_data['items']) + + quantity_ok = True + if min_quantity is not None and total_items_quantity < min_quantity: + quantity_ok = False + if max_quantity is not None and total_items_quantity > max_quantity: + quantity_ok = False + + if quantity_ok: + filtered_carts.append(cart_data) + + result_carts = filtered_carts + + # Сортируем по ID + result_carts.sort(key=lambda x: x['id']) + + # Пагинация + start_idx = offset + end_idx = offset + limit + paginated_result = result_carts[start_idx:end_idx] + + return paginated_result + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error getting carts: {str(e)}") + finally: + await db_conn.close() + + +@app.post("/cart/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int): + db_conn = await get_db_connection() + + try: + # Проверяем существование корзины + cart_exists = await db_conn.fetchval( + "SELECT 1 FROM carts WHERE id = $1", cart_id + ) + if not cart_exists: + raise HTTPException(status_code=404, detail="Cart not found") + + # Проверяем существование товара + item = await db_conn.fetchrow( + "SELECT id, name, price FROM products WHERE id = $1 AND NOT deleted", item_id + ) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + async with db_conn.transaction(): + # Проверяем, есть ли товар уже в корзине + existing_item = await db_conn.fetchrow( + "SELECT quantity, price FROM cart_items WHERE cart_id = $1 AND product_id = $2", + cart_id, item_id + ) + + if existing_item: + # Обновляем количество + await db_conn.execute( + "UPDATE cart_items SET quantity = quantity + 1 WHERE cart_id = $1 AND product_id = $2", + cart_id, item_id + ) + price_to_add = float(existing_item['price']) + else: + # Добавляем новый товар + await db_conn.execute( + """INSERT INTO cart_items (cart_id, product_id, quantity, price) + VALUES ($1, $2, 1, $3)""", + cart_id, item_id, float(item['price']) + ) + price_to_add = float(item['price']) + + # Обновляем общую стоимость корзины + await db_conn.execute( + "UPDATE carts SET total_price = total_price + $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2", + price_to_add, cart_id + ) + + # Инвалидируем кеш корзины + await invalidate_cart_cache(cart_id) + + # Возвращаем обновленную корзину с конвертацией Decimal + cart_data = await get_cart_with_cache(cart_id) + if cart_data: + # Конвертируем все Decimal значения в float + cart_data['price'] = float(cart_data['price']) + for item in cart_data['items']: + item['price'] = float(item['price']) + + return jsonable_encoder(cart_data) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error adding to cart: {str(e)}") + finally: + await db_conn.close() + +### ITEM ENDPOINTS + + +@app.get("/item/{item_id}") +async def get_item(item_id: int): + item_data = await get_item_from_db(item_id) + + if not item_data: + raise HTTPException(status_code=404, detail="Item not found") + + # Для удаленных товаров возвращаем 404 + if item_data.get('deleted', False): + raise HTTPException(status_code=404, detail="Item not found") + + # Добавляем quantity для обратной совместимости + item_data['quantity'] = 1 + + return item_data + + +@app.get("/item") +async def get_items( + offset: int = Query(0, ge=0, description="Смещение по списку"), + limit: int = Query(10, gt=0, description="Ограничение на количество"), + min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена"), + max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена"), + show_deleted: bool = Query(False, description="Показывать удаленные товары") +): + db_conn = await get_db_connection() + + try: + # Базовый запрос + query = "SELECT id, name, price, deleted, created_at FROM products" + where_conditions = [] + params = [] + param_count = 0 + + # Фильтр по статусу удаления + if not show_deleted: + where_conditions.append("NOT deleted") + + # Фильтры по цене + if min_price is not None: + param_count += 1 + where_conditions.append(f"price >= ${param_count}") + params.append(float(min_price)) + + if max_price is not None: + param_count += 1 + where_conditions.append(f"price <= ${param_count}") + params.append(float(max_price)) + + # Добавляем условия WHERE если есть фильтры + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + # Сортировка + query += " ORDER BY id" + + # Теперь правильно добавляем пагинацию + # Сначала считаем общее количество параметров + total_params = len(params) + + # Добавляем LIMIT и OFFSET с правильными номерами параметров + query += f" LIMIT ${total_params + 1} OFFSET ${total_params + 2}" + params.extend([limit, offset]) + + items = await db_conn.fetch(query, *params) + + result = [] + for item in items: + item_data = { + 'id': item['id'], + 'name': item['name'], + 'price': float(item['price']), + 'quantity': 1, # Для обратной совместимости + 'deleted': item['deleted'], + 'created_at': item['created_at'].isoformat() if item['created_at'] else None + } + result.append(item_data) + + return jsonable_encoder(result) + + except Exception as e: + print(f"Error in get_items: {e}") # Для отладки + raise HTTPException(status_code=500, detail=f"Error getting items: {str(e)}") + finally: + await db_conn.close() + + +@app.post("/item") +async def create_item(item_data: dict): + db_conn = await get_db_connection() + + try: + # Проверяем обязательные поля + if 'name' not in item_data or 'price' not in item_data: + raise HTTPException( + status_code=422, + detail="Name and price are required" + ) + + # Создаем товар + if 'price' in item_data: + if float(item_data['price']) < 0: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) + item_id = await db_conn.fetchval( + """INSERT INTO products (name, price, deleted) + VALUES ($1, $2, false) RETURNING id""", + item_data['name'], float(item_data['price']) + ) + + # Получаем созданный товар (без created_at для совместимости) + item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", + item_id + ) + + item_response = { + 'id': item['id'], + 'name': item['name'], + 'price': float(item['price']), + 'quantity': 1, # Для обратной совместимости + 'deleted': item['deleted'] + # Исключаем created_at для совместимости с тестами + } + + response = Response( + content=json.dumps(jsonable_encoder(item_response)), + status_code=HTTPStatus.CREATED, + media_type="application/json", + headers={} + ) + return response + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error creating item: {str(e)}") + finally: + await db_conn.close() + + +@app.put("/item/{item_id}") +async def update_item(item_id: int, item_data: dict): + db_conn = await get_db_connection() + + try: + # Проверяем существование товара + existing_item = await db_conn.fetchrow( + "SELECT id, deleted FROM products WHERE id = $1", item_id + ) + if not existing_item: + raise HTTPException(status_code=404, detail="Item not found") + + # Проверяем, что товар не удален + if existing_item['deleted']: + raise HTTPException(status_code=404, detail="Item not found") + + # Проверяем обязательные поля + if 'name' not in item_data or 'price' not in item_data: + raise HTTPException( + status_code=422, + detail="Name and price are required for PUT" + ) + if 'price' in item_data: + if float(item_data['price']) < 0: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) + # Обновляем товар + await db_conn.execute( + "UPDATE products SET name = $1, price = $2 WHERE id = $3", + item_data['name'], float(item_data['price']), item_id + ) + + # Получаем обновленный товар (без created_at) + updated_item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", + item_id + ) + + response = { + 'id': updated_item['id'], + 'name': updated_item['name'], + 'price': float(updated_item['price']), + 'quantity': 1, + 'deleted': updated_item['deleted'] + # Исключаем created_at + } + + return jsonable_encoder(response) + + except HTTPException: + raise + finally: + await db_conn.close() + + +@app.patch("/item/{item_id}") +async def patch_item(item_id: int, item_data: dict): + db_conn = await get_db_connection() + + try: + # Проверяем существование товара + existing_item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", item_id + ) + if not existing_item: + raise HTTPException(status_code=404, detail="Item not found") + + # Для удаленных товаров возвращаем 304 + if existing_item['deleted']: + raise HTTPException(status_code=304, detail="Item is deleted") + + # Проверяем на лишние поля + allowed_fields = {'name', 'price'} + extra_fields = set(item_data.keys()) - allowed_fields + if extra_fields: + raise HTTPException( + status_code=422, + detail=f"Extra fields not allowed: {extra_fields}" + ) + # Если тело пустое - возвращаем текущий товар без изменений + if not item_data: + response = { + 'id': existing_item['id'], + 'name': existing_item['name'], + 'price': float(existing_item['price']), + 'quantity': 1, + 'deleted': existing_item['deleted'] + # Исключаем created_at + } + return jsonable_encoder(response) + + # Подготавливаем данные для обновления + update_fields = [] + update_params = [] + param_count = 0 + + if 'name' in item_data: + param_count += 1 + update_fields.append(f"name = ${param_count}") + update_params.append(item_data['name']) + + if 'price' in item_data: + if item_data['price'] < 0: + raise HTTPException(status_code=422, detail="Price cannot be negative") + param_count += 1 + update_fields.append(f"price = ${param_count}") + update_params.append(float(item_data['price'])) + + # Выполняем обновление + if update_fields: + param_count += 1 + update_query = f"UPDATE products SET {', '.join(update_fields)} WHERE id = ${param_count}" + update_params.append(item_id) + await db_conn.execute(update_query, *update_params) + + # Получаем обновленный товар (без created_at) + updated_item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", + item_id + ) + + response = { + 'id': updated_item['id'], + 'name': updated_item['name'], + 'price': float(updated_item['price']), + 'quantity': 1, + 'deleted': updated_item['deleted'] + # Исключаем created_at + } + + return jsonable_encoder(response) + + except HTTPException: + raise + finally: + await db_conn.close() + + +@app.delete("/item/{item_id}") +async def delete_item(item_id: int): + db_conn = await get_db_connection() + + try: + # Проверяем существование товара + existing_item = await db_conn.fetchrow( + "SELECT id, deleted FROM products WHERE id = $1", item_id + ) + if not existing_item: + return Response(status_code=HTTPStatus.NOT_FOUND) + + # Если уже удален - все равно успех + if not existing_item['deleted']: + await db_conn.execute( + "UPDATE products SET deleted = true WHERE id = $1", + item_id + ) + + # Возвращаем просто статус OK + return Response(status_code=HTTPStatus.OK) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error deleting item: {str(e)}") + finally: + await db_conn.close() + diff --git a/hw2/hw/shop_api/requirements.txt b/hw2/hw/shop_api/requirements.txt new file mode 100644 index 00000000..a99227f0 Binary files /dev/null and b/hw2/hw/shop_api/requirements.txt differ diff --git a/hw2/hw/test_my_test.py b/hw2/hw/test_my_test.py new file mode 100644 index 00000000..12756936 --- /dev/null +++ b/hw2/hw/test_my_test.py @@ -0,0 +1,1129 @@ +import pytest +import asyncio +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient +from fastapi import HTTPException +from http import HTTPStatus +import json +from decimal import Decimal + +from .shop_api import main +from .shop_api.main import app +client = TestClient(app) + +target = 'hw.shop_api.main' +class TestDatabaseHelpers: + """Тесты вспомогательных функций базы данных""" + + @pytest.mark.asyncio + @pytest.mark.asyncio + async def test_get_db_connection(self): + """Тест подключения к БД""" + with patch(f'{target}.asyncpg.connect') as mock_connect, \ + patch.dict('os.environ', {}, clear=True): + mock_conn = AsyncMock() + mock_connect.return_value = mock_conn + + connection = await main.get_db_connection() + + mock_connect.assert_called_once_with( + database='shop', + user='user', + password='password', + host='postgres', + port='5432' + ) + assert connection == mock_conn + + @pytest.mark.asyncio + async def test_get_redis_connection(self): + """Тест подключения к Redis""" + with patch(f'{target}.redis.Redis') as mock_redis, \ + patch.dict('os.environ', {}, clear=True): + + mock_redis_instance = AsyncMock() + mock_redis.return_value = mock_redis_instance + + connection = await main.get_redis_connection() + + mock_redis.assert_called_once_with( + host='redis', + port=6379, + decode_responses=True + ) + + @pytest.mark.asyncio + async def test_get_cart_from_db_success(self): + """Тест получения корзины из БД - успешный случай""" + mock_conn = AsyncMock() + mock_cart = { + 'id': 1, + 'price': Decimal('100.50'), + 'created_at': None + } + mock_items = [ + {'product_id': 1, 'quantity': 2, 'price': Decimal('50.25')} + ] + + mock_conn.fetchrow.return_value = mock_cart + mock_conn.fetch.return_value = mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + result = await main.get_cart_from_db(1) + + expected = { + 'id': 1, + 'items': [{ + 'id': 1, + 'quantity': 2, + 'price': 50.25 + }], + 'price': 100.50, + 'created_at': None + } + assert result == expected + + @pytest.mark.asyncio + async def test_get_cart_from_db_not_found(self): + """Тест получения несуществующей корзины из БД""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = None + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + result = await main.get_cart_from_db(999) + assert result is None + + @pytest.mark.asyncio + async def test_get_cart_with_cache_hit(self): + """Тест получения корзины из кеша""" + cached_cart = { + 'id': 1, + 'items': [{'id': 1, 'quantity': 1, 'price': 50.0}], + 'price': 50.0 + } + + mock_redis = AsyncMock() + mock_redis.get.return_value = json.dumps(cached_cart) + + with patch(f'{target}.get_redis_connection', return_value=mock_redis): + result = await main.get_cart_with_cache(1) + + assert result == cached_cart + mock_redis.get.assert_called_once_with('cart:1') + + @pytest.mark.asyncio + async def test_get_cart_with_cache_miss(self): + """Тест получения корзины при промахе кеша""" + mock_redis = AsyncMock() + mock_redis.get.return_value = None + + db_cart = { + 'id': 1, + 'items': [{'id': 1, 'quantity': 1, 'price': 50.0}], + 'price': 50.0 + } + + with patch(f'{target}.get_redis_connection', return_value=mock_redis), \ + patch(f'{target}.get_cart_from_db', return_value=db_cart): + result = await main.get_cart_with_cache(1) + + assert result == db_cart + mock_redis.setex.assert_called_once() + + @pytest.mark.asyncio + async def test_invalidate_cart_cache(self): + """Тест инвалидации кеша корзины""" + mock_redis = AsyncMock() + + with patch(f'{target}.get_redis_connection', return_value=mock_redis): + await main.invalidate_cart_cache(1) + + mock_redis.delete.assert_called_once_with('cart:1') + + @pytest.mark.asyncio + async def test_get_item_from_db_success(self): + """Тест получения товара из БД""" + mock_conn = AsyncMock() + mock_item = { + 'id': 1, + 'name': 'Test Item', + 'price': Decimal('99.99'), + 'deleted': False + } + mock_conn.fetchrow.return_value = mock_item + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + result = await main.get_item_from_db(1) + + expected = { + 'id': 1, + 'name': 'Test Item', + 'price': 99.99, + 'deleted': False + } + assert result == expected + + @pytest.mark.asyncio + async def test_get_item_from_db_not_found(self): + """Тест получения несуществующего товара из БД""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = None + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + result = await main.get_item_from_db(999) + assert result is None + + +class TestRootEndpoint: + """Тесты корневого эндпоинта""" + + def test_root(self): + """Тест корневого эндпоинта""" + response = client.get("/") + assert response.status_code == HTTPStatus.OK + assert response.json() == {"status": "ok"} + + +class TestCartEndpoints: + """Тесты эндпоинтов корзины""" + + @pytest.mark.asyncio + async def test_create_cart_success(self): + """Тест успешного создания корзины""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data['id'] == 1 + assert data['items'] == [] + assert data['price'] == 0.0 + assert 'Location' in response.headers + + @pytest.mark.asyncio + async def test_create_cart_database_error(self): + """Тест ошибки при создании корзины""" + mock_conn = AsyncMock() + mock_conn.fetchval.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error creating cart" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_get_cart_success(self): + """Тест успешного получения корзины""" + cart_data = { + 'id': 1, + 'items': [{'id': 1, 'quantity': 1, 'price': 50.0}], + 'price': 50.0 + } + + with patch(f'{target}.get_cart_with_cache', return_value=cart_data): + response = client.get("/cart/1") + + assert response.status_code == HTTPStatus.OK + assert response.json() == cart_data + + @pytest.mark.asyncio + async def test_get_cart_not_found(self): + """Тест получения несуществующей корзины""" + with patch(f'{target}.get_cart_with_cache', return_value=None): + response = client.get("/cart/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "Cart not found" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_get_carts_success(self): + """Тест успешного получения списка корзин""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 2}, + {'id': 2, 'price': Decimal('200.0'), 'created_at': None, 'total_quantity': 1} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 2, 'price': Decimal('50.0')}], + [{'product_id': 2, 'quantity': 1, 'price': Decimal('200.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 2 + assert data[0]['id'] == 1 + assert data[1]['id'] == 2 + + @pytest.mark.asyncio + async def test_get_carts_with_filters(self): + """Тест получения корзин с фильтрами""" + mock_conn = AsyncMock() + mock_carts = [{'id': 1, 'price': Decimal('150.0'), 'created_at': None, 'total_quantity': 3}] + mock_items = [[{'product_id': 1, 'quantity': 3, 'price': Decimal('50.0')}]] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?min_price=100&max_price=200&min_quantity=2&max_quantity=5") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 + + @pytest.mark.asyncio + async def test_get_carts_database_error(self): + """Тест ошибки при получении списка корзин""" + mock_conn = AsyncMock() + mock_conn.fetch.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error getting carts" in response.json()['detail'] + + + @pytest.mark.asyncio + async def test_add_to_cart_cart_not_found(self): + """Тест добавления в несуществующую корзину""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = None # cart doesn't exist + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart/999/add/1") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "Cart not found" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_add_to_cart_item_not_found(self): + """Тест добавления несуществующего товара""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = True # cart exists + mock_conn.fetchrow.return_value = None # item doesn't exist + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart/1/add/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "Item not found" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_add_to_cart_database_error(self): + """Тест ошибки при добавлении в корзину""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = True + mock_conn.fetchrow.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart/1/add/1") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error adding to cart" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_add_to_cart_database_error(self): + """Тест ошибки при добавлении в корзину""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = True + mock_conn.fetchrow.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart/1/add/1") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error adding to cart" in response.json()['detail'] + + +class TestItemEndpoints: + """Тесты эндпоинтов товаров""" + + @pytest.mark.asyncio + async def test_get_item_success(self): + """Тест успешного получения товара""" + item_data = { + 'id': 1, + 'name': 'Test Item', + 'price': 99.99, + 'deleted': False + } + + with patch(f'{target}.get_item_from_db', return_value=item_data): + response = client.get("/item/1") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['id'] == 1 + assert data['quantity'] == 1 # Добавлено для обратной совместимости + + @pytest.mark.asyncio + async def test_get_item_not_found(self): + """Тест получения несуществующего товара""" + with patch(f'{target}.get_item_from_db', return_value=None): + response = client.get("/item/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_get_item_deleted(self): + """Тест получения удаленного товара""" + item_data = { + 'id': 1, + 'name': 'Deleted Item', + 'price': 99.99, + 'deleted': True + } + + with patch(f'{target}.get_item_from_db', return_value=item_data): + response = client.get("/item/1") + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_get_items_success(self): + """Тест успешного получения списка товаров""" + mock_conn = AsyncMock() + mock_items = [ + { + 'id': 1, + 'name': 'Item 1', + 'price': Decimal('50.0'), + 'deleted': False, + 'created_at': None + } + ] + mock_conn.fetch.return_value = mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 + assert data[0]['name'] == 'Item 1' + + @pytest.mark.asyncio + async def test_get_items_with_filters(self): + """Тест получения товаров с фильтрами""" + mock_conn = AsyncMock() + mock_items = [ + { + 'id': 1, + 'name': 'Item 1', + 'price': Decimal('75.0'), + 'deleted': False, + 'created_at': None + } + ] + mock_conn.fetch.return_value = mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item?min_price=50&max_price=100&show_deleted=true&offset=0&limit=10") + + assert response.status_code == HTTPStatus.OK + + @pytest.mark.asyncio + async def test_get_items_database_error(self): + """Тест ошибки при получении списка товаров""" + mock_conn = AsyncMock() + mock_conn.fetch.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error getting items" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_create_item_success(self): + """Тест успешного создания товара""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetchrow.return_value = { + 'id': 1, + 'name': 'New Item', + 'price': Decimal('99.99'), + 'deleted': False + } + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/item", json={"name": "New Item", "price": 99.99}) + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data['id'] == 1 + assert data['name'] == 'New Item' + assert data['price'] == 99.99 + + @pytest.mark.asyncio + async def test_create_item_missing_fields(self): + """Тест создания товара без обязательных полей""" + response = client.post("/item", json={"name": "Only Name"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + response = client.post("/item", json={"price": 100.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_create_item_negative_price(self): + """Тест создания товара с отрицательной ценой""" + response = client.post("/item", json={"name": "Test", "price": -10.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_create_item_database_error(self): + """Тест ошибки при создании товара""" + mock_conn = AsyncMock() + mock_conn.fetchval.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/item", json={"name": "Test", "price": 100.0}) + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error creating item" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_update_item_success(self): + """Тест успешного обновления товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'deleted': False}, # existing item check + {'id': 1, 'name': 'Updated', 'price': Decimal('150.0'), 'deleted': False} # updated item + ] + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Updated", "price": 150.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['name'] == 'Updated' + assert data['price'] == 150.0 + + @pytest.mark.asyncio + async def test_update_item_not_found(self): + """Тест обновления несуществующего товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = None + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/999", json={"name": "Test", "price": 100.0}) + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_update_item_deleted(self): + """Тест обновления удаленного товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': True} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Test", "price": 100.0}) + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_update_item_missing_fields(self): + """Тест обновления товара без обязательных полей""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Only Name"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + response = client.put("/item/1", json={"price": 100.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_update_item_negative_price(self): + """Тест обновления товара с отрицательной ценой""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Test", "price": -10.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_patch_item_success(self): + """Тест успешного частичного обновления товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False}, + {'id': 1, 'name': 'Patched', 'price': Decimal('120.0'), 'deleted': False} + ] + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "Patched", "price": 120.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['name'] == 'Patched' + assert data['price'] == 120.0 + + + @pytest.mark.asyncio + async def test_patch_item_deleted(self): + """Тест частичного обновления удаленного товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'name': 'Deleted', 'price': Decimal('100.0'), 'deleted': True} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "Updated"}) + + assert response.status_code == HTTPStatus.NOT_MODIFIED + + @pytest.mark.asyncio + async def test_patch_item_extra_fields(self): + """Тест частичного обновления с лишними полями""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "Test", "invalid_field": "value"}) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_patch_item_negative_price(self): + """Тест частичного обновления с отрицательной ценой""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"price": -10.0}) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_delete_item_success(self): + """Тест успешного удаления товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.delete("/item/1") + + assert response.status_code == HTTPStatus.OK + + @pytest.mark.asyncio + async def test_delete_item_not_found(self): + """Тест удаления несуществующего товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = None + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.delete("/item/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_delete_item_already_deleted(self): + """Тест удаления уже удаленного товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': True} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.delete("/item/1") + + assert response.status_code == HTTPStatus.OK # Все равно успех + + @pytest.mark.asyncio + async def test_delete_item_database_error(self): + """Тест ошибки при удалении товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.delete("/item/1") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error deleting item" in response.json()['detail'] + + + @pytest.mark.asyncio + async def test_get_cart_with_cache_empty_cart_data(self): + """Тест получения корзины когда cart_data is None""" + mock_redis = AsyncMock() + mock_redis.get.return_value = None # Кеш пустой + mock_redis.aclose = AsyncMock() + + with patch(f'{target}.get_redis_connection', return_value=mock_redis), \ + patch(f'{target}.get_cart_from_db', return_value=None): + result = await main.get_cart_with_cache(1) + + assert result is None + # Не должен вызывать setex если cart_data is None + + + @pytest.mark.asyncio + async def test_get_carts_with_quantity_filters_only_min(self): + """Тест фильтрации корзин только по min_quantity""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 3}, + {'id': 2, 'price': Decimal('50.0'), 'created_at': None, 'total_quantity': 1} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 3, 'price': Decimal('33.33')}], + [{'product_id': 2, 'quantity': 1, 'price': Decimal('50.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?min_quantity=2") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 # Только корзина с quantity >= 2 + assert data[0]['id'] == 1 + + @pytest.mark.asyncio + async def test_get_carts_with_quantity_filters_only_max(self): + """Тест фильтрации корзин только по max_quantity""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 3}, + {'id': 2, 'price': Decimal('50.0'), 'created_at': None, 'total_quantity': 1} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 3, 'price': Decimal('33.33')}], + [{'product_id': 2, 'quantity': 1, 'price': Decimal('50.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?max_quantity=2") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 # Только корзина с quantity <= 2 + assert data[0]['id'] == 2 + + @pytest.mark.asyncio + async def test_get_carts_with_quantity_filters_both(self): + """Тест фильтрации корзин по min_quantity и max_quantity""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 3}, + {'id': 2, 'price': Decimal('50.0'), 'created_at': None, 'total_quantity': 2}, + {'id': 3, 'price': Decimal('25.0'), 'created_at': None, 'total_quantity': 1} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 3, 'price': Decimal('33.33')}], + [{'product_id': 2, 'quantity': 2, 'price': Decimal('25.0')}], + [{'product_id': 3, 'quantity': 1, 'price': Decimal('25.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?min_quantity=2&max_quantity=2") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 # Только корзина с quantity = 2 + assert data[0]['id'] == 2 + + @pytest.mark.asyncio + async def test_get_carts_with_quantity_filters_no_matches(self): + """Тест фильтрации корзин когда нет совпадений""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 5}, + {'id': 2, 'price': Decimal('50.0'), 'created_at': None, 'total_quantity': 10} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 5, 'price': Decimal('20.0')}], + [{'product_id': 2, 'quantity': 10, 'price': Decimal('5.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?min_quantity=20") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 0 # Нет корзин с quantity >= 20 + + + @pytest.mark.asyncio + async def test_create_item_price_zero(self): + """Тест создания товара с ценой 0""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetchrow.return_value = { + 'id': 1, + 'name': 'Free Item', + 'price': Decimal('0.0'), + 'deleted': False + } + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/item", json={"name": "Free Item", "price": 0.0}) + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data['price'] == 0.0 + + @pytest.mark.asyncio + async def test_create_item_price_float_conversion(self): + """Тест создания товара с преобразованием float цены""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetchrow.return_value = { + 'id': 1, + 'name': 'Test Item', + 'price': Decimal('99.99'), + 'deleted': False + } + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/item", json={"name": "Test Item", "price": 99.99}) + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data['price'] == 99.99 + + @pytest.mark.asyncio + async def test_update_item_price_zero(self): + """Тест обновления товара с ценой 0""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'deleted': False}, # existing item check + {'id': 1, 'name': 'Free Item', 'price': Decimal('0.0'), 'deleted': False} # updated item + ] + mock_conn.execute = AsyncMock() + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Free Item", "price": 0.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['price'] == 0.0 + + @pytest.mark.asyncio + async def test_update_item_same_price(self): + """Тест обновления товара с той же ценой""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'deleted': False}, + {'id': 1, 'name': 'Same Price', 'price': Decimal('100.0'), 'deleted': False} + ] + mock_conn.execute = AsyncMock() + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Same Price", "price": 100.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['price'] == 100.0 + + @pytest.mark.asyncio + async def test_patch_item_only_name(self): + """Тест частичного обновления только имени товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False}, + {'id': 1, 'name': 'Updated Name', 'price': Decimal('100.0'), 'deleted': False} + ] + mock_conn.execute = AsyncMock() + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "Updated Name"}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['name'] == 'Updated Name' + assert data['price'] == 100.0 # Цена не изменилась + + @pytest.mark.asyncio + async def test_patch_item_only_price(self): + """Тест частичного обновления только цены товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Test Item', 'price': Decimal('50.0'), 'deleted': False}, + {'id': 1, 'name': 'Test Item', 'price': Decimal('75.0'), 'deleted': False} + ] + mock_conn.execute = AsyncMock() + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"price": 75.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['name'] == 'Test Item' # Имя не изменилось + assert data['price'] == 75.0 + + @pytest.mark.asyncio + async def test_patch_item_price_zero(self): + """Тест частичного обновления цены на 0""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Test Item', 'price': Decimal('100.0'), 'deleted': False}, + {'id': 1, 'name': 'Test Item', 'price': Decimal('0.0'), 'deleted': False} + ] + mock_conn.execute = AsyncMock() + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"price": 0.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['price'] == 0.0 + + def test_decimal_encoder_other_types(self): + """Тест DecimalEncoder с другими типами данных""" + encoder = main.DecimalEncoder() + + # Тест с обычными типами + test_data = { + 'string': 'test', + 'integer': 123, + 'float': 45.67, + 'list': [1, 2, 3], + 'none': None, + 'boolean': True + } + + result = encoder.encode(test_data) + parsed = json.loads(result) + + assert parsed['string'] == 'test' + assert parsed['integer'] == 123 + assert parsed['float'] == 45.67 + assert parsed['list'] == [1, 2, 3] + assert parsed['none'] is None + assert parsed['boolean'] is True + + def test_decimal_encoder_nested_decimal(self): + """Тест DecimalEncoder с вложенными Decimal""" + encoder = main.DecimalEncoder() + + test_data = { + 'prices': [Decimal('10.50'), Decimal('20.75')], + 'nested': { + 'subprice': Decimal('30.25') + } + } + + result = encoder.encode(test_data) + parsed = json.loads(result) + + assert parsed['prices'] == [10.50, 20.75] + assert parsed['nested']['subprice'] == 30.25 + + @pytest.mark.asyncio + async def test_add_to_cart_success_existing_item(self): + """Тест успешного добавления существующего товара в корзину""" + with patch(f'{target}.get_db_connection') as mock_get_db, \ + patch(f'{target}.get_redis_connection'), \ + patch(f'{target}.get_cart_with_cache') as mock_get_cart, \ + patch(f'{target}.invalidate_cart_cache', new_callable=AsyncMock) as mock_invalidate: + mock_conn = AsyncMock() + mock_get_db.return_value = mock_conn + + # Базовые моки + mock_conn.fetchval.return_value = True + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Test', 'price': Decimal('50.0')}, + {'quantity': 1, 'price': Decimal('50.0')} + ] + mock_conn.execute = AsyncMock() + + # Transaction как простой async context manager + mock_conn.transaction = MagicMock() + mock_conn.transaction.return_value.__aenter__ = AsyncMock(return_value=None) + mock_conn.transaction.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_get_cart.return_value = { + 'id': 1, + 'items': [{'id': 1, 'quantity': 2, 'price': 50.0}], + 'price': 100.0 + } + + response = client.post("/cart/1/add/1") + + assert response.status_code == HTTPStatus.OK + mock_invalidate.assert_called_once_with(1) + + @pytest.mark.asyncio + async def test_patch_item_empty_body_fields(self): + """Тест частичного обновления с пустыми полями в теле""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "", "price": 0.0}) + + assert response.status_code == HTTPStatus.OK + # Должен обработать пустые значения без ошибки + + @pytest.mark.asyncio + async def test_get_carts_empty_result(self): + """Тест получения пустого списка корзин""" + mock_conn = AsyncMock() + mock_conn.fetch.return_value = [] # Нет корзин + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data == [] + + @pytest.mark.asyncio + async def test_get_items_empty_result(self): + """Тест получения пустого списка товаров""" + mock_conn = AsyncMock() + mock_conn.fetch.return_value = [] # Нет товаров + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data == [] + + @pytest.mark.asyncio + async def test_get_items_show_deleted_false(self): + """Тест получения товаров с show_deleted=false""" + mock_conn = AsyncMock() + mock_items = [ + { + 'id': 1, + 'name': 'Active Item', + 'price': Decimal('50.0'), + 'deleted': False, + 'created_at': None + } + ] + mock_conn.fetch.return_value = mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item?show_deleted=false") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 + assert data[0]['deleted'] is False + + @pytest.mark.asyncio + async def test_patch_item_not_found(self): + """Тест частичного обновления несуществующего товара""" + with patch(f'{target}.get_db_connection') as mock_get_db: + mock_conn = AsyncMock() + mock_get_db.return_value = mock_conn + + # Товар не существует + mock_conn.fetchrow.return_value = None + + response = client.patch("/item/999", json={"name": "Updated Name"}) + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "Item not found" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_patch_item_empty_body(self): + """Тест частичного обновления с пустым телом""" + with patch(f'{target}.get_db_connection') as mock_get_db: + mock_conn = AsyncMock() + mock_get_db.return_value = mock_conn + + # Существующий товар + existing_item = { + 'id': 1, + 'name': 'Original Name', + 'price': Decimal('100.0'), + 'deleted': False + } + mock_conn.fetchrow.return_value = existing_item + + # Не вызываем execute, так как обновления не должно быть + + response = client.patch("/item/1", json={}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + + # Проверяем что вернулся товар без изменений + assert data['id'] == 1 + assert data['name'] == 'Original Name' + assert data['price'] == 100.0 + assert data['quantity'] == 1 + assert data['deleted'] is False + + # Проверяем что execute не вызывался (нет обновления в БД) + mock_conn.execute.assert_not_called() + + @pytest.mark.asyncio + async def test_add_to_cart_success_new_item(self): + """Тест успешного добавления нового товара в корзину""" + try: + mock_conn = AsyncMock() + + # Включаем отладку для всех методов + def debug_call(*args, **kwargs): + print(f"Called: {args}, {kwargs}") + return AsyncMock() + + mock_conn.fetchval = AsyncMock(return_value=True) + mock_conn.fetchrow = AsyncMock(side_effect=[ + {'id': 1, 'name': 'New Product', 'price': Decimal('75.50')}, + None + ]) + mock_conn.execute = AsyncMock() + + # Детальная настройка transaction + mock_transaction = MagicMock() + mock_transaction.__aenter__ = AsyncMock(return_value=None) + mock_transaction.__aexit__ = AsyncMock(return_value=None) + mock_conn.transaction = MagicMock(return_value=mock_transaction) + + with patch(f'{target}.get_db_connection', return_value=mock_conn), \ + patch(f'{target}.get_redis_connection'), \ + patch(f'{target}.get_cart_with_cache', return_value={'id': 1, 'items': [], 'price': 75.50}), \ + patch(f'{target}.invalidate_cart_cache', new_callable=AsyncMock) as mock_invalidate: + + print("Before making request...") + response = client.post("/cart/1/add/1") + print(f"Response: {response.status_code}, {response.text}") + + assert response.status_code == HTTPStatus.OK + + except Exception as e: + print(f"ERROR: {e}") + print(f"Error type: {type(e)}") + import traceback + traceback.print_exc() + raise +class TestDecimalEncoder: + """Тесты для кастомного JSON энкодера""" + + def test_decimal_encoder(self): + """Тест кодирования Decimal в JSON""" + + encoder = main.DecimalEncoder() + + # Тест с Decimal + decimal_value = Decimal('123.45') + result = encoder.encode({'price': decimal_value}) + assert '123.45' in result + + # Тест с обычными типами + regular_data = {'name': 'test', 'number': 100} + result = encoder.encode(regular_data) + assert 'test' in result + assert '100' in result diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..f9a58cd5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[tool:pytest] +addopts = -v --tb=short +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..a99227f0 Binary files /dev/null and b/requirements.txt differ