diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index be7fc297..6a185d26 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -15,25 +15,48 @@ jobs: strategy: matrix: python-version: ["3.12", "3.13"] - + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: shop_db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + 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 database migrations + working-directory: hw2/hw/shop_api + env: + DATABASE_URL: postgresql+asyncpg://admin:admin@localhost:5432/shop_db + run: | + alembic upgrade head + - name: Run tests working-directory: hw2/hw env: PYTHONPATH: ${{ github.workspace }}/hw2/hw + DATABASE_URL: postgresql+asyncpg://admin:admin@localhost:5432/shop_db run: | pytest test_homework2.py -v diff --git a/.github/workflows/hw5-tests.yml b/.github/workflows/hw5-tests.yml new file mode 100644 index 00000000..eee648f3 --- /dev/null +++ b/.github/workflows/hw5-tests.yml @@ -0,0 +1,60 @@ +name: HW5 Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.13' + + - name: Install dependencies + run: | + cd hw2/hw + pip install -r requirements.txt + + - name: Run database migrations + env: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_db + run: | + cd hw2/hw/shop_api + alembic upgrade head + + - name: Run tests with coverage + env: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_db + run: | + cd hw2/hw + pytest --cov=shop_api --cov-report=term-missing --cov-report=xml --cov-fail-under=95 + + - name: Upload coverage to Codecov (optional) + uses: codecov/codecov-action@v3 + with: + file: ./hw2/hw/coverage.xml + flags: hw5 + name: hw5-coverage \ No newline at end of file diff --git a/.gitignore b/.gitignore index 852216e6..a5f920a3 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,7 @@ dmypy.json # macOS .DS_Store + +# Custom +explain.md +test_analysis.md \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..55754770 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -12,8 +12,229 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + + def get_params(query_string: str) -> dict[str, str]: + """Парсит query_string в словарь параметров вида {"key": "value"}""" + + query_params = {} + for pair in query_string.split("&"): + if "=" in pair: + key, value = pair.split("=", 1) + query_params[key] = value + return query_params + + async def send_response(status: int, message: str) -> None: + """Отправляет ответ клиенту""" + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + [b"content-type", b"application/json"], + ], + } + ) + await send( + { + "type": "http.response.body", + "body": message.encode(), + } + ) + + if scope["type"] == "http": + + path = scope["path"] + query_string = scope.get("query_string", b"").decode() + + # Реализация API для расчета чисел Фибоначчи + if path.startswith("/fibonacci/"): + + path_parsed = path.split("/") + n_value = path_parsed[2] + + # Проверка наличия параметра для расчета + if not n_value: + message = ( + '{"error": "Number parameter \\"/fibonacci/{n}\\" is required"}' + ) + await send_response(422, message) + return + + # Попытка преобразовать параметр в число + try: + n = int(n_value) + except ValueError: + message = f'{{"error": "Parameter \\"n\\" must be an integer, got \\"{n_value}\\""}}' + await send_response(422, message) + return + + # Проверка на отрицательное число + if n < 0: + message = ( + f'{{"error": "Parameter \\"n\\" cannot be negative, got {n}"}}' + ) + await send_response(400, message) + return + + # Расчет числа Фибоначчи + else: + + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + + factorial_number = a + result = f'{{"result": {factorial_number}}}' + + await send_response(200, result) + return + + # Реализация API для расчета факториала + elif path.startswith("/factorial"): + + query_params = get_params(query_string) + + # Проверка наличия параметра n + if "n" not in query_params: + message = '{"error": "Parameter \\"n\\" is required"}' + await send_response(422, message) + return + + n_value = query_params["n"] + + # Проверка, что параметр не пустой + if not n_value: + message = '{"error": "Parameter \\"n\\" cannot be empty"}' + await send_response(422, message) + return + + # Попытка преобразовать параметр в число + try: + n = int(n_value) + except ValueError: + message = f'{{"error": "Parameter \\"n\\" must be an integer, got \\"{n_value}\\""}}' + await send_response(422, message) + return + + # Проверка на отрицательное число + if n < 0: + message = ( + f'{{"error": "Parameter \\"n\\" cannot be negative, got {n}"}}' + ) + await send_response(400, message) + return + + # Расчет факториала + else: + + factorial_number = 1 + for i in range(1, n + 1): + factorial_number *= i + + result = f'{{"result": {factorial_number}}}' + await send_response(200, result) + return + + # Реализация API для расчета среднего + elif path.startswith("/mean"): + + query_params = get_params(query_string) + + # Проверка наличия параметра numbers + try: + numbers_list = [] + + # Получение данных из тела запроса + body = b"" + more_body = True + + while more_body: + message = await receive() + body += message.get("body", b"") + more_body = message.get("more_body", False) + + if body: + + body_str = body.decode().strip() + + # Проверка, что данные не пустые + if body_str == "[]": + message = '{"error": "Parameter \\"numbers\\" cannot be empty"}' + await send_response(400, message) + return + + # Обработка JSON из тела запроса + if body_str.startswith("[") and body_str.endswith("]"): + numbers_str = body_str[1:-1].strip() + if numbers_str: + for num_str in numbers_str.split(","): + num_clean = num_str.strip() + if num_clean: + numbers_list.append(float(num_clean)) + + # Проверка query_params, если данных нет в теле запроса + else: + + # Проверка наличия параметра numbers + if "numbers" not in query_params: + message = '{"error": "Parameter \\"numbers\\" is required"}' + await send_response(422, message) + return + + numbers_value = query_params["numbers"] + + # Проверка, что параметр не пустой + if not numbers_value: + message = '{"error": "Parameter \\"numbers\\" cannot be empty"}' + await send_response(400, message) + return + + # Попытка преобразования параметра в список чисел + try: + numbers_list = numbers_value.replace("%20", "").split(",") + numbers_list = [int(float(number)) for number in numbers_list] + except ValueError: + message = f'{{"error": "Parameter \\"numbers\\" must be a list of integers, got \\"{numbers_value}\\""}}' + await send_response(422, message) + return + + mean_number = sum(numbers_list) / len(numbers_list) + result = f'{{"result": {mean_number}}}' + await send_response(200, result) + return + + except Exception: + message = '{"error": "Invalid request"}' + await send_response(422, message) + return + + # Обработка запроса favicon.ico + elif path.startswith("/favicon.ico"): + message = '{"error": "No Content favicon"}' + await send_response(204, message) + + # Возвращение ошибки 404, если путь не соответствует ни одному из ожидаемых + else: + message = '{"error": "Not found"}' + await send_response(404, message) + return + + # Обработка запросов жизненного цикла (startup/shutdown) + elif 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 + else: + message = '{"error": "Unsupported request type"}' + await send_response(422, message) + return + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) diff --git a/hw2/hw/.coveragerc b/hw2/hw/.coveragerc new file mode 100644 index 00000000..b75fdca4 --- /dev/null +++ b/hw2/hw/.coveragerc @@ -0,0 +1,21 @@ +[run] +source = shop_api +omit = + */migrations/* + */tests/* + */conftest.py + */__pycache__/* + venv/* + .venv/* + */transaction_scripts/* + */not_pytests/* + */old_*.py + */alembic/* + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..f258cff2 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.13.7 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +ENV APP_ROOT=/app + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY . ./ + +FROM base AS local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw2/hw/assets/availability.png b/hw2/hw/assets/availability.png new file mode 100644 index 00000000..fc1be6e5 Binary files /dev/null and b/hw2/hw/assets/availability.png differ diff --git a/hw2/hw/assets/cpu_usage.png b/hw2/hw/assets/cpu_usage.png new file mode 100644 index 00000000..dfe6e2bc Binary files /dev/null and b/hw2/hw/assets/cpu_usage.png differ diff --git a/hw2/hw/assets/error_rate_4xx.png b/hw2/hw/assets/error_rate_4xx.png new file mode 100644 index 00000000..e32731b2 Binary files /dev/null and b/hw2/hw/assets/error_rate_4xx.png differ diff --git a/hw2/hw/assets/https_status_codes.png b/hw2/hw/assets/https_status_codes.png new file mode 100644 index 00000000..5469ddba Binary files /dev/null and b/hw2/hw/assets/https_status_codes.png differ diff --git a/hw2/hw/assets/latency.png b/hw2/hw/assets/latency.png new file mode 100644 index 00000000..dea40d77 Binary files /dev/null and b/hw2/hw/assets/latency.png differ diff --git a/hw2/hw/assets/process_uptime.png b/hw2/hw/assets/process_uptime.png new file mode 100644 index 00000000..2ef2cff3 Binary files /dev/null and b/hw2/hw/assets/process_uptime.png differ diff --git a/hw2/hw/assets/ram_usage.png b/hw2/hw/assets/ram_usage.png new file mode 100644 index 00000000..52a1fd88 Binary files /dev/null and b/hw2/hw/assets/ram_usage.png differ diff --git a/hw2/hw/assets/rps.png b/hw2/hw/assets/rps.png new file mode 100644 index 00000000..7097865c Binary files /dev/null and b/hw2/hw/assets/rps.png differ diff --git a/hw2/hw/assets/throughput.png b/hw2/hw/assets/throughput.png new file mode 100644 index 00000000..3ce56d46 Binary files /dev/null and b/hw2/hw/assets/throughput.png differ diff --git a/hw2/hw/chat/README.md b/hw2/hw/chat/README.md new file mode 100644 index 00000000..e6ab9cf0 --- /dev/null +++ b/hw2/hw/chat/README.md @@ -0,0 +1,91 @@ +# 💬 WebSocket Chat Application + +Многопользовательское чат-приложение на WebSocket с поддержкой нескольких чат-комнат. + +## ✨ Особенности + +- 🚀 Мгновенный обмен сообщениями в реальном времени +- 🏠 Поддержка множественных чат-комнат +- 👤 Автоматическая генерация уникальных имен пользователей +- 🔄 Уведомления о подключении/отключении участников +- 💾 Хранение активных соединений в памяти (stateful архитектура) + +## 🛠 Технологии + +- **Backend**: FastAPI + WebSocket +- **Client**: Python websocket-client + threading + +## 📋 Требования + +```bash +pip install fastapi uvicorn websocket-client +``` + +## 🚀 Быстрый старт + +### 1. Запуск сервера + +```bash +uvicorn server:app --reload +``` + +Сервер запустится на `http://localhost:8000` + +### 2. Запуск клиента + +```bash +# Подключиться к комнате по умолчанию +python client.py + +# Подключиться к конкретной комнате +python client.py room_name +``` + +### 3. Общение + +Просто вводите сообщения и нажимайте Enter. Сообщения будут видны всем участникам комнаты. + +## 📝 Примеры использования + +```bash +# Терминал 1: запуск сервера +uvicorn server:app --reload + +# Терминал 2: первый пользователь в комнате "griffindor" +python client.py griffindor + +# Терминал 3: второй пользователь в той же комнате +python client.py griffindor + +# Терминал 4: пользователь в другой комнате "slytherin" +python client.py slytherin +``` + +## 🏗 Архитектура + +### Server (server.py) + +- **ChatRoom** - класс для управления подписчиками и рассылкой сообщений +- **WebSocket endpoint** `/chat/{chat_name}` - точка подключения клиентов к конкретной комнате +- Автоматическое создание комнат при первом подключении +- Генерация случайных имен пользователей в формате `user_xxxxxx##` + +### Client (client.py) + +- Многопоточная архитектура (отдельный поток для приема сообщений) +- Валидация названий комнат (только буквы, цифры, `-`, `_`) +- Интерактивный ввод сообщений +- Graceful shutdown при `Ctrl+C` + +## ⚠️ Ограничения + +- Соединения хранятся в оперативной памяти процесса +- При перезапуске сервера все соединения теряются + +## 🔧 Формат сообщений + +Все сообщения отображаются в формате: +``` +username :: message text +``` + diff --git a/hw2/hw/chat/__init__.py b/hw2/hw/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/chat/client.py b/hw2/hw/chat/client.py new file mode 100644 index 00000000..e7bde71a --- /dev/null +++ b/hw2/hw/chat/client.py @@ -0,0 +1,39 @@ +from websocket import create_connection +import sys +import threading +import re + +chat_name = sys.argv[1] if len(sys.argv) > 1 else "default" + +if not re.fullmatch(r"[a-zA-Z0-9_-]+", chat_name): + print("Error: Сhat name can only contain letters, digits, '-', and '_'") + sys.exit(1) + +ws = create_connection(f"ws://localhost:8000/chat/{chat_name}") + +print(f"Connected to chat: {chat_name}") +print("Type message and press Enter to send. Ctrl+C to exit.\n") + + +def receive_messages(): + """Gets messages from the server and prints them to the console""" + try: + while True: + message = ws.recv() + print(f"\r{message}") + print("> ", end="", flush=True) + except KeyboardInterrupt: + ws.close() + + +receiver_thread = threading.Thread(target=receive_messages, daemon=True) +receiver_thread.start() + +try: + while True: + user_input = input("> ") + if user_input: + ws.send(user_input) +except KeyboardInterrupt: + ws.close() + print("\nDisconnected") diff --git a/hw2/hw/chat/server.py b/hw2/hw/chat/server.py new file mode 100644 index 00000000..0b396535 --- /dev/null +++ b/hw2/hw/chat/server.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass, field +import random +import string + + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect + +app = FastAPI() + + +@dataclass(slots=True) +class ChatRoom: + """Class representing a chat room.""" + + subscribers: dict[WebSocket, str] = field(init=False, default_factory=dict) + + async def subscribe(self, ws: WebSocket) -> str: + """Subscribes a new client to the chat room.""" + + await ws.accept() + random_letters = "".join(random.choices(string.ascii_lowercase, k=6)) + random_numbers = random.randint(10, 99) + username = f"user_{random_letters+str(random_numbers)}" + self.subscribers[ws] = username + return username + + async def unsubscribe(self, ws: WebSocket) -> None: + """Unsubscribes a client from the chat room.""" + + if ws in self.subscribers: + del self.subscribers[ws] + + async def broadcast( + self, message: str, sender_ws: WebSocket, username: str | None = None + ) -> None: + """Broadcasts a message to all clients in the chat room.""" + + if username is None: + username = self.subscribers.get(sender_ws, "unknown") + formatted_message = f"{username} :: {message}" + + for ws in self.subscribers: + await ws.send_text(formatted_message) + + +# Dictionary to store chat rooms by their names +chat_rooms: dict[str, ChatRoom] = {} + + +def get_or_create_room(chat_name: str) -> ChatRoom: + """Returns an existing chat room or creates a new one.""" + + if chat_name not in chat_rooms: + chat_rooms[chat_name] = ChatRoom() + return chat_rooms[chat_name] + + +@app.websocket("/chat/{chat_name}") +async def ws_chat(ws: WebSocket, chat_name: str): + """Handles WebSocket connections for the chat application.""" + + room = get_or_create_room(chat_name) + username = await room.subscribe(ws) + + try: + while True: + text = await ws.receive_text() + await room.broadcast(text, ws) + except WebSocketDisconnect: + await room.unsubscribe(ws) + + # Send system message about user leaving + for subscriber_ws in room.subscribers: + await subscriber_ws.send_text(f"{username} left the chat") diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..4d8c600c --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,46 @@ +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.pool import NullPool +from typing import AsyncGenerator + +from shop_api.main import app +from shop_api.database import get_db + +# Get DATABASE_URL from environment or use default +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://admin:admin@localhost:5432/shop_db" +) + +# Create a test engine with NullPool to avoid connection reuse across event loops +test_engine = create_async_engine( + DATABASE_URL, + echo=True, + future=True, + poolclass=NullPool, # Don't pool connections - create new ones each time +) + +# Create test session factory +TestAsyncSessionLocal = async_sessionmaker( + test_engine, + class_=AsyncSession, + expire_on_commit=False +) + + +# Override the default get_db dependency +async def override_get_db() -> AsyncGenerator[AsyncSession, None]: + """Override database dependency to use NullPool for tests.""" + async with TestAsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +# Apply the override globally for all tests +app.dependency_overrides[get_db] = override_get_db diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..e2721de9 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,57 @@ +version: "1" + +services: + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - postgres_data:/var/lib/postgresql/data + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin -d shop_db"] + interval: 10s + timeout: 5s + retries: 5 + + shop: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + environment: + DATABASE_URL: postgresql+asyncpg://admin:admin@postgres:5432/shop_db + depends_on: + postgres: + condition: service_healthy + + 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: + driver: local diff --git a/hw2/hw/generate_errors.py b/hw2/hw/generate_errors.py new file mode 100644 index 00000000..b5b3b363 --- /dev/null +++ b/hw2/hw/generate_errors.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Script to generate HTTP 4xx errors for testing Grafana dashboard metrics. +""" + +import asyncio +import random +import httpx +from datetime import datetime + + +API_BASE_URL = "http://localhost:8080" + + +async def generate_404_errors(client: httpx.AsyncClient, count: int = 20): + """Generate 404 errors by requesting non-existent items.""" + print(f"[{datetime.now().strftime('%H:%M:%S')}] Generating {count} 404 errors...") + + tasks = [] + for i in range(count): + # Request non-existent item IDs + non_existent_id = random.randint(999999, 9999999) + tasks.append(client.get(f"{API_BASE_URL}/item/{non_existent_id}")) + + responses = await asyncio.gather(*tasks, return_exceptions=True) + + status_counts = {} + for resp in responses: + if isinstance(resp, httpx.Response): + status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1 + + print(f" ✓ Generated: {status_counts}") + + +async def generate_404_cart_errors(client: httpx.AsyncClient, count: int = 15): + """Generate 404 errors by requesting non-existent carts.""" + print(f"[{datetime.now().strftime('%H:%M:%S')}] Generating {count} cart 404 errors...") + + tasks = [] + for i in range(count): + non_existent_cart_id = random.randint(999999, 9999999) + tasks.append(client.get(f"{API_BASE_URL}/cart/{non_existent_cart_id}")) + + responses = await asyncio.gather(*tasks, return_exceptions=True) + + status_counts = {} + for resp in responses: + if isinstance(resp, httpx.Response): + status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1 + + print(f" ✓ Generated: {status_counts}") + + +async def generate_validation_errors(client: httpx.AsyncClient, count: int = 10): + """Generate 422 validation errors with invalid query params.""" + print(f"[{datetime.now().strftime('%H:%M:%S')}] Generating {count} validation errors...") + + tasks = [] + for i in range(count): + # Invalid query params (negative values, invalid types) + invalid_params = [ + {"offset": -1, "limit": 10}, + {"offset": 0, "limit": -5}, + {"min_price": -100}, + {"max_price": -50}, + {"offset": "invalid", "limit": "bad"}, + ] + params = random.choice(invalid_params) + tasks.append(client.get(f"{API_BASE_URL}/item/", params=params)) + + responses = await asyncio.gather(*tasks, return_exceptions=True) + + status_counts = {} + for resp in responses: + if isinstance(resp, httpx.Response): + status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1 + + print(f" ✓ Generated: {status_counts}") + + +async def generate_slow_requests(client: httpx.AsyncClient, count: int = 10, delay: float = 3.0): + """Generate slow requests to populate Active Connections metric.""" + print(f"[{datetime.now().strftime('%H:%M:%S')}] Generating {count} slow requests (delay={delay}s)...") + + tasks = [] + for _ in range(count): + tasks.append(client.get(f"{API_BASE_URL}/item/slow?delay={delay}")) + + responses = await asyncio.gather(*tasks, return_exceptions=True) + + status_counts = {} + for resp in responses: + if isinstance(resp, httpx.Response): + status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1 + + print(f" ✓ Completed: {status_counts}") + + +async def generate_successful_requests(client: httpx.AsyncClient, count: int = 100): + """Generate successful 2xx requests to make error rate more realistic.""" + print(f"[{datetime.now().strftime('%H:%M:%S')}] Generating {count} successful requests...") + + # First create some items + tasks = [] + for _ in range(20): + item_data = { + "name": f"Test Item {random.randint(1, 1000)}", + "price": round(random.uniform(10.0, 500.0), 2) + } + tasks.append(client.post(f"{API_BASE_URL}/item/", json=item_data)) + + await asyncio.gather(*tasks, return_exceptions=True) + + # Then make valid GET requests + tasks = [] + for _ in range(count): + endpoint = random.choice([ + f"{API_BASE_URL}/item/", + f"{API_BASE_URL}/cart/", + f"{API_BASE_URL}/item/{random.randint(1, 10)}", + ]) + tasks.append(client.get(endpoint)) + + responses = await asyncio.gather(*tasks, return_exceptions=True) + + status_counts = {} + for resp in responses: + if isinstance(resp, httpx.Response): + status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1 + + print(f" ✓ Generated: {status_counts}") + + +async def continuous_load(duration_seconds: int = 300, interval: float = 2.0): + """ + Generate continuous mixed load for specified duration. + + Args: + duration_seconds: How long to run (default 5 minutes) + interval: Seconds between batches (default 2 seconds) + """ + print(f"\n{'='*60}") + print(f"Starting continuous load generation for {duration_seconds}s") + print(f"Interval between batches: {interval}s") + print(f"{'='*60}\n") + + async with httpx.AsyncClient(timeout=10.0) as client: + start_time = asyncio.get_event_loop().time() + iteration = 0 + + while (asyncio.get_event_loop().time() - start_time) < duration_seconds: + iteration += 1 + print(f"\n--- Iteration {iteration} ---") + + # Mix of successful and error requests (realistic ratio) + await generate_successful_requests(client, count=50) + await generate_404_errors(client, count=10) + await generate_404_cart_errors(client, count=5) + await generate_validation_errors(client, count=3) + + # Generate slow requests to show Active Connections + await generate_slow_requests(client, count=15, delay=5.0) + + elapsed = asyncio.get_event_loop().time() - start_time + remaining = duration_seconds - elapsed + print(f" Time remaining: {remaining:.0f}s") + + if remaining > 0: + await asyncio.sleep(interval) + + print(f"\n{'='*60}") + print(f"Load generation completed!") + print(f"{'='*60}\n") + + +async def single_burst(): + """Generate a single burst of errors (for quick testing).""" + print(f"\n{'='*60}") + print(f"Generating single burst of errors...") + print(f"{'='*60}\n") + + async with httpx.AsyncClient(timeout=30.0) as client: + await generate_successful_requests(client, count=100) + await generate_404_errors(client, count=30) + await generate_404_cart_errors(client, count=20) + await generate_validation_errors(client, count=15) + await generate_slow_requests(client, count=20, delay=5.0) + + print(f"\n{'='*60}") + print(f"Burst completed! Check Grafana dashboard.") + print(f"{'='*60}\n") + + +if __name__ == "__main__": + import sys + + print("\nShop API Error Generator") + print("=" * 60) + + if len(sys.argv) > 1 and sys.argv[1] == "continuous": + # Continuous mode: python generate_errors.py continuous [duration] + duration = int(sys.argv[2]) if len(sys.argv) > 2 else 300 + asyncio.run(continuous_load(duration_seconds=duration)) + else: + # Single burst mode (default) + print("Mode: Single burst") + print("For continuous load: python generate_errors.py continuous [duration_seconds]") + print("=" * 60) + asyncio.run(single_burst()) diff --git a/hw2/hw/grafana-dashboard.json b/hw2/hw/grafana-dashboard.json new file mode 100644 index 00000000..d96b6ae5 --- /dev/null +++ b/hw2/hw/grafana-dashboard.json @@ -0,0 +1,1074 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenterZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(rate(http_requests_total{job=\"shop-api\"}[1m])) by (handler)", + "legendFormat": "{{handler}}", + "range": true, + "refId": "A" + } + ], + "title": "RPS (Requests Per Second) by Endpoint", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenterZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "mean", + "max", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{job=\"shop-api\"}[5m])) by (le, handler))", + "legendFormat": "p50 - {{handler}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"shop-api\"}[5m])) by (le, handler))", + "hide": false, + "legendFormat": "p95 - {{handler}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{job=\"shop-api\"}[5m])) by (le, handler))", + "hide": false, + "legendFormat": "p99 - {{handler}}", + "range": true, + "refId": "C" + } + ], + "title": "Latency (Request Duration) - Percentiles", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenterZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "4xx" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "5xx" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(rate(http_requests_total{job=\"shop-api\", status=~\"4..\"}[1m])) / sum(rate(http_requests_total{job=\"shop-api\"}[1m]))", + "legendFormat": "4xx", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(rate(http_requests_total{job=\"shop-api\", status=~\"5..\"}[1m])) / sum(rate(http_requests_total{job=\"shop-api\"}[1m]))", + "hide": false, + "legendFormat": "5xx", + "range": true, + "refId": "B" + } + ], + "title": "Error Rate (HTTP 4xx/5xx)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.01 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(rate(http_requests_total{job=\"shop-api\", status=~\"5..\"}[5m])) / sum(rate(http_requests_total{job=\"shop-api\"}[5m]))", + "range": true, + "refId": "A" + } + ], + "title": "5xx Error Rate (Current)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "index": 1, + "text": "DOWN" + }, + "1": { + "color": "green", + "index": 0, + "text": "UP" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 8 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "up{job=\"shop-api\"}", + "range": true, + "refId": "A" + } + ], + "title": "Availability / Uptime", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenterZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rate(process_cpu_seconds_total{job=\"shop-api\"}[1m]) * 100", + "legendFormat": "CPU Usage", + "range": true, + "refId": "A" + } + ], + "title": "CPU Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenterZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "mean", + "max", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "process_resident_memory_bytes{job=\"shop-api\"}", + "legendFormat": "Resident Memory (RSS)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "process_virtual_memory_bytes{job=\"shop-api\"}", + "hide": false, + "legendFormat": "Virtual Memory", + "range": true, + "refId": "B" + } + ], + "title": "RAM / Memory Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenterZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(rate(http_request_size_bytes_sum{job=\"shop-api\"}[1m]))", + "legendFormat": "Request Throughput (bytes/sec)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(rate(http_response_size_bytes_sum{job=\"shop-api\"}[1m]))", + "hide": false, + "legendFormat": "Response Throughput (bytes/sec)", + "range": true, + "refId": "B" + } + ], + "title": "Throughput (Request/Response Size)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenterZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 10, + "options": { + "legend": { + "calcs": [ + "sum" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(increase(http_requests_total{job=\"shop-api\", status=~\"2..\"}[1m])) by (status)", + "legendFormat": "{{status}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(increase(http_requests_total{job=\"shop-api\", status=~\"4..\"}[1m])) by (status)", + "hide": false, + "legendFormat": "{{status}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "sum(increase(http_requests_total{job=\"shop-api\", status=~\"5..\"}[1m])) by (status)", + "hide": false, + "legendFormat": "{{status}}", + "range": true, + "refId": "C" + } + ], + "title": "HTTP Status Codes Distribution", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "time() - process_start_time_seconds{job=\"shop-api\"}", + "legendFormat": "Uptime", + "range": true, + "refId": "A" + } + ], + "title": "Process Uptime", + "type": "stat" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": [ + "shop-api", + "fastapi", + "prometheus" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "Prometheus", + "value": "prometheus" + }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Shop API - Performance Dashboard", + "uid": "shop-api-dashboard", + "version": 1, + "weekStart": "" +} diff --git a/hw2/hw/pytest.ini b/hw2/hw/pytest.ini new file mode 100644 index 00000000..bae06d17 --- /dev/null +++ b/hw2/hw/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session +testpaths = tests +norecursedirs = .git .pytest_cache __pycache__ shop_api/test_scripts shop_api/transaction_scripts shop_api/not_pytests shop_api/data/old_* alembic settings +pythonpath = . + +# Custom markers for test organization +markers = + unit: Unit tests - fast, isolated, use mocks (run with: pytest -m unit) + integration: Integration tests - test multiple components with database (run with: pytest -m integration) + e2e: End-to-end tests - test complete HTTP API flow (run with: pytest -m e2e) + +# Automatically apply markers based on test location +# Tests in tests/unit/ get @pytest.mark.unit automatically +# Tests in tests/integration/ get @pytest.mark.integration automatically +# Tests in tests/e2e/ get @pytest.mark.e2e automatically \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..5c786e7c 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,16 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-fastapi-instrumentator +sqlalchemy>=2.0.0 +asyncpg>=0.29.0 +alembic>=1.13.0 +psycopg2-binary>=2.9.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 httpx>=0.27.2 Faker>=37.8.0 +aiosqlite diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..47878c19 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api + metrics_path: /metrics + static_configs: + - targets: + - shop:8080 diff --git a/hw2/hw/shop_api/README.md b/hw2/hw/shop_api/README.md new file mode 100644 index 00000000..a31be986 --- /dev/null +++ b/hw2/hw/shop_api/README.md @@ -0,0 +1,781 @@ +# Shop API + +REST API для управления интернет-магазином с поддержкой товаров и корзин покупателей. + +## Возможности + +- 🛍️ Управление товарами (CRUD операции) +- 🛒 Управление корзинами покупателей +- 📊 Фильтрация и пагинация +- 🗑️ Мягкое удаление товаров +- 📍 REST-совместимые эндпоинты с правильными HTTP статусами + +## Технологии + +- **FastAPI** - современный веб-фреймворк для создания API +- **Python 3.10+** - с поддержкой type hints +- **Uvicorn** - ASGI сервер +- **Pydantic** - валидация данных + +## Установка + +```bash +# Установка зависимостей +pip install -r requirements.txt +``` + +## Настройка базы данных PostgreSQL + +Для работы с базой данных используется PostgreSQL через Docker. + +### Запуск PostgreSQL + +```bash +cd ./python-backend-hw/hw2/hw +docker-compose up -d postgres +``` + +### Проверка подключения + +```bash +psql -h localhost -p 5432 -U admin -d shop_db +``` + +### Параметры подключения по умолчанию + +- **Host:** localhost +- **Port:** 5432 +- **Database:** shop_db +- **User:** admin +- **Password:** admin + +## Запуск + +```bash +# Запуск сервера +uvicorn shop_api.main:app --reload + +# Сервер будет доступен по адресу http://localhost:8000 +``` + +## Документация API + +После запуска сервера документация доступна по адресам: +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +## API Endpoints + +### 📦 Items (Товары) + +#### Создать товар +```http +POST /item/ +Content-Type: application/json + +{ + "name": "iPhone 15", + "price": 79990.0 +} + +Response: 201 Created +Location: /item/{id} +{ + "id": 1, + "name": "iPhone 15", + "price": 79990.0, + "deleted": false +} +``` + +#### Получить товар по ID +```http +GET /item/{id} + +Response: 200 OK +{ + "id": 1, + "name": "iPhone 15", + "price": 79990.0, + "deleted": false +} +``` + +#### Получить список товаров +```http +GET /item/?offset=0&limit=10&min_price=1000&max_price=100000&show_deleted=false + +Response: 200 OK +[ + { + "id": 1, + "name": "iPhone 15", + "price": 79990.0, + "deleted": false + } +] +``` + +**Query параметры:** +- `offset` (int, >=0, default: 0) - номер страницы +- `limit` (int, >=1, default: 10) - размер страницы +- `min_price` (float, >=0, optional) - минимальная цена +- `max_price` (float, >=0, optional) - максимальная цена +- `show_deleted` (bool, default: false) - показывать удаленные товары + +#### Обновить товар (полностью) +```http +PUT /item/{id}?upsert=false +Content-Type: application/json + +{ + "name": "iPhone 15 Pro", + "price": 99990.0 +} + +Response: 200 OK +{ + "id": 1, + "name": "iPhone 15 Pro", + "price": 99990.0, + "deleted": false +} +``` + +#### Обновить товар (частично) +```http +PATCH /item/{id} +Content-Type: application/json + +{ + "price": 89990.0 +} + +Response: 200 OK +{ + "id": 1, + "name": "iPhone 15 Pro", + "price": 89990.0, + "deleted": false +} +``` + +#### Удалить товар +```http +DELETE /item/{id} + +Response: 200 OK +{ + "id": 1, + "name": "iPhone 15 Pro", + "price": 89990.0, + "deleted": true +} +``` + +> ⚠️ Товары удаляются мягко - помечаются флагом `deleted=true` + +### 🛒 Cart (Корзины) + +#### Создать корзину +```http +POST /cart/ + +Response: 201 Created +Location: /cart/{id} +{ + "id": 1, + "items": [], + "price": 0.0 +} +``` + +#### Получить корзину по ID +```http +GET /cart/{id} + +Response: 200 OK +{ + "id": 1, + "items": [ + { + "id": 1, + "name": "iPhone 15", + "quantity": 2, + "available": true + } + ], + "price": 159980.0 +} +``` + +#### Получить список корзин +```http +GET /cart/?offset=0&limit=10&min_price=1000&max_price=500000&min_quantity=1&max_quantity=10 + +Response: 200 OK +[ + { + "id": 1, + "items": [...], + "price": 159980.0 + } +] +``` + +**Query параметры:** +- `offset` (int, >=0, default: 0) - номер страницы +- `limit` (int, >=1, default: 10) - размер страницы +- `min_price` (float, >=0, optional) - минимальная цена корзины +- `max_price` (float, >=0, optional) - максимальная цена корзины +- `min_quantity` (int, >=0, optional) - минимальное количество товаров +- `max_quantity` (int, >=0, optional) - максимальное количество товаров + +#### Добавить товар в корзину +```http +POST /cart/{cart_id}/add/{item_id} + +Response: 201 Created +Location: /cart/{cart_id} +{ + "id": 1, + "items": [ + { + "id": 1, + "name": "iPhone 15", + "quantity": 1, + "available": true + } + ], + "price": 79990.0 +} +``` + +## Модели данных + +### ItemResponse +```json +{ + "id": 1, + "name": "string", + "price": 0.0, + "deleted": false +} +``` + +### ItemRequest +```json +{ + "name": "string", + "price": 0.0 +} +``` + +### PatchItemRequest +```json +{ + "name": "string", // optional + "price": 0.0 // optional +} +``` + +### CartResponse +```json +{ + "id": 1, + "items": [ + { + "id": 1, + "name": "string", + "quantity": 1, + "available": true + } + ], + "price": 0.0 +} +``` + +### CartItemInfo +```json +{ + "id": 1, + "name": "string", + "quantity": 1, + "available": true +} +``` + +## Коды ответов HTTP + +| Код | Описание | +|-----|----------| +| 200 | OK - Успешный запрос | +| 201 | Created - Ресурс успешно создан | +| 304 | Not Modified - Ресурс не был изменен | +| 404 | Not Found - Ресурс не найден | +| 422 | Unprocessable Entity - Ошибка валидации | + +## Примеры использования + +### Python (httpx) +```python +import httpx + +# Создание товара +async with httpx.AsyncClient() as client: + response = await client.post( + "http://localhost:8000/item/", + json={"name": "MacBook Pro", "price": 199990.0} + ) + item = response.json() + print(f"Created item: {item['id']}") + + # Получение товара + response = await client.get(f"http://localhost:8000/item/{item['id']}") + print(response.json()) +``` + +### cURL +```bash +# Создание товара +curl -X POST "http://localhost:8000/item/" \ + -H "Content-Type: application/json" \ + -d '{"name": "MacBook Pro", "price": 199990.0}' + +# Получение списка товаров +curl "http://localhost:8000/item/?offset=0&limit=10" + +# Создание корзины +curl -X POST "http://localhost:8000/cart/" + +# Добавление товара в корзину +curl -X POST "http://localhost:8000/cart/1/add/1" +``` + +## Структура проекта + +``` +hw2/hw/ +├── shop_api/ # API магазина +│ ├── __init__.py +│ ├── main.py # Точка входа приложения (FastAPI) +│ ├── database.py # Конфигурация БД (SQLAlchemy) +│ ├── README.md # Документация API +│ ├── api/ +│ │ ├── __init__.py +│ │ └── shop/ +│ │ ├── __init__.py +│ │ ├── routes.py # HTTP эндпоинты (REST) +│ │ └── contracts.py # Pydantic модели запросов/ответов +│ ├── data/ +│ │ ├── __init__.py +│ │ ├── db_models.py # SQLAlchemy модели (БД) +│ │ ├── models.py # Domain модели (dataclasses) +│ │ ├── item_queries.py # Работа с товарами (PostgreSQL) +│ │ └── cart_queries.py # Работа с корзинами (PostgreSQL) +│ ├── transaction_scripts/ # Демонстрация уровней изоляции +│ │ ├── README.md # Документация транзакций +│ │ ├── config.py # Конфигурация БД +│ │ ├── models.py # Модели для демонстраций +│ │ ├── 0_dirty_read_solved.py +│ │ ├── 1_non_repeatable_read_problem.py +│ │ ├── 2_non_repeatable_read_solved.py +│ │ ├── 3_phantom_read_problem.py +│ │ └── 4_phantom_read_solved.py +│ └── alembic/ # Миграции базы данных +│ ├── alembic.ini +│ ├── env.py +│ └── versions/ +│ +├── tests/ # 🧪 Тесты (203 теста, 98% coverage) +│ ├── conftest.py # Pytest фикстуры +│ ├── unit/ # Unit тесты +│ │ ├── test_contracts.py +│ │ ├── test_db_models.py +│ │ ├── test_routes.py +│ │ └── test_database_config.py +│ ├── integration/ # Integration тесты +│ │ ├── test_item_queries.py +│ │ ├── test_cart_queries.py +│ │ └── test_database_session.py +│ └── e2e/ # End-to-End тесты +│ ├── test_item_api.py +│ ├── test_cart_api.py +│ ├── test_workflows.py +│ ├── test_edge_cases.py +│ └── test_validation.py +│ +├── chat/ # WebSocket чат +│ ├── __init__.py +│ ├── server.py # WebSocket сервер +│ ├── client.py # WebSocket клиент +│ └── README.md # Документация чата +│ +├── settings/ # Конфигурация мониторинга +│ └── prometheus/ +│ └── prometheus.yml # Конфиг Prometheus (scrape targets) +│ +├── assets/ # Скриншоты дашбордов +│ ├── rps.png +│ ├── latency.png +│ ├── cpu_usage.png +│ ├── ram_usage.png +│ ├── error_rate_4xx.png +│ ├── throughput.png +│ └── https_status_codes.png +│ +├── .coveragerc # Конфигурация coverage +├── pytest.ini # Конфигурация pytest +├── Dockerfile # Docker образ для Shop API +├── docker-compose.yml # Оркестрация (shop + postgres + prometheus + grafana) +├── grafana-dashboard.json # Готовый дашборд Grafana +├── generate_errors.py # Скрипт генерации нагрузки и ошибок +└── requirements.txt # Python зависимости +``` + +## Мониторинг и метрики + +### 📊 Prometheus + Grafana + +API автоматически экспортирует метрики в формате Prometheus через эндпоинт `/metrics`. + +#### Запуск мониторинга + +```bash +# Запуск полного стека (API + Prometheus + Grafana) +docker-compose up --build + +# Проверка статуса +docker compose ps +``` + +**Доступные сервисы:** +- **Shop API**: http://localhost:8080 +- **Prometheus**: http://localhost:9090 +- **Grafana**: http://localhost:3000 (по умолчанию admin/admin) +- **Metrics endpoint**: http://localhost:8080/metrics + +#### Просмотр метрик в Grafana + +1. Откройте Grafana: http://localhost:3000 +2. Перейдите в **Dashboards** → **Shop API - Performance Dashboard** + + +---- + +### Собираемые метрики + +--- + +#### RED метрики (основные для мониторинга SLA) + +--- + +**1. RPS (Requests Per Second)** + +Количество запросов, обрабатываемых системой в секунду. + +![alt text](../assets/rps.png) + + + +**2. Error Rate** + +Доля неудачных запросов (например, HTTP 5xx/4xx ошибок) относительно общего числа запросов. + +![alt text](../assets/error_rate_4xx.png) + + + + +**3. Latency (Duration)** + +Время отклика системы: сколько времени проходит между отправкой запроса и получением ответа. + +![alt text](../assets/latency.png) + +--- + +#### USE метрики (системные ресурсы) + +--- + +**4. CPU Usage** + +Загрузка центрального процессора. + +![alt text](../assets/cpu_usage.png) + + +**5. Memory (RAM)** + +Использование оперативной памяти (Random Access Memory). + + +![alt text](../assets/ram_usage.png) + + +--- + +#### Дополнительные метрики + +--- + +**6. Throughput** + +Объём данных или операций, обрабатываемых системой за единицу времени. + +![alt text](../assets/throughput.png) + + +**7. Availability** + +Показатель доступности сервиса. + +![alt text](../assets/availability.png) + +**8. HTTP Status Codes** + +- Распределение 2xx/4xx/5xx статус-кодов +- История изменений во времени + +![alt text](../assets/https_status_codes.png) + + +**9. Process Uptime** + +Показатель того, как долго процесс непрерывно работает без перезапуска. + +![alt text](../assets/process_uptime.png) + +--- + +### Генерация тестовой нагрузки + +Для проверки метрик используйте скрипт генерации запросов: + +```bash +# Одиночный burst (быстрый тест) +python generate_errors.py + +# Непрерывная нагрузка (5 минут) +python generate_errors.py continuous 300 + +# Кастомная длительность (10 минут) +python generate_errors.py continuous 600 +``` + +**Что генерирует скрипт:** +- Успешные запросы (2xx) — создание items, чтение списков +- 404 ошибки — запросы несуществующих items/carts +- 422 ошибки — невалидные query параметры +- Медленные запросы — `/item/slow?delay=5` для Active Connections + +--- + +## Транзакции и уровни изоляции + +### Демонстрационные скрипты + +Проект включает демонстрационные скрипты для изучения уровней изоляции транзакций PostgreSQL. Скрипты показывают проблемы параллельного доступа и способы их решения. + +#### Запуск демонстраций + +**Вариант 1: Из директории `shop_api`** +```bash +cd ./python-backend-hw/hw2/hw/shop_api + +python -m transaction_scripts.0_dirty_read_solved +python -m transaction_scripts.1_non_repeatable_read_problem +python -m transaction_scripts.2_non_repeatable_read_solved +python -m transaction_scripts.3_phantom_read_problem +python -m transaction_scripts.4_phantom_read_solved +``` + +**Вариант 2: Из директории `transaction_scripts`** +```bash +cd ./python-backend-hw/hw2/hw/shop_api/transaction_scripts + +python 0_dirty_read_solved.py +python 1_non_repeatable_read_problem.py +python 2_non_repeatable_read_solved.py +python 3_phantom_read_problem.py +python 4_phantom_read_solved.py +``` + +## 🧪 Тестирование + +### Структура тестов + +Проект содержит **203 теста** с покрытием кода **98%**. + +``` +tests/ +├── conftest.py # Фикстуры (client, db_session) +├── unit/ # Unit тесты (изолированные) +│ ├── test_contracts.py # Pydantic модели +│ ├── test_db_models.py # SQLAlchemy модели +│ ├── test_routes.py # HTTP handlers (edge cases) +│ └── test_database_config.py +├── integration/ # Integration тесты (с БД) +│ ├── test_item_queries.py # CRUD операции товаров +│ ├── test_cart_queries.py # CRUD операции корзин +│ └── test_database_session.py +└── e2e/ # End-to-End тесты (полный flow) + ├── test_item_api.py # REST API товаров + ├── test_cart_api.py # REST API корзин + ├── test_workflows.py # Бизнес-сценарии + ├── test_edge_cases.py # Граничные случаи + └── test_validation.py # Валидация входных данных +``` + +### Запуск тестов + +```bash +# Все тесты +pytest + +# Тесты с покрытием +pytest --cov=shop_api --cov-report=term-missing + +# Только unit тесты +pytest -m unit + +# Только integration тесты +pytest -m integration + +# Только E2E тесты +pytest -m e2e + +# С подробным выводом +pytest -vv + +# Проверка минимального coverage (95%) +pytest --cov=shop_api --cov-fail-under=95 +``` + +### Особенности тестирования + +#### ✅ Валидация входных данных + +API проверяет корректность данных: + +```python +# ❌ Отрицательная цена +POST /item/ {"name": "Item", "price": -10.0} +→ 422 Unprocessable Entity + +# ❌ Пустое имя +POST /item/ {"name": "", "price": 10.0} +→ 422 Unprocessable Entity + +# ❌ Слишком длинное имя (>255 символов) +POST /item/ {"name": "A"*256, "price": 10.0} +→ 422 Unprocessable Entity + +# ✅ Корректные данные +POST /item/ {"name": "Valid Item", "price": 99.99} +→ 201 Created +``` + +#### 🗄️ Тестовая база данных + +Тесты используют отдельную PostgreSQL БД: + +- **Автоматическое создание/очистка** БД для каждого теста +- **Изолированные транзакции** - rollback после каждого теста +- **Миграции Alembic** применяются автоматически + +#### 📊 Coverage отчет + +```bash +# HTML отчет +pytest --cov=shop_api --cov-report=html + +# Открыть в браузере +open htmlcov/index.html +``` + +### Типы тестов + +#### Unit тесты (`tests/unit/`) +- Тестируют отдельные компоненты +- Используют моки для изоляции +- Очень быстрые (без БД) + +#### Integration тесты (`tests/integration/`) +- Тестируют взаимодействие с БД +- Проверяют SQL queries +- Используют реальную PostgreSQL + +#### E2E тесты (`tests/e2e/`) +- Тестируют полный HTTP → Routes → Queries → DB flow +- Проверяют бизнес-сценарии +- Максимально близки к реальному использованию + +### CI/CD + +Тесты автоматически запускаются в GitHub Actions: + +```yaml +# .github/workflows/hw5-tests.yml +- name: Run tests with coverage + run: | + cd hw2/hw + pytest --cov=shop_api --cov-report=xml --cov-fail-under=95 +``` + +### Примеры тестов + +#### Unit тест (Pydantic валидация) +```python +def test_create_item_with_negative_price(): + with pytest.raises(ValidationError): + ItemRequest(name="Item", price=-10.0) +``` + +#### Integration тест (БД операции) +```python +async def test_add_item_to_database(db_session): + info = ItemInfo(name="Book", price=10.0, deleted=False) + item = await item_queries.add(db_session, info) + + assert item.id is not None + assert item.info.name == "Book" +``` + +#### E2E тест (HTTP API) +```python +async def test_create_and_get_item(client): + # Создаем товар + response = await client.post( + "/item/", + json={"name": "iPhone", "price": 99990.0} + ) + assert response.status_code == 201 + item_id = response.json()["id"] + + # Получаем товар + response = await client.get(f"/item/{item_id}") + assert response.status_code == 200 + assert response.json()["name"] == "iPhone" +``` + +### Отладка тестов + +```bash +# Запустить конкретный тест +pytest tests/e2e/test_item_api.py::TestItemCRUD::test_create_item + +# Остановиться на первом падении +pytest -x + +# Показать локальные переменные при ошибке +pytest --showlocals + +# Запустить последний упавший тест +pytest --lf + +# Интерактивная отладка +pytest --pdb +``` \ No newline at end of file diff --git a/hw2/hw/shop_api/alembic.ini b/hw2/hw/shop_api/alembic.ini new file mode 100644 index 00000000..45450809 --- /dev/null +++ b/hw2/hw/shop_api/alembic.ini @@ -0,0 +1,148 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# Мы будем использовать переменную окружения DATABASE_URL вместо этого +# sqlalchemy.url = postgresql+asyncpg://admin:admin@localhost:5432/shop_db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/hw2/hw/shop_api/alembic/README b/hw2/hw/shop_api/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/hw2/hw/shop_api/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/hw2/hw/shop_api/alembic/env.py b/hw2/hw/shop_api/alembic/env.py new file mode 100644 index 00000000..50c83263 --- /dev/null +++ b/hw2/hw/shop_api/alembic/env.py @@ -0,0 +1,99 @@ +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Добавляем путь к проекту для импорта моделей +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# Импортируем Base и модели +# Импортируем напрямую из файлов, чтобы избежать циклических импортов +import importlib.util +spec = importlib.util.spec_from_file_location("database", os.path.join(os.path.dirname(os.path.dirname(__file__)), "database.py")) +database_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(database_module) +Base = database_module.Base + +# Импортируем модели для регистрации в Base.metadata +spec_models = importlib.util.spec_from_file_location("db_models", os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "db_models.py")) +models_module = importlib.util.module_from_spec(spec_models) +spec_models.loader.exec_module(models_module) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Получаем DATABASE_URL из переменной окружения +database_url = os.getenv("DATABASE_URL", "postgresql+asyncpg://admin:admin@localhost:5432/shop_db") +# Alembic работает с синхронным драйвером, меняем asyncpg на psycopg2 +database_url = database_url.replace("postgresql+asyncpg://", "postgresql://") +config.set_main_option("sqlalchemy.url", database_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Добавляем MetaData для autogenerate +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/hw2/hw/shop_api/alembic/script.py.mako b/hw2/hw/shop_api/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/hw2/hw/shop_api/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/hw2/hw/shop_api/alembic/versions/d27f5ffd487e_initial_migration_create_items_carts_.py b/hw2/hw/shop_api/alembic/versions/d27f5ffd487e_initial_migration_create_items_carts_.py new file mode 100644 index 00000000..cd5e2a8d --- /dev/null +++ b/hw2/hw/shop_api/alembic/versions/d27f5ffd487e_initial_migration_create_items_carts_.py @@ -0,0 +1,58 @@ +"""Initial migration: create items, carts and cart_items tables + +Revision ID: d27f5ffd487e +Revises: +Create Date: 2025-10-18 00:17:27.458269 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# Информация о ревизии +revision: str = 'd27f5ffd487e' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Создание таблицы items + op.create_table( + 'items', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('deleted', sa.Boolean(), nullable=False, server_default='false'), + sa.PrimaryKeyConstraint('id') + ) + + # Создание таблицы carts + op.create_table( + 'carts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('price', sa.Float(), nullable=False, server_default='0.0'), + sa.PrimaryKeyConstraint('id') + ) + + # Создание промежуточной таблицы cart_items + op.create_table( + 'cart_items', + sa.Column('cart_id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'), + sa.ForeignKeyConstraint(['cart_id'], ['carts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('cart_id', 'item_id') + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # Удаление таблицы в обратном порядке + op.drop_table('cart_items') + op.drop_table('carts') + op.drop_table('items') diff --git a/hw2/hw/shop_api/api/__init__.py b/hw2/hw/shop_api/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/api/shop/__init__.py b/hw2/hw/shop_api/api/shop/__init__.py new file mode 100644 index 00000000..b378e3b0 --- /dev/null +++ b/hw2/hw/shop_api/api/shop/__init__.py @@ -0,0 +1,21 @@ +from .contracts import ( + CartResponse, + CartRequest, + PatchCartRequest, + ItemResponse, + ItemRequest, + PatchItemRequest, +) + +from .routes import cart_router, item_router + +__all__ = [ + "CartResponse", + "CartRequest", + "PatchCartRequest", + "ItemResponse", + "ItemRequest", + "PatchItemRequest", + "cart_router", + "item_router", +] \ No newline at end of file diff --git a/hw2/hw/shop_api/api/shop/contracts.py b/hw2/hw/shop_api/api/shop/contracts.py new file mode 100644 index 00000000..d317ac2a --- /dev/null +++ b/hw2/hw/shop_api/api/shop/contracts.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from ...data.models import ( + CartItemInfo, + CartInfo, + CartEntity, + PatchCartInfo, + ItemInfo, + ItemEntity, + PatchItemInfo, +) + + +class CartResponse(BaseModel): + id: int + items: list[CartItemInfo] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + return CartResponse( + id=entity.id, items=entity.info.items, price=entity.info.price + ) + + +class CartRequest(BaseModel): + items: list[CartItemInfo] = [] + price: float = 0.0 + + def as_cart_info(self) -> CartInfo: + return CartInfo(items=self.items, price=self.price) + + +class PatchCartRequest(BaseModel): + items: list[CartItemInfo] | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_cart_info(self) -> PatchCartInfo: + return PatchCartInfo(items=self.items) + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str = Field(min_length=1, max_length=255) + price: float = Field(gt=0) + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=False) + + +class PatchItemRequest(BaseModel): + name: str | None = Field(None, min_length=1, max_length=255) + price: float | None = Field(None, gt=0) + + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price, deleted=None) diff --git a/hw2/hw/shop_api/api/shop/routes.py b/hw2/hw/shop_api/api/shop/routes.py new file mode 100644 index 00000000..62acc814 --- /dev/null +++ b/hw2/hw/shop_api/api/shop/routes.py @@ -0,0 +1,316 @@ +from http import HTTPStatus +from typing import Annotated +import asyncio + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from pydantic import Field +from sqlalchemy.ext.asyncio import AsyncSession + +from ... import data +from ...database import get_db + +from .contracts import ( + CartResponse, + ItemResponse, + ItemRequest, + PatchItemRequest, +) + +cart_router = APIRouter(prefix="/cart") +item_router = APIRouter(prefix="/item") + + +@item_router.get("/slow") +async def slow_endpoint(delay: Annotated[float, Query(ge=0, le=30)] = 5.0): + """Slow endpoint for testing Active Connections metric (delays response).""" + await asyncio.sleep(delay) + return {"message": f"Delayed response after {delay}s"} + + +@cart_router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_cart( + response: Response, + session: AsyncSession = Depends(get_db) +) -> CartResponse: + """Creates new cart""" + + entity = await data.cart_queries.add(session, data.CartInfo(items=[], price=0.0)) + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@cart_router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id( + id: int, + session: AsyncSession = Depends(get_db) +) -> CartResponse: + """Returns cart by id""" + + entity = await data.cart_queries.get_one(session, id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@cart_router.get( + "/", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested carts list by query params", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested carts list by query params", + }, + }, +) +async def get_carts_list( + session: AsyncSession = Depends(get_db), + offset: Annotated[int | None, Field(ge=0), Query(description="Page number")] = 0, + limit: Annotated[int | None, Field(ge=1), Query(description="Page size")] = 10, + min_price: Annotated[ + float | None, Field(ge=0), Query(description="Minimum price") + ] = None, + max_price: Annotated[ + float | None, Field(ge=0), Query(description="Maximum price") + ] = None, + min_quantity: Annotated[ + int | None, Field(ge=0), Query(description="Minimum quantity") + ] = None, + max_quantity: Annotated[ + int | None, Field(ge=0), Query(description="Maximum quantity") + ] = None, +) -> list[CartResponse]: + """Returns carts list by query params""" + + entities = await data.cart_queries.get_many( + session, offset, limit, min_price, max_price, min_quantity, max_quantity + ) + + if not entities: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/ was not found", + ) + + return [CartResponse.from_entity(entity) for entity in entities] + + +@cart_router.post( + "/{cart_id}/add/{item_id}", + status_code=HTTPStatus.CREATED, +) +async def post_item_to_cart( + cart_id: int, + item_id: int, + response: Response, + session: AsyncSession = Depends(get_db) +) -> CartResponse: + """Adds item to cart""" + + entity = await data.cart_queries.add_item_to_cart(session, cart_id, item_id, 1) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart {cart_id} or item {item_id} not found", + ) + + response.headers["location"] = f"/cart/{cart_id}" + + return CartResponse.from_entity(entity) + + +@item_router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_item( + item: ItemRequest, + response: Response, + session: AsyncSession = Depends(get_db) +) -> ItemResponse: + entity = await data.item_queries.add(session, item.as_item_info()) + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@item_router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id( + id: int, + session: AsyncSession = Depends(get_db) +) -> ItemResponse: + """Returns item by id""" + + entity = await data.item_queries.get_one(session, id) + + if not entity or entity.info.deleted: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@item_router.get( + "/", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested items list by query params", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested items list by query params", + }, + }, +) +async def get_items_list( + session: AsyncSession = Depends(get_db), + offset: Annotated[int | None, Field(ge=0), Query(description="Page number")] = 0, + limit: Annotated[int | None, Field(ge=1), Query(description="Page size")] = 10, + min_price: Annotated[ + float | None, Field(ge=0), Query(description="Minimum price") + ] = None, + max_price: Annotated[ + float | None, Field(ge=0), Query(description="Maximum price") + ] = None, + show_deleted: Annotated[ + bool | None, Query(description="Show deleted items") + ] = False, +) -> list[ItemResponse]: + """Returns items list by query params""" + + entities = await data.item_queries.get_many( + session, offset, limit, min_price, max_price, show_deleted + ) + + return [ItemResponse.from_entity(entity) for entity in entities] + + +@item_router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, + session: AsyncSession = Depends(get_db), + upsert: Annotated[bool, Query()] = False, +) -> ItemResponse: + """Updates or upserts item by id""" + + existing = await data.item_queries.get_one(session, id) + if not upsert and existing is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + entity = ( + await data.item_queries.upsert(session, id, info.as_item_info()) + if upsert + else await data.item_queries.update(session, id, info.as_item_info()) + ) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@item_router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item( + id: int, + info: PatchItemRequest, + session: AsyncSession = Depends(get_db) +) -> ItemResponse: + """Patches item by id""" + + existing = await data.item_queries.get_one(session, id) + if existing and existing.info.deleted: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + entity = await data.item_queries.patch(session, id, info.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@item_router.delete( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Item successfully deleted (marked as deleted)", + }, + }, +) +async def delete_item( + id: int, + session: AsyncSession = Depends(get_db) +) -> ItemResponse: + """Deletes item by id""" + + entity = await data.item_queries.delete(session, id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item {id} not found", + ) + + return ItemResponse.from_entity(entity) diff --git a/hw2/hw/shop_api/data/__init__.py b/hw2/hw/shop_api/data/__init__.py new file mode 100644 index 00000000..53184500 --- /dev/null +++ b/hw2/hw/shop_api/data/__init__.py @@ -0,0 +1,24 @@ +from .models import ( + CartItemInfo, + CartInfo, + CartEntity, + PatchCartInfo, + ItemInfo, + ItemEntity, + PatchItemInfo +) + +from . import item_queries +from . import cart_queries + +__all__ = [ + "CartItemInfo", + "CartInfo", + "CartEntity", + "PatchCartInfo", + "ItemInfo", + "ItemEntity", + "PatchItemInfo", + "item_queries", + "cart_queries", +] diff --git a/hw2/hw/shop_api/data/cart_queries.py b/hw2/hw/shop_api/data/cart_queries.py new file mode 100644 index 00000000..6d952e00 --- /dev/null +++ b/hw2/hw/shop_api/data/cart_queries.py @@ -0,0 +1,306 @@ +from sqlalchemy import select, delete as sql_delete +from sqlalchemy.ext.asyncio import AsyncSession + +from .models import CartInfo, CartItemInfo, CartEntity, PatchCartInfo +from .db_models import CartDB, ItemDB, cart_items_table + + +async def add(session: AsyncSession, info: CartInfo) -> CartEntity: + """Create new cart with items.""" + cart_db = CartDB(price=info.price) + session.add(cart_db) + await session.flush() + + # Добавление товаров в корзину + for item_info in info.items: + result = await session.execute(select(ItemDB).where(ItemDB.id == item_info.id)) + item_db = result.scalar_one_or_none() + + if item_db: + # Добавление связи через промежуточную таблицу + await session.execute( + cart_items_table.insert().values( + cart_id=cart_db.id, item_id=item_db.id, quantity=item_info.quantity + ) + ) + + await session.flush() + + # Пересчитывание цены корзины + cart_db.price = await _calculate_price(session, cart_db.id) + await session.flush() + + return await get_one(session, cart_db.id) + + +async def delete(session: AsyncSession, id: int) -> None: + """Delete cart by ID.""" + await session.execute(sql_delete(CartDB).where(CartDB.id == id)) + await session.flush() + + +async def get_one(session: AsyncSession, id: int) -> CartEntity | None: + """Get cart by ID.""" + result = await session.execute(select(CartDB).where(CartDB.id == id)) + cart_db = result.scalar_one_or_none() + + if cart_db is None: + return None + + # Получение товаров из корзины + items_query = ( + select(ItemDB.id, ItemDB.name, ItemDB.deleted, cart_items_table.c.quantity) + .join(cart_items_table, ItemDB.id == cart_items_table.c.item_id) + .where(cart_items_table.c.cart_id == cart_db.id) + ) + + result = await session.execute(items_query) + items_data = result.all() + + cart_items = [ + CartItemInfo(id=item_id, name=name, quantity=quantity, available=not deleted) + for item_id, name, deleted, quantity in items_data + ] + + return CartEntity( + id=cart_db.id, info=CartInfo(items=cart_items, price=cart_db.price) + ) + + +async def get_many( + session: AsyncSession, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None, +) -> list[CartEntity]: + """Get many carts by query params.""" + + query = select(CartDB) + + # Фильтры по цене + if min_price is not None: + query = query.where(CartDB.price >= min_price) + + if max_price is not None: + query = query.where(CartDB.price <= max_price) + + # Пагинация + query = query.offset(offset).limit(limit) + + result = await session.execute(query) + carts_db = result.scalars().all() + + carts = [] + for cart_db in carts_db: + cart_entity = await get_one(session, cart_db.id) + if cart_entity is None: + continue + + # Фильтр по общему количеству товаров + total_quantity = sum(item.quantity for item in cart_entity.info.items) + + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + carts.append(cart_entity) + + return carts + + +async def update(session: AsyncSession, id: int, info: CartInfo) -> CartEntity | None: + """Update cart by ID""" + result = await session.execute(select(CartDB).where(CartDB.id == id)) + cart_db = result.scalar_one_or_none() + + if cart_db is None: + return None + + # Удаление старых связей с товарами + await session.execute( + sql_delete(cart_items_table).where(cart_items_table.c.cart_id == id) + ) + + # Добавление новых товаров + for item_info in info.items: + result = await session.execute(select(ItemDB).where(ItemDB.id == item_info.id)) + item_db = result.scalar_one_or_none() + + if item_db: + await session.execute( + cart_items_table.insert().values( + cart_id=cart_db.id, item_id=item_db.id, quantity=item_info.quantity + ) + ) + + # Пересчитываем цену + cart_db.price = await _calculate_price(session, cart_db.id) + await session.flush() + + return await get_one(session, cart_db.id) + + +async def upsert(session: AsyncSession, id: int, info: CartInfo) -> CartEntity: + """Upsert cart by ID""" + result = await session.execute(select(CartDB).where(CartDB.id == id)) + cart_db = result.scalar_one_or_none() + + if cart_db is None: + + cart_db = CartDB(id=id, price=0.0) + session.add(cart_db) + await session.flush() + + await session.execute( + sql_delete(cart_items_table).where(cart_items_table.c.cart_id == id) + ) + + for item_info in info.items: + result = await session.execute(select(ItemDB).where(ItemDB.id == item_info.id)) + item_db = result.scalar_one_or_none() + + if item_db: + await session.execute( + cart_items_table.insert().values( + cart_id=cart_db.id, item_id=item_db.id, quantity=item_info.quantity + ) + ) + + cart_db.price = await _calculate_price(session, cart_db.id) + await session.flush() + + return await get_one(session, cart_db.id) + + +async def patch( + session: AsyncSession, id: int, patch_info: PatchCartInfo +) -> CartEntity | None: + """Patch cart by ID""" + result = await session.execute(select(CartDB).where(CartDB.id == id)) + cart_db = result.scalar_one_or_none() + + if cart_db is None: + return None + + if patch_info.items is not None: + + await session.execute( + sql_delete(cart_items_table).where(cart_items_table.c.cart_id == id) + ) + + for item_info in patch_info.items: + result = await session.execute( + select(ItemDB).where(ItemDB.id == item_info.id) + ) + item_db = result.scalar_one_or_none() + + if item_db: + await session.execute( + cart_items_table.insert().values( + cart_id=cart_db.id, + item_id=item_db.id, + quantity=item_info.quantity, + ) + ) + + cart_db.price = await _calculate_price(session, cart_db.id) + + await session.flush() + return await get_one(session, cart_db.id) + + +async def _calculate_price(session: AsyncSession, cart_id: int) -> float: + """Calculate cart price""" + query = ( + select(ItemDB.price, cart_items_table.c.quantity) + .join(cart_items_table, ItemDB.id == cart_items_table.c.item_id) + .where(cart_items_table.c.cart_id == cart_id) + ) + + result = await session.execute(query) + items = result.all() + + total = sum(price * quantity for price, quantity in items) + return total + + +async def add_item_to_cart( + session: AsyncSession, cart_id: int, product_id: int, quantity: int +) -> CartEntity | None: + """Add item to cart""" + # Проверка существования корзины + result = await session.execute(select(CartDB).where(CartDB.id == cart_id)) + cart_db = result.scalar_one_or_none() + + if cart_db is None: + return None + + # Проверка существования товара + result = await session.execute(select(ItemDB).where(ItemDB.id == product_id)) + product_db = result.scalar_one_or_none() + + if product_db is None: + return None + + # Проверка наличия товара в корзине + result = await session.execute( + select(cart_items_table.c.quantity).where( + cart_items_table.c.cart_id == cart_id, + cart_items_table.c.item_id == product_id, + ) + ) + existing_quantity = result.scalar_one_or_none() + + if existing_quantity is not None: + # Обновление количества существующего товара + await session.execute( + cart_items_table.update() + .where( + cart_items_table.c.cart_id == cart_id, + cart_items_table.c.item_id == product_id, + ) + .values(quantity=existing_quantity + quantity) + ) + else: + # Добавлением нового товара + await session.execute( + cart_items_table.insert().values( + cart_id=cart_id, item_id=product_id, quantity=quantity + ) + ) + + cart_db.price = await _calculate_price(session, cart_id) + await session.flush() + + return await get_one(session, cart_id) + + +async def remove_item_from_cart( + session: AsyncSession, cart_id: int, product_id: int +) -> CartEntity | None: + """Delete item from cart""" + + result = await session.execute(select(CartDB).where(CartDB.id == cart_id)) + cart_db = result.scalar_one_or_none() + + if cart_db is None: + return None + + result = await session.execute( + sql_delete(cart_items_table).where( + cart_items_table.c.cart_id == cart_id, + cart_items_table.c.item_id == product_id, + ) + ) + + if result.rowcount == 0: + return None + + cart_db.price = await _calculate_price(session, cart_id) + await session.flush() + + return await get_one(session, cart_id) diff --git a/hw2/hw/shop_api/data/db_models.py b/hw2/hw/shop_api/data/db_models.py new file mode 100644 index 00000000..8f886faf --- /dev/null +++ b/hw2/hw/shop_api/data/db_models.py @@ -0,0 +1,59 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, Table +from sqlalchemy.orm import relationship, Mapped, mapped_column +from typing import List + +try: + from ..database import Base +except ImportError: + # Для Alembic миграций используем абсолютный импорт + from database import Base + + +# Таблица связи many-to-many между корзинами и товарами +cart_items_table = Table( + "cart_items", + Base.metadata, + Column( + "cart_id", Integer, ForeignKey("carts.id", ondelete="CASCADE"), primary_key=True + ), + Column( + "item_id", Integer, ForeignKey("items.id", ondelete="CASCADE"), primary_key=True + ), + Column("quantity", Integer, nullable=False, default=1), +) + + +class ItemDB(Base): + """Model of an item in the database""" + + __tablename__ = "items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + price: Mapped[float] = mapped_column(Float, nullable=False) + deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Связь с корзинами (через промежуточную таблицу) + carts: Mapped[List["CartDB"]] = relationship( + "CartDB", secondary=cart_items_table, back_populates="items" + ) + + def __repr__(self): + return f"" + + +class CartDB(Base): + """Model of a cart in the database""" + + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) + + # Связь с товарами (через промежуточную таблицу) + items: Mapped[List["ItemDB"]] = relationship( + "ItemDB", secondary=cart_items_table, back_populates="carts" + ) + + def __repr__(self): + return f"" diff --git a/hw2/hw/shop_api/data/item_queries.py b/hw2/hw/shop_api/data/item_queries.py new file mode 100644 index 00000000..d551da71 --- /dev/null +++ b/hw2/hw/shop_api/data/item_queries.py @@ -0,0 +1,154 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from .models import ItemInfo, ItemEntity, PatchItemInfo +from .db_models import ItemDB + + +async def add(session: AsyncSession, info: ItemInfo) -> ItemEntity: + """Create new item""" + item_db = ItemDB(name=info.name, price=info.price, deleted=info.deleted) + + session.add(item_db) # добавляем в сессию и сохранение + await session.flush() # получаем ID без commit + + return ItemEntity(id=item_db.id, info=info) + + +async def delete(session: AsyncSession, id: int) -> ItemEntity | None: + """Marks item as deleted""" + + result = await session.execute( + select(ItemDB).where(ItemDB.id == id) + ) # получаем товар из БД + item_db = result.scalar_one_or_none() # получаем одну запись + + if item_db is None: + return None + + item_db.deleted = True + await session.flush() + + return ItemEntity( + id=item_db.id, + info=ItemInfo(name=item_db.name, price=item_db.price, deleted=item_db.deleted), + ) + + +async def get_one(session: AsyncSession, id: int) -> ItemEntity | None: + """Get item by ID""" + result = await session.execute(select(ItemDB).where(ItemDB.id == id)) + item_db = result.scalar_one_or_none() + + if item_db is None: + return None + + return ItemEntity( + id=item_db.id, + info=ItemInfo(name=item_db.name, price=item_db.price, deleted=item_db.deleted), + ) + + +async def get_many( + session: AsyncSession, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False, +) -> list[ItemEntity]: + """Get many items by query params""" + + query = select(ItemDB) + + # Фильтры + if not show_deleted: + query = query.where(ItemDB.deleted == False) + + if min_price is not None: + query = query.where(ItemDB.price >= min_price) + + if max_price is not None: + query = query.where(ItemDB.price <= max_price) + + # Пагинация + query = query.offset(offset).limit(limit) + + # Выполнение запроса + result = await session.execute(query) + items_db = result.scalars().all() + + return [ + ItemEntity( + id=item_db.id, + info=ItemInfo( + name=item_db.name, price=item_db.price, deleted=item_db.deleted + ), + ) + for item_db in items_db + ] + + +async def update(session: AsyncSession, id: int, info: ItemInfo) -> ItemEntity | None: + """Update item by ID""" + result = await session.execute(select(ItemDB).where(ItemDB.id == id)) + item_db = result.scalar_one_or_none() + + if item_db is None: + return None + + item_db.name = info.name + item_db.price = info.price + item_db.deleted = info.deleted + + await session.flush() + + return ItemEntity(id=item_db.id, info=info) + + +async def upsert(session: AsyncSession, id: int, info: ItemInfo) -> ItemEntity: + """Upsert item by ID""" + result = await session.execute(select(ItemDB).where(ItemDB.id == id)) + item_db = result.scalar_one_or_none() + + if item_db is None: + # Создание нового товара с заданным ID + item_db = ItemDB(id=id, name=info.name, price=info.price, deleted=info.deleted) + session.add(item_db) + else: + # Обновление существующего товара + item_db.name = info.name + item_db.price = info.price + item_db.deleted = info.deleted + + await session.flush() + + return ItemEntity(id=item_db.id, info=info) + + +async def patch( + session: AsyncSession, id: int, patch_info: PatchItemInfo +) -> ItemEntity | None: + """Patch item by ID""" + result = await session.execute(select(ItemDB).where(ItemDB.id == id)) + item_db = result.scalar_one_or_none() + + if item_db is None: + return None + + # Обновление только указанных полей + if patch_info.name is not None: + item_db.name = patch_info.name + + if patch_info.price is not None: + item_db.price = patch_info.price + + if patch_info.deleted is not None: + item_db.deleted = patch_info.deleted + + await session.flush() + + return ItemEntity( + id=item_db.id, + info=ItemInfo(name=item_db.name, price=item_db.price, deleted=item_db.deleted), + ) diff --git a/hw2/hw/shop_api/data/models.py b/hw2/hw/shop_api/data/models.py new file mode 100644 index 00000000..5cb6843a --- /dev/null +++ b/hw2/hw/shop_api/data/models.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo + + +@dataclass(slots=True) +class PatchCartInfo: + items: list[CartItemInfo] | None = None + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None + deleted: bool | None = None diff --git a/hw2/hw/shop_api/data/old_cart_queries.py b/hw2/hw/shop_api/data/old_cart_queries.py new file mode 100644 index 00000000..315eca26 --- /dev/null +++ b/hw2/hw/shop_api/data/old_cart_queries.py @@ -0,0 +1,148 @@ +from typing import Iterable + +from .models import ( + CartInfo, + CartItemInfo, + CartEntity, + PatchCartInfo, +) + +from . import item_queries + +_data = dict[int, CartInfo]() + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: CartInfo) -> CartEntity: + _id = next(_id_generator) + _data[_id] = info + + return CartEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> CartEntity | None: + if id not in _data: + return None + + return CartEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None, +) -> Iterable[CartEntity]: + curr = 0 + yielded = 0 + + for id, info in _data.items(): + if min_price is not None and info.price < min_price: + continue + if max_price is not None and info.price > max_price: + continue + + total_quantity = sum(item.quantity for item in info.items) + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + if curr >= offset: + yield CartEntity(id, info) + yielded += 1 + if yielded >= limit: + break + + curr += 1 + + +def update(id: int, info: CartInfo) -> CartEntity | None: + if id not in _data: + return None + + _data[id] = info + + return CartEntity(id=id, info=info) + + +def upsert(id: int, info: CartInfo) -> CartEntity: + _data[id] = info + + return CartEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchCartInfo) -> CartEntity | None: + if id not in _data: + return None + + if patch_info.items is not None: + _data[id].items = patch_info.items + + return CartEntity(id=id, info=_data[id]) + + +def _calculate_price(cart_info: CartInfo) -> float: + total = 0.0 + for item in cart_info.items: + product = item_queries.get_one(item.id) + if product: + total += product.info.price * item.quantity + return total + + +def add_item_to_cart(cart_id: int, product_id: int, quantity: int) -> CartEntity | None: + if cart_id not in _data: + return None + + product = item_queries.get_one(product_id) + + if not product: + return None + + for item in _data[cart_id].items: + if item.id == product_id: + item.quantity += quantity + _data[cart_id].price = _calculate_price(_data[cart_id]) + return CartEntity(id=cart_id, info=_data[cart_id]) + + cart_item = CartItemInfo( + id=product.id, + name=product.info.name, + quantity=quantity, + available=not product.info.deleted, + ) + + _data[cart_id].items.append(cart_item) + _data[cart_id].price = _calculate_price(_data[cart_id]) + + return CartEntity(id=cart_id, info=_data[cart_id]) + + +def remove_item_from_cart(cart_id: int, product_id: int) -> CartEntity | None: + if cart_id not in _data: + return None + + for item in _data[cart_id].items: + if item.id == product_id: + _data[cart_id].items.remove(item) + _data[cart_id].price = _calculate_price(_data[cart_id]) + return CartEntity(id=cart_id, info=_data[cart_id]) + + return None diff --git a/hw2/hw/shop_api/data/old_item_queries.py b/hw2/hw/shop_api/data/old_item_queries.py new file mode 100644 index 00000000..f01f49f0 --- /dev/null +++ b/hw2/hw/shop_api/data/old_item_queries.py @@ -0,0 +1,96 @@ +from typing import Iterable + +from .models import ( + ItemInfo, + ItemEntity, + PatchItemInfo, +) + +_data = dict[int, ItemInfo]() + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _data[_id] = info + + return ItemEntity(_id, info) + + +def delete(id: int) -> ItemEntity | None: + if id not in _data: + return None + + _data[id].deleted = True + return ItemEntity(id=id, info=_data[id]) + + +def get_one(id: int) -> ItemEntity | None: + if id not in _data: + return None + + return ItemEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _data.items(): + if not show_deleted and info.deleted: + continue + + if min_price is not None and info.price < min_price: + continue + + if max_price is not None and info.price > max_price: + continue + + if offset <= curr < offset + limit: + yield ItemEntity(id, info) + + curr += 1 + + +def update(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + _data[id] = info + + return ItemEntity(id=id, info=info) + + +def upsert(id: int, info: ItemInfo) -> ItemEntity: + _data[id] = info + + return ItemEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.price is not None: + _data[id].price = patch_info.price + + if patch_info.deleted is not None: + _data[id].deleted = patch_info.deleted + + return ItemEntity(id=id, info=_data[id]) diff --git a/hw2/hw/shop_api/database.py b/hw2/hw/shop_api/database.py new file mode 100644 index 00000000..2bb93ab0 --- /dev/null +++ b/hw2/hw/shop_api/database.py @@ -0,0 +1,55 @@ +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from typing import AsyncGenerator + + +# Получание URL БД из переменной окружения +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://admin:admin@localhost:5432/shop_db" +) + +# Создание async engine для подключения к БД +engine = create_async_engine( + DATABASE_URL, + echo=True, # выводит SQL запросы в консоль + future=True # включает асинхронность +) + +# Создание фабрики сессий +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False # объекты остаются доступными после commit +) + + +# Базовый класс для всех моделей +class Base(DeclarativeBase): + pass + + +# Dependency для получения сессии БД в роутах FastAPI +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +# Создание всех таблиц +async def create_tables(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +# Удаление всех таблиц +async def drop_tables(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..c562f695 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,11 @@ from fastapi import FastAPI +from .api.shop import cart_router, item_router + +from prometheus_fastapi_instrumentator import Instrumentator app = FastAPI(title="Shop API") + +app.include_router(cart_router) +app.include_router(item_router) + +Instrumentator().instrument(app).expose(app) \ No newline at end of file diff --git a/hw2/hw/shop_api/not_pytests/README.md b/hw2/hw/shop_api/not_pytests/README.md new file mode 100644 index 00000000..f9a123b8 --- /dev/null +++ b/hw2/hw/shop_api/not_pytests/README.md @@ -0,0 +1,221 @@ +# Tests для Shop API + +Этот каталог содержит тесты для проверки интеграции с PostgreSQL. + +## Структура тестов + +### 1. `test_db.py` - Тесты CRUD операций с товарами + +Проверяет основные операции с базой данных для товаров: + +- ✅ Создание товара (`add`) +- ✅ Получение товара по ID (`get_one`) +- ✅ Получение списка товаров (`get_many`) +- ✅ Полное обновление товара (`update`) +- ✅ Частичное обновление товара (`patch`) +- ✅ Мягкое удаление товара (`delete`) +- ✅ Фильтрация удаленных товаров (`show_deleted`) + +**Запуск:** +```bash +python tests/test_db.py +``` + +**Что проверяет:** +- Корректность SQL запросов +- Работу async/await +- Сохранение данных в PostgreSQL +- Автоинкремент ID + +--- + +### 2. `test_cart.py` - Тесты операций с корзинами + +Проверяет работу с корзинами и связями many-to-many: + +- ✅ Создание пустой корзины +- ✅ Добавление товаров в корзину +- ✅ Автоматический расчет цены корзины +- ✅ Удаление товаров из корзины +- ✅ Обновление количества товаров +- ✅ Отслеживание доступности товаров (`available`) +- ✅ Создание корзины с товарами сразу + +**Запуск:** +```bash +python tests/test_cart.py +``` + +**Что проверяет:** +- Связи many-to-many через `cart_items` +- JOIN запросы +- Расчет итоговой цены +- Каскадное удаление (CASCADE) + +--- + +### 3. `test_api.py` - Тесты HTTP API endpoints + +Проверяет работу REST API через HTTP запросы: + +- ✅ POST `/item/` - создание товара +- ✅ GET `/item/{id}` - получение товара +- ✅ GET `/item/` - список товаров +- ✅ PUT `/item/{id}` - обновление товара +- ✅ DELETE `/item/{id}` - удаление товара +- ✅ POST `/cart/` - создание корзины +- ✅ POST `/cart/{cart_id}/add/{item_id}` - добавление товара в корзину +- ✅ GET `/cart/{id}` - получение корзины + +**Запуск:** +```bash +# Сервер должен быть запущен на http://localhost:8080 +python tests/test_api.py +``` + +**Что проверяет:** +- HTTP статус коды +- JSON сериализация/десериализация +- Валидацию данных +- Интеграцию роутеров с БД + +--- + +## Требования + +Перед запуском тестов убедитесь, что: + +1. **PostgreSQL запущен:** + ```bash + docker-compose up -d postgres + ``` + +2. **Миграции применены:** + ```bash + alembic upgrade head + ``` + +3. **Зависимости установлены:** + ```bash + pip install -r requirements.txt + ``` + +--- + +## Запуск всех тестов + +```bash +# Из директории shop_api/ +python tests/test_db.py +python tests/test_cart.py + +# Для test_api.py нужен запущенный сервер: +docker-compose up -d shop +python tests/test_api.py +``` + +--- + +## Проверка данных в БД + +После запуска тестов можно проверить данные: + +```bash +# Все товары +docker exec hw-postgres-1 psql -U admin -d shop_db -c "SELECT * FROM items;" + +# Все корзины +docker exec hw-postgres-1 psql -U admin -d shop_db -c "SELECT * FROM carts;" + +# Связи корзина-товары +docker exec hw-postgres-1 psql -U admin -d shop_db -c "SELECT * FROM cart_items;" + +# Корзины с товарами (JOIN) +docker exec hw-postgres-1 psql -U admin -d shop_db -c " +SELECT c.id as cart_id, i.name, i.price, ci.quantity, (i.price * ci.quantity) as total +FROM carts c +JOIN cart_items ci ON c.id = ci.cart_id +JOIN items i ON ci.item_id = i.id; +" +``` + +--- + +## Очистка тестовых данных + +```bash +# Удалить все данные из таблиц +docker exec hw-postgres-1 psql -U admin -d shop_db -c " +TRUNCATE items, carts, cart_items RESTART IDENTITY CASCADE; +" +``` + +--- + +## Примеры успешного вывода + +### test_db.py +``` +✓ Таблицы созданы/проверены +✓ Создан товар: ItemEntity(id=1, info=ItemInfo(...)) +✓ Получен товар: ItemEntity(id=1, ...) +✓ Обновлен товар: ItemEntity(id=1, ...) +✓ Получен список товаров (1 шт.) +✓ Частично обновлен товар: ItemEntity(...) +✓ Удален товар: ItemEntity(...) +✓ Товары без удаленных: 0 +✓ Товары с удаленными: 1 + +✅ Все тесты пройдены успешно! +``` + +### test_cart.py +``` +✓ Созданы товары: Laptop (id=2), Mouse (id=3), Keyboard (id=4) +✓ Создана пустая корзина: CartEntity(id=1, ...) +✓ Добавлен laptop в корзину. Цена: 1500.0 +✓ Добавлена мышь (2шт) в корзину. Цена: 1600.0 +✓ Корзина 1: + Общая цена: $1600.0 + Товары: + - Laptop x1 (доступен: True) + - Mouse x2 (доступен: True) +✓ Удалена мышь из корзины. Новая цена: 1500.0 +✓ После удаления laptop из каталога: + - Laptop: доступен=False + +✅ Все тесты корзины пройдены успешно! +``` + +--- + +## Troubleshooting + +### Ошибка подключения к БД +``` +sqlalchemy.exc.OperationalError: could not connect to server +``` +**Решение:** Запустите PostgreSQL +```bash +docker-compose up -d postgres +``` + +### Таблицы не найдены +``` +sqlalchemy.exc.ProgrammingError: relation "items" does not exist +``` +**Решение:** Примените миграции +```bash +alembic upgrade head +``` + +### API тесты не работают +``` +httpx.ConnectError: [Errno 111] Connection refused +``` +**Решение:** Запустите сервер +```bash +docker-compose up -d shop +# или локально: +uvicorn main:app --host 0.0.0.0 --port 8080 +``` diff --git a/hw2/hw/shop_api/not_pytests/__init__.py b/hw2/hw/shop_api/not_pytests/__init__.py new file mode 100644 index 00000000..3a76bff2 --- /dev/null +++ b/hw2/hw/shop_api/not_pytests/__init__.py @@ -0,0 +1 @@ +"""Tests for Shop API with PostgreSQL integration""" diff --git a/hw2/hw/shop_api/not_pytests/test_api.py b/hw2/hw/shop_api/not_pytests/test_api.py new file mode 100644 index 00000000..0059c9aa --- /dev/null +++ b/hw2/hw/shop_api/not_pytests/test_api.py @@ -0,0 +1,75 @@ +"""Тестовый скрипт для проверки API с PostgreSQL""" +import asyncio +import httpx + + +async def test_api(): + """Тестирует API endpoints""" + + # Предполагаем, что сервер запущен на порту 8080 + base_url = "http://localhost:8080" + + async with httpx.AsyncClient() as client: + print("Тестирование Shop API с PostgreSQL\n") + + # 1. Создание товара + print("1. Создание товара...") + response = await client.post( + f"{base_url}/item/", + json={"name": "MacBook Pro", "price": 2500.0} + ) + print(f" Статус: {response.status_code}") + item = response.json() + print(f" Товар: {item}") + item_id = item["id"] + + # 2. Получение товара + print("\n2. Получение товара...") + response = await client.get(f"{base_url}/item/{item_id}") + print(f" Статус: {response.status_code}") + print(f" Товар: {response.json()}") + + # 3. Создание корзины + print("\n3. Создание корзины...") + response = await client.post(f"{base_url}/cart/") + print(f" Статус: {response.status_code}") + cart = response.json() + print(f" Корзина: {cart}") + cart_id = cart["id"] + + # 4. Добавление товара в корзину + print(f"\n4. Добавление товара {item_id} в корзину {cart_id}...") + response = await client.post(f"{base_url}/cart/{cart_id}/add/{item_id}") + print(f" Статус: {response.status_code}") + cart = response.json() + print(f" Корзина: {cart}") + print(f" Цена корзины: ${cart['price']}") + + # 5. Получение списка товаров + print("\n5. Получение списка товаров...") + response = await client.get(f"{base_url}/item/") + print(f" Статус: {response.status_code}") + items = response.json() + print(f" Найдено товаров: {len(items)}") + + # 6. Обновление товара + print(f"\n6. Обновление товара {item_id}...") + response = await client.put( + f"{base_url}/item/{item_id}", + json={"name": "MacBook Pro M3", "price": 2800.0} + ) + print(f" Статус: {response.status_code}") + print(f" Обновленный товар: {response.json()}") + + # 7. Удаление товара + print(f"\n7. Удаление товара {item_id}...") + response = await client.delete(f"{base_url}/item/{item_id}") + print(f" Статус: {response.status_code}") + deleted_item = response.json() + print(f" Deleted: {deleted_item['deleted']}") + + print("\n✅ Все API тесты пройдены!") + + +if __name__ == "__main__": + asyncio.run(test_api()) diff --git a/hw2/hw/shop_api/not_pytests/test_cart.py b/hw2/hw/shop_api/not_pytests/test_cart.py new file mode 100644 index 00000000..9683e51f --- /dev/null +++ b/hw2/hw/shop_api/not_pytests/test_cart.py @@ -0,0 +1,74 @@ +"""Тестовый скрипт для проверки работы с корзинами""" +import asyncio +from database import AsyncSessionLocal +from data import item_queries, cart_queries +from data.models import ItemInfo, CartInfo, CartItemInfo + + +async def test_cart(): + """Тестирует операции с корзинами""" + + async with AsyncSessionLocal() as session: + # 1. Создаем несколько товаров + laptop = await item_queries.add(session, ItemInfo(name="Laptop", price=1500.0, deleted=False)) + mouse = await item_queries.add(session, ItemInfo(name="Mouse", price=50.0, deleted=False)) + keyboard = await item_queries.add(session, ItemInfo(name="Keyboard", price=100.0, deleted=False)) + await session.commit() + print(f"✓ Созданы товары: Laptop (id={laptop.id}), Mouse (id={mouse.id}), Keyboard (id={keyboard.id})") + + # 2. Создаем пустую корзину + empty_cart = await cart_queries.add(session, CartInfo(items=[], price=0.0)) + await session.commit() + print(f"✓ Создана пустая корзина: {empty_cart}") + + # 3. Добавляем товары в корзину + cart_with_laptop = await cart_queries.add_item_to_cart(session, empty_cart.id, laptop.id, quantity=1) + await session.commit() + print(f"✓ Добавлен laptop в корзину. Цена: {cart_with_laptop.info.price}") + + cart_with_mouse = await cart_queries.add_item_to_cart(session, empty_cart.id, mouse.id, quantity=2) + await session.commit() + print(f"✓ Добавлена мышь (2шт) в корзину. Цена: {cart_with_mouse.info.price}") + + # 4. Проверяем содержимое корзины + cart = await cart_queries.get_one(session, empty_cart.id) + print(f"\n✓ Корзина {cart.id}:") + print(f" Общая цена: ${cart.info.price}") + print(f" Товары:") + for item in cart.info.items: + print(f" - {item.name} x{item.quantity} (доступен: {item.available})") + + # 5. Удаляем товар из корзины + cart_without_mouse = await cart_queries.remove_item_from_cart(session, empty_cart.id, mouse.id) + await session.commit() + print(f"\n✓ Удалена мышь из корзины. Новая цена: {cart_without_mouse.info.price}") + + # 6. Помечаем товар как удаленный и проверяем available + await item_queries.delete(session, laptop.id) + await session.commit() + + cart_final = await cart_queries.get_one(session, empty_cart.id) + print(f"\n✓ После удаления laptop из каталога:") + for item in cart_final.info.items: + print(f" - {item.name}: доступен={item.available}") + + # 7. Создаем корзину с товарами сразу + cart_items = [ + CartItemInfo(id=mouse.id, name=mouse.info.name, quantity=3, available=True), + CartItemInfo(id=keyboard.id, name=keyboard.info.name, quantity=1, available=True) + ] + new_cart = await cart_queries.add( + session, + CartInfo(items=cart_items, price=0.0) + ) + await session.commit() + print(f"\n✓ Создана новая корзина с товарами:") + print(f" ID: {new_cart.id}") + print(f" Цена: ${new_cart.info.price}") + print(f" Товаров: {len(new_cart.info.items)}") + + print("\n✅ Все тесты корзины пройдены успешно!") + + +if __name__ == "__main__": + asyncio.run(test_cart()) diff --git a/hw2/hw/shop_api/not_pytests/test_db.py b/hw2/hw/shop_api/not_pytests/test_db.py new file mode 100644 index 00000000..26bd02e5 --- /dev/null +++ b/hw2/hw/shop_api/not_pytests/test_db.py @@ -0,0 +1,59 @@ +"""Тестовый скрипт для проверки интеграции PostgreSQL""" +import asyncio +from database import AsyncSessionLocal, create_tables +from data import item_queries +from data.models import ItemInfo + + +async def test_database(): + """Тестирует CRUD операции с базой данных""" + + # Создаем таблицы (если еще не созданы) + await create_tables() + print("✓ Таблицы созданы/проверены") + + # Создаем сессию + async with AsyncSessionLocal() as session: + # 1. Создание товара + item_info = ItemInfo(name="Laptop", price=1500.0, deleted=False) + item_entity = await item_queries.add(session, item_info) + await session.commit() + print(f"✓ Создан товар: {item_entity}") + + # 2. Получение товара + retrieved_item = await item_queries.get_one(session, item_entity.id) + print(f"✓ Получен товар: {retrieved_item}") + + # 3. Обновление товара + updated_info = ItemInfo(name="Gaming Laptop", price=2000.0, deleted=False) + updated_item = await item_queries.update(session, item_entity.id, updated_info) + await session.commit() + print(f"✓ Обновлен товар: {updated_item}") + + # 4. Получение списка товаров + items = await item_queries.get_many(session, offset=0, limit=10) + print(f"✓ Получен список товаров ({len(items)} шт.)") + + # 5. Частичное обновление (patch) + from data.models import PatchItemInfo + patch_info = PatchItemInfo(price=1800.0) + patched_item = await item_queries.patch(session, item_entity.id, patch_info) + await session.commit() + print(f"✓ Частично обновлен товар: {patched_item}") + + # 6. Мягкое удаление + deleted_item = await item_queries.delete(session, item_entity.id) + await session.commit() + print(f"✓ Удален товар: {deleted_item}") + + # 7. Проверка, что товар помечен как удаленный + items_without_deleted = await item_queries.get_many(session, show_deleted=False) + items_with_deleted = await item_queries.get_many(session, show_deleted=True) + print(f"✓ Товары без удаленных: {len(items_without_deleted)}") + print(f"✓ Товары с удаленными: {len(items_with_deleted)}") + + print("\n✅ Все тесты пройдены успешно!") + + +if __name__ == "__main__": + asyncio.run(test_database()) diff --git a/hw2/hw/shop_api/transaction_scripts/0_dirty_read_solved.py b/hw2/hw/shop_api/transaction_scripts/0_dirty_read_solved.py new file mode 100644 index 00000000..a51dd981 --- /dev/null +++ b/hw2/hw/shop_api/transaction_scripts/0_dirty_read_solved.py @@ -0,0 +1,135 @@ +import time +import threading +import sys +from pathlib import Path + +# Add parent directory to path to allow absolute imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from transaction_scripts.config import engine, SessionLocal +from transaction_scripts.models import Base, DemoItem + +from sqlalchemy.orm import sessionmaker + +# Create session with READ UNCOMMITTED isolation level +SessionReadUncommitted = sessionmaker( + bind=engine.execution_options(isolation_level="READ UNCOMMITTED") +) + + +def setup_database(): + """Create tables and test data""" + + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + session = SessionLocal() + + try: + item = DemoItem(id=1, name="Laptop", price=1299.99, deleted=False) + session.add(item) # add transaction without commit + session.commit() # commit transaction + print("Database prepared: Laptop costs 1299.99") + finally: + session.close() + + +def transaction_1_modify(): + """Transaction T1: Administrator tries to apply discount, but rolls back due to error""" + + session = SessionLocal() + try: + print("\n[T1 - Admin] Transaction started (READ COMMITTED)") + + item = ( + session.query(DemoItem).filter_by(id=1).first() + ) # modify item price (apply discount) + old_price = item.price + item.price = 999.99 + + print( + f"[T1 - Admin] Applying discount to '{item.name}': {old_price} → {item.price}" + ) + print("[T1 - Admin] Changes NOT committed...") + print("[T1 - Admin] Waiting for confirmation or validation...") + + time.sleep(2) + + session.rollback() + print("\n[T1 - Admin] ROLLBACK - discount cancelled!") + print(f"[T1 - Admin] Price of '{item.name}' returned to 1299.99\n") + + except Exception as e: + print(f"[T1 - Admin] Error: {e}") + session.rollback() + finally: + session.close() + + +def transaction_2_read(): + """Transaction T2: Client tries to read the item price for purchase""" + + time.sleep(1) # wait for T2 to modify data + + session = SessionReadUncommitted() + try: + print("\n[T2 - Client] Transaction started (READ UNCOMMITTED)") + print( + "[T2 - Client] Isolation level set: READ UNCOMMITTED but PostgreSQL uses READ COMMITTED" + ) + + item = session.query(DemoItem).filter_by(id=1).first() # read item price + print(f"\n[T2 - Client] Reading price of '{item.name}': {item.price}") + + if item.price == 999.99: + print("[T2 - Client] Read uncommitted data (Dirty read)") + print("[T2 - Client] Seeing discount price that was not confirmed!") + else: + print("[T2 - Client] Read only committed data") + print("[T2 - Client] Dirty Read did NOT happen thanks to PostgreSQL") + + time.sleep(2) # wait for T2 to ROLLBACK + + session.commit() + print("[T2 - Client] Transaction completed\n") + + except Exception as e: + print(f"[T2 - Client] Error: {e}") + session.rollback() + finally: + session.close() + + +def main(): + """Run demonstration""" + + print("=" * 70) + print("DEMONSTRATION: Attempt Dirty Read in PostgreSQL") + print("=" * 70) + print("Scenario: Client checks item price while administrator") + print(" tries to apply discount, but rolls back changes") + print("Goal: Show that PostgreSQL does NOT allow dirty reads") + print(" even at READ UNCOMMITTED isolation level") + print("=" * 70) + + setup_database() + + # Run two transactions in parallel + t1 = threading.Thread(target=transaction_1_modify) + T1 = threading.Thread(target=transaction_2_read) + + t1.start() + T1.start() + + t1.join() + T1.join() + + print("=" * 70) + print("PostgreSQL does NOT support READ UNCOMMITTED") + print("READ UNCOMMITTED works as READ COMMITTED") + print("Dirty Read is impossible in PostgreSQL") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/hw2/hw/shop_api/transaction_scripts/1_non_repeatable_read_problem.py b/hw2/hw/shop_api/transaction_scripts/1_non_repeatable_read_problem.py new file mode 100644 index 00000000..39305afa --- /dev/null +++ b/hw2/hw/shop_api/transaction_scripts/1_non_repeatable_read_problem.py @@ -0,0 +1,136 @@ +import time +import threading +import sys +from pathlib import Path + +# Add parent directory to path to allow absolute imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from transaction_scripts.config import engine, SessionLocal +from transaction_scripts.models import Base, DemoItem + + +def setup_database(): + """Create tables and test data""" + + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + session = SessionLocal() + try: + item = DemoItem(id=1, name="Wireless Headphones", price=2999.0, deleted=False) + session.add(item) + session.commit() + print("Database prepared: Wireless Headphones cost 2999.0") + finally: + session.close() + + +def transaction_1_read(): + """ + Transaction T1: Client checks item price twice + Expected problem: different values at READ COMMITTED + """ + + session = SessionLocal() # READ COMMITTED isolation level by default + + try: + print("\n[T1 - Client] Transaction started (READ COMMITTED)") + + item = ( + session.query(DemoItem).filter_by(id=1).first() + ) # first read of the price before checkout + price_1 = item.price + print(f"[T1 - Client] First read: price of '{item.name}' = {price_1}") + print(f"[T1 - Client] Adding item to cart at price {price_1}") + print("[T1 - Client] Got distracted for 3 seconds...") + time.sleep(3) + + session.expire_all() # clear session cache + item = ( + session.query(DemoItem).filter_by(id=1).first() + ) # second read of THE SAME price before checkout + price_2 = item.price + print( + f"[T1 - Client] Second read before purchase: price of '{item.name}' = {price_2}" + ) + + if price_1 != price_2: + print(f"\nNON-REPEATABLE READ detected!") + print( + f"Price changed from {price_1} to {price_2} within one transaction..." + ) + print(f"Client sees different prices of same item during purchase!") + else: + print(f"\nData did not change") + + session.commit() + print("\n[T1 - Client] Transaction completed\n") + + except Exception as e: + print(f"[T1 - Client] Error: {e}") + session.rollback() + finally: + session.close() + + +def transaction_2_modify(): + """ + Transaction T2: Administrator changes price between T1's reads + """ + + time.sleep(1) # wait for T1 to do first read + + session = SessionLocal() + try: + print("\n[T2 - Admin] Transaction started (READ COMMITTED)") + + item = ( + session.query(DemoItem).filter_by(id=1).first() + ) # change item price (increase) + old_price = item.price + item.price = 3499.0 + + print( + f"[T2 - Admin] Changing price of '{item.name}': {old_price} → {item.price}" + ) + + session.commit() # commit changes before T1 sees them (READ COMMITTED) + print("[T2 - Admin] Changes committed\n") + + except Exception as e: + print(f"[T2 - Admin] Error: {e}") + session.rollback() + finally: + session.close() + + +def main(): + """Run demonstration""" + + print("=" * 70) + print("DEMONSTRATION: Non-Repeatable Read at READ COMMITTED") + print("=" * 70) + print("Scenario: Client checks item price twice in one transaction,") + print(" but between checks administrator changes the price") + print("=" * 70) + + setup_database() + + # Run two transactions in parallel + t1 = threading.Thread(target=transaction_1_read) + t2 = threading.Thread(target=transaction_2_modify) + + t1.start() + t2.start() + + t1.join() + t2.join() + + print("=" * 70) + print("At READ COMMITTED isolation level Non-Repeatable Read problem is possible") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/hw2/hw/shop_api/transaction_scripts/2_non_repeatable_read_solved.py b/hw2/hw/shop_api/transaction_scripts/2_non_repeatable_read_solved.py new file mode 100644 index 00000000..e90edf9a --- /dev/null +++ b/hw2/hw/shop_api/transaction_scripts/2_non_repeatable_read_solved.py @@ -0,0 +1,142 @@ +import time +import threading +import sys +from pathlib import Path + +# Add parent directory to path to allow absolute imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from transaction_scripts.config import engine, SessionLocal +from transaction_scripts.models import Base, DemoItem + +from sqlalchemy.orm import sessionmaker + +# Create session with REPEATABLE READ isolation level +SessionRepeatableRead = sessionmaker( + bind=engine.execution_options(isolation_level="REPEATABLE READ") +) + + +def setup_database(): + """Create tables and test data""" + + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + session = SessionLocal() + try: + item = DemoItem(id=1, name="Wireless Headphones", price=2999.0, deleted=False) + session.add(item) + session.commit() + print("Database prepared: Wireless Headphones cost 2999.0") + finally: + session.close() + + +def transaction_1_read(): + """ + Transaction T1: Client checks item price twice at REPEATABLE READ + Expected result: same values at both reads + """ + + session = SessionRepeatableRead() + try: + print("\n[T1 - Client] Transaction started (REPEATABLE READ)") + + item = ( + session.query(DemoItem).filter_by(id=1).first() + ) # first read of the price before checkout + price_1 = item.price + print(f"[T1 - Client] First read: price of '{item.name}' = {price_1}") + print(f"[T1 - Client] Adding item to cart at price {price_1}") + print("[T1 - Client] Got distracted for 3 seconds...") + time.sleep(3) + + session.expire_all() + item = ( + session.query(DemoItem).filter_by(id=1).first() + ) # second read of THE SAME price before checkout + price_2 = item.price + print( + f"[T1 - Client] Second read before purchase: price of '{item.name}' = {price_2}" + ) + + if price_1 != price_2: + print(f"\nNON-REPEATABLE READ detected!") + print(f"Price changed from {price_1} to {price_2}") + else: + print(f"\nData did NOT change (both reads returned {price_1})") + print("REPEATABLE READ prevented Non-Repeatable Read") + print("Client sees consistent price throughout the transaction") + + session.commit() + print("\n[T1 - Client] Transaction completed\n") + + except Exception as e: + print(f"[T1 - Client] Error: {e}") + session.rollback() + finally: + session.close() + + +def transaction_2_modify(): + """Transaction T2: Administrator changes price between T1's reads""" + + time.sleep(1) # wait for T1 to do first read + + session = SessionLocal() + try: + print("\n[T2 - Admin] Transaction started (READ COMMITTED)") + + item = session.query(DemoItem).filter_by(id=1).first() # change item price + old_price = item.price + item.price = 3499.0 + + print( + f"[T2 - Admin] Changing price of '{item.name}': {old_price} → {item.price}" + ) + + session.commit() + print("[T2 - Admin] Changes committed") + print( + "[T2 - Admin] Note: T1 will not see these changes due to REPEATABLE READ\n" + ) + + except Exception as e: + print(f"[T2 - Admin] Error: {e}") + session.rollback() + finally: + session.close() + + +def main(): + """Run demonstration""" + + print("=" * 70) + print("DEMONSTRATION: Non-Repeatable Read solution with REPEATABLE READ") + print("=" * 70) + print("Scenario: Client checks price twice, administrator changes it,") + print(" but thanks to REPEATABLE READ client sees consistent price") + print("=" * 70) + + setup_database() + + # Run two transactions in parallel + t1 = threading.Thread(target=transaction_1_read) + t2 = threading.Thread(target=transaction_2_modify) + + t1.start() + t2.start() + + t1.join() + t2.join() + + print("=" * 70) + print("At REPEATABLE READ isolation level") + print("Non-Repeatable Read problem is solved -") + print("transaction sees consistent snapshot of data at the moment it started") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/hw2/hw/shop_api/transaction_scripts/3_phantom_read_problem.py b/hw2/hw/shop_api/transaction_scripts/3_phantom_read_problem.py new file mode 100644 index 00000000..02c6d5a1 --- /dev/null +++ b/hw2/hw/shop_api/transaction_scripts/3_phantom_read_problem.py @@ -0,0 +1,146 @@ +import time +import threading +import sys +from pathlib import Path + +# Add parent directory to path to allow absolute imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from transaction_scripts.config import engine, SessionLocal +from transaction_scripts.models import Base, DemoItem + +from sqlalchemy.orm import sessionmaker + +# Create session with REPEATABLE READ isolation level +SessionRepeatableRead = sessionmaker( + bind=engine.execution_options(isolation_level="REPEATABLE READ") +) + + +def setup_database(): + """Create tables and test data""" + + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + session = SessionLocal() + try: + items = [ + DemoItem(id=1, name="Gaming Laptop", price=75999.0, deleted=False), + DemoItem(id=2, name="Smartphone", price=45999.0, deleted=False), + ] + session.add_all(items) + session.commit() + print("Database prepared: Gaming Laptop (75999), Smartphone (45999)") + finally: + session.close() + + +def transaction_1_read(): + """ + Transaction T1: Manager counts expensive items for report twice + At REPEATABLE READ may see phantom reads in some databases + """ + + session = SessionRepeatableRead() + try: + + print("\n[T1 - Manager] Transaction started (REPEATABLE READ)") + print("[T1 - Manager] Preparing report on expensive items...") + + count_1 = ( + session.query(DemoItem).filter(DemoItem.price > 40000).count() + ) # first count of expensive items + print(f"[T1 - Manager] First count: {count_1} items with price > 40000") + print("[T1 - Manager] Processing other data for report...") + time.sleep(3) + + session.expire_all() + count_2 = ( + session.query(DemoItem).filter(DemoItem.price > 40000).count() + ) # second count with same condition for verification + print( + f"[T1 - Manager] Second count (verification): {count_2} items with price > 40000" + ) + + if count_1 != count_2: + print(f"\nPHANTOM READ detected") + print(f"Count changed from {count_1} to {count_2}") + print(f"Phantom items appeared/disappeared in report") + else: + print(f"\nCount did NOT change (both queries returned {count_1})") + print("In PostgreSQL REPEATABLE READ prevents Phantom Reads") + print("thanks to MVCC mechanism (snapshot isolation)") + + session.commit() + print("\n[T1 - Manager] Transaction completed\n") + + except Exception as e: + print(f"[T1 - Manager] Error: {e}") + session.rollback() + finally: + session.close() + + +def transaction_2_modify(): + """Transaction T2: Administrator adds new expensive item between T1's counts""" + + time.sleep(1) + + session = SessionLocal() + try: + print("\n[T2 - Admin] Transaction started (READ COMMITTED)") + + # Add new expensive item (price > 40000) + new_item = DemoItem( + id=3, name="Professional Camera", price=89999.0, deleted=False + ) + session.add(new_item) + + print(f"[T2 - Admin] Adding new item: '{new_item.name}' for {new_item.price}") + + session.commit() + print("[T2 - Admin] Changes committed") + print("[T2 - Admin] Note: In PostgreSQL T1 will not see this row\n") + + except Exception as e: + print(f"[T2 - Admin] Error: {e}") + session.rollback() + finally: + session.close() + + +def main(): + """Run demonstration""" + + print("=" * 70) + print("DEMONSTRATION: Phantom Read at REPEATABLE READ") + print("=" * 70) + print("Scenario: Manager counts expensive items for report,") + print(" administrator adds new item between counts") + print("IMPORTANT: PostgreSQL uses Snapshot Isolation for REPEATABLE READ,") + print(" which actually prevents Phantom Reads.") + print(" However by SQL standard they are possible at this level.") + print("=" * 70) + + setup_database() + + # Run two transactions in parallel + t1 = threading.Thread(target=transaction_1_read) + t2 = threading.Thread(target=transaction_2_modify) + + t1.start() + t2.start() + + t1.join() + t2.join() + + print("=" * 70) + print("PostgreSQL REPEATABLE READ prevents Phantom Reads") + print("thanks to MVCC. In other databases (e.g., MySQL InnoDB)") + print("at REPEATABLE READ level phantom reads are possible.") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/hw2/hw/shop_api/transaction_scripts/4_phantom_read_solved.py b/hw2/hw/shop_api/transaction_scripts/4_phantom_read_solved.py new file mode 100644 index 00000000..a8f55b9e --- /dev/null +++ b/hw2/hw/shop_api/transaction_scripts/4_phantom_read_solved.py @@ -0,0 +1,155 @@ +import time +import threading +import sys +from pathlib import Path + +# Add parent directory to path to allow absolute imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy.exc import OperationalError +from transaction_scripts.config import engine, SessionLocal +from transaction_scripts.models import Base, DemoItem + +from sqlalchemy.orm import sessionmaker + +# Create session with SERIALIZABLE isolation level +SessionSerializable = sessionmaker( + bind=engine.execution_options(isolation_level="SERIALIZABLE") +) + + +def setup_database(): + """Create tables and test data""" + + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + session = SessionLocal() + try: + items = [ + DemoItem(id=1, name="Gaming Laptop", price=75999.0, deleted=False), + DemoItem(id=2, name="Smartphone", price=45999.0, deleted=False), + ] + session.add_all(items) + session.commit() + print("Database prepared: Gaming Laptop (75999), Smartphone (45999)") + finally: + session.close() + + +def transaction_1_read(): + """ + Transaction T1: Manager counts expensive items for report twice + At SERIALIZABLE phantom reads are guaranteed to not occur + """ + + # session = SessionLocal() + session = SessionSerializable() + try: + # Set SERIALIZABLE isolation level + # session.connection(execution_options={"isolation_level": "SERIALIZABLE"}) + + print("\n[T1 - Manager] Transaction started (SERIALIZABLE)") + print("[T1 - Manager] Preparing critical report on expensive items...") + + count_1 = ( + session.query(DemoItem).filter(DemoItem.price > 40000).count() + ) # first count of expensive items + print(f"[T1 - Manager] First count: {count_1} items with price > 40000") + print("[T1 - Manager] Processing other data for report...") + time.sleep(3) + + session.expire_all() + count_2 = ( + session.query(DemoItem).filter(DemoItem.price > 40000).count() + ) # second count with same condition for verification + print( + f"[T1 - Manager] Second count (verification): {count_2} items with price > 40000" + ) + + if count_1 != count_2: + print(f"\nPHANTOM READ detected") + print(f"Count changed from {count_1} to {count_2}") + else: + print(f"\nCount did NOT change (both queries returned {count_1})") + print("SERIALIZABLE prevented Phantom Reads") + print("Report will be fully consistent") + + session.commit() + print("\n[T1 - Manager] Transaction completed successfully\n") + + except OperationalError as e: + if "could not serialize" in str(e): + print(f"\n[T1 - Manager] Serialization error!") + print(f"PostgreSQL detected a conflict and cancelled the transaction") + print(f"This is normal behavior at SERIALIZABLE") + else: + print(f"[T1 - Manager] Error: {e}") + session.rollback() + except Exception as e: + print(f"[T1 - Manager] Error: {e}") + session.rollback() + finally: + session.close() + + +def transaction_2_modify(): + """ + Transaction T2: Administrator adds new expensive item between T1's counts + At READ COMMITTED phantom reads are guaranteed to not occur + """ + + time.sleep(1) + + session = SessionLocal() + try: + print("\n[T2 - Admin] Transaction started (READ COMMITTED)") + + # Add new expensive item (price > 40000) + new_item = DemoItem( + id=3, name="Professional Camera", price=89999.0, deleted=False + ) + session.add(new_item) + + print(f"[T2 - Admin] Adding new item: '{new_item.name}' for {new_item.price}") + + session.commit() + print("[T2 - Admin] Changes committed successfully\n") + + except Exception as e: + print(f"[T2 - Admin] Error: {e}") + session.rollback() + finally: + session.close() + + +def main(): + """Run demonstration""" + print("=" * 70) + print("DEMONSTRATION: Phantom Read solution with SERIALIZABLE") + print("=" * 70) + print("Scenario: Manager prepares report at SERIALIZABLE,") + print(" administrator adds item - PostgreSQL prevents") + print(" phantom reads, guaranteeing full isolation") + print("=" * 70) + + setup_database() + + # Run two transactions in parallel + t1 = threading.Thread(target=transaction_1_read) + t2 = threading.Thread(target=transaction_2_modify) + + t1.start() + t2.start() + + t1.join() + t2.join() + + print("=" * 70) + print("At SERIALIZABLE isolation level PostgreSQL") + print("guarantees absence of all anomalies, including Phantom Reads.") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/hw2/hw/shop_api/transaction_scripts/README.md b/hw2/hw/shop_api/transaction_scripts/README.md new file mode 100644 index 00000000..41e2936d --- /dev/null +++ b/hw2/hw/shop_api/transaction_scripts/README.md @@ -0,0 +1,44 @@ +# Демонстрация уровней изоляции транзакций + +## Быстрый старт + +Все демонстрационные скрипты находятся в папке `transaction_scripts/`. + +### Запуск отдельных демонстраций + +**Вариант 1: Из директории `shop_api` (как модуль)** +```bash +cd ./python-backend-hw/hw2/hw/shop_api + +python -m transaction_scripts.0_dirty_read_solved +python -m transaction_scripts.1_non_repeatable_read_problem +python -m transaction_scripts.2_non_repeatable_read_solved +python -m transaction_scripts.3_phantom_read_problem +python -m transaction_scripts.4_phantom_read_solved +``` + +**Вариант 2: Из директории `transaction_scripts`** +```bash +cd ./python-backend-hw/hw2/hw/shop_api/transaction_scripts + +python 0_dirty_read_solved.py +python 1_non_repeatable_read_problem.py +python 2_non_repeatable_read_solved.py +python 3_phantom_read_problem.py +python 4_phantom_read_solved.py +``` + + +## Структура файлов + +``` +transaction_scripts/ +├── README.md # Документация +├── config.py # Конфигурация подключения к БД +├── models.py # Модели для демонстрации +├── 0_dirty_read_solved.py # Dirty Read и PostgreSQL +├── 1_non_repeatable_read_problem.py # Non-Repeatable Read: проблема +├── 2_non_repeatable_read_solved.py # Non-Repeatable Read: решение +├── 3_phantom_read_problem.py # Phantom Read: проблема +└── 4_phantom_read_solved.py # Phantom Read: решение +``` diff --git a/hw2/hw/shop_api/transaction_scripts/__init__.py b/hw2/hw/shop_api/transaction_scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/transaction_scripts/config.py b/hw2/hw/shop_api/transaction_scripts/config.py new file mode 100644 index 00000000..1f08ec6d --- /dev/null +++ b/hw2/hw/shop_api/transaction_scripts/config.py @@ -0,0 +1,16 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +DATABASE_URL = os.getenv( + "DATABASE_URL_SYNC", "postgresql://admin:admin@localhost:5432/shop_db" +) + +engine = create_engine( + DATABASE_URL, + echo=False, # disable SQL output for cleaner demonstration + pool_pre_ping=True, +) + +SessionLocal = sessionmaker(bind=engine) +Base = declarative_base() diff --git a/hw2/hw/shop_api/transaction_scripts/models.py b/hw2/hw/shop_api/transaction_scripts/models.py new file mode 100644 index 00000000..08a53620 --- /dev/null +++ b/hw2/hw/shop_api/transaction_scripts/models.py @@ -0,0 +1,51 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, Table, ForeignKey +from sqlalchemy.orm import relationship +from .config import Base + + +# Many-to-many association table between carts and items for demo +demo_cart_items_table = Table( + "demo_cart_items", + Base.metadata, + Column( + "cart_id", Integer, ForeignKey("demo_carts.id", ondelete="CASCADE"), primary_key=True + ), + Column( + "item_id", Integer, ForeignKey("demo_items.id", ondelete="CASCADE"), primary_key=True + ), + Column("quantity", Integer, nullable=False, default=1), +) + + +class DemoItem(Base): + """Demonstration model of a shop item""" + __tablename__ = "demo_items" + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False, nullable=False) + + # Relationship with carts (via association table) + carts = relationship( + "DemoCart", secondary=demo_cart_items_table, back_populates="items" + ) + + def __repr__(self): + return f"" + + +class DemoCart(Base): + """Demonstration model of a shopping cart""" + __tablename__ = "demo_carts" + + id = Column(Integer, primary_key=True) + price = Column(Float, default=0.0, nullable=False) + + # Relationship with items (via association table) + items = relationship( + "DemoItem", secondary=demo_cart_items_table, back_populates="carts" + ) + + def __repr__(self): + return f"" diff --git a/hw2/hw/tests/__init__.py b/hw2/hw/tests/__init__.py new file mode 100644 index 00000000..d4839a6b --- /dev/null +++ b/hw2/hw/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/hw2/hw/tests/conftest.py b/hw2/hw/tests/conftest.py new file mode 100644 index 00000000..6be55784 --- /dev/null +++ b/hw2/hw/tests/conftest.py @@ -0,0 +1,87 @@ +"""Pytest configuration and fixtures for all tests + +Provides fixtures for different test types: +- unit tests: use mocks and isolated components +- integration tests: use real database session (SQLite in-memory) +- e2e tests: use HTTP client with full application stack +""" +import pytest +import asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from shop_api.main import app +from shop_api.database import get_db +from shop_api.data.db_models import Base + + +def pytest_configure(config): + """Register custom markers""" + config.addinivalue_line( + "markers", "unit: Unit tests - fast, isolated, use mocks" + ) + config.addinivalue_line( + "markers", "integration: Integration tests - test multiple components with database" + ) + config.addinivalue_line( + "markers", "e2e: End-to-end tests - test complete HTTP API flow" + ) + + +def pytest_collection_modifyitems(items): + """Automatically apply markers based on test location""" + for item in items: + # Get test file path + test_path = str(item.fspath) + + # Apply markers based on directory + if "/unit/" in test_path or "\\unit\\" in test_path: + item.add_marker(pytest.mark.unit) + elif "/integration/" in test_path or "\\integration\\" in test_path: + item.add_marker(pytest.mark.integration) + elif "/e2e/" in test_path or "\\e2e\\" in test_path: + item.add_marker(pytest.mark.e2e) + + +@pytest.fixture(scope="session") +def event_loop(): + """Creates event loop for pytest-asyncio (prevents 'attached to a different loop' error).""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +async def client(): + """Async test client fixture for API tests (uses real PostgreSQL)""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +@pytest.fixture(scope="function") +async def db_session(): + """Creates temporary in-memory SQLite DB for each unit test.""" + # Use SQLite in memory to avoid transaction conflicts + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + future=True, + ) + + # Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Create session + async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + async with async_session() as session: + yield session + await session.rollback() # Clean up changes + + await engine.dispose() diff --git a/hw2/hw/tests/e2e/__init__.py b/hw2/hw/tests/e2e/__init__.py new file mode 100644 index 00000000..3cf3d673 --- /dev/null +++ b/hw2/hw/tests/e2e/__init__.py @@ -0,0 +1,5 @@ +"""E2E (End-to-End) tests package + +E2E tests test the complete application flow from HTTP API to database. +They verify that all layers work together correctly. +""" diff --git a/hw2/hw/tests/e2e/test_cart_api.py b/hw2/hw/tests/e2e/test_cart_api.py new file mode 100644 index 00000000..a4c253f5 --- /dev/null +++ b/hw2/hw/tests/e2e/test_cart_api.py @@ -0,0 +1,244 @@ +"""E2E tests for Cart API + +Tests all cart endpoints through the HTTP API layer. +These tests verify the complete flow: HTTP → Routes → Queries → Database. +""" +from http import HTTPStatus + + +class TestCartCRUD: + """Tests for basic Cart CRUD operations""" + + async def test_create_cart(self, client): + """Test POST /cart/ - create new cart""" + response = await client.post("/cart/") + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert "id" in data + assert data["items"] == [] + assert data["price"] == 0.0 + assert response.headers["location"] == f"/cart/{data['id']}" + + async def test_get_cart_by_id(self, client): + """Test GET /cart/{id} - get cart by ID""" + # Create cart + create_response = await client.post("/cart/") + cart_id = create_response.json()["id"] + + # Get cart + response = await client.get(f"/cart/{cart_id}") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["id"] == cart_id + assert isinstance(data["items"], list) + assert isinstance(data["price"], float) + + async def test_get_cart_not_found(self, client): + """Test GET /cart/{id} - cart not found""" + response = await client.get("/cart/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + assert "was not found" in response.json()["detail"] + + +class TestCartList: + """Tests for cart listing and filtering""" + + async def test_get_carts_list_empty(self, client): + """Test GET /cart/ - returns 404 when no carts""" + response = await client.get("/cart/") + # Based on routes.py line 108, empty list returns 404 + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + + async def test_get_carts_list_with_carts(self, client): + """Test GET /cart/ - list with carts""" + # Create carts + await client.post("/cart/") + await client.post("/cart/") + + response = await client.get("/cart/") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert isinstance(data, list) + assert len(data) >= 2 + + async def test_get_carts_list_with_pagination(self, client): + """Test GET /cart/ - pagination""" + # Create multiple carts + for _ in range(5): + await client.post("/cart/") + + # Test offset and limit + response = await client.get("/cart/?offset=1&limit=2") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) <= 2 + + async def test_get_carts_list_with_price_filter(self, client): + """Test GET /cart/ - price filtering""" + # Create items + item1_response = await client.post("/item/", json={"name": "Cheap", "price": 10.0}) + item2_response = await client.post("/item/", json={"name": "Expensive", "price": 100.0}) + + item1_id = item1_response.json()["id"] + item2_id = item2_response.json()["id"] + + # Create carts and add items + cart1_response = await client.post("/cart/") + cart1_id = cart1_response.json()["id"] + await client.post(f"/cart/{cart1_id}/add/{item1_id}") + + cart2_response = await client.post("/cart/") + cart2_id = cart2_response.json()["id"] + await client.post(f"/cart/{cart2_id}/add/{item2_id}") + + # Filter by min_price + response = await client.get("/cart/?min_price=50") + assert response.status_code == HTTPStatus.OK + data = response.json() + for cart in data: + assert cart["price"] >= 50 + + # Filter by max_price + response = await client.get("/cart/?max_price=50") + assert response.status_code == HTTPStatus.OK + data = response.json() + for cart in data: + assert cart["price"] <= 50 + + async def test_get_carts_list_with_quantity_filter(self, client): + """Test GET /cart/ - quantity filtering""" + # Create items + item_response = await client.post("/item/", json={"name": "Item", "price": 10.0}) + item_id = item_response.json()["id"] + + # Create cart with items + cart_response = await client.post("/cart/") + cart_id = cart_response.json()["id"] + await client.post(f"/cart/{cart_id}/add/{item_id}") + await client.post(f"/cart/{cart_id}/add/{item_id}") # Add twice + + # Filter by min_quantity + response = await client.get("/cart/?min_quantity=2") + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + if response.status_code == HTTPStatus.OK: + data = response.json() + for cart in data: + total_qty = sum(item["quantity"] for item in cart["items"]) + assert total_qty >= 2 + + # Filter by max_quantity + response = await client.get("/cart/?max_quantity=1") + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + + +class TestCartItems: + """Tests for adding items to cart""" + + async def test_add_item_to_cart(self, client): + """Test POST /cart/{cart_id}/add/{item_id} - add item to cart""" + # Create item and cart + item_response = await client.post("/item/", json={"name": "Test Item", "price": 25.0}) + item_id = item_response.json()["id"] + + cart_response = await client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Add item to cart + response = await client.post(f"/cart/{cart_id}/add/{item_id}") + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == item_id + assert data["items"][0]["quantity"] == 1 + assert data["price"] == 25.0 + assert response.headers["location"] == f"/cart/{cart_id}" + + async def test_add_item_to_cart_multiple_times(self, client): + """Test adding same item multiple times increases quantity""" + # Create item and cart + item_response = await client.post("/item/", json={"name": "Item", "price": 10.0}) + item_id = item_response.json()["id"] + + cart_response = await client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Add item twice + await client.post(f"/cart/{cart_id}/add/{item_id}") + response = await client.post(f"/cart/{cart_id}/add/{item_id}") + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["quantity"] == 2 + assert data["price"] == 20.0 + + async def test_add_item_to_cart_cart_not_found(self, client): + """Test POST /cart/{cart_id}/add/{item_id} - cart not found""" + # Create item + item_response = await client.post("/item/", json={"name": "Item", "price": 10.0}) + item_id = item_response.json()["id"] + + # Try to add to non-existent cart + response = await client.post(f"/cart/999999/add/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + async def test_add_item_to_cart_item_not_found(self, client): + """Test POST /cart/{cart_id}/add/{item_id} - item not found""" + # Create cart + cart_response = await client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Try to add non-existent item + response = await client.post(f"/cart/{cart_id}/add/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestCartItemAvailability: + """Tests for item availability in cart""" + + async def test_cart_item_availability(self, client): + """Test that deleted items show as unavailable in cart""" + # Create item and cart + item_response = await client.post("/item/", json={"name": "Item", "price": 10.0}) + item_id = item_response.json()["id"] + + cart_response = await client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Add item to cart + await client.post(f"/cart/{cart_id}/add/{item_id}") + + # Delete item + await client.delete(f"/item/{item_id}") + + # Check cart - item should be unavailable + response = await client.get(f"/cart/{cart_id}") + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["available"] is False + + +class TestCartPriceCalculation: + """Tests for cart price calculation""" + + async def test_cart_price_calculation(self, client): + """Test cart price is calculated correctly""" + # Create items with different prices + item1_response = await client.post("/item/", json={"name": "Item1", "price": 10.0}) + item2_response = await client.post("/item/", json={"name": "Item2", "price": 20.0}) + + item1_id = item1_response.json()["id"] + item2_id = item2_response.json()["id"] + + # Create cart + cart_response = await client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Add items + await client.post(f"/cart/{cart_id}/add/{item1_id}") + await client.post(f"/cart/{cart_id}/add/{item1_id}") # Add item1 twice + response = await client.post(f"/cart/{cart_id}/add/{item2_id}") + + data = response.json() + # Expected: 10*2 + 20*1 = 40.0 + assert data["price"] == 40.0 diff --git a/hw2/hw/tests/e2e/test_edge_cases.py b/hw2/hw/tests/e2e/test_edge_cases.py new file mode 100644 index 00000000..031cf710 --- /dev/null +++ b/hw2/hw/tests/e2e/test_edge_cases.py @@ -0,0 +1,239 @@ +"""E2E tests for edge cases and error scenarios + +Tests boundary conditions, validation errors, and error handling. +""" +from http import HTTPStatus + + +class TestItemEdgeCases: + """Tests for item edge cases""" + + async def test_item_with_very_small_price(self, client): + """Test item with very small price""" + response = await client.post("/item/", json={"name": "Cheap Item", "price": 0.01}) + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data["price"] == 0.01 + + async def test_item_price_filters_edge_values(self, client): + """Test item price filters with exact boundary values""" + # Create item with specific price + await client.post("/item/", json={"name": "Boundary", "price": 100.0}) + + # Test exact min_price match + response = await client.get("/item/?min_price=100.0") + assert response.status_code == HTTPStatus.OK + + # Test exact max_price match + response = await client.get("/item/?max_price=100.0") + assert response.status_code == HTTPStatus.OK + + async def test_item_list_with_conflicting_price_filters(self, client): + """Test item list where min_price > max_price""" + response = await client.get("/item/?min_price=100&max_price=50") + assert response.status_code == HTTPStatus.OK + # Should return empty list + data = response.json() + assert len(data) == 0 + + async def test_patch_item_empty_payload(self, client): + """Test patch with no fields to update""" + # Create item + create_response = await client.post("/item/", json={"name": "Item", "price": 10.0}) + item_id = create_response.json()["id"] + + # Patch with empty body + response = await client.patch(f"/item/{item_id}", json={}) + assert response.status_code == HTTPStatus.OK + data = response.json() + # Should remain unchanged + assert data["name"] == "Item" + assert data["price"] == 10.0 + + async def test_get_empty_items_list(self, client): + """Test getting items when none exist (edge case)""" + # This might have items from other tests, just check it returns OK + response = await client.get("/item/?offset=10000&limit=1") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert isinstance(data, list) + + +class TestCartEdgeCases: + """Tests for cart edge cases""" + + async def test_cart_empty_after_creation(self, client): + """Test that newly created cart is empty""" + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + + response = await client.get(f"/cart/{cart_id}") + data = response.json() + + assert data["items"] == [] + assert data["price"] == 0.0 + + async def test_cart_with_multiple_different_items(self, client): + """Test cart with multiple different items""" + # Create items + item1 = await client.post("/item/", json={"name": "Item1", "price": 10.0}) + item2 = await client.post("/item/", json={"name": "Item2", "price": 20.0}) + item3 = await client.post("/item/", json={"name": "Item3", "price": 30.0}) + + item1_id = item1.json()["id"] + item2_id = item2.json()["id"] + item3_id = item3.json()["id"] + + # Create cart and add all items + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + + await client.post(f"/cart/{cart_id}/add/{item1_id}") + await client.post(f"/cart/{cart_id}/add/{item2_id}") + response = await client.post(f"/cart/{cart_id}/add/{item3_id}") + + data = response.json() + assert len(data["items"]) == 3 + assert data["price"] == 60.0 + + async def test_multiple_items_in_cart_price_calculation(self, client): + """Test complex cart price calculation""" + # Create items with various prices + item1 = await client.post("/item/", json={"name": "A", "price": 12.50}) + item2 = await client.post("/item/", json={"name": "B", "price": 7.25}) + item3 = await client.post("/item/", json={"name": "C", "price": 99.99}) + + item1_id = item1.json()["id"] + item2_id = item2.json()["id"] + item3_id = item3.json()["id"] + + # Create cart + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + + # Add items with quantities + await client.post(f"/cart/{cart_id}/add/{item1_id}") + await client.post(f"/cart/{cart_id}/add/{item1_id}") # 12.50 * 2 + await client.post(f"/cart/{cart_id}/add/{item2_id}") # 7.25 * 1 + response = await client.post(f"/cart/{cart_id}/add/{item3_id}") # 99.99 * 1 + + # Expected: 12.50*2 + 7.25 + 99.99 = 132.24 + data = response.json() + assert abs(data["price"] - 132.24) < 0.01 + + async def test_cart_pagination_edge_cases(self, client): + """Test cart list pagination edge cases""" + # Test offset beyond available items + response = await client.get("/cart/?offset=10000&limit=10") + # Should return empty list or 404 + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + + async def test_cart_quantity_filter_boundary(self, client): + """Test cart quantity filters at boundaries""" + # Create item and cart + item = await client.post("/item/", json={"name": "Item", "price": 10.0}) + item_id = item.json()["id"] + + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + + # Add exactly 5 items + for _ in range(5): + await client.post(f"/cart/{cart_id}/add/{item_id}") + + # Test exact match - may return OK or NOT_FOUND depending on other carts + response = await client.get("/cart/?min_quantity=5&max_quantity=5") + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + + +class TestValidationErrors: + """Tests for validation and error responses""" + + async def test_item_update_without_upsert_not_found(self, client): + """Test updating non-existent item without upsert returns NOT_MODIFIED""" + response = await client.put( + "/item/99999", + json={"name": "NonExistent", "price": 50.0} + ) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + async def test_item_upsert_existing(self, client): + """Test upsert on existing item updates it""" + # Create item + create_response = await client.post("/item/", json={"name": "Original", "price": 10.0}) + item_id = create_response.json()["id"] + + # Upsert should update + response = await client.put( + f"/item/{item_id}?upsert=true", + json={"name": "Updated", "price": 20.0} + ) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["name"] == "Updated" + assert data["price"] == 20.0 + + async def test_add_nonexistent_item_to_cart(self, client): + """Test adding non-existent item to cart""" + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + + response = await client.post(f"/cart/{cart_id}/add/999888777") + assert response.status_code == HTTPStatus.NOT_FOUND + assert "not found" in response.json()["detail"] + + async def test_add_item_to_nonexistent_cart(self, client): + """Test adding item to non-existent cart""" + item = await client.post("/item/", json={"name": "Item", "price": 10.0}) + item_id = item.json()["id"] + + response = await client.post(f"/cart/999888777/add/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert "not found" in response.json()["detail"] + + async def test_get_carts_with_combined_filters(self, client): + """Test getting carts with multiple filters""" + # Create items + item1 = await client.post("/item/", json={"name": "Cheap", "price": 5.0}) + item2 = await client.post("/item/", json={"name": "Mid", "price": 50.0}) + + item1_id = item1.json()["id"] + item2_id = item2.json()["id"] + + # Create carts + cart1 = await client.post("/cart/") + cart1_id = cart1.json()["id"] + await client.post(f"/cart/{cart1_id}/add/{item1_id}") + + cart2 = await client.post("/cart/") + cart2_id = cart2.json()["id"] + await client.post(f"/cart/{cart2_id}/add/{item2_id}") + + # Filter by price range + response = await client.get("/cart/?min_price=10&max_price=100") + assert response.status_code == HTTPStatus.OK + + +class TestSlowEndpoint: + """Tests for slow endpoint edge cases""" + + async def test_slow_endpoint_with_custom_delay(self, client): + """Test slow endpoint with various delay values""" + # Test with 0 delay + response = await client.get("/item/slow?delay=0") + assert response.status_code == HTTPStatus.OK + + # Test with small delay + response = await client.get("/item/slow?delay=0.01") + assert response.status_code == HTTPStatus.OK + assert "Delayed response" in response.json()["message"] + + async def test_slow_endpoint_edge_delays(self, client): + """Test slow endpoint with edge case delays""" + # Test maximum allowed delay + response = await client.get("/item/slow?delay=30") + assert response.status_code == HTTPStatus.OK + + # Test minimum delay + response = await client.get("/item/slow?delay=0") + assert response.status_code == HTTPStatus.OK diff --git a/hw2/hw/tests/e2e/test_item_api.py b/hw2/hw/tests/e2e/test_item_api.py new file mode 100644 index 00000000..1ba3c8bf --- /dev/null +++ b/hw2/hw/tests/e2e/test_item_api.py @@ -0,0 +1,271 @@ +"""E2E tests for Item API + +Tests all item endpoints through the HTTP API layer. +These tests verify the complete flow: HTTP → Routes → Queries → Database. +""" +from http import HTTPStatus + + +class TestItemCRUD: + """Tests for basic Item CRUD operations""" + + async def test_create_item(self, client): + """Test POST /item/ - create new item""" + response = await client.post( + "/item/", + json={"name": "Test Item", "price": 100.0} + ) + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data["name"] == "Test Item" + assert data["price"] == 100.0 + assert data["deleted"] is False + assert "id" in data + assert response.headers["location"] == f"/item/{data['id']}" + + async def test_get_item_by_id(self, client): + """Test GET /item/{id} - get item by ID""" + # Create item first + create_response = await client.post( + "/item/", + json={"name": "Test Item", "price": 50.0} + ) + item_id = create_response.json()["id"] + + # Get item + response = await client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["id"] == item_id + assert data["name"] == "Test Item" + assert data["price"] == 50.0 + + async def test_get_item_not_found(self, client): + """Test GET /item/{id} - item not found""" + response = await client.get("/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + assert "was not found" in response.json()["detail"] + + async def test_update_item(self, client): + """Test PUT /item/{id} - update existing item""" + # Create item + create_response = await client.post("/item/", json={"name": "Old Name", "price": 10.0}) + item_id = create_response.json()["id"] + + # Update item + response = await client.put( + f"/item/{item_id}", + json={"name": "New Name", "price": 20.0} + ) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["id"] == item_id + assert data["name"] == "New Name" + assert data["price"] == 20.0 + + async def test_update_item_not_found(self, client): + """Test PUT /item/{id} - item not found without upsert""" + response = await client.put( + "/item/999999", + json={"name": "Test", "price": 10.0} + ) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + async def test_patch_item_name(self, client): + """Test PATCH /item/{id} - update only name""" + # Create item + create_response = await client.post("/item/", json={"name": "Original", "price": 100.0}) + item_id = create_response.json()["id"] + + # Patch name only + response = await client.patch(f"/item/{item_id}", json={"name": "Patched Name"}) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["name"] == "Patched Name" + assert data["price"] == 100.0 # Price unchanged + + async def test_patch_item_price(self, client): + """Test PATCH /item/{id} - update only price""" + # Create item + create_response = await client.post("/item/", json={"name": "Item", "price": 50.0}) + item_id = create_response.json()["id"] + + # Patch price only + response = await client.patch(f"/item/{item_id}", json={"price": 75.0}) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["name"] == "Item" # Name unchanged + assert data["price"] == 75.0 + + async def test_patch_item_not_found(self, client): + """Test PATCH /item/{id} - item not found""" + response = await client.patch("/item/999999", json={"name": "Test"}) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + async def test_delete_item(self, client): + """Test DELETE /item/{id} - soft delete item""" + # Create item + create_response = await client.post("/item/", json={"name": "To Delete", "price": 10.0}) + item_id = create_response.json()["id"] + + # Delete item + response = await client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["deleted"] is True + + async def test_delete_item_not_found(self, client): + """Test DELETE /item/{id} - item not found""" + response = await client.delete("/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestItemUpsert: + """Tests for item upsert functionality""" + + async def test_upsert_item_create_new(self, client): + """Test PUT /item/{id}?upsert=true - create new item""" + response = await client.put( + "/item/12345?upsert=true", + json={"name": "Upserted Item", "price": 30.0} + ) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["id"] == 12345 + assert data["name"] == "Upserted Item" + + async def test_upsert_item_update_existing(self, client): + """Test PUT /item/{id}?upsert=true - update existing item""" + # Create item + create_response = await client.post("/item/", json={"name": "Original", "price": 10.0}) + item_id = create_response.json()["id"] + + # Upsert (update) + response = await client.put( + f"/item/{item_id}?upsert=true", + json={"name": "Updated via Upsert", "price": 40.0} + ) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["name"] == "Updated via Upsert" + assert data["price"] == 40.0 + + +class TestItemList: + """Tests for item listing and filtering""" + + async def test_get_items_list_empty(self, client): + """Test GET /item/ - empty list""" + response = await client.get("/item/") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert isinstance(data, list) + + async def test_get_items_list_with_items(self, client): + """Test GET /item/ - list with items""" + # Create items + await client.post("/item/", json={"name": "Item 1", "price": 10.0}) + await client.post("/item/", json={"name": "Item 2", "price": 20.0}) + + response = await client.get("/item/") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) >= 2 + + async def test_get_items_list_with_pagination(self, client): + """Test GET /item/ - pagination""" + # Create multiple items + for i in range(5): + await client.post("/item/", json={"name": f"Item {i}", "price": float(i * 10)}) + + # Test offset and limit + response = await client.get("/item/?offset=1&limit=2") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) <= 2 + + async def test_get_items_list_with_price_filter(self, client): + """Test GET /item/ - price filtering""" + # Create items with different prices + await client.post("/item/", json={"name": "Cheap", "price": 10.0}) + await client.post("/item/", json={"name": "Expensive", "price": 100.0}) + + # Filter by min_price + response = await client.get("/item/?min_price=50") + assert response.status_code == HTTPStatus.OK + data = response.json() + for item in data: + assert item["price"] >= 50 + + # Filter by max_price + response = await client.get("/item/?max_price=50") + assert response.status_code == HTTPStatus.OK + data = response.json() + for item in data: + assert item["price"] <= 50 + + async def test_get_items_list_show_deleted(self, client): + """Test GET /item/?show_deleted=true""" + # Create and delete item + create_response = await client.post("/item/", json={"name": "ToDelUnique123", "price": 15.0}) + item_id = create_response.json()["id"] + await client.delete(f"/item/{item_id}") + + # Without show_deleted + response = await client.get("/item/") + data = response.json() + deleted_items = [item for item in data if item.get("id") == item_id] + assert len(deleted_items) == 0 + + # With show_deleted - should include deleted items + response = await client.get("/item/?show_deleted=true") + data = response.json() + # Check if any deleted items exist in response + has_deleted = any(item.get("deleted", False) for item in data) + # The deleted item might or might not appear based on pagination, just check endpoint works + assert response.status_code == 200 + + +class TestItemDeleted: + """Tests for deleted item behavior""" + + async def test_get_deleted_item_not_found(self, client): + """Test GET /item/{id} - deleted item returns 404""" + # Create and delete item + create_response = await client.post( + "/item/", + json={"name": "To Delete", "price": 10.0} + ) + item_id = create_response.json()["id"] + await client.delete(f"/item/{item_id}") + + # Try to get deleted item + response = await client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + async def test_patch_deleted_item(self, client): + """Test PATCH /item/{id} - cannot patch deleted item""" + # Create and delete item + create_response = await client.post("/item/", json={"name": "Item", "price": 10.0}) + item_id = create_response.json()["id"] + await client.delete(f"/item/{item_id}") + + # Try to patch + response = await client.patch(f"/item/{item_id}", json={"name": "New Name"}) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + +class TestItemSlowEndpoint: + """Tests for slow endpoint""" + + async def test_slow_endpoint(self, client): + """Test GET /item/slow - slow endpoint""" + response = await client.get("/item/slow?delay=0.1") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert "Delayed response" in data["message"] + + async def test_slow_endpoint_default_delay(self, client): + """Test GET /item/slow - default delay""" + response = await client.get("/item/slow") + assert response.status_code == HTTPStatus.OK diff --git a/hw2/hw/tests/e2e/test_validation.py b/hw2/hw/tests/e2e/test_validation.py new file mode 100644 index 00000000..99c30a22 --- /dev/null +++ b/hw2/hw/tests/e2e/test_validation.py @@ -0,0 +1,159 @@ +"""E2E tests for input validation + +Tests that API properly validates input data and rejects invalid requests. +""" +import pytest +from http import HTTPStatus + + +class TestItemValidation: + """Tests for item input validation""" + + async def test_create_item_with_negative_price(self, client): + """Test that negative price is rejected""" + response = await client.post( + "/item/", + json={"name": "Invalid Item", "price": -10.0} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + error = response.json() + assert "price" in str(error).lower() + + async def test_create_item_with_zero_price_rejected(self, client): + """Test that zero price is rejected (price must be > 0)""" + response = await client.post( + "/item/", + json={"name": "Free Item", "price": 0.0} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + error = response.json() + assert "price" in str(error).lower() + + async def test_create_item_with_empty_name(self, client): + """Test that empty name is rejected""" + response = await client.post( + "/item/", + json={"name": "", "price": 10.0} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + error = response.json() + assert "name" in str(error).lower() + + async def test_create_item_with_too_long_name(self, client): + """Test that very long name is rejected""" + long_name = "A" * 256 # Max is 255 + response = await client.post( + "/item/", + json={"name": long_name, "price": 10.0} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + error = response.json() + assert "name" in str(error).lower() + + async def test_create_item_with_invalid_price_type(self, client): + """Test that non-numeric price is rejected""" + response = await client.post( + "/item/", + json={"name": "Test", "price": "not_a_number"} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + async def test_create_item_missing_required_fields(self, client): + """Test that missing required fields are rejected""" + # Missing price + response = await client.post( + "/item/", + json={"name": "Test"} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + # Missing name + response = await client.post( + "/item/", + json={"price": 10.0} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + async def test_patch_item_with_negative_price(self, client): + """Test that patch with negative price is rejected""" + # Create item first + create_resp = await client.post( + "/item/", + json={"name": "Test", "price": 10.0} + ) + item_id = create_resp.json()["id"] + + # Try to patch with negative price + response = await client.patch( + f"/item/{item_id}", + json={"price": -5.0} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + async def test_patch_item_with_empty_name(self, client): + """Test that patch with empty name is rejected""" + # Create item first + create_resp = await client.post( + "/item/", + json={"name": "Test", "price": 10.0} + ) + item_id = create_resp.json()["id"] + + # Try to patch with empty name + response = await client.patch( + f"/item/{item_id}", + json={"name": ""} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + async def test_put_item_with_invalid_data(self, client): + """Test that PUT with invalid data is rejected""" + response = await client.put( + "/item/1", + json={"name": "", "price": -10.0} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +class TestValidItemsAfterValidation: + """Tests to ensure valid items still work after adding validation""" + + async def test_create_item_with_valid_data_still_works(self, client): + """Test that valid items are still accepted""" + response = await client.post( + "/item/", + json={"name": "Valid Item", "price": 99.99} + ) + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data["name"] == "Valid Item" + assert data["price"] == 99.99 + + async def test_create_item_with_minimum_valid_name(self, client): + """Test that single character name is accepted""" + response = await client.post( + "/item/", + json={"name": "A", "price": 1.0} + ) + assert response.status_code == HTTPStatus.CREATED + + async def test_create_item_with_maximum_valid_name(self, client): + """Test that 255 character name is accepted""" + max_name = "A" * 255 + response = await client.post( + "/item/", + json={"name": max_name, "price": 1.0} + ) + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert len(data["name"]) == 255 + + async def test_create_item_with_small_positive_price(self, client): + """Test that very small positive price is accepted""" + response = await client.post( + "/item/", + json={"name": "Penny", "price": 0.01} + ) + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data["price"] == 0.01 diff --git a/hw2/hw/tests/e2e/test_workflows.py b/hw2/hw/tests/e2e/test_workflows.py new file mode 100644 index 00000000..5600d271 --- /dev/null +++ b/hw2/hw/tests/e2e/test_workflows.py @@ -0,0 +1,256 @@ +"""E2E tests for complete user workflows + +Tests complex, multi-step scenarios that simulate real user interactions. +""" +from http import HTTPStatus + + +class TestShoppingWorkflows: + """Tests for complete shopping workflows""" + + async def test_full_shopping_workflow(self, client): + """Test complete shopping workflow""" + # Create items + item1 = await client.post("/item/", json={"name": "Laptop", "price": 1000.0}) + item2 = await client.post("/item/", json={"name": "Mouse", "price": 25.0}) + item3 = await client.post("/item/", json={"name": "Keyboard", "price": 75.0}) + + item1_id = item1.json()["id"] + item2_id = item2.json()["id"] + item3_id = item3.json()["id"] + + # Create cart + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + + # Add items to cart + await client.post(f"/cart/{cart_id}/add/{item1_id}") + await client.post(f"/cart/{cart_id}/add/{item2_id}") + await client.post(f"/cart/{cart_id}/add/{item2_id}") # Buy 2 mice + await client.post(f"/cart/{cart_id}/add/{item3_id}") + + # Check final cart + cart_response = await client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + + assert len(cart_data["items"]) == 3 + # 1000 + 25*2 + 75 = 1125 + assert cart_data["price"] == 1125.0 + + # Find mouse item and check quantity + mouse_item = [item for item in cart_data["items"] if item["id"] == item2_id][0] + assert mouse_item["quantity"] == 2 + + async def test_cart_with_mix_of_available_and_deleted_items(self, client): + """Test cart contains both available and deleted items""" + # Create items + item1 = await client.post("/item/", json={"name": "Available", "price": 10.0}) + item2 = await client.post("/item/", json={"name": "ToDelete", "price": 20.0}) + + item1_id = item1.json()["id"] + item2_id = item2.json()["id"] + + # Create cart and add items + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + await client.post(f"/cart/{cart_id}/add/{item1_id}") + await client.post(f"/cart/{cart_id}/add/{item2_id}") + + # Delete one item + await client.delete(f"/item/{item2_id}") + + # Check cart + cart_response = await client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + + assert len(cart_data["items"]) == 2 + available_items = [item for item in cart_data["items"] if item["available"]] + unavailable_items = [item for item in cart_data["items"] if not item["available"]] + + assert len(available_items) == 1 + assert len(unavailable_items) == 1 + + async def test_update_item_preserves_old_cart_price(self, client): + """Test that updating item price doesn't affect existing cart prices""" + # Create item + item = await client.post("/item/", json={"name": "Item", "price": 50.0}) + item_id = item.json()["id"] + + # Create cart and add item + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + await client.post(f"/cart/{cart_id}/add/{item_id}") + await client.post(f"/cart/{cart_id}/add/{item_id}") # Quantity 2 + + # Check initial price + cart_response = await client.get(f"/cart/{cart_id}") + initial_price = cart_response.json()["price"] + assert initial_price == 100.0 # 50 * 2 + + # Update item price + await client.put(f"/item/{item_id}", json={"name": "Item", "price": 100.0}) + + # Check cart - price should remain the same (stored at add time) + cart_response = await client.get(f"/cart/{cart_id}") + cart_data = cart_response.json() + + # Price should remain unchanged - cart stores price at add time + assert cart_data["price"] == 100.0 + + async def test_multiple_carts_with_same_items(self, client): + """Test multiple carts can contain the same items""" + # Create item + item = await client.post("/item/", json={"name": "PopularItem", "price": 30.0}) + item_id = item.json()["id"] + + # Create multiple carts + cart1 = await client.post("/cart/") + cart2 = await client.post("/cart/") + cart3 = await client.post("/cart/") + + cart1_id = cart1.json()["id"] + cart2_id = cart2.json()["id"] + cart3_id = cart3.json()["id"] + + # Add same item to all carts + await client.post(f"/cart/{cart1_id}/add/{item_id}") + await client.post(f"/cart/{cart2_id}/add/{item_id}") + await client.post(f"/cart/{cart2_id}/add/{item_id}") + await client.post(f"/cart/{cart3_id}/add/{item_id}") + + # Verify each cart + c1 = await client.get(f"/cart/{cart1_id}") + c2 = await client.get(f"/cart/{cart2_id}") + c3 = await client.get(f"/cart/{cart3_id}") + + assert c1.json()["price"] == 30.0 + assert c2.json()["price"] == 60.0 + assert c3.json()["price"] == 30.0 + + async def test_complex_cart_scenario_with_quantity_changes(self, client): + """Test complex cart scenario""" + # Create items + item1 = await client.post("/item/", json={"name": "A", "price": 10.0}) + item2 = await client.post("/item/", json={"name": "B", "price": 15.0}) + + item1_id = item1.json()["id"] + item2_id = item2.json()["id"] + + # Create cart + cart = await client.post("/cart/") + cart_id = cart.json()["id"] + + # Add item1 three times + await client.post(f"/cart/{cart_id}/add/{item1_id}") + await client.post(f"/cart/{cart_id}/add/{item1_id}") + await client.post(f"/cart/{cart_id}/add/{item1_id}") + + # Add item2 twice + await client.post(f"/cart/{cart_id}/add/{item2_id}") + response = await client.post(f"/cart/{cart_id}/add/{item2_id}") + + data = response.json() + assert len(data["items"]) == 2 + # 10*3 + 15*2 = 60 + assert data["price"] == 60.0 + + item1_in_cart = [item for item in data["items"] if item["id"] == item1_id][0] + item2_in_cart = [item for item in data["items"] if item["id"] == item2_id][0] + + assert item1_in_cart["quantity"] == 3 + assert item2_in_cart["quantity"] == 2 + + +class TestItemManagementWorkflows: + """Tests for item management workflows""" + + async def test_patch_item_multiple_fields(self, client): + """Test patching multiple fields at once""" + # Create item + item = await client.post("/item/", json={"name": "Original", "price": 50.0}) + item_id = item.json()["id"] + + # Patch both name and price + response = await client.patch( + f"/item/{item_id}", + json={"name": "Updated", "price": 75.0} + ) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["name"] == "Updated" + assert data["price"] == 75.0 + + async def test_create_many_items_and_list(self, client): + """Test creating many items and listing them""" + # Create 15 items + for i in range(15): + await client.post("/item/", json={"name": f"Item{i}", "price": float(i * 5)}) + + # Test pagination + response = await client.get("/item/?offset=0&limit=5") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) <= 5 + + response = await client.get("/item/?offset=5&limit=5") + assert response.status_code == HTTPStatus.OK + + response = await client.get("/item/?offset=10&limit=10") + assert response.status_code == HTTPStatus.OK + + +class TestFilteringWorkflows: + """Tests for filtering workflows""" + + async def test_cart_list_filters_all_combinations(self, client): + """Test cart list with all filter combinations""" + # Create items with different prices + cheap = await client.post("/item/", json={"name": "Cheap", "price": 5.0}) + mid = await client.post("/item/", json={"name": "Mid", "price": 50.0}) + expensive = await client.post("/item/", json={"name": "Expensive", "price": 200.0}) + + cheap_id = cheap.json()["id"] + mid_id = mid.json()["id"] + + # Create carts with different characteristics + cart1 = await client.post("/cart/") + cart1_id = cart1.json()["id"] + await client.post(f"/cart/{cart1_id}/add/{cheap_id}") + + cart2 = await client.post("/cart/") + cart2_id = cart2.json()["id"] + await client.post(f"/cart/{cart2_id}/add/{mid_id}") + await client.post(f"/cart/{cart2_id}/add/{mid_id}") + + cart3 = await client.post("/cart/") + cart3_id = cart3.json()["id"] + for _ in range(5): + await client.post(f"/cart/{cart3_id}/add/{cheap_id}") + + # Test various filter combinations + response = await client.get("/cart/?min_price=10&max_price=150") + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + + response = await client.get("/cart/?min_quantity=2") + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + + response = await client.get("/cart/?min_price=10&min_quantity=2") + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + + async def test_item_list_with_all_filter_combinations(self, client): + """Test item list with various filter combinations""" + # Create items with different prices + await client.post("/item/", json={"name": "Low", "price": 10.0}) + await client.post("/item/", json={"name": "Mid", "price": 50.0}) + await client.post("/item/", json={"name": "High", "price": 100.0}) + + # Test various combinations + response = await client.get("/item/?min_price=20&max_price=80") + assert response.status_code == HTTPStatus.OK + data = response.json() + for item in data: + assert 20 <= item["price"] <= 80 + + response = await client.get("/item/?offset=1&limit=1") + assert response.status_code == HTTPStatus.OK diff --git a/hw2/hw/tests/integration/__init__.py b/hw2/hw/tests/integration/__init__.py new file mode 100644 index 00000000..742e0ddf --- /dev/null +++ b/hw2/hw/tests/integration/__init__.py @@ -0,0 +1,5 @@ +"""Integration tests package + +Integration tests test the interaction between multiple components, +typically involving a real database but without the HTTP layer. +""" diff --git a/hw2/hw/tests/integration/test_cart_queries.py b/hw2/hw/tests/integration/test_cart_queries.py new file mode 100644 index 00000000..bda55154 --- /dev/null +++ b/hw2/hw/tests/integration/test_cart_queries.py @@ -0,0 +1,470 @@ +"""Integration tests for cart queries + +Tests the cart database operations in shop_api/data/cart_queries.py +These tests use a real database session to verify queries work correctly. +""" +from shop_api.data.db_models import CartDB, ItemDB +from shop_api.data.models import CartInfo, CartItemInfo, PatchCartInfo +from shop_api.data import cart_queries + + +class TestCartQueriesAdd: + """Tests for cart_queries.add function""" + + async def test_add_empty_cart(self, db_session): + """Test creating empty cart""" + info = CartInfo(items=[], price=0.0) + cart = await cart_queries.add(db_session, info) + + assert cart.id is not None + assert cart.info.price == 0.0 + assert len(cart.info.items) == 0 + + async def test_add_cart_with_items(self, db_session): + """Test creating cart with items and price calculation""" + # Create items first + db_session.add_all([ + ItemDB(id=1, name="Apple", price=2.0, deleted=False), + ItemDB(id=2, name="Banana", price=3.0, deleted=False), + ]) + await db_session.flush() + + info = CartInfo( + items=[ + CartItemInfo(id=1, name="Apple", quantity=2, available=True), + CartItemInfo(id=2, name="Banana", quantity=1, available=True), + ], + price=0.0 + ) + + cart = await cart_queries.add(db_session, info) + + assert cart.id is not None + assert cart.info.price == 7.0 # 2*2 + 3*1 + assert len(cart.info.items) == 2 + + async def test_add_cart_with_nonexistent_item(self, db_session): + """Test creating cart with non-existent item (should skip it)""" + db_session.add(ItemDB(id=1, name="Apple", price=2.0, deleted=False)) + await db_session.flush() + + info = CartInfo( + items=[ + CartItemInfo(id=1, name="Apple", quantity=1, available=True), + CartItemInfo(id=999, name="Ghost", quantity=1, available=True), + ], + price=0.0 + ) + + cart = await cart_queries.add(db_session, info) + + assert len(cart.info.items) == 1 # Only Apple added + assert cart.info.price == 2.0 + + +class TestCartQueriesGetOne: + """Tests for cart_queries.get_one function""" + + async def test_get_one_existing_cart(self, db_session): + """Test getting existing cart""" + db_session.add(CartDB(id=1, price=10.0)) + await db_session.flush() + + cart = await cart_queries.get_one(db_session, 1) + + assert cart is not None + assert cart.id == 1 + + async def test_get_one_nonexistent_cart(self, db_session): + """Test getting non-existent cart returns None""" + cart = await cart_queries.get_one(db_session, 999) + assert cart is None + + async def test_get_one_with_items(self, db_session): + """Test getting cart with items""" + # Create items + db_session.add_all([ + ItemDB(id=1, name="Pen", price=2.0, deleted=False), + ItemDB(id=2, name="Pencil", price=1.0, deleted=False), + ]) + await db_session.flush() + + # Create cart with items + info = CartInfo( + items=[ + CartItemInfo(id=1, name="Pen", quantity=2, available=True), + CartItemInfo(id=2, name="Pencil", quantity=3, available=True), + ], + price=0.0 + ) + cart = await cart_queries.add(db_session, info) + + # Get cart + retrieved = await cart_queries.get_one(db_session, cart.id) + + assert retrieved is not None + assert len(retrieved.info.items) == 2 + assert retrieved.info.price == 7.0 # 2*2 + 1*3 + + +class TestCartQueriesGetMany: + """Tests for cart_queries.get_many function""" + + async def test_get_many_empty_database(self, db_session): + """Test getting carts from empty database""" + carts = await cart_queries.get_many(db_session) + assert isinstance(carts, list) + + async def test_get_many_skips_none_cart_entities(self, db_session): + """Test that get_many continues when get_one returns None (line 100)""" + # Create a cart directly in DB without proper setup + # This simulates a corrupted cart that get_one can't load + from shop_api.data.db_models import CartDB + from unittest.mock import patch, AsyncMock + + # Add a cart to database + db_session.add(CartDB(id=999, price=10.0)) + await db_session.flush() + + # Mock get_one to return None for this cart + original_get_one = cart_queries.get_one + + async def mock_get_one(session, cart_id): + if cart_id == 999: + return None # Simulate corrupted cart + return await original_get_one(session, cart_id) + + with patch("shop_api.data.cart_queries.get_one", side_effect=mock_get_one): + carts = await cart_queries.get_many(db_session) + # Should skip the None cart (line 100: continue) + assert all(cart is not None for cart in carts) + + async def test_get_many_with_pagination(self, db_session): + """Test pagination works correctly""" + # Create 5 carts + for i in range(5): + db_session.add(CartDB(price=float(i * 10))) + await db_session.flush() + + # Get first 2 + carts = await cart_queries.get_many(db_session, offset=0, limit=2) + assert len(carts) == 2 + + # Get next 2 + carts = await cart_queries.get_many(db_session, offset=2, limit=2) + assert len(carts) == 2 + + async def test_get_many_with_price_filter(self, db_session): + """Test price filtering""" + db_session.add_all([ + CartDB(price=10.0), + CartDB(price=50.0), + CartDB(price=100.0), + ]) + await db_session.flush() + + # Filter by min_price + carts = await cart_queries.get_many(db_session, min_price=40.0) + assert len(carts) == 2 + assert all(cart.info.price >= 40.0 for cart in carts) + + # Filter by max_price + carts = await cart_queries.get_many(db_session, max_price=60.0) + assert len(carts) == 2 + assert all(cart.info.price <= 60.0 for cart in carts) + + # Filter by range + carts = await cart_queries.get_many(db_session, min_price=40.0, max_price=60.0) + assert len(carts) == 1 + assert carts[0].info.price == 50.0 + + async def test_get_many_with_quantity_filter(self, db_session): + """Test quantity filtering""" + # Create items + db_session.add_all([ + ItemDB(id=1, name="Item", price=10.0, deleted=False), + ]) + await db_session.flush() + + # Create carts with different quantities + info1 = CartInfo(items=[CartItemInfo(id=1, name="Item", quantity=2, available=True)], price=0.0) + info2 = CartInfo(items=[CartItemInfo(id=1, name="Item", quantity=5, available=True)], price=0.0) + info3 = CartInfo(items=[CartItemInfo(id=1, name="Item", quantity=10, available=True)], price=0.0) + + await cart_queries.add(db_session, info1) + await cart_queries.add(db_session, info2) + await cart_queries.add(db_session, info3) + + # Filter by min_quantity + carts = await cart_queries.get_many(db_session, min_quantity=5) + assert len(carts) == 2 + for cart in carts: + total = sum(item.quantity for item in cart.info.items) + assert total >= 5 + + # Filter by max_quantity + carts = await cart_queries.get_many(db_session, max_quantity=5) + assert len(carts) == 2 + + +class TestCartQueriesUpdate: + """Tests for cart_queries.update function""" + + async def test_update_existing_cart(self, db_session): + """Test updating existing cart""" + # Create items + db_session.add_all([ + ItemDB(id=1, name="Item1", price=10.0, deleted=False), + ItemDB(id=2, name="Item2", price=20.0, deleted=False), + ]) + await db_session.flush() + + # Create cart + info = CartInfo(items=[CartItemInfo(id=1, name="Item1", quantity=1, available=True)], price=0.0) + cart = await cart_queries.add(db_session, info) + assert cart.info.price == 10.0 + + # Update cart with different items + new_info = CartInfo(items=[CartItemInfo(id=2, name="Item2", quantity=2, available=True)], price=0.0) + updated = await cart_queries.update(db_session, cart.id, new_info) + + assert updated is not None + assert updated.info.price == 40.0 # 20*2 + assert len(updated.info.items) == 1 + assert updated.info.items[0].id == 2 + + async def test_update_nonexistent_cart(self, db_session): + """Test updating non-existent cart returns None""" + info = CartInfo(items=[], price=0.0) + result = await cart_queries.update(db_session, 999, info) + assert result is None + + +class TestCartQueriesUpsert: + """Tests for cart_queries.upsert function""" + + async def test_upsert_creates_new_cart(self, db_session): + """Test upsert creates new cart when it doesn't exist""" + db_session.add(ItemDB(id=1, name="Book", price=10.0, deleted=False)) + await db_session.flush() + + info = CartInfo(items=[CartItemInfo(id=1, name="Book", quantity=1, available=True)], price=0.0) + cart = await cart_queries.upsert(db_session, 100, info) + + assert cart.id == 100 + assert cart.info.price == 10.0 + + async def test_upsert_updates_existing_cart(self, db_session): + """Test upsert updates existing cart""" + # Create items + db_session.add_all([ + ItemDB(id=1, name="Book", price=10.0, deleted=False), + ItemDB(id=2, name="Pen", price=2.0, deleted=False), + ]) + await db_session.flush() + + # Create initial cart + info = CartInfo(items=[CartItemInfo(id=1, name="Book", quantity=1, available=True)], price=0.0) + cart = await cart_queries.upsert(db_session, 1, info) + assert cart.info.price == 10.0 + + # Upsert with new data + new_info = CartInfo(items=[CartItemInfo(id=1, name="Book", quantity=2, available=True)], price=0.0) + updated = await cart_queries.upsert(db_session, 1, new_info) + assert updated.info.price == 20.0 + + +class TestCartQueriesPatch: + """Tests for cart_queries.patch function""" + + async def test_patch_existing_cart(self, db_session): + """Test patching existing cart""" + # Create items + db_session.add_all([ + ItemDB(id=1, name="Item1", price=10.0, deleted=False), + ItemDB(id=2, name="Item2", price=20.0, deleted=False), + ]) + await db_session.flush() + + # Create cart + info = CartInfo(items=[CartItemInfo(id=1, name="Item1", quantity=1, available=True)], price=0.0) + cart = await cart_queries.add(db_session, info) + + # Patch with new items + patch_info = PatchCartInfo(items=[CartItemInfo(id=2, name="Item2", quantity=1, available=True)]) + patched = await cart_queries.patch(db_session, cart.id, patch_info) + + assert patched is not None + assert patched.info.price == 20.0 + + async def test_patch_nonexistent_cart(self, db_session): + """Test patching non-existent cart returns None""" + patch_info = PatchCartInfo(items=[]) + result = await cart_queries.patch(db_session, 999, patch_info) + assert result is None + + async def test_patch_with_none_items(self, db_session): + """Test patching cart with None items (no change)""" + # Create cart + db_session.add(CartDB(id=1, price=50.0)) + await db_session.flush() + + # Patch with None items + patch_info = PatchCartInfo(items=None) + patched = await cart_queries.patch(db_session, 1, patch_info) + + assert patched is not None + assert patched.info.price == 50.0 # Price unchanged + + +class TestCartQueriesDelete: + """Tests for cart_queries.delete function""" + + async def test_delete_existing_cart(self, db_session): + """Test deleting existing cart""" + db_session.add(CartDB(id=1, price=10.0)) + await db_session.flush() + + await cart_queries.delete(db_session, 1) + + # Verify cart is deleted + cart = await cart_queries.get_one(db_session, 1) + assert cart is None + + async def test_delete_nonexistent_cart(self, db_session): + """Test deleting non-existent cart (should not raise error)""" + await cart_queries.delete(db_session, 999) + # Should complete without error + + +class TestCartQueriesAddItemToCart: + """Tests for cart_queries.add_item_to_cart function""" + + async def test_add_item_to_empty_cart(self, db_session): + """Test adding item to empty cart""" + db_session.add_all([ + CartDB(id=1, price=0.0), + ItemDB(id=1, name="Pen", price=2.0, deleted=False), + ]) + await db_session.flush() + + cart = await cart_queries.add_item_to_cart(db_session, 1, 1, 3) + + assert cart is not None + assert len(cart.info.items) == 1 + assert cart.info.items[0].quantity == 3 + assert cart.info.price == 6.0 + + async def test_add_item_increases_quantity(self, db_session): + """Test adding item that already exists increases quantity""" + db_session.add_all([ + CartDB(id=1, price=0.0), + ItemDB(id=1, name="Pen", price=2.0, deleted=False), + ]) + await db_session.flush() + + # Add item first time + await cart_queries.add_item_to_cart(db_session, 1, 1, 1) + + # Add same item again + cart = await cart_queries.add_item_to_cart(db_session, 1, 1, 2) + + assert cart is not None + assert len(cart.info.items) == 1 + assert cart.info.items[0].quantity == 3 # 1 + 2 + assert cart.info.price == 6.0 + + async def test_add_item_to_nonexistent_cart(self, db_session): + """Test adding item to non-existent cart returns None""" + db_session.add(ItemDB(id=1, name="Item", price=10.0, deleted=False)) + await db_session.flush() + + result = await cart_queries.add_item_to_cart(db_session, 999, 1, 1) + assert result is None + + async def test_add_nonexistent_item_to_cart(self, db_session): + """Test adding non-existent item to cart returns None""" + db_session.add(CartDB(id=1, price=0.0)) + await db_session.flush() + + result = await cart_queries.add_item_to_cart(db_session, 1, 999, 1) + assert result is None + + +class TestCartQueriesRemoveItemFromCart: + """Tests for cart_queries.remove_item_from_cart function""" + + async def test_remove_item_from_cart(self, db_session): + """Test removing item from cart""" + # Create items and cart + db_session.add_all([ + ItemDB(id=1, name="Item1", price=10.0, deleted=False), + ItemDB(id=2, name="Item2", price=20.0, deleted=False), + ]) + await db_session.flush() + + info = CartInfo( + items=[ + CartItemInfo(id=1, name="Item1", quantity=1, available=True), + CartItemInfo(id=2, name="Item2", quantity=1, available=True), + ], + price=0.0 + ) + cart = await cart_queries.add(db_session, info) + assert cart.info.price == 30.0 + + # Remove one item + updated = await cart_queries.remove_item_from_cart(db_session, cart.id, 1) + + assert updated is not None + assert len(updated.info.items) == 1 + assert updated.info.items[0].id == 2 + assert updated.info.price == 20.0 + + async def test_remove_nonexistent_item_from_cart(self, db_session): + """Test removing non-existent item from cart returns None""" + db_session.add(CartDB(id=1, price=0.0)) + await db_session.flush() + + result = await cart_queries.remove_item_from_cart(db_session, 1, 999) + assert result is None + + async def test_remove_item_from_nonexistent_cart(self, db_session): + """Test removing item from non-existent cart returns None""" + result = await cart_queries.remove_item_from_cart(db_session, 999, 1) + assert result is None + + +class TestCalculatePrice: + """Tests for cart_queries._calculate_price helper function""" + + async def test_calculate_price_empty_cart(self, db_session): + """Test calculating price of empty cart""" + db_session.add(CartDB(id=1, price=0.0)) + await db_session.flush() + + price = await cart_queries._calculate_price(db_session, 1) + assert price == 0.0 + + async def test_calculate_price_with_items(self, db_session): + """Test calculating price with multiple items""" + # Create items + db_session.add_all([ + ItemDB(id=1, name="Item1", price=10.0, deleted=False), + ItemDB(id=2, name="Item2", price=5.0, deleted=False), + ]) + await db_session.flush() + + # Create cart + info = CartInfo( + items=[ + CartItemInfo(id=1, name="Item1", quantity=2, available=True), + CartItemInfo(id=2, name="Item2", quantity=3, available=True), + ], + price=0.0 + ) + cart = await cart_queries.add(db_session, info) + + # Calculate price + price = await cart_queries._calculate_price(db_session, cart.id) + assert price == 35.0 # 10*2 + 5*3 diff --git a/hw2/hw/tests/integration/test_database_session.py b/hw2/hw/tests/integration/test_database_session.py new file mode 100644 index 00000000..2562387a --- /dev/null +++ b/hw2/hw/tests/integration/test_database_session.py @@ -0,0 +1,114 @@ +"""Integration tests for database session management + +Tests the get_db() function and database session handling in shop_api/database.py +""" +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from shop_api import database + + +class TestGetDbIntegration: + """Integration tests for get_db with real session""" + + async def test_get_db_yields_async_session(self): + """Test that get_db yields an AsyncSession instance""" + gen = database.get_db() + session_iterator = gen.__aiter__() + + try: + session = await session_iterator.__anext__() + assert isinstance(session, AsyncSession) + + # Close the generator properly + await session_iterator.aclose() + except StopAsyncIteration: + pass + + async def test_get_db_context_manager_usage(self): + """Test get_db can be used as async context manager""" + async for session in database.get_db(): + assert isinstance(session, AsyncSession) + # Just test one iteration + break + + +class TestDatabaseFunctions: + """Tests for database utility functions""" + + async def test_create_and_drop_tables(self): + """Test create_tables and drop_tables functions""" + from shop_api.database import create_tables, drop_tables + + # These functions are typically called at startup/shutdown + # Testing them ensures they work correctly + try: + await create_tables() + await drop_tables() + await create_tables() # Recreate for other tests + except Exception as e: + # Tables might already exist, that's okay + pytest.skip(f"Database operation failed: {e}") + + +class TestModelIntegration: + """Integration tests for models with database""" + + async def test_create_item_in_db(self, db_session): + """Test creating ItemDB instance in database""" + from shop_api.data.db_models import ItemDB + + item = ItemDB(name="Integration Test Item", price=25.5, deleted=False) + db_session.add(item) + await db_session.flush() + + assert item.id is not None + assert item.name == "Integration Test Item" + assert item.price == 25.5 + assert item.deleted is False + + async def test_create_cart_in_db(self, db_session): + """Test creating CartDB instance in database""" + from shop_api.data.db_models import CartDB + + cart = CartDB(price=0.0) + db_session.add(cart) + await db_session.flush() + + assert cart.id is not None + assert cart.price == 0.0 + + async def test_item_cart_relationship(self, db_session): + """Test many-to-many relationship between items and carts""" + from shop_api.data.db_models import ItemDB, CartDB, cart_items_table + from sqlalchemy import insert, select + + # Create item and cart + item = ItemDB(name="Relational Item", price=10.0, deleted=False) + cart = CartDB(price=0.0) + + db_session.add(item) + db_session.add(cart) + await db_session.flush() + + # Add relationship through association table + await db_session.execute( + insert(cart_items_table).values( + cart_id=cart.id, + item_id=item.id, + quantity=2 + ) + ) + await db_session.flush() + + # Verify the relationship was created + result = await db_session.execute( + select(cart_items_table).where( + cart_items_table.c.cart_id == cart.id, + cart_items_table.c.item_id == item.id + ) + ) + row = result.first() + + assert row is not None + assert row.quantity == 2 diff --git a/hw2/hw/tests/integration/test_item_queries.py b/hw2/hw/tests/integration/test_item_queries.py new file mode 100644 index 00000000..4dcec8b8 --- /dev/null +++ b/hw2/hw/tests/integration/test_item_queries.py @@ -0,0 +1,394 @@ +"""Integration tests for item queries + +Tests the item database operations in shop_api/data/item_queries.py +These tests use a real database session to verify queries work correctly. +""" +from sqlalchemy import select + +from shop_api.data.db_models import ItemDB +from shop_api.data.models import ItemInfo, PatchItemInfo +from shop_api.data import item_queries + + +class TestItemQueriesAdd: + """Tests for item_queries.add function""" + + async def test_add_creates_item(self, db_session): + """Test creating new item""" + info = ItemInfo(name="Book", price=10.0, deleted=False) + item = await item_queries.add(db_session, info) + + assert item.id is not None + assert item.info.name == "Book" + assert item.info.price == 10.0 + assert item.info.deleted is False + + # Verify in database + result = await db_session.execute( + select(ItemDB).where(ItemDB.id == item.id) + ) + db_item = result.scalar_one() + assert db_item.name == "Book" + assert db_item.price == 10.0 + + async def test_add_creates_item_with_deleted_flag(self, db_session): + """Test creating item with deleted=True""" + info = ItemInfo(name="OldItem", price=5.0, deleted=True) + item = await item_queries.add(db_session, info) + + assert item.info.deleted is True + + +class TestItemQueriesGetOne: + """Tests for item_queries.get_one function""" + + async def test_get_one_returns_correct_item(self, db_session): + """Test getting existing item by ID""" + db_session.add(ItemDB(id=1, name="Pen", price=2.5, deleted=False)) + await db_session.flush() + + item = await item_queries.get_one(db_session, 1) + + assert item is not None + assert item.id == 1 + assert item.info.name == "Pen" + assert item.info.price == 2.5 + assert not item.info.deleted + + async def test_get_one_returns_none_if_not_found(self, db_session): + """Test getting non-existent item returns None""" + item = await item_queries.get_one(db_session, 999) + assert item is None + + async def test_get_one_returns_deleted_item(self, db_session): + """Test getting deleted item (it's still returned by get_one)""" + db_session.add(ItemDB(id=1, name="Deleted", price=1.0, deleted=True)) + await db_session.flush() + + item = await item_queries.get_one(db_session, 1) + assert item is not None + assert item.info.deleted is True + + +class TestItemQueriesGetMany: + """Tests for item_queries.get_many function""" + + async def test_get_many_returns_empty_list(self, db_session): + """Test getting items from empty database""" + items = await item_queries.get_many(db_session) + assert items == [] + + async def test_get_many_returns_all_non_deleted_items(self, db_session): + """Test getting all non-deleted items""" + db_session.add_all([ + ItemDB(name="Item1", price=10.0, deleted=False), + ItemDB(name="Item2", price=20.0, deleted=False), + ItemDB(name="Deleted", price=5.0, deleted=True), + ]) + await db_session.flush() + + items = await item_queries.get_many(db_session) + assert len(items) == 2 + assert all(not item.info.deleted for item in items) + + async def test_get_many_with_show_deleted(self, db_session): + """Test getting items including deleted ones""" + db_session.add_all([ + ItemDB(name="Active", price=10.0, deleted=False), + ItemDB(name="Deleted", price=5.0, deleted=True), + ]) + await db_session.flush() + + items = await item_queries.get_many(db_session, show_deleted=True) + assert len(items) == 2 + + async def test_get_many_with_price_filters(self, db_session): + """Test filtering items by price""" + db_session.add_all([ + ItemDB(name="Cheap", price=1.0, deleted=False), + ItemDB(name="Medium", price=50.0, deleted=False), + ItemDB(name="Expensive", price=100.0, deleted=False), + ]) + await db_session.flush() + + # Filter by min_price + items = await item_queries.get_many(db_session, min_price=10.0) + assert len(items) == 2 + assert all(item.info.price >= 10.0 for item in items) + + # Filter by max_price + items = await item_queries.get_many(db_session, max_price=60.0) + assert len(items) == 2 + assert all(item.info.price <= 60.0 for item in items) + + # Filter by range + items = await item_queries.get_many(db_session, min_price=10.0, max_price=60.0) + assert len(items) == 1 + assert items[0].info.price == 50.0 + + async def test_get_many_with_pagination(self, db_session): + """Test pagination with offset and limit""" + # Create 5 items + for i in range(5): + db_session.add(ItemDB(name=f"Item{i}", price=float(i * 10), deleted=False)) + await db_session.flush() + + # Get first 2 + items = await item_queries.get_many(db_session, offset=0, limit=2) + assert len(items) == 2 + + # Get next 2 + items = await item_queries.get_many(db_session, offset=2, limit=2) + assert len(items) == 2 + + # Get with large offset + items = await item_queries.get_many(db_session, offset=10, limit=10) + assert len(items) == 0 + + async def test_get_many_excludes_deleted_by_default(self, db_session): + """Test that deleted items are excluded by default""" + db_session.add_all([ + ItemDB(name="Active1", price=10.0, deleted=False), + ItemDB(name="Active2", price=20.0, deleted=False), + ItemDB(name="Deleted1", price=5.0, deleted=True), + ItemDB(name="Deleted2", price=15.0, deleted=True), + ]) + await db_session.flush() + + items = await item_queries.get_many(db_session, show_deleted=False) + assert len(items) == 2 + + +class TestItemQueriesDelete: + """Tests for item_queries.delete function""" + + async def test_delete_marks_item_as_deleted(self, db_session): + """Test soft delete marks item as deleted""" + db_session.add(ItemDB(id=1, name="Toy", price=5.0, deleted=False)) + await db_session.flush() + + deleted_item = await item_queries.delete(db_session, 1) + + assert deleted_item is not None + assert deleted_item.info.deleted is True + + # Verify in database + result = await db_session.execute(select(ItemDB).where(ItemDB.id == 1)) + db_item = result.scalar_one() + assert db_item.deleted is True + + async def test_delete_returns_none_if_not_found(self, db_session): + """Test deleting non-existent item returns None""" + result = await item_queries.delete(db_session, 999) + assert result is None + + async def test_delete_already_deleted_item(self, db_session): + """Test deleting already deleted item""" + db_session.add(ItemDB(id=1, name="AlreadyDeleted", price=10.0, deleted=True)) + await db_session.flush() + + result = await item_queries.delete(db_session, 1) + assert result is not None + assert result.info.deleted is True + + +class TestItemQueriesUpdate: + """Tests for item_queries.update function""" + + async def test_update_modifies_existing_item(self, db_session): + """Test updating existing item""" + db_session.add(ItemDB(id=1, name="Pen", price=2.0, deleted=False)) + await db_session.flush() + + info = ItemInfo(name="Pencil", price=3.0, deleted=False) + updated = await item_queries.update(db_session, 1, info) + + assert updated is not None + assert updated.info.name == "Pencil" + assert updated.info.price == 3.0 + + # Verify in database + result = await db_session.execute(select(ItemDB).where(ItemDB.id == 1)) + db_item = result.scalar_one() + assert db_item.name == "Pencil" + assert db_item.price == 3.0 + + async def test_update_returns_none_if_not_exists(self, db_session): + """Test updating non-existent item returns None""" + info = ItemInfo(name="Ghost", price=9.99, deleted=False) + result = await item_queries.update(db_session, 999, info) + assert result is None + + async def test_update_can_change_deleted_flag(self, db_session): + """Test update can change deleted flag""" + db_session.add(ItemDB(id=1, name="Item", price=10.0, deleted=False)) + await db_session.flush() + + info = ItemInfo(name="Item", price=10.0, deleted=True) + updated = await item_queries.update(db_session, 1, info) + + assert updated.info.deleted is True + + +class TestItemQueriesUpsert: + """Tests for item_queries.upsert function""" + + async def test_upsert_creates_if_not_exists(self, db_session): + """Test upsert creates new item if it doesn't exist""" + info = ItemInfo(name="Apple", price=1.5, deleted=False) + created = await item_queries.upsert(db_session, 100, info) + + assert created.id == 100 + assert created.info.name == "Apple" + assert created.info.price == 1.5 + + # Verify in database + result = await db_session.execute(select(ItemDB).where(ItemDB.id == 100)) + db_item = result.scalar_one() + assert db_item.name == "Apple" + + async def test_upsert_updates_if_exists(self, db_session): + """Test upsert updates existing item""" + db_session.add(ItemDB(id=1, name="Apple", price=1.5, deleted=False)) + await db_session.flush() + + updated_info = ItemInfo(name="Green Apple", price=2.0, deleted=False) + updated = await item_queries.upsert(db_session, 1, updated_info) + + assert updated.id == 1 + assert updated.info.name == "Green Apple" + assert updated.info.price == 2.0 + + # Verify in database + result = await db_session.execute(select(ItemDB).where(ItemDB.id == 1)) + db_item = result.scalar_one() + assert db_item.name == "Green Apple" + assert db_item.price == 2.0 + + async def test_upsert_with_specific_id(self, db_session): + """Test upsert with specific ID""" + info = ItemInfo(name="SpecificID", price=50.0, deleted=False) + item = await item_queries.upsert(db_session, 42, info) + + assert item.id == 42 + + +class TestItemQueriesPatch: + """Tests for item_queries.patch function""" + + async def test_patch_updates_partial_fields_name_only(self, db_session): + """Test patching only name field""" + db_session.add(ItemDB(id=1, name="Table", price=50.0, deleted=False)) + await db_session.flush() + + patch_info = PatchItemInfo(name="Desk", price=None, deleted=None) + patched = await item_queries.patch(db_session, 1, patch_info) + + assert patched is not None + assert patched.info.name == "Desk" + assert patched.info.price == 50.0 # Unchanged + + async def test_patch_updates_partial_fields_price_only(self, db_session): + """Test patching only price field""" + db_session.add(ItemDB(id=1, name="Chair", price=30.0, deleted=False)) + await db_session.flush() + + patch_info = PatchItemInfo(name=None, price=40.0, deleted=None) + patched = await item_queries.patch(db_session, 1, patch_info) + + assert patched is not None + assert patched.info.name == "Chair" # Unchanged + assert patched.info.price == 40.0 + + async def test_patch_updates_multiple_fields(self, db_session): + """Test patching multiple fields""" + db_session.add(ItemDB(id=1, name="Old", price=10.0, deleted=False)) + await db_session.flush() + + patch_info = PatchItemInfo(name="New", price=20.0, deleted=None) + patched = await item_queries.patch(db_session, 1, patch_info) + + assert patched.info.name == "New" + assert patched.info.price == 20.0 + + async def test_patch_returns_none_if_item_not_found(self, db_session): + """Test patching non-existent item returns None""" + patch_info = PatchItemInfo(name="Ghost", price=None, deleted=None) + result = await item_queries.patch(db_session, 999, patch_info) + assert result is None + + async def test_patch_with_empty_fields(self, db_session): + """Test patching with all None fields (no change)""" + db_session.add(ItemDB(id=1, name="Unchanged", price=15.0, deleted=False)) + await db_session.flush() + + patch_info = PatchItemInfo(name=None, price=None, deleted=None) + patched = await item_queries.patch(db_session, 1, patch_info) + + assert patched.info.name == "Unchanged" + assert patched.info.price == 15.0 + + async def test_patch_can_change_deleted_flag(self, db_session): + """Test patch can change deleted flag""" + db_session.add(ItemDB(id=1, name="Item", price=10.0, deleted=False)) + await db_session.flush() + + patch_info = PatchItemInfo(name=None, price=None, deleted=True) + patched = await item_queries.patch(db_session, 1, patch_info) + + assert patched.info.deleted is True + + +class TestItemQueriesEdgeCases: + """Tests for edge cases and boundary conditions""" + + async def test_add_item_with_zero_price(self, db_session): + """Test creating item with zero price""" + info = ItemInfo(name="Free", price=0.0, deleted=False) + item = await item_queries.add(db_session, info) + + assert item.info.price == 0.0 + + async def test_add_item_with_very_large_price(self, db_session): + """Test creating item with very large price""" + info = ItemInfo(name="Expensive", price=999999.99, deleted=False) + item = await item_queries.add(db_session, info) + + assert item.info.price == 999999.99 + + async def test_get_many_with_zero_offset_and_limit(self, db_session): + """Test pagination with offset=0 and small limit""" + db_session.add_all([ + ItemDB(name="Item1", price=10.0, deleted=False), + ItemDB(name="Item2", price=20.0, deleted=False), + ItemDB(name="Item3", price=30.0, deleted=False), + ]) + await db_session.flush() + + items = await item_queries.get_many(db_session, offset=0, limit=1) + assert len(items) == 1 + + async def test_get_many_with_equal_min_max_price(self, db_session): + """Test filtering with min_price = max_price""" + db_session.add_all([ + ItemDB(name="Exact", price=50.0, deleted=False), + ItemDB(name="Higher", price=60.0, deleted=False), + ItemDB(name="Lower", price=40.0, deleted=False), + ]) + await db_session.flush() + + items = await item_queries.get_many(db_session, min_price=50.0, max_price=50.0) + assert len(items) == 1 + assert items[0].info.price == 50.0 + + async def test_update_deleted_item(self, db_session): + """Test updating a deleted item is possible""" + db_session.add(ItemDB(id=1, name="Deleted", price=10.0, deleted=True)) + await db_session.flush() + + info = ItemInfo(name="Restored", price=15.0, deleted=False) + updated = await item_queries.update(db_session, 1, info) + + assert updated is not None + assert updated.info.deleted is False + assert updated.info.name == "Restored" diff --git a/hw2/hw/tests/unit/__init__.py b/hw2/hw/tests/unit/__init__.py new file mode 100644 index 00000000..ef1434a4 --- /dev/null +++ b/hw2/hw/tests/unit/__init__.py @@ -0,0 +1,5 @@ +"""Unit tests package + +Unit tests test individual components in isolation, often using mocks. +They are fast and don't require external dependencies like databases. +""" diff --git a/hw2/hw/tests/unit/test_contracts.py b/hw2/hw/tests/unit/test_contracts.py new file mode 100644 index 00000000..bde4a531 --- /dev/null +++ b/hw2/hw/tests/unit/test_contracts.py @@ -0,0 +1,169 @@ +"""Unit tests for API contracts (Pydantic models) + +Tests the data validation and transformation logic in shop_api/api/shop/contracts.py +""" +import pytest +from pydantic import ValidationError + +from shop_api.api.shop.contracts import ( + ItemResponse, + ItemRequest, + PatchItemRequest, + CartResponse, + CartRequest, + PatchCartRequest, +) +from shop_api.data.models import ItemEntity, ItemInfo, CartEntity, CartInfo, CartItemInfo + + +class TestItemContracts: + """Tests for Item contracts (Pydantic models)""" + + def test_item_response_from_entity(self): + """Test ItemResponse.from_entity method""" + entity = ItemEntity( + id=1, + info=ItemInfo(name="Test Item", price=50.0, deleted=False) + ) + + response = ItemResponse.from_entity(entity) + assert response.id == 1 + assert response.name == "Test Item" + assert response.price == 50.0 + assert response.deleted is False + + def test_item_response_from_entity_with_deleted(self): + """Test ItemResponse.from_entity with deleted item""" + entity = ItemEntity( + id=1, + info=ItemInfo(name="Deleted", price=10.0, deleted=True) + ) + + response = ItemResponse.from_entity(entity) + assert response.id == 1 + assert response.deleted is True + + def test_item_request_as_item_info(self): + """Test ItemRequest.as_item_info method""" + request = ItemRequest(name="New Item", price=100.0) + + item_info = request.as_item_info() + assert item_info.name == "New Item" + assert item_info.price == 100.0 + assert item_info.deleted is False + + def test_item_request_as_item_info_sets_deleted_false(self): + """Test ItemRequest.as_item_info sets deleted=False""" + request = ItemRequest(name="New", price=10.0) + item_info = request.as_item_info() + + assert item_info.name == "New" + assert item_info.price == 10.0 + assert item_info.deleted is False + + def test_patch_item_request_as_patch_item_info_name_only(self): + """Test PatchItemRequest.as_patch_item_info with name only""" + request = PatchItemRequest(name="Updated Name") + + patch_info = request.as_patch_item_info() + assert patch_info.name == "Updated Name" + assert patch_info.price is None + assert patch_info.deleted is None + + def test_patch_item_request_as_patch_item_info_price_only(self): + """Test PatchItemRequest.as_patch_item_info with price only""" + request = PatchItemRequest(price=75.0) + + patch_info = request.as_patch_item_info() + assert patch_info.name is None + assert patch_info.price == 75.0 + assert patch_info.deleted is None + + def test_patch_item_request_as_patch_item_info_both(self): + """Test PatchItemRequest.as_patch_item_info with both fields""" + request = PatchItemRequest(name="Updated", price=125.0) + + patch_info = request.as_patch_item_info() + assert patch_info.name == "Updated" + assert patch_info.price == 125.0 + assert patch_info.deleted is None + + def test_patch_item_request_with_none_values(self): + """Test PatchItemRequest with all None values""" + request = PatchItemRequest() + patch_info = request.as_patch_item_info() + + assert patch_info.name is None + assert patch_info.price is None + assert patch_info.deleted is None + + def test_patch_item_request_extra_field_forbidden(self): + """Test PatchItemRequest validation with extra field""" + # This should raise ValidationError due to extra="forbid" + with pytest.raises(ValidationError): + PatchItemRequest(name="New", extra_field="forbidden") + + +class TestCartContracts: + """Tests for Cart contracts (Pydantic models)""" + + def test_cart_response_from_entity(self): + """Test CartResponse.from_entity method""" + entity = CartEntity( + id=1, + info=CartInfo( + items=[CartItemInfo(id=1, name="Item", quantity=2, available=True)], + price=20.0 + ) + ) + + response = CartResponse.from_entity(entity) + assert response.id == 1 + assert len(response.items) == 1 + assert response.price == 20.0 + + def test_cart_response_with_empty_items(self): + """Test CartResponse.from_entity with empty items list""" + entity = CartEntity( + id=1, + info=CartInfo(items=[], price=0.0) + ) + + response = CartResponse.from_entity(entity) + assert response.id == 1 + assert response.items == [] + assert response.price == 0.0 + + def test_cart_request_as_cart_info(self): + """Test CartRequest.as_cart_info method""" + request = CartRequest( + items=[CartItemInfo(id=1, name="Item", quantity=1, available=True)], + price=10.0 + ) + + cart_info = request.as_cart_info() + assert len(cart_info.items) == 1 + assert cart_info.price == 10.0 + + def test_patch_cart_request_as_patch_cart_info(self): + """Test PatchCartRequest.as_patch_cart_info method""" + request = PatchCartRequest( + items=[CartItemInfo(id=1, name="Item", quantity=2, available=True)] + ) + + patch_info = request.as_patch_cart_info() + assert patch_info.items is not None + assert len(patch_info.items) == 1 + + def test_patch_cart_request_with_none_items(self): + """Test PatchCartRequest with None items""" + request = PatchCartRequest() + patch_info = request.as_patch_cart_info() + + assert patch_info.items is None + + def test_patch_cart_request_extra_field_forbidden(self): + """Test PatchCartRequest validation with extra field""" + # This should raise ValidationError due to extra="forbid" + with pytest.raises(ValidationError): + PatchCartRequest(items=[], extra_field="forbidden") diff --git a/hw2/hw/tests/unit/test_database_config.py b/hw2/hw/tests/unit/test_database_config.py new file mode 100644 index 00000000..f844045f --- /dev/null +++ b/hw2/hw/tests/unit/test_database_config.py @@ -0,0 +1,106 @@ +"""Unit tests for database configuration + +Tests the database configuration and setup in shop_api/database.py +""" +import pytest +from unittest.mock import AsyncMock, patch +from sqlalchemy.orm import DeclarativeBase + + +class TestDatabaseConfiguration: + """Tests for database configuration""" + + def test_database_url_from_env(self, monkeypatch): + """Test DATABASE_URL can be set from environment variable""" + from shop_api import database + + test_url = "postgresql+asyncpg://test:test@testhost:5432/testdb" + monkeypatch.setenv("DATABASE_URL", test_url) + + # Reimport to get new DATABASE_URL + import importlib + importlib.reload(database) + + assert database.DATABASE_URL == test_url + + def test_database_url_default(self): + """Test DATABASE_URL has a default value""" + from shop_api import database + + # This test just verifies the module loads and has DATABASE_URL + assert hasattr(database, 'DATABASE_URL') + assert isinstance(database.DATABASE_URL, str) + assert 'postgresql+asyncpg://' in database.DATABASE_URL + + def test_engine_exists(self): + """Test that engine is created""" + from shop_api import database + + assert hasattr(database, 'engine') + assert database.engine is not None + + def test_async_session_local_exists(self): + """Test that AsyncSessionLocal is created""" + from shop_api import database + + assert hasattr(database, 'AsyncSessionLocal') + assert database.AsyncSessionLocal is not None + + def test_base_declarative_exists(self): + """Test that Base DeclarativeBase exists""" + from shop_api import database + + assert hasattr(database, 'Base') + assert issubclass(database.Base, DeclarativeBase) + + +class TestGetDbWithMocks: + """Tests for get_db() function with mocks""" + + async def test_get_db_success(self): + """Test that commit and close are called on success""" + from shop_api import database + + mock_session = AsyncMock() + mock_session.commit = AsyncMock() + mock_session.rollback = AsyncMock() + mock_session.close = AsyncMock() + + # Create a mock context manager + mock_context_manager = AsyncMock() + mock_context_manager.__aenter__.return_value = mock_session + mock_context_manager.__aexit__.return_value = None + + with patch("shop_api.database.AsyncSessionLocal", return_value=mock_context_manager): + async for session in database.get_db(): + # Session is yielded successfully + assert session == mock_session + + # Verify correct methods were called + mock_session.commit.assert_awaited_once() + mock_session.close.assert_awaited_once() + assert mock_session.rollback.await_count == 0 + + async def test_get_db_exception_during_commit(self): + """Test exception handling when commit fails""" + from shop_api import database + + mock_session = AsyncMock() + mock_session.commit = AsyncMock(side_effect=RuntimeError("Commit failed")) + mock_session.rollback = AsyncMock() + mock_session.close = AsyncMock() + + # Create a mock context manager + mock_context_manager = AsyncMock() + mock_context_manager.__aenter__.return_value = mock_session + mock_context_manager.__aexit__.return_value = None + + with patch("shop_api.database.AsyncSessionLocal", return_value=mock_context_manager): + with pytest.raises(RuntimeError, match="Commit failed"): + async for _session in database.get_db(): + pass # Normal work, commit happens in finally + + # Verify rollback and close were called after commit failure + mock_session.commit.assert_awaited_once() + mock_session.rollback.assert_awaited_once() + mock_session.close.assert_awaited_once() diff --git a/hw2/hw/tests/unit/test_db_models.py b/hw2/hw/tests/unit/test_db_models.py new file mode 100644 index 00000000..9b2d5bf3 --- /dev/null +++ b/hw2/hw/tests/unit/test_db_models.py @@ -0,0 +1,180 @@ +"""Unit tests for database models + +Tests the database model definitions in shop_api/data/db_models.py +""" +import pytest +from sqlalchemy.orm import DeclarativeBase + + +class TestDBModelsImport: + """Tests for db_models.py import fallback mechanism""" + + def test_normal_import_succeeds(self): + """Test that normal import works""" + # This tests the normal path (try block) + import shop_api.data.db_models as db_models + + # Verify Base is imported correctly + assert hasattr(db_models, "Base") + from shop_api.database import Base + # Just check they're both DeclarativeBase types + assert issubclass(db_models.Base, DeclarativeBase) + assert issubclass(Base, DeclarativeBase) + + + +class TestItemDBModel: + """Tests for ItemDB model""" + + def test_item_db_repr(self): + """Test ItemDB __repr__ method""" + from shop_api.data.db_models import ItemDB + + item = ItemDB(id=1, name="Test Item", price=10.5, deleted=False) + repr_str = repr(item) + + assert "