diff --git a/.github/workflows/hw-4-5-tests.yml b/.github/workflows/hw-4-5-tests.yml new file mode 100644 index 00000000..eb8e50ec --- /dev/null +++ b/.github/workflows/hw-4-5-tests.yml @@ -0,0 +1,76 @@ +name: "HW4-5 Tests" + +# Запускаем тесты при изменении файлов в hw4-5/** +on: + pull_request: + branches: [ main ] + paths: [ 'hw4-5/**' ] + push: + branches: [ main, hw-5 ] + paths: [ 'hw4-5/**' ] + +jobs: + test-hw4-5: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: shop_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --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 system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libpq-dev + + - name: Install Python dependencies + working-directory: hw4-5 + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Wait for PostgreSQL + run: | + until pg_isready -h localhost -p 5432 -U postgres; do + echo "Waiting for PostgreSQL..." + sleep 2 + done + + - name: Run tests with coverage + working-directory: hw4-5 + env: + PYTHONPATH: ${{ github.workspace }}/hw4-5 + DATABASE_URL: postgresql+psycopg2://postgres:password@localhost:5432/shop_db + run: | + coverage run --source=service -m pytest tests -v + coverage xml + coverage report --fail-under=95 + continue-on-error: false + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report-${{ matrix.python-version }} + path: hw4-5/coverage.xml \ No newline at end of file diff --git a/hw2/hw/client.py b/hw2/hw/client.py new file mode 100644 index 00000000..e56a18f4 --- /dev/null +++ b/hw2/hw/client.py @@ -0,0 +1,8 @@ +from websocket import create_connection + +ws = create_connection("ws://localhost:8000/chat/chat1") + +while True: + print(ws.recv()) + message = input("Enter message: ") + ws.send(message) \ No newline at end of file diff --git a/hw2/hw/client2.py b/hw2/hw/client2.py new file mode 100644 index 00000000..5a98735c --- /dev/null +++ b/hw2/hw/client2.py @@ -0,0 +1,8 @@ +from websocket import create_connection + +ws = create_connection("ws://localhost:8000/chat/chat2") + +while True: + print(ws.recv()) + message = input("Enter message: ") + ws.send(message) \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..ba0460cd 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -2,6 +2,9 @@ fastapi>=0.117.1 uvicorn>=0.24.0 +# Доп WebSocket +websocket-client>=1.8.0 + # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 diff --git a/hw2/hw/server.py b/hw2/hw/server.py new file mode 100644 index 00000000..45cd2606 --- /dev/null +++ b/hw2/hw/server.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass, field +from uuid import uuid4 +from typing import Optional + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect + +app = FastAPI(title="Chat API") + +@dataclass(slots=True) +class Broadcaster: + subscribers: dict[str, WebSocket] = field(init=False, default_factory=dict) + + async def subscribe(self, ws: WebSocket, username: str) -> None: + await ws.accept() + self.subscribers[username] = ws + + async def unsubscribe(self, username: str) -> None: + self.subscribers.pop(username, None) + + async def publish(self, message: str, sender_username: Optional[str] = None) -> None: + if sender_username is not None: + formatted_message = f"{sender_username} :: {message}" + for username, ws in self.subscribers.items(): + if username != sender_username: + await ws.send_text(formatted_message) + else: + for ws in self.subscribers.values(): + await ws.send_text(message) + +chat_channels: dict[str, Broadcaster] = {} + +@app.websocket("/chat/{chat_name}") +async def ws_chat(ws: WebSocket, chat_name: str): + username = str(uuid4().hex[:8]) + if chat_name not in chat_channels: + chat_channels[chat_name] = Broadcaster() + broadcaster = chat_channels[chat_name] + + await broadcaster.subscribe(ws, username) + await broadcaster.publish(f"user {username} joined the chat") + + try: + while True: + text = await ws.receive_text() + await broadcaster.publish(text, username) + except WebSocketDisconnect: + await broadcaster.unsubscribe(username) + await broadcaster.publish(f"left the chat", username) + await broadcaster.publish(f"user {username} left the chat") + if not broadcaster.subscribers: + chat_channels.pop(chat_name, None) + + \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..f8bc8361 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,163 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Query, Response +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional +from http import HTTPStatus app = FastAPI(title="Shop API") + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + +class ItemInCart(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[ItemInCart] = [] + price: float + +class ItemCreatingObj(BaseModel): + name: str + price: float = Field(..., gt=0) + +class ItemUpdatingObj(BaseModel): + name: Optional[str] = None + price: Optional[float] = None + + model_config = ConfigDict(extra="forbid") + +items_dict = {} +carts_dict = {} +item_id_counter = 0 +cart_id_counter = 0 + +@app.post("/cart", response_model=dict, status_code=HTTPStatus.CREATED) +async def create_cart(response: Response): + global cart_id_counter + cart_id_counter += 1 + carts_dict[cart_id_counter] = {"id": cart_id_counter, "items": [], "price": 0.0} + response.headers["location"] = f"/cart/{cart_id_counter}" + return {"id": cart_id_counter} + +@app.get("/cart/{id}", response_model=Cart) +async def get_cart(id: int): + if id not in carts_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + return carts_dict[id] + +@app.get("/cart", response_model=List[Cart]) +async def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +): + carts = list(carts_dict.values()) + if min_price is not None: + carts = [cart for cart in carts if cart["price"] >= min_price] + if max_price is not None: + carts = [cart for cart in carts if cart["price"] <= max_price] + if min_quantity is not None: + carts = [cart for cart in carts if sum(item["quantity"] for item in cart["items"]) >= min_quantity] + if max_quantity is not None: + carts = [cart for cart in carts if sum(item["quantity"] for item in cart["items"]) <= max_quantity] + return carts[offset:offset + limit] + +@app.post("/cart/{cart_id}/add/{item_id}", response_model=Cart) +async def add_item_to_cart(cart_id: int, item_id: int): + if cart_id not in carts_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + if item_id not in items_dict or items_dict[item_id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + cart = carts_dict[cart_id] + for cart_item in cart["items"]: + if cart_item["id"] == item_id: + cart_item["quantity"] += 1 + cart["price"] += items_dict[item_id]["price"] + return cart + + cart["items"].append({ + "id": item_id, + "name": items_dict[item_id]["name"], + "quantity": 1, + "available": True + }) + cart["price"] += items_dict[item_id]["price"] + return cart + +@app.post("/item", response_model=Item, status_code=HTTPStatus.CREATED) +async def create_item(item: ItemCreatingObj): + global item_id_counter + item_id_counter += 1 + items_dict[item_id_counter] = { + "id": item_id_counter, + "name": item.name, + "price": item.price, + "deleted": False + } + return items_dict[item_id_counter] + +@app.get("/item/{id}", response_model=Item) +async def get_item(id: int): + if id not in items_dict or items_dict[id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + return items_dict[id] + +@app.get("/item", response_model=List[Item]) +async def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + items = [item for item in items_dict.values() if show_deleted or not item["deleted"]] + if min_price is not None: + items = [item for item in items if item["price"] >= min_price] + if max_price is not None: + items = [item for item in items if item["price"] <= max_price] + return items[offset:offset + limit] + +@app.put("/item/{id}", response_model=Item) +async def update_item(id: int, item: ItemCreatingObj): + if id not in items_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + items_dict[id].update({ + "name": item.name, + "price": item.price, + "deleted": items_dict[id]["deleted"] + }) + return items_dict[id] + +@app.patch("/item/{id}", response_model=Item) +async def partial_update_item(id: int, item: ItemUpdatingObj): + if id not in items_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + if items_dict[id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_MODIFIED, detail="Item is deleted") + if item.name is not None: + items_dict[id]["name"] = item.name + if item.price is not None: + items_dict[id]["price"] = item.price + return items_dict[id] + +@app.delete("/item/{id}", response_model=dict) +async def delete_item(id: int): + if id not in items_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + items_dict[id]["deleted"] = True + for cart in carts_dict.values(): + for cart_item in cart["items"]: + if cart_item["id"] == id: + cart_item["available"] = False + cart["price"] -= cart_item["quantity"] * items_dict[id]["price"] + return {"status_code": HTTPStatus.OK} \ No newline at end of file diff --git a/hw3/Dockerfile b/hw3/Dockerfile new file mode 100644 index 00000000..27b0b636 --- /dev/null +++ b/hw3/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw3/ddoser.py b/hw3/ddoser.py new file mode 100644 index 00000000..c39e0650 --- /dev/null +++ b/hw3/ddoser.py @@ -0,0 +1,27 @@ +from concurrent.futures import ThreadPoolExecutor +import requests +from faker import Faker + +faker = Faker() + +def create_items(): + for _ in range(500): + response = requests.post( + "http://localhost:8080/item", + json={"name": faker.word(), "price": faker.pyfloat(min_value=1, max_value=100, positive=True)} + ) + print(f"Create item: {response.status_code}") + +def create_carts_and_add(): + for _ in range(500): + cart_response = requests.post("http://localhost:8080/cart") + if cart_response.status_code == 201: + cart_id = cart_response.json()["id"] + item_id = faker.random_int(min=1, max=50) + response = requests.post(f"http://localhost:8080/cart/{cart_id}/add/{item_id}") + print(f"Add to cart: {response.status_code}") + +with ThreadPoolExecutor() as executor: + for _ in range(5): + executor.submit(create_items) + executor.submit(create_carts_and_add) \ No newline at end of file diff --git a/hw3/docker-compose.yml b/hw3/docker-compose.yml new file mode 100644 index 00000000..d3ec3ffb --- /dev/null +++ b/hw3/docker-compose.yml @@ -0,0 +1,39 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-data:/var/lib/grafana + + 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: + grafana-data: diff --git a/hw3/grafana-reports/grafana-load.png b/hw3/grafana-reports/grafana-load.png new file mode 100644 index 00000000..8d986c90 Binary files /dev/null and b/hw3/grafana-reports/grafana-load.png differ diff --git a/hw3/grafana-reports/grafana-simple.png b/hw3/grafana-reports/grafana-simple.png new file mode 100644 index 00000000..f76e6541 Binary files /dev/null and b/hw3/grafana-reports/grafana-simple.png differ diff --git a/hw3/requirements.txt b/hw3/requirements.txt new file mode 100644 index 00000000..10df294a --- /dev/null +++ b/hw3/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +prometheus-fastapi-instrumentator +requests +faker \ No newline at end of file diff --git a/hw3/settings/prometheus/prometheus.yml b/hw3/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw3/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw3/shop_api/main.py b/hw3/shop_api/main.py new file mode 100644 index 00000000..83d7399e --- /dev/null +++ b/hw3/shop_api/main.py @@ -0,0 +1,165 @@ +from fastapi import FastAPI, HTTPException, Query, Response +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional +from http import HTTPStatus +from prometheus_fastapi_instrumentator import Instrumentator + +app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + +class ItemInCart(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[ItemInCart] = [] + price: float + +class ItemCreatingObj(BaseModel): + name: str + price: float = Field(..., gt=0) + +class ItemUpdatingObj(BaseModel): + name: Optional[str] = None + price: Optional[float] = None + + model_config = ConfigDict(extra="forbid") + +items_dict = {} +carts_dict = {} +item_id_counter = 0 +cart_id_counter = 0 + +@app.post("/cart", response_model=dict, status_code=HTTPStatus.CREATED) +async def create_cart(response: Response): + global cart_id_counter + cart_id_counter += 1 + carts_dict[cart_id_counter] = {"id": cart_id_counter, "items": [], "price": 0.0} + response.headers["location"] = f"/cart/{cart_id_counter}" + return {"id": cart_id_counter} + +@app.get("/cart/{id}", response_model=Cart) +async def get_cart(id: int): + if id not in carts_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + return carts_dict[id] + +@app.get("/cart", response_model=List[Cart]) +async def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +): + carts = list(carts_dict.values()) + if min_price is not None: + carts = [cart for cart in carts if cart["price"] >= min_price] + if max_price is not None: + carts = [cart for cart in carts if cart["price"] <= max_price] + if min_quantity is not None: + carts = [cart for cart in carts if sum(item["quantity"] for item in cart["items"]) >= min_quantity] + if max_quantity is not None: + carts = [cart for cart in carts if sum(item["quantity"] for item in cart["items"]) <= max_quantity] + return carts[offset:offset + limit] + +@app.post("/cart/{cart_id}/add/{item_id}", response_model=Cart) +async def add_item_to_cart(cart_id: int, item_id: int): + if cart_id not in carts_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + if item_id not in items_dict or items_dict[item_id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + cart = carts_dict[cart_id] + for cart_item in cart["items"]: + if cart_item["id"] == item_id: + cart_item["quantity"] += 1 + cart["price"] += items_dict[item_id]["price"] + return cart + + cart["items"].append({ + "id": item_id, + "name": items_dict[item_id]["name"], + "quantity": 1, + "available": True + }) + cart["price"] += items_dict[item_id]["price"] + return cart + +@app.post("/item", response_model=Item, status_code=HTTPStatus.CREATED) +async def create_item(item: ItemCreatingObj): + global item_id_counter + item_id_counter += 1 + items_dict[item_id_counter] = { + "id": item_id_counter, + "name": item.name, + "price": item.price, + "deleted": False + } + return items_dict[item_id_counter] + +@app.get("/item/{id}", response_model=Item) +async def get_item(id: int): + if id not in items_dict or items_dict[id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + return items_dict[id] + +@app.get("/item", response_model=List[Item]) +async def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + items = [item for item in items_dict.values() if show_deleted or not item["deleted"]] + if min_price is not None: + items = [item for item in items if item["price"] >= min_price] + if max_price is not None: + items = [item for item in items if item["price"] <= max_price] + return items[offset:offset + limit] + +@app.put("/item/{id}", response_model=Item) +async def update_item(id: int, item: ItemCreatingObj): + if id not in items_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + items_dict[id].update({ + "name": item.name, + "price": item.price, + "deleted": items_dict[id]["deleted"] + }) + return items_dict[id] + +@app.patch("/item/{id}", response_model=Item) +async def partial_update_item(id: int, item: ItemUpdatingObj): + if id not in items_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + if items_dict[id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_MODIFIED, detail="Item is deleted") + if item.name is not None: + items_dict[id]["name"] = item.name + if item.price is not None: + items_dict[id]["price"] = item.price + return items_dict[id] + +@app.delete("/item/{id}", response_model=dict) +async def delete_item(id: int): + if id not in items_dict: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + items_dict[id]["deleted"] = True + for cart in carts_dict.values(): + for cart_item in cart["items"]: + if cart_item["id"] == id: + cart_item["available"] = False + cart["price"] -= cart_item["quantity"] * items_dict[id]["price"] + return {"status_code": HTTPStatus.OK} \ No newline at end of file diff --git a/hw4-5/Dockerfile b/hw4-5/Dockerfile new file mode 100644 index 00000000..dd9f73c3 --- /dev/null +++ b/hw4-5/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR /app/src +COPY ./service ./service +COPY ./requirements.txt ./ +COPY ./tests ./tests + +ENV VIRTUAL_ENV=/app/src/.venv \ + PATH=/app/src/.venv/bin:$PATH +RUN python -m venv $VIRTUAL_ENV + +RUN pip install -r requirements.txt + +FROM base AS local + +CMD ["uvicorn", "service.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw4-5/docker-compose.yml b/hw4-5/docker-compose.yml new file mode 100644 index 00000000..384edfc5 --- /dev/null +++ b/hw4-5/docker-compose.yml @@ -0,0 +1,40 @@ +version: '3.8' + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + container_name: shop-api + restart: always + ports: + - "8800:8080" + environment: + - DATABASE_URL=postgresql+psycopg2://postgres:password@db:5432/shop_db + depends_on: + db: + condition: service_healthy + volumes: + - ./service:/app/src/service + + db: + image: postgres:15 + container_name: shop_db + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw4-5/isolation-scripts/dirty_read.py b/hw4-5/isolation-scripts/dirty_read.py new file mode 100644 index 00000000..dae81766 --- /dev/null +++ b/hw4-5/isolation-scripts/dirty_read.py @@ -0,0 +1,94 @@ +import sys, os, time +import threading +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session + +os.environ["DATABASE_URL"] = "postgresql+psycopg2://postgres:password@localhost:5433/shop_db" +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from service.main import ItemOrm, Base + +# Database connection +DATABASE_URL = "postgresql+psycopg2://postgres:password@localhost:5433/shop_db" +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def transaction_1(): + print("Transaction 1: Starting") + db: Session = SessionLocal() + try: + db.begin() + item = db.query(ItemOrm).filter_by(id=1).first() + if not item: + print("Transaction 1: Item not found, creating new one with price 150") + item = ItemOrm(id=1, name="Apple", price=150.0, deleted=False) + db.add(item) + db.flush() + else: + print(f"Transaction 1: Item found, it's price = {item.price}") + item.price = 200.0 + db.flush() + print("Transaction 1: Changed price to 200 (not committed)") + + time.sleep(5) + + print("Transaction 1: Rolling back") + db.rollback() + except Exception as e: + print(f"Transaction 1: Error - {e}") + db.rollback() + finally: + db.close() + +def transaction_2(): + # Wait for transaction 1 to change price + time.sleep(2) + + print("Transaction 2: Starting") + db: Session = SessionLocal() + try: + # Read item price in Read Committed + db.begin() + item = db.query(ItemOrm).filter_by(id=1).first() + if item: + print(f"Transaction 2: Read price = {item.price}") + else: + print("Transaction 2: No item found") + db.commit() + finally: + db.close() + +def run_dirty_read(): + db = SessionLocal() + try: + db.query(ItemOrm).filter(ItemOrm.id == 1).delete() + db.commit() + print("\nCleared item with id=1") + + item = ItemOrm(id=1, name="Apple", price=150.0, deleted=False) + db.add(item) + db.commit() + print("Created item with id=1 and price=150.0") + except Exception as e: + print(f"Setup: Error - {e}") + db.rollback() + finally: + db.close() + + Base.metadata.create_all(bind=engine) + + print("\n===== DIRTY READ TEST =====") + + print("\n=== Test with Read Committed ===") + print("(Read Uncommitted is not supported in Postgres where default isolation level is Read Committed)") + + t1 = threading.Thread(target=transaction_1) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + +if __name__ == "__main__": + run_dirty_read() \ No newline at end of file diff --git a/hw4-5/isolation-scripts/main.py b/hw4-5/isolation-scripts/main.py new file mode 100644 index 00000000..bd2029c6 --- /dev/null +++ b/hw4-5/isolation-scripts/main.py @@ -0,0 +1,16 @@ +import sys, os + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from dirty_read import run_dirty_read +from non_repeatable_read import run_non_repeatable_read +from phantom_read import run_phantom_read + +if __name__ == "__main__": + print("Starting all isolation level tests...") + + run_dirty_read() + run_non_repeatable_read() + run_phantom_read() + + print("\nAll tests completed.") \ No newline at end of file diff --git a/hw4-5/isolation-scripts/non_repeatable_read.py b/hw4-5/isolation-scripts/non_repeatable_read.py new file mode 100644 index 00000000..13e1145a --- /dev/null +++ b/hw4-5/isolation-scripts/non_repeatable_read.py @@ -0,0 +1,117 @@ +import sys, os, time +import threading +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.sql import text + +os.environ["DATABASE_URL"] = "postgresql+psycopg2://postgres:password@localhost:5433/shop_db" +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from service.main import ItemOrm, Base + +# Database connection +DATABASE_URL = "postgresql+psycopg2://postgres:password@localhost:5433/shop_db" +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def transaction_1(isolation_level: str): + print(f"Transaction 1: Starting with {isolation_level}") + db: Session = SessionLocal() + try: + db.execute(text(f"SET TRANSACTION ISOLATION LEVEL {isolation_level}")) + item = db.query(ItemOrm).filter_by(id=1).first() + if not item: + print("Transaction 1: Item not found, creating new one with price 150") + item = ItemOrm(id=1, name="Apple", price=150.0, deleted=False) + db.add(item) + db.flush() + print(f"Transaction 1: First read, price = {item.price}") + + time.sleep(5) + + db.expire(item) + item = db.query(ItemOrm).filter_by(id=1).first() + print(f"Transaction 1: Second read, price = {item.price}") + db.commit() + except Exception as e: + print(f"Transaction 1: Error - {e}") + db.rollback() + finally: + db.close() + +def transaction_2(): + # Wait for transaction 1 to do first read + time.sleep(2) + + print("Transaction 2: Starting") + db: Session = SessionLocal() + try: + db.begin() + item = db.query(ItemOrm).filter_by(id=1).first() + if item: + item.price = 200.0 + db.commit() + print("Transaction 2: Changed price to 200 and committed") + else: + print("Transaction 2: No item found") + except Exception as e: + print(f"Transaction 2: Error - {e}") + db.rollback() + finally: + db.close() + +def run_non_repeatable_read(): + db = SessionLocal() + try: + db.query(ItemOrm).filter(ItemOrm.id == 1).delete() + db.commit() + print("\nCleared item with id=1") + + item = ItemOrm(id=1, name="Apple", price=150.0, deleted=False) + db.add(item) + db.commit() + print("Created item with id=1 and price=150.0") + except Exception as e: + print(f"Setup: Error - {e}") + db.rollback() + finally: + db.close() + + Base.metadata.create_all(bind=engine) + + print("\n===== NON-REPEATABLE READ TEST =====") + + print("\n=== Test with Read Committed (should show non-repeatable read) ===") + t1 = threading.Thread(target=transaction_1, args=("READ COMMITTED",)) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Reset price to 150 + db = SessionLocal() + try: + item = db.query(ItemOrm).filter_by(id=1).first() + item.price = 150.0 + db.commit() + print("\nReset price to 150.0") + except Exception as e: + print(f"Reset: Error - {e}") + db.rollback() + finally: + db.close() + + print("\n=== Test with Repeatable Read (should not show non-repeatable read) ===") + t1 = threading.Thread(target=transaction_1, args=("REPEATABLE READ",)) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + +if __name__ == "__main__": + run_non_repeatable_read() \ No newline at end of file diff --git a/hw4-5/isolation-scripts/phantom_read.py b/hw4-5/isolation-scripts/phantom_read.py new file mode 100644 index 00000000..d43c42c9 --- /dev/null +++ b/hw4-5/isolation-scripts/phantom_read.py @@ -0,0 +1,141 @@ +import sys, os, time +import threading +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.sql import text + +os.environ["DATABASE_URL"] = "postgresql+psycopg2://postgres:password@localhost:5433/shop_db" +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from service.main import ItemOrm, Base + +# Database connection +DATABASE_URL = "postgresql+psycopg2://postgres:password@localhost:5433/shop_db" +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def transaction_1(isolation_level: str): + print(f"Transaction 1: Starting with {isolation_level}") + db: Session = SessionLocal() + try: + db.execute(text(f"SET TRANSACTION ISOLATION LEVEL {isolation_level}")) + items = db.query(ItemOrm).filter(ItemOrm.price > 100).all() + print(f"Transaction 1: First read, items = {[item.id for item in items]}") + + time.sleep(5) + + db.expire_all() + items = db.query(ItemOrm).filter(ItemOrm.price > 100).all() + print(f"Transaction 1: Second read, items = {[item.id for item in items]}") + db.commit() + except Exception as e: + print(f"Transaction 1: Error - {e}") + db.rollback() + finally: + db.close() + +def transaction_2(): + # Wait for transaction 1 to do first read + time.sleep(2) + + print("Transaction 2: Starting") + db: Session = SessionLocal() + try: + db.begin() + item = ItemOrm(id=2, name="Orange", price=200.0, deleted=False) + db.add(item) + db.commit() + print("Transaction 2: Added item with id=2 and price=200") + except Exception as e: + print(f"Transaction 2: Error - {e}") + db.rollback() + finally: + db.close() + +def run_phantom_read(): + db = SessionLocal() + try: + db.query(ItemOrm).filter(ItemOrm.id.in_([1, 2])).delete() + db.commit() + print("\nCleared items with id=1 and id=2") + + item = ItemOrm(id=1, name="Apple", price=150.0, deleted=False) + db.add(item) + db.commit() + print("Created item with id=1 and price=150.0") + except Exception as e: + print(f"Setup: Error - {e}") + db.rollback() + finally: + db.close() + + Base.metadata.create_all(bind=engine) + + print("\n===== PHANTOM READ TEST =====") + + print("\n=== Test with Read Committed (should show phantom read) ===") + t1 = threading.Thread(target=transaction_1, args=("READ COMMITTED",)) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Reset database + db = SessionLocal() + try: + db.query(ItemOrm).filter(ItemOrm.id.in_([1, 2])).delete() + db.commit() + print("\nCleared items with id=1 and id=2") + + item = ItemOrm(id=1, name="Apple", price=150.0, deleted=False) + db.add(item) + db.commit() + print("Created item with id=1 and price=150.0") + except Exception as e: + print(f"Reset: Error - {e}") + db.rollback() + finally: + db.close() + + print("\n=== Test with Repeatable Read (should show phantom read) ===") + print("(But Postgres realizes snapshot isolation which eliminated phantom read in most cases)") + t1 = threading.Thread(target=transaction_1, args=("REPEATABLE READ",)) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Reset database + db = SessionLocal() + try: + db.query(ItemOrm).filter(ItemOrm.id.in_([1, 2])).delete() + db.commit() + print("\nCleared items with id=1 and id=2") + + item = ItemOrm(id=1, name="Apple", price=150.0, deleted=False) + db.add(item) + db.commit() + print("Created item with id=1 and price=150.0") + except Exception as e: + print(f"Reset: Error - {e}") + db.rollback() + finally: + db.close() + + print("\n=== Test with Serializable (should not show phantom read) ===") + t1 = threading.Thread(target=transaction_1, args=("SERIALIZABLE",)) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + +if __name__ == "__main__": + run_phantom_read() \ No newline at end of file diff --git a/hw4-5/isolation-scripts/report.txt b/hw4-5/isolation-scripts/report.txt new file mode 100644 index 00000000..1bdbd3cc --- /dev/null +++ b/hw4-5/isolation-scripts/report.txt @@ -0,0 +1,71 @@ +Starting all isolation level tests... + +Cleared item with id=1 +Created item with id=1 and price=150.0 + +===== DIRTY READ TEST ===== + +=== Test with Read Committed === +(Read Uncommitted is not supported in Postgres where default isolation level is Read Committed) +Transaction 1: Starting +Transaction 1: Item found, it's price = 150.0 +Transaction 1: Changed price to 200 (not committed) +Transaction 2: Starting +Transaction 2: Read price = 150.0 +Transaction 1: Rolling back + +Cleared item with id=1 +Created item with id=1 and price=150.0 + +===== NON-REPEATABLE READ TEST ===== + +=== Test with Read Committed (should show non-repeatable read) === +Transaction 1: Starting with READ COMMITTED +Transaction 1: First read, price = 150.0 +Transaction 2: Starting +Transaction 2: Changed price to 200 and committed +Transaction 1: Second read, price = 200.0 + +Reset price to 150.0 + +=== Test with Repeatable Read (should not show non-repeatable read) === +Transaction 1: Starting with REPEATABLE READ +Transaction 1: First read, price = 150.0 +Transaction 2: Starting +Transaction 2: Changed price to 200 and committed +Transaction 1: Second read, price = 150.0 + +Cleared items with id=1 and id=2 +Created item with id=1 and price=150.0 + +===== PHANTOM READ TEST ===== + +=== Test with Read Committed (should show phantom read) === +Transaction 1: Starting with READ COMMITTED +Transaction 1: First read, items = [1] +Transaction 2: Starting +Transaction 2: Added item with id=2 and price=200 +Transaction 1: Second read, items = [1, 2] + +Cleared items with id=1 and id=2 +Created item with id=1 and price=150.0 + +=== Test with Repeatable Read (should show phantom read) === +(But Postgres realizes snapshot isolation which eliminated phantom read in most cases) +Transaction 1: Starting with REPEATABLE READ +Transaction 1: First read, items = [1] +Transaction 2: Starting +Transaction 2: Added item with id=2 and price=200 +Transaction 1: Second read, items = [1] + +Cleared items with id=1 and id=2 +Created item with id=1 and price=150.0 + +=== Test with Serializable (should not show phantom read) === +Transaction 1: Starting with SERIALIZABLE +Transaction 1: First read, items = [1] +Transaction 2: Starting +Transaction 2: Added item with id=2 and price=200 +Transaction 1: Second read, items = [1] + +All tests completed. \ No newline at end of file diff --git a/hw4-5/isolation-scripts/test.py b/hw4-5/isolation-scripts/test.py new file mode 100644 index 00000000..7576b1dc --- /dev/null +++ b/hw4-5/isolation-scripts/test.py @@ -0,0 +1,2 @@ +if __name__ == "__main__": + print("Hello, world!") \ No newline at end of file diff --git a/hw4-5/requirements.txt b/hw4-5/requirements.txt new file mode 100644 index 00000000..5731c5ed --- /dev/null +++ b/hw4-5/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.117.1 +pydantic>=2.0.0 +uvicorn>=0.24.0 +sqlalchemy>=2.0.25 +psycopg2-binary>=2.9.9 +pytest-cov +pytest-mock +pytest +pytest-asyncio +httpx +Faker diff --git a/hw4-5/service/main.py b/hw4-5/service/main.py new file mode 100644 index 00000000..10cd727d --- /dev/null +++ b/hw4-5/service/main.py @@ -0,0 +1,395 @@ +from fastapi import FastAPI, HTTPException, Query, Response, Depends +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional +from http import HTTPStatus +from dataclasses import dataclass, field +from abc import ABC, abstractmethod +import os +import time + +from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, ForeignKey +from sqlalchemy.orm import declarative_base +from sqlalchemy.orm import sessionmaker, Session, relationship + +app = FastAPI(title="Shop API") + +# === Доменные модели (без привязки к БД) === +@dataclass +class Item: + id: int = 1 + name: str = "" + price: float = 0.0 + deleted: bool = False + +@dataclass +class ItemInCart: + id: int = 1 + name: str = "" + quantity: int = 0 + available: bool = True + +@dataclass +class Cart: + id: int = 1 + items: List[ItemInCart] = field(default_factory=list) + price: float = 0.0 + +# === Pydantic модели === +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool = False + +class ItemInCartResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartResponse(BaseModel): + id: int + items: List[ItemInCartResponse] = [] + price: float + +class ItemCreatingObj(BaseModel): + name: str + price: float = Field(..., gt=0) + +class ItemUpdatingObj(BaseModel): + name: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + + model_config = ConfigDict(extra="forbid") + +# === SQLAlchemy модели (для мапинга с БД) === +Base = declarative_base() + +class ItemOrm(Base): + __tablename__ = 'items' + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + +class ItemInCartOrm(Base): + __tablename__ = 'items_in_cart' + id = Column(Integer, primary_key=True) + cart_id = Column(Integer, ForeignKey('carts.id'), nullable=False) + item_id = Column(Integer, ForeignKey('items.id'), nullable=False) + name = Column(String(255), nullable=False) + quantity = Column(Integer, nullable=False) + available = Column(Boolean, default=True) + cart = relationship("CartOrm", back_populates="items") + +class CartOrm(Base): + __tablename__ = 'carts' + id = Column(Integer, primary_key=True) + price = Column(Float, default=0.0) + items = relationship("ItemInCartOrm", back_populates="cart", cascade="all, delete-orphan") + +# === Мапперы (преобразование между доменными моделями и ORM) === +class ItemMapper: + """Маппер для преобразования между Item и ItemOrm""" + @staticmethod + def to_domain(orm_item: ItemOrm) -> Item: + """Преобразование ORM модели в доменную""" + return Item(id=orm_item.id, name=orm_item.name, price=orm_item.price, deleted=orm_item.deleted) + + @staticmethod + def to_orm(domain_item: Item, orm_item: Optional[ItemOrm] = None) -> ItemOrm: + """Преобразование доменной модели в ORM""" + if orm_item is None: + orm_item = ItemOrm() + orm_item.name = domain_item.name + orm_item.price = domain_item.price + orm_item.deleted = domain_item.deleted + return orm_item + +class ItemInCartMapper: + """Маппер для преобразования между ItemInCart и ItemInCartOrm""" + @staticmethod + def to_domain(orm_item: ItemInCartOrm) -> ItemInCart: + """Преобразование ORM модели в доменную""" + return ItemInCart(id=orm_item.item_id, name=orm_item.name, quantity=orm_item.quantity, available=orm_item.available) + + @staticmethod + def to_orm(domain_item: ItemInCart, orm_item: Optional[ItemInCartOrm] = None) -> ItemInCartOrm: + """Преобразование доменной модели в ORM""" + if orm_item is None: + orm_item = ItemInCartOrm() + orm_item.item_id = domain_item.id + orm_item.name = domain_item.name + orm_item.quantity = domain_item.quantity + orm_item.available = domain_item.available + return orm_item + +class CartMapper: + """Маппер для преобразования между Cart и CartOrm""" + @staticmethod + def to_domain(orm_cart: CartOrm) -> Cart: + """Преобразование ORM модели в доменную""" + items = [ItemInCartMapper.to_domain(item) for item in orm_cart.items] + return Cart(id=orm_cart.id, items=items, price=orm_cart.price) + + @staticmethod + def to_orm(domain_cart: Cart, orm_cart: Optional[CartOrm] = None) -> CartOrm: + """Преобразование доменной модели в ORM""" + if orm_cart is None: + orm_cart = CartOrm() + orm_cart.price = domain_cart.price + return orm_cart + +# === Абстрактные интерфейсы репозиториев === +class ItemRepositoryInterface(ABC): + """Интерфейс репозитория товаров""" + @abstractmethod + def create(self, item: Item) -> Item: + pass + @abstractmethod + def find_by_id(self, item_id: int) -> Optional[Item]: + pass + @abstractmethod + def get_all(self, offset: int, limit: int, min_price: Optional[float], max_price: Optional[float], show_deleted: bool) -> List[Item]: + pass + @abstractmethod + def update(self, item: Item) -> Item: + pass + @abstractmethod + def delete(self, item_id: int) -> None: + pass + +class CartRepositoryInterface(ABC): + """Интерфейс репозитория корзин""" + @abstractmethod + def create(self) -> Cart: + pass + @abstractmethod + def find_by_id(self, cart_id: int) -> Optional[Cart]: + pass + @abstractmethod + def get_all(self, offset: int, limit: int, min_price: Optional[float], max_price: Optional[float], min_quantity: Optional[int], max_quantity: Optional[int]) -> List[Cart]: + pass + @abstractmethod + def add_item(self, cart_id: int, item_id: int) -> Cart: + pass + +# === Конкретные реализации репозиториев === +class SqlAlchemyItemRepository(ItemRepositoryInterface): + """SQLAlchemy реализация репозитория товаров""" + def __init__(self, session: Session): + self.session = session + + def create(self, item: Item) -> Item: + orm_item = ItemMapper.to_orm(item) + self.session.add(orm_item) + self.session.flush() + return ItemMapper.to_domain(orm_item) + + def find_by_id(self, item_id: int) -> Optional[Item]: + orm_item = self.session.query(ItemOrm).filter_by(id=item_id).first() + return ItemMapper.to_domain(orm_item) if orm_item else None + + def get_all(self, offset: int, limit: int, min_price: Optional[float], max_price: Optional[float], show_deleted: bool) -> List[Item]: + query = self.session.query(ItemOrm) + if not show_deleted: + query = query.filter_by(deleted=False) + if min_price is not None: + query = query.filter(ItemOrm.price >= min_price) + if max_price is not None: + query = query.filter(ItemOrm.price <= max_price) + orm_items = query.offset(offset).limit(limit).all() + return [ItemMapper.to_domain(item) for item in orm_items] + + def update(self, item: Item) -> Item: + orm_item = self.session.query(ItemOrm).filter_by(id=item.id).first() + if not orm_item: + raise ValueError(f"Item with id {item.id} not found") + old_price = orm_item.price + ItemMapper.to_orm(item, orm_item) + orm_cart_items = self.session.query(ItemInCartOrm).filter_by(item_id=item.id).all() + for cart_item in orm_cart_items: + orm_cart = self.session.query(CartOrm).filter_by(id=cart_item.cart_id).first() + orm_cart.price += (item.price - old_price) * cart_item.quantity + self.session.flush() + return ItemMapper.to_domain(orm_item) + + def delete(self, item_id: int) -> None: + orm_item = self.session.query(ItemOrm).filter_by(id=item_id).first() + if not orm_item: + raise ValueError(f"Item with id {item_id} not found") + orm_item.deleted = True + for orm_cart_item in self.session.query(ItemInCartOrm).filter_by(item_id=item_id).all(): + orm_cart_item.available = False + orm_cart = self.session.query(CartOrm).filter_by(id=orm_cart_item.cart_id).first() + orm_cart.price -= orm_cart_item.quantity * orm_item.price + self.session.flush() + +class SqlAlchemyCartRepository(CartRepositoryInterface): + """SQLAlchemy реализация репозитория корзин""" + def __init__(self, session: Session): + self.session = session + + def create(self) -> Cart: + orm_cart = CartOrm(price=0.0) + self.session.add(orm_cart) + self.session.flush() + return CartMapper.to_domain(orm_cart) + + def find_by_id(self, cart_id: int) -> Optional[Cart]: + orm_cart = self.session.query(CartOrm).filter_by(id=cart_id).first() + return CartMapper.to_domain(orm_cart) if orm_cart else None + + def get_all(self, offset: int, limit: int, min_price: Optional[float], max_price: Optional[float], min_quantity: Optional[int], max_quantity: Optional[int]) -> List[Cart]: + from sqlalchemy.sql import func + query = self.session.query(CartOrm) + if min_price is not None: + query = query.filter(CartOrm.price >= min_price) + if max_price is not None: + query = query.filter(CartOrm.price <= max_price) + if min_quantity is not None or max_quantity is not None: + query = query.join(ItemInCartOrm).group_by(CartOrm.id) + if min_quantity is not None: + query = query.having(func.sum(ItemInCartOrm.quantity) >= min_quantity) + if max_quantity is not None: + query = query.having(func.sum(ItemInCartOrm.quantity) <= max_quantity) + orm_carts = query.offset(offset).limit(limit).all() + return [CartMapper.to_domain(cart) for cart in orm_carts] + + def add_item(self, cart_id: int, item_id: int) -> Cart: + orm_cart = self.session.query(CartOrm).filter_by(id=cart_id).first() + if not orm_cart: + raise ValueError(f"Cart with id {cart_id} not found") + orm_item = self.session.query(ItemOrm).filter_by(id=item_id).first() + if not orm_item or orm_item.deleted: + raise ValueError(f"Item with id {item_id} not found") + + for cart_item in orm_cart.items: + if cart_item.item_id == item_id: + cart_item.quantity += 1 + orm_cart.price += orm_item.price + self.session.flush() + return CartMapper.to_domain(orm_cart) + + orm_cart_item = ItemInCartOrm(cart_id=cart_id, item_id=item_id, name=orm_item.name, quantity=1, available=True) + orm_cart.items.append(orm_cart_item) + orm_cart.price += orm_item.price + self.session.flush() + return CartMapper.to_domain(orm_cart) + +# === Инициализация БД === +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://postgres:password@db:5432/shop_db") +time.sleep(5) +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base.metadata.create_all(bind=engine) + +# Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# === Эндпоинты === +@app.post("/cart", response_model=dict, status_code=HTTPStatus.CREATED) +async def create_cart(response: Response, db: Session = Depends(get_db)): + repo = SqlAlchemyCartRepository(db) + cart = repo.create() + response.headers["location"] = f"/cart/{cart.id}" + db.commit() + return {"id": cart.id} + +@app.get("/cart/{id}", response_model=CartResponse) +async def get_cart(id: int, db: Session = Depends(get_db)): + repo = SqlAlchemyCartRepository(db) + cart = repo.find_by_id(id) + if not cart: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + return cart + +@app.get("/cart", response_model=List[CartResponse]) +async def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), + db: Session = Depends(get_db) +): + repo = SqlAlchemyCartRepository(db) + return repo.get_all(offset, limit, min_price, max_price, min_quantity, max_quantity) + +@app.post("/cart/{cart_id}/add/{item_id}", response_model=CartResponse) +async def add_item_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + repo = SqlAlchemyCartRepository(db) + try: + cart = repo.add_item(cart_id, item_id) + db.commit() + return cart + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + +@app.post("/item", response_model=ItemResponse, status_code=HTTPStatus.CREATED) +async def create_item(item: ItemCreatingObj, db: Session = Depends(get_db)): + repo = SqlAlchemyItemRepository(db) + domain_item = Item(name=item.name, price=item.price) + created_item = repo.create(domain_item) + db.commit() + return created_item + +@app.get("/item/{id}", response_model=ItemResponse) +async def get_item(id: int, db: Session = Depends(get_db)): + repo = SqlAlchemyItemRepository(db) + item = repo.find_by_id(id) + if not item or item.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + return item + +@app.get("/item", response_model=List[ItemResponse]) +async def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False, + db: Session = Depends(get_db) +): + repo = SqlAlchemyItemRepository(db) + return repo.get_all(offset, limit, min_price, max_price, show_deleted) + +@app.put("/item/{id}", response_model=ItemResponse) +async def update_item(id: int, item: ItemCreatingObj, db: Session = Depends(get_db)): + repo = SqlAlchemyItemRepository(db) + domain_item = Item(id=id, name=item.name, price=item.price) + try: + updated_item = repo.update(domain_item) + db.commit() + return updated_item + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + +@app.patch("/item/{id}", response_model=ItemResponse) +async def partial_update_item(id: int, item: ItemUpdatingObj, db: Session = Depends(get_db)): + repo = SqlAlchemyItemRepository(db) + existing_item = repo.find_by_id(id) + if not existing_item: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + if existing_item.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_MODIFIED, detail="Item is deleted") + domain_item = Item(id=id, name=item.name or existing_item.name, price=item.price or existing_item.price, deleted=existing_item.deleted) + updated_item = repo.update(domain_item) + db.commit() + return updated_item + +@app.delete("/item/{id}", response_model=dict) +async def delete_item(id: int, db: Session = Depends(get_db)): + repo = SqlAlchemyItemRepository(db) + try: + repo.delete(id) + db.commit() + return {"status_code": HTTPStatus.OK} + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) \ No newline at end of file diff --git a/hw4-5/tests/test_homework2.py b/hw4-5/tests/test_homework2.py new file mode 100644 index 00000000..936c1e89 --- /dev/null +++ b/hw4-5/tests/test_homework2.py @@ -0,0 +1,287 @@ +import sys, os +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from service.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + for item_id in faker.random_elements(existing_items, unique=False, length=3): + client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + print(client.get(f"/item/{item_id}").json()) + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK diff --git a/hw4-5/tests/test_service.py b/hw4-5/tests/test_service.py new file mode 100644 index 00000000..0186e0b8 --- /dev/null +++ b/hw4-5/tests/test_service.py @@ -0,0 +1,682 @@ +import sys, os +import pytest +from unittest.mock import Mock, MagicMock +from http import HTTPStatus +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from service.main import ( + app, + Item, Cart, ItemInCart, + ItemMapper, CartMapper, ItemInCartMapper, + SqlAlchemyItemRepository, SqlAlchemyCartRepository, + ItemOrm, CartOrm, ItemInCartOrm, Base +) + +class TestItemRepository: + @pytest.fixture + def mock_session(self): + session = Mock(spec=Session) + session.query.return_value.filter_by.return_value.first.return_value = None + return session + + @pytest.fixture + def item_repo(self, mock_session): + return SqlAlchemyItemRepository(mock_session) + + def test_delete_item_in_carts(self, item_repo, mock_session, mocker): + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.price = 100.0 + orm_item.deleted = False + + orm_cart_item = MagicMock(spec=ItemInCartOrm) + orm_cart_item.item_id = 1 + orm_cart_item.cart_id = 1 + orm_cart_item.quantity = 2 + orm_cart_item.available = True + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 200.0 # 2 * 100.0 + + mock_session.query.return_value.filter_by.return_value.first.side_effect = [orm_item, orm_cart] + mock_session.query.return_value.filter_by.return_value.all.return_value = [orm_cart_item] + + mock_session.flush = Mock() + + item_repo.delete(item_id=1) + + assert orm_item.deleted is True + assert orm_cart_item.available is False + assert orm_cart.price == 0.0 + mock_session.query.return_value.filter_by.assert_any_call(id=1) + mock_session.query.return_value.filter_by.assert_any_call(item_id=1) + mock_session.query.return_value.filter_by.assert_any_call(id=1) + mock_session.flush.assert_called_once() + + def test_create_item(self, item_repo, mock_session, mocker): + item = Item(name="Apple", price=150.0) + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = None + orm_item.name = "Apple" + orm_item.price = 150.0 + + mocker.patch('service.main.ItemMapper.to_orm', return_value=orm_item) + def flush_side_effect(): + orm_item.configure_mock(id=1) + return None + mock_session.add.return_value = None + mock_session.flush.side_effect = flush_side_effect + + result = item_repo.create(item) + + assert result.id == 1 + assert result.name == "Apple" + assert result.price == 150.0 + mock_session.add.assert_called_once() + mock_session.flush.assert_called_once() + + def test_find_by_id_found(self, item_repo, mock_session): + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.name = "Apple" + orm_item.price = 150.0 + orm_item.deleted = False + mock_session.query.return_value.filter_by.return_value.first.return_value = orm_item + + result = item_repo.find_by_id(1) + + assert result.id == 1 + assert result.name == "Apple" + assert result.price == 150.0 + assert result.deleted == False + mock_session.query.return_value.filter_by.assert_called_once_with(id=1) + + def test_get_all(self, item_repo, mock_session, mocker): + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.name = "Apple" + orm_item.price = 150.0 + orm_item.deleted = False + + mock_query = Mock() + mock_session.query.return_value = mock_query + + mock_filter_by = Mock() + mock_query.filter_by.return_value = mock_filter_by + + mock_filter_min = Mock() + mock_filter_by.filter.return_value = mock_filter_min + mock_filter_max = Mock() + mock_filter_min.filter.return_value = mock_filter_max + + mock_offset = Mock() + mock_filter_max.offset.return_value = mock_offset + mock_limit = Mock() + mock_offset.limit.return_value = mock_limit + + mock_limit.all.return_value = [orm_item] + + mocker.patch('service.main.ItemMapper.to_domain', return_value=Item(id=1, name="Apple", price=150.0, deleted=False)) + + result = item_repo.get_all(offset=0, limit=10, min_price=100.0, max_price=200.0, show_deleted=False) + + assert len(result) == 1 + assert result[0].id == 1 + assert result[0].name == "Apple" + assert result[0].price == 150.0 + assert result[0].deleted == False + mock_query.filter_by.assert_called_once_with(deleted=False) + mock_filter_by.filter.assert_called_once() + mock_filter_min.filter.assert_called_once() + mock_filter_max.offset.assert_called_once_with(0) + mock_offset.limit.assert_called_once_with(10) + mock_limit.all.assert_called_once() + + + def test_update_success(self, item_repo, mock_session, mocker): + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.name = "Apple" + orm_item.price = 150.0 + orm_item.deleted = False + mock_session.query.return_value.filter_by.return_value.first.return_value = orm_item + mock_session.query.return_value.filter_by.return_value.all.return_value = [] # Пустой список для ItemInCartOrm + mocker.patch('service.main.ItemMapper.to_orm', return_value=orm_item) + + def flush_side_effect(): + orm_item.configure_mock(name="Updated Apple", price=200.0) + return None + + mock_session.flush.side_effect = flush_side_effect + + item = Item(id=1, name="Updated Apple", price=200.0, deleted=False) + result = item_repo.update(item) + + assert result.id == 1 + assert result.name == "Updated Apple" + assert result.price == 200.0 + assert result.deleted == False + mock_session.query.return_value.filter_by.assert_any_call(id=1) + mock_session.flush.assert_called_once() + + def test_update_not_found(self, item_repo, mock_session): + mock_session.query.return_value.filter_by.return_value.first.return_value = None + + item = Item(id=1, name="Apple", price=150.0, deleted=False) + with pytest.raises(ValueError, match="Item with id 1 not found"): + item_repo.update(item) + + mock_session.query.return_value.filter_by.assert_called_once_with(id=1) + + def test_delete_success(self, item_repo, mock_session): + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.name = "Apple" + orm_item.price = 150.0 + orm_item.deleted = False + mock_session.query.return_value.filter_by.return_value.first.return_value = orm_item + mock_session.query.return_value.filter_by.return_value.all.return_value = [] # Пустой список для ItemInCartOrm + + def flush_side_effect(): + orm_item.configure_mock(deleted=True) + return None + + mock_session.flush.side_effect = flush_side_effect + + item_repo.delete(1) + + assert orm_item.deleted is True + mock_session.query.return_value.filter_by.assert_any_call(id=1) + mock_session.flush.assert_called_once() + + def test_delete_not_found(self, item_repo, mock_session): + mock_session.query.return_value.filter_by.return_value.first.return_value = None + + with pytest.raises(ValueError, match="Item with id 1 not found"): + item_repo.delete(1) + + mock_session.query.return_value.filter_by.assert_called_once_with(id=1) + + + def test_update_with_cart_items(self, item_repo, mock_session, mocker): + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.name = "Apple" + orm_item.price = 150.0 + orm_item.deleted = False + + orm_cart_item = MagicMock(spec=ItemInCartOrm) + orm_cart_item.cart_id = 1 + orm_cart_item.quantity = 2 + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 300.0 + + mock_session.query.return_value.filter_by.return_value.first.side_effect = [orm_item, orm_cart] + mock_session.query.return_value.filter_by.return_value.all.return_value = [orm_cart_item] + + mocker.patch('service.main.ItemMapper.to_orm', return_value=orm_item) + mocker.patch('service.main.ItemMapper.to_domain', return_value=Item(id=1, name="Updated Apple", price=200.0, deleted=False)) + + def flush_side_effect(): + orm_item.configure_mock(name="Updated Apple", price=200.0) + orm_cart.configure_mock(price=400.0) + return None + mock_session.flush.side_effect = flush_side_effect + + item = Item(id=1, name="Updated Apple", price=200.0, deleted=False) + result = item_repo.update(item) + + assert result.id == 1 + assert result.name == "Updated Apple" + assert result.price == 200.0 + assert result.deleted is False + assert orm_cart.price == 400.0 + mock_session.flush.assert_called_once() + +class TestCartRepository: + @pytest.fixture + def mock_session(self): + session = Mock(spec=Session) + session.query.return_value.filter_by.return_value.first.return_value = None + return session + + @pytest.fixture + def cart_repo(self, mock_session): + return SqlAlchemyCartRepository(mock_session) + + def test_create_cart(self, cart_repo, mock_session, mocker): + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = None + orm_cart.price = 0.0 + orm_cart.items = [] + + mocker.patch('service.main.CartOrm', return_value=orm_cart) + def flush_side_effect(): + orm_cart.configure_mock(id=1) + return None + mock_session.add.return_value = None + mock_session.flush.side_effect = flush_side_effect + + mocker.patch('service.main.CartMapper.to_domain', return_value=Cart(id=1, items=[], price=0.0)) + + result = cart_repo.create() + + assert result.id == 1 + assert result.price == 0.0 + assert result.items == [] + mock_session.add.assert_called_once() + mock_session.flush.assert_called_once() + + def test_find_by_id_found(self, cart_repo, mock_session, mocker): + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 0.0 + orm_cart.items = [] + mock_session.query.return_value.filter_by.return_value.first.return_value = orm_cart + + mocker.patch('service.main.CartMapper.to_domain', return_value=Cart(id=1, items=[], price=0.0)) + + result = cart_repo.find_by_id(1) + + assert result.id == 1 + assert result.price == 0.0 + assert result.items == [] + mock_session.query.return_value.filter_by.assert_called_once_with(id=1) + + def test_get_all(self, cart_repo, mock_session, mocker): + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 100.0 + orm_cart.items = [] + + mock_query = Mock() + mock_session.query.return_value = mock_query + mock_filter_min = Mock() + mock_query.filter.return_value = mock_filter_min + mock_filter_max = Mock() + mock_filter_min.filter.return_value = mock_filter_max + mock_offset = Mock() + mock_filter_max.offset.return_value = mock_offset + mock_limit = Mock() + mock_offset.limit.return_value = mock_limit + mock_limit.all.return_value = [orm_cart] + + mocker.patch('service.main.CartMapper.to_domain', return_value=Cart(id=1, items=[], price=100.0)) + + result = cart_repo.get_all(offset=0, limit=10, min_price=50.0, max_price=150.0, min_quantity=None, max_quantity=None) + + assert len(result) == 1 + assert result[0].id == 1 + assert result[0].price == 100.0 + assert result[0].items == [] + mock_query.filter.assert_called_once() + mock_filter_min.filter.assert_called_once() + mock_filter_max.offset.assert_called_once_with(0) + mock_offset.limit.assert_called_once_with(10) + mock_limit.all.assert_called_once() + + def test_add_item_new_item(self, cart_repo, mock_session, mocker): + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 0.0 + orm_cart.items = [] + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.name = "Apple" + orm_item.price = 150.0 + orm_item.deleted = False + + mock_session.query.return_value.filter_by.return_value.first.side_effect = [orm_cart, orm_item] + + orm_cart_item = MagicMock(spec=ItemInCartOrm) + mocker.patch('service.main.ItemInCartOrm', return_value=orm_cart_item) + + mocker.patch('service.main.CartMapper.to_domain', return_value=Cart(id=1, items=[ItemInCart(id=1, name="Apple", quantity=1, available=True)], price=150.0)) + + def flush_side_effect(): + orm_cart.configure_mock(price=150.0) + orm_cart_item.configure_mock(id=1) + return None + mock_session.flush.side_effect = flush_side_effect + + result = cart_repo.add_item(cart_id=1, item_id=1) + + assert result.id == 1 + assert result.price == 150.0 + assert len(result.items) == 1 + assert result.items[0].id == 1 + assert result.items[0].name == "Apple" + assert result.items[0].quantity == 1 + assert result.items[0].available is True + mock_session.query.return_value.filter_by.assert_any_call(id=1) + mock_session.flush.assert_called_once() + + def test_add_item_existing_item(self, cart_repo, mock_session, mocker): + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 150.0 + orm_cart_item = MagicMock(spec=ItemInCartOrm) + orm_cart_item.item_id = 1 + orm_cart_item.quantity = 1 + orm_cart.items = [orm_cart_item] + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.name = "Apple" + orm_item.price = 150.0 + orm_item.deleted = False + + mock_session.query.return_value.filter_by.return_value.first.side_effect = [orm_cart, orm_item] + + mocker.patch('service.main.CartMapper.to_domain', return_value=Cart(id=1, items=[ItemInCart(id=1, name="Apple", quantity=2, available=True)], price=300.0)) + + def flush_side_effect(): + orm_cart.configure_mock(price=300.0) + orm_cart_item.configure_mock(quantity=2) + return None + mock_session.flush.side_effect = flush_side_effect + + result = cart_repo.add_item(cart_id=1, item_id=1) + + assert result.id == 1 + assert result.price == 300.0 + assert len(result.items) == 1 + assert result.items[0].id == 1 + assert result.items[0].name == "Apple" + assert result.items[0].quantity == 2 + assert result.items[0].available is True + mock_session.query.return_value.filter_by.assert_any_call(id=1) + mock_session.flush.assert_called_once() + + def test_add_item_cart_not_found(self, cart_repo, mock_session): + mock_session.query.return_value.filter_by.return_value.first.return_value = None + + with pytest.raises(ValueError, match="Cart with id 1 not found"): + cart_repo.add_item(cart_id=1, item_id=1) + + mock_session.query.return_value.filter_by.assert_called_once_with(id=1) + + def test_add_item_item_not_found(self, cart_repo, mock_session): + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 0.0 + orm_cart.items = [] + + mock_session.query.return_value.filter_by.return_value.first.side_effect = [orm_cart, None] + + with pytest.raises(ValueError, match="Item with id 1 not found"): + cart_repo.add_item(cart_id=1, item_id=1) + + mock_session.query.return_value.filter_by.assert_any_call(id=1) + + def test_get_all_with_quantity_filters(self, cart_repo, mock_session, mocker): + orm_cart = MagicMock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 100.0 + orm_cart.items = [] + + mock_query = Mock() + mock_session.query.return_value = mock_query + mock_join = Mock() + mock_query.join.return_value = mock_join + mock_group_by = Mock() + mock_join.group_by.return_value = mock_group_by + mock_having_min = Mock() + mock_group_by.having.return_value = mock_having_min + mock_having_max = Mock() + mock_having_min.having.return_value = mock_having_max + mock_offset = Mock() + mock_having_max.offset.return_value = mock_offset + mock_limit = Mock() + mock_offset.limit.return_value = mock_limit + mock_limit.all.return_value = [orm_cart] + + mocker.patch('service.main.CartMapper.to_domain', return_value=Cart(id=1, items=[], price=100.0)) + + result = cart_repo.get_all(offset=0, limit=10, min_price=None, max_price=None, min_quantity=1, max_quantity=5) + + assert len(result) == 1 + assert result[0].id == 1 + assert result[0].price == 100.0 + assert result[0].items == [] + mock_query.join.assert_called_once_with(ItemInCartOrm) + mock_join.group_by.assert_called_once_with(CartOrm.id) + mock_group_by.having.assert_called_once() + mock_having_min.having.assert_called_once() + mock_having_max.offset.assert_called_once_with(0) + mock_offset.limit.assert_called_once_with(10) + mock_limit.all.assert_called_once() + + +class TestMappers: + def test_item_mapper_to_orm_new(self): + domain_item = Item(id=None, name="Apple", price=150.0, deleted=False) + + result = ItemMapper.to_orm(domain_item) + + assert isinstance(result, ItemOrm) + assert result.id is None + assert result.name == "Apple" + assert result.price == 150.0 + assert result.deleted is False + + def test_cart_mapper_to_domain_with_items(self, mocker): + orm_item = Mock(spec=ItemInCartOrm) + orm_item.id = 1 + orm_item.item_id = 2 + orm_item.name = "Apple" + orm_item.quantity = 3 + orm_item.available = True + + orm_cart = Mock(spec=CartOrm) + orm_cart.id = 1 + orm_cart.price = 150.0 + orm_cart.items = [orm_item] + + domain_item = ItemInCart(id=2, name="Apple", quantity=3, available=True) + mocker.patch('service.main.ItemInCartMapper.to_domain', return_value=domain_item) + + result = CartMapper.to_domain(orm_cart) + + assert isinstance(result, Cart) + assert result.id == 1 + assert result.price == 150.0 + assert result.items == [domain_item] + + def test_item_mapper_to_orm_existing(self): + domain_item = Item(id=1, name="Updated Apple", price=200.0, deleted=True) + orm_item = MagicMock(spec=ItemOrm) + orm_item.id = 1 + orm_item.name = "Apple" + orm_item.price = 150.0 + orm_item.deleted = False + + result = ItemMapper.to_orm(domain_item, orm_item) + + assert result is orm_item + assert result.name == "Updated Apple" + assert result.price == 200.0 + assert result.deleted is True + + def test_item_in_cart_mapper_to_domain(self): + orm_item_in_cart = MagicMock(spec=ItemInCartOrm) + orm_item_in_cart.id = 1 + orm_item_in_cart.item_id = 2 + orm_item_in_cart.name = "Apple" + orm_item_in_cart.quantity = 3 + orm_item_in_cart.available = True + + result = ItemInCartMapper.to_domain(orm_item_in_cart) + + assert isinstance(result, ItemInCart) + assert result.id == 2 + assert result.name == "Apple" + assert result.quantity == 3 + assert result.available is True + + def test_item_in_cart_mapper_to_orm_new(self): + domain_item_in_cart = ItemInCart(id=2, name="Apple", quantity=3, available=True) + + result = ItemInCartMapper.to_orm(domain_item_in_cart) + + assert isinstance(result, ItemInCartOrm) + assert result.item_id == 2 + assert result.name == "Apple" + assert result.quantity == 3 + assert result.available is True + + def test_cart_mapper_to_orm_new(self): + domain_cart = Cart(id=None, items=[], price=0.0) + + result = CartMapper.to_orm(domain_cart) + + assert isinstance(result, CartOrm) + assert result.price == 0.0 + assert result.items == [] + +class TestCartAPI: + @pytest.fixture + def client(self, mocker): + # Мокаем базу данных + db = Mock(spec=Session) + mock_session_local = Mock() + mock_session_local.return_value = db + mocker.patch('service.main.SessionLocal', mock_session_local) + return TestClient(app) + + def test_create_cart(self, client, mocker): + mock_cart = Cart(id=1, items=[], price=0.0) + mocker.patch('service.main.SqlAlchemyCartRepository.create', return_value=mock_cart) + + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert response.json() == {"id": 1} + assert response.headers["location"] == "/cart/1" + + def test_list_carts_default(self, client, mocker): + mock_cart = Cart(id=1, items=[ItemInCart(id=1, name="Apple", quantity=1, available=True)], price=150.0) + mocker.patch('service.main.SqlAlchemyCartRepository.get_all', return_value=[mock_cart]) + + response = client.get("/cart?offset=0&limit=10") + + assert response.status_code == HTTPStatus.OK + assert response.json() == [ + { + "id": 1, + "items": [{"id": 1, "name": "Apple", "quantity": 1, "available": True}], + "price": 150.0 + } + ] + + def test_get_cart_found(self, client, mocker): + mock_cart = Cart(id=1, items=[], price=0.0) + mocker.patch('service.main.SqlAlchemyCartRepository.find_by_id', return_value=mock_cart) + + response = client.get("/cart/1") + + assert response.status_code == HTTPStatus.OK + assert response.json() == {"id": 1, "items": [], "price": 0.0} + + def test_get_cart_not_found(self, client, mocker): + mocker.patch('service.main.SqlAlchemyCartRepository.find_by_id', return_value=None) + + response = client.get("/cart/1") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json() == {"detail": "Cart not found"} + +class TestItemAPI: + @pytest.fixture + def client(self, mocker): + db = Mock(spec=Session) + mock_session_local = Mock() + mock_session_local.return_value = db + mocker.patch('service.main.SessionLocal', mock_session_local) + return TestClient(app) + + def test_create_item(self, client, mocker): + mock_item = Item(id=1, name="Apple", price=150.0, deleted=False) + mocker.patch('service.main.SqlAlchemyItemRepository.create', return_value=mock_item) + + response = client.post("/item", json={"name": "Apple", "price": 150.0}) + + assert response.status_code == HTTPStatus.CREATED + assert response.json() == {"id": 1, "name": "Apple", "price": 150.0, "deleted": False} + + def test_get_item_found(self, client, mocker): + mock_item = Item(id=1, name="Apple", price=150.0, deleted=False) + mocker.patch('service.main.SqlAlchemyItemRepository.find_by_id', return_value=mock_item) + + response = client.get("/item/1") + + assert response.status_code == HTTPStatus.OK + assert response.json() == {"id": 1, "name": "Apple", "price": 150.0, "deleted": False} + + def test_get_item_not_found(self, client, mocker): + mocker.patch('service.main.SqlAlchemyItemRepository.find_by_id', return_value=None) + + response = client.get("/item/1") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json() == {"detail": "Item not found"} + + def test_get_all_items(self, client, mocker): + mock_item = Item(id=1, name="Apple", price=150.0, deleted=False) + mocker.patch('service.main.SqlAlchemyItemRepository.get_all', return_value=[mock_item]) + + response = client.get("/item?offset=0&limit=10&min_price=100.0&max_price=200.0&show_deleted=false") + + assert response.status_code == HTTPStatus.OK + assert response.json() == [{"id": 1, "name": "Apple", "price": 150.0, "deleted": False}] + + def test_update_item(self, client, mocker): + mock_item = Item(id=1, name="Updated Apple", price=200.0, deleted=False) + mocker.patch('service.main.SqlAlchemyItemRepository.update', return_value=mock_item) + + response = client.put("/item/1", json={"name": "Updated Apple", "price": 200.0}) + + assert response.status_code == HTTPStatus.OK + assert response.json() == {"id": 1, "name": "Updated Apple", "price": 200.0, "deleted": False} + + def test_patch_item(self, client, mocker): + mock_item = Item(id=1, name="Updated Apple", price=200.0, deleted=False) + mocker.patch('service.main.SqlAlchemyItemRepository.find_by_id', return_value=Item(id=1, name="Apple", price=150.0, deleted=False)) + mocker.patch('service.main.SqlAlchemyItemRepository.update', return_value=mock_item) + + response = client.patch("/item/1", json={"price": 200.0, "name": "Updated Apple"}) + + assert response.status_code == HTTPStatus.OK + assert response.json() == {"id": 1, "name": "Updated Apple", "price": 200.0, "deleted": False} + + def test_delete_item(self, client, mocker): + mocker.patch('service.main.SqlAlchemyItemRepository.delete', return_value=None) + + response = client.delete("/item/1") + + assert response.status_code == HTTPStatus.OK + + def test_add_item_to_cart(self, client, mocker): + mock_cart = Cart(id=1, items=[ItemInCart(id=1, name="Apple", quantity=1, available=True)], price=150.0) + mocker.patch('service.main.SqlAlchemyCartRepository.add_item', return_value=mock_cart) + + response = client.post("/cart/1/add/1") + + assert response.status_code == HTTPStatus.OK + assert response.json() == { + "id": 1, + "items": [{"id": 1, "name": "Apple", "quantity": 1, "available": True}], + "price": 150.0 + } + + def test_add_item_to_cart_not_found(self, client, mocker): + mocker.patch( + 'service.main.SqlAlchemyCartRepository.add_item', + side_effect=ValueError("Cart or item not found") + ) + response = client.post("/cart/999/add/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json() == {"detail": "Cart or item not found"} \ No newline at end of file