From f56cab1a3f6f31b26a0e18c615b7247d167c172e Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sat, 27 Sep 2025 09:53:56 +0000 Subject: [PATCH 01/12] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..29e7adc1 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,6 +1,41 @@ +import json +from math import factorial from typing import Any, Awaitable, Callable +def fibonacci(n: int): + if n < 0: + raise ValueError(f"Expected parameter n must be non-negative. Got n={n}") + + if n == 0: + return 0 + + first_value, second_value = 0, 1 + + for i in range(n - 1): + first_value, second_value = second_value, first_value + second_value + + return second_value + + +async def send_response( + response_status_code: int, + response_content_type: bytes, + response_body: bytes, + send: Callable[[dict[str, Any]], Awaitable[None]], +): + await send( + { + "type": "http.response.start", + "status": response_status_code, + "headers": [ + [b"content-type", response_content_type], + ], + } + ) + await send({"type": "http.response.body", "body": response_body}) + + async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -12,8 +47,86 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + break + + elif scope["type"] == "http": + path = scope["path"] + + if path == "/" or path == "/not_found": + await send_response(404, b"text/plain", b"not found", send) + + elif path == "/factorial": + try: + n = int(scope["query_string"].decode().replace("n=", "")) + if n < 0: + await send_response( + 400, + b"text/plain", + b"Invalid value for n, must be non-negative", + send, + ) + else: + result = factorial(n) + response_body = bytes( + json.dumps({"result": result}), encoding="utf-8" + ) + await send_response(200, b"application/json", response_body, send) + except ValueError: + await send_response(422, b"text/plain", b"unprocessible entity", send) + + elif path.startswith("/fibonacci/"): + try: + n = int(path.split("/")[2]) + if n < 0: + await send_response( + 400, + b"text/plain", + b"Invalid value for n, must be non-negative", + send, + ) + else: + result = fibonacci(n) + response_body = bytes( + json.dumps({"result": result}), encoding="utf-8" + ) + await send_response(200, b"application/json", response_body, send) + except ValueError: + await send_response(422, b"text/plain", b"unprocessible entity", send) + + elif path == "/mean": + body = b"" + event = await receive() + if event["type"] == "http.request": + body = event.get("body", b"") + inp_arr = json.loads(body.decode()) + if inp_arr is None: + await send_response(422, b"text/plain", b"unprocessible entity", send) + else: + if len(inp_arr) == 0: + await send_response( + 400, + b"text/plain", + b"Invalid value for body, must be non-empty array of floats", + send, + ) + else: + result = sum(inp_arr) / len(inp_arr) + response_body = bytes( + json.dumps({"result": result}), encoding="utf-8" + ) + await send_response(200, b"application/json", response_body, send) + else: + await send_response(422, b"text/plain", b"unprocessible entity", send) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) From afe759b8b33e25fbd168809b76284c28383dddb4 Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 5 Oct 2025 13:24:04 +0000 Subject: [PATCH 02/12] Implemented REST + RPC API for dummy market --- hw2/hw/shop_api/core/schemas.py | 49 +++++++++++ hw2/hw/shop_api/core/storage.py | 142 ++++++++++++++++++++++++++++++++ hw2/hw/shop_api/main.py | 16 +++- hw2/hw/shop_api/routers/cart.py | 99 ++++++++++++++++++++++ hw2/hw/shop_api/routers/item.py | 107 ++++++++++++++++++++++++ 5 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 hw2/hw/shop_api/core/schemas.py create mode 100644 hw2/hw/shop_api/core/storage.py create mode 100644 hw2/hw/shop_api/routers/cart.py create mode 100644 hw2/hw/shop_api/routers/item.py diff --git a/hw2/hw/shop_api/core/schemas.py b/hw2/hw/shop_api/core/schemas.py new file mode 100644 index 00000000..f7547e01 --- /dev/null +++ b/hw2/hw/shop_api/core/schemas.py @@ -0,0 +1,49 @@ +# from __future__ import annotations +from typing import Optional, Annotated +from pydantic import BaseModel, Field, ConfigDict + +ItemName = Annotated[str, Field(description="Наименование товара", min_length=1)] +ItemId = Annotated[int, Field(description="Идентификатор корзины", ge=0)] +ItemPrice = Annotated[float, Field(description="Цена товара", ge=0)] + +CartId = Annotated[int, Field(description="Идентификатор корзины")] + + +# ---- Item ---- +class ItemOut(BaseModel): + id: ItemId + name: ItemName + price: ItemPrice + deleted: bool = Field(description="Удален ли товар", default=False) + + +class ItemCreate(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPut(BaseModel): + name: ItemName + price: ItemPrice + + +class ItemPatch(BaseModel): + # Разрешаем частичное обновление ТОЛЬКО name/price; лишние поля → 422 + model_config = ConfigDict(extra="forbid") + + name: Optional[ItemName] = None + price: Optional[ItemPrice] = None + + +# ---- Cart ---- +class CartItemView(BaseModel): + id: ItemId + name: ItemName + quantity: int = Field(description="Количество товара в корзине", ge=0) + available: bool = Field(description="Доступен ли товар") + + +class CartView(BaseModel): + id: int + items: list[CartItemView] + price: float = Field(description="Общая сумма заказа", ge=0.0) diff --git a/hw2/hw/shop_api/core/storage.py b/hw2/hw/shop_api/core/storage.py new file mode 100644 index 00000000..bc3c6cb9 --- /dev/null +++ b/hw2/hw/shop_api/core/storage.py @@ -0,0 +1,142 @@ +from __future__ import annotations +from threading import ( + RLock, +) # стандартная библиотека Python: https://docs.python.org/3/library/threading.html +from typing import Optional +from fastapi import HTTPException, status +from .schemas import ( + ItemOut, + CartItemView, + CartView, + ItemCreate, + ItemPut, + ItemPatch, +) + +# ------------------------- +# In-memory хранилище + блокировки +# ------------------------- +_items_lock = RLock() +_carts_lock = RLock() + +_items: dict[int, ItemOut] = {} +_next_item_id = 1 + +# cart_id -> { item_id -> quantity } +_carts: dict[int, dict[int, int]] = {} +_next_cart_id = 1 + + +def new_item_id() -> int: + global _next_item_id + with _items_lock: + nid = _next_item_id + _next_item_id += 1 + return nid + + +def new_cart_id() -> int: + global _next_cart_id + with _carts_lock: + nid = _next_cart_id + _next_cart_id += 1 + return nid + + +def get_item_or_404(item_id: int) -> ItemOut: + with _items_lock: + item = _items.get(item_id) + if item is None or item.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + return item + + +def get_item_soft(item_id: int) -> Optional[ItemOut]: + with _items_lock: + return _items.get(item_id) + + +def cart_or_404(cart_id: int) -> dict[int, int]: + with _carts_lock: + cart = _carts.get(cart_id) + if cart is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" + ) + return cart + + +def build_cart_view(cart_id: int) -> CartView: + with _carts_lock: + cart = _carts.get(cart_id, {}) + kv = list(cart.items()) + + items = [] + total = 0.0 + for item_id, qty in kv: + item = get_item_soft(item_id) + name = item.name if item else f"item:{item_id}" + available = bool(item and not item.deleted) + items.append( + CartItemView(id=item_id, name=name, quantity=qty, available=available) + ) + if available: + total += item.price * qty + + return CartView(id=cart_id, items=items, price=total) + + +def create_item(data: ItemCreate) -> ItemOut: + item_id = new_item_id() + item = ItemOut(id=item_id, name=data.name, price=data.price, deleted=False) + with _items_lock: + _items[item_id] = item + return item + + +def put_item(item_id: int, data: ItemPut) -> ItemOut: + with _items_lock: + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + existing.name = data.name + existing.price = data.price + return existing + + +def patch_item(item_id: int, data: ItemPatch) -> ItemOut: + with _items_lock: + existing = _items.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if existing.deleted: + # Пробрасываем семантику 304 на верхний уровень + raise HTTPException( + status_code=status.HTTP_304_NOT_MODIFIED, detail="Item deleted" + ) + + if data.name is not None: + existing.name = data.name + if data.price is not None: + if data.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + existing.price = data.price + + return existing + + +def delete_item(item_id: int) -> dict: + with _items_lock: + existing = _items.get(item_id) + if existing is not None: + existing.deleted = True + return {"ok": True} diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..183d2bb0 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,13 @@ -from fastapi import FastAPI - -app = FastAPI(title="Shop API") +from fastapi import FastAPI +from shop_api.routers.item import router as item +from shop_api.routers.cart import router as cart + +app = FastAPI(title="Shop API") + +app.include_router(item) +app.include_router(cart) + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, port=8001) diff --git a/hw2/hw/shop_api/routers/cart.py b/hw2/hw/shop_api/routers/cart.py new file mode 100644 index 00000000..9e040b6f --- /dev/null +++ b/hw2/hw/shop_api/routers/cart.py @@ -0,0 +1,99 @@ +from typing import List, Optional +from fastapi import APIRouter, Query, Response, status + +from shop_api.core.schemas import CartView +from shop_api.core.storage import ( + _carts, + _carts_lock, + new_cart_id, + cart_or_404, + build_cart_view, + get_item_or_404, +) + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + """ + POST /cart — RPC: создаёт пустую корзину, тело не принимает. + Возвращает 201 и JSON {"id": }, а также заголовок Location: /cart/{id}. + """ + cart_id = new_cart_id() + with _carts_lock: + _carts[cart_id] = {} + response.headers["Location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@router.get("/{cart_id}", response_model=CartView) +def get_cart(cart_id: int) -> CartView: + """ + GET /cart/{id} — получить корзину по id. + """ + cart_or_404(cart_id) + return build_cart_view(cart_id) + + +@router.get("", response_model=List[CartView]) +def list_carts( + offset: int = Query(0, ge=0, description="Смещение по списку (offset)"), + limit: int = Query(10, gt=0, description="Лимит количества (limit)"), + min_price: Optional[float] = Query( + None, ge=0.0, description="Мин. сумма корзины (включительно)" + ), + max_price: Optional[float] = Query( + None, ge=0.0, description="Макс. сумма корзины (включительно)" + ), + min_quantity: Optional[int] = Query( + None, ge=0, description="Мин. общее число товаров (включительно)" + ), + max_quantity: Optional[int] = Query( + None, ge=0, description="Макс. общее число товаров (включительно)" + ), +) -> List[CartView]: + """ + GET /cart — список корзин с фильтрами и пагинацией. + + Фильтры: + • min_price/max_price — по суммарной стоимости корзины (включительно); + • min_quantity/max_quantity — по суммарному количеству позиций в корзине (включительно). + Порядок: фильтрация -> offset/limit. + """ + with _carts_lock: + ids = list(_carts.keys()) + + views: List[CartView] = [] + for cid in ids: + v = build_cart_view(cid) + + if min_price is not None and v.price < min_price: + continue + if max_price is not None and v.price > max_price: + continue + + qsum = sum(it.quantity for it in v.items) + if min_quantity is not None and qsum < min_quantity: + continue + if max_quantity is not None and qsum > max_quantity: + continue + + views.append(v) + + return views[offset : offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int): + """ + POST /cart/{cart_id}/add/{item_id} — добавить товар в корзину. + Если товар уже есть — увеличивает его количество. + """ + cart = cart_or_404(cart_id) + get_item_or_404(item_id) # проверка на товар + + with _carts_lock: + cart[item_id] = cart.get(item_id, 0) + 1 + + return {"ok": True} diff --git a/hw2/hw/shop_api/routers/item.py b/hw2/hw/shop_api/routers/item.py new file mode 100644 index 00000000..acc511f6 --- /dev/null +++ b/hw2/hw/shop_api/routers/item.py @@ -0,0 +1,107 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query, Response, status + +from shop_api.core.schemas import ItemOut, ItemCreate, ItemPut, ItemPatch +from shop_api.core.storage import _items, _items_lock, get_item_or_404, new_item_id + +router = APIRouter(prefix="/item", tags=["items"]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=ItemOut) +def create_item(body: ItemCreate) -> ItemOut: + """ + POST /item - добавление нового товара + """ + item_id = new_item_id() + obj = ItemOut(id=item_id, name=body.name, price=body.price, deleted=False) + with _items_lock: + _items[item_id] = obj + return obj + + +@router.get("/{item_id}", response_model=ItemOut) +def get_item(item_id: int) -> ItemOut: + """ + GET /item/{id} - получение товара по id + """ + return get_item_or_404(item_id) + + +@router.get("", response_model=List[ItemOut]) +def list_items( + offset: int = Query(0, ge=0, description="Смещение (offset)"), + limit: int = Query(10, gt=0, description="Количество (limit)"), + min_price: Optional[float] = Query(None, ge=0, description="Мин. цена"), + max_price: Optional[float] = Query(None, ge=0, description="Макс. цена"), + show_deleted: bool = Query(False, description="Показывать ли удалённые"), +) -> List[ItemOut]: + """ + GET /item - получение списка товаров с фильтрами и пагинацией + """ + with _items_lock: + items = list(_items.values()) + + if not show_deleted: + items = [i for i in items if not i.deleted] + if min_price is not None: + items = [i for i in items if i.price >= min_price] + if max_price is not None: + items = [i for i in items if i.price <= max_price] + + return items[offset : offset + limit] + + +@router.put("/{item_id}", response_model=ItemOut) +def put_item(item_id: int, body: ItemPut) -> ItemOut: + """ + PUT /item/{id} - замена товара по id (создание запрещено) + """ + with _items_lock: + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + existing.name = body.name + existing.price = body.price + return existing + + +@router.patch("/{item_id}", response_model=ItemOut) +def patch_item(item_id: int, body: ItemPatch): + """ + PATCH /item/{id} - частичное обновление (разрешено менять всё кроме deleted) + Если товар удалён — 304 Not Modified. + """ + with _items_lock: + existing = _items.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if existing.deleted: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + + if body.name is not None: + existing.name = body.name + if body.price is not None: + if body.price < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid price", + ) + existing.price = body.price + + return existing + + +@router.delete("/{item_id}") +def delete_item(item_id: int): + """ + DELETE /item/{id} - мягкое удаление (deleted=True), идемпотентно + """ + with _items_lock: + existing = _items.get(item_id) + if existing is not None: + existing.deleted = True + return {"ok": True} From 042e0987f046d8baf3b526e7570ffc3b5ec64b2e Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 12 Oct 2025 18:11:16 +0000 Subject: [PATCH 03/12] Promehteus + Grafana monitoring for HW2 --- hw2/hw/Dockerfile | 23 + hw2/hw/docker-compose.yaml | 34 ++ hw2/hw/prometheus.jpg | Bin 0 -> 96954 bytes hw2/hw/requirements.txt | 1 + hw2/hw/settings/grafana-dashboard.json | 607 +++++++++++++++++++++ hw2/hw/settings/prometheus/prometheus.yaml | 10 + hw2/hw/shop_api/main.py | 2 + 7 files changed, 677 insertions(+) create mode 100644 hw2/hw/Dockerfile create mode 100644 hw2/hw/docker-compose.yaml create mode 100644 hw2/hw/prometheus.jpg create mode 100644 hw2/hw/settings/grafana-dashboard.json create mode 100644 hw2/hw/settings/prometheus/prometheus.yaml diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..43812477 --- /dev/null +++ b/hw2/hw/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"] \ No newline at end of file diff --git a/hw2/hw/docker-compose.yaml b/hw2/hw/docker-compose.yaml new file mode 100644 index 00000000..cca96c41 --- /dev/null +++ b/hw2/hw/docker-compose.yaml @@ -0,0 +1,34 @@ +services: + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yaml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + depends_on: + - prometheus + - grafana + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw2/hw/prometheus.jpg b/hw2/hw/prometheus.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3d6f56bd2f87123c36ab129263a0761f9d90567d GIT binary patch literal 96954 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
    9s0hTq%>Lr^B3vtlN|z!T7i%S8i6%Rk$biP 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 XhP;r%1bFP7uK9fb1F=u?XXXC@3g!0P literal 0 HcmV?d00001 diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..792f5919 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-fastapi-instrumentator==7.1.0 # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/settings/grafana-dashboard.json b/hw2/hw/settings/grafana-dashboard.json new file mode 100644 index 00000000..de97d72d --- /dev/null +++ b/hw2/hw/settings/grafana-dashboard.json @@ -0,0 +1,607 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "4xx" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "HTTP 500" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#bf1b00", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 0, + "y": 0 + }, + "id": 13, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:140", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "sum by (status) (rate(http_requests_total[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ status }}", + "range": true, + "refId": "A" + } + ], + "title": "Request per minute", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 14, + "x": 5, + "y": 0 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:146", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "http_request_duration_seconds_sum{handler!=\"none\"} / http_request_duration_seconds_count", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ handler }}", + "range": true, + "refId": "A" + } + ], + "title": "Average response time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 0 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:638", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "rate(process_cpu_seconds_total{}[30s])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "cpu", + "range": true, + "refId": "A" + } + ], + "title": "CPU usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 19, + "x": 0, + "y": 7 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:214", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "increase(http_requests_total{}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ method }} {{ handler }}", + "range": true, + "refId": "A" + } + ], + "title": "Total requests per minute", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df01l3rlsp728b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 7 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "$$hashKey": "object:638", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "process_resident_memory_bytes{}", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "mem", + "range": true, + "refId": "A" + } + ], + "title": "Memory usage", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "auto", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [] + }, + "timezone": "", + "title": "FastAPI Dashboard", + "uid": "_eX4mpl3", + "version": 2 +} \ No newline at end of file diff --git a/hw2/hw/settings/prometheus/prometheus.yaml b/hw2/hw/settings/prometheus/prometheus.yaml new file mode 100644 index 00000000..275d8544 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yaml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: demo-service-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 183d2bb0..af15990b 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,8 +1,10 @@ from fastapi import FastAPI from shop_api.routers.item import router as item from shop_api.routers.cart import router as cart +from prometheus_fastapi_instrumentator import Instrumentator app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) app.include_router(item) app.include_router(cart) From d7f16cabb92c387016277f03f397820201ecd5fd Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 16:44:57 +0000 Subject: [PATCH 04/12] feat(pip): changed pip to uv --- hw2/hw/Dockerfile | 5 +++-- hw2/hw/__init__.py | 0 hw2/hw/pyproject.toml | 39 +++++++++++++++++++++++++++++++++++++++ hw2/hw/requirements.txt | 10 ---------- 4 files changed, 42 insertions(+), 12 deletions(-) delete mode 100644 hw2/hw/__init__.py create mode 100644 hw2/hw/pyproject.toml delete mode 100644 hw2/hw/requirements.txt diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile index 43812477..58a77480 100644 --- a/hw2/hw/Dockerfile +++ b/hw2/hw/Dockerfile @@ -9,6 +9,7 @@ ARG PYTHONFAULTHANDLER=1 \ RUN apt-get update && apt-get install -y gcc RUN python -m pip install --upgrade pip +RUN python -m pip install uv WORKDIR $APP_ROOT/src COPY . ./ @@ -16,8 +17,8 @@ COPY . ./ ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ PATH=$APP_ROOT/src/.venv/bin:$PATH -RUN pip install -r requirements.txt +RUN uv sync FROM base as local -CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] \ No newline at end of file +CMD ["uv", "run", "uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/hw2/hw/__init__.py b/hw2/hw/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/hw2/hw/pyproject.toml b/hw2/hw/pyproject.toml new file mode 100644 index 00000000..38dc7942 --- /dev/null +++ b/hw2/hw/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "hw" +version = "0.1.0" +description = "Homework 2 - Shop API" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "faker>=37.8.0", + "fastapi>=0.117.1", + "httpx>=0.27.2", + "prometheus-fastapi-instrumentator==7.1.0", + "psycopg2-binary>=2.9", + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "sqlalchemy[asyncio]>=2.0", + "uvicorn>=0.24.0", + "asyncpg>=0.30.0", + "aiosqlite>=0.21.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-v --cov=shop_api --cov-report=term-missing" + +[tool.coverage.run] +source = ["shop_api"] +omit = ["tests/*", "**/__init__.py", "shop_api/main.py"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if __name__ == .__main__.:", + "raise NotImplementedError", + "pass", +] diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt deleted file mode 100644 index 792f5919..00000000 --- a/hw2/hw/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -# Основные зависимости для ASGI приложения -fastapi>=0.117.1 -uvicorn>=0.24.0 -prometheus-fastapi-instrumentator==7.1.0 - -# Зависимости для тестирования -pytest>=7.4.0 -pytest-asyncio>=0.21.0 -httpx>=0.27.2 -Faker>=37.8.0 From 1f06ba2c0d33c0c3474bf3be81865dea2db0f46c Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 16:50:43 +0000 Subject: [PATCH 05/12] feat(db): Added SQLite/PostgreSQL database --- hw2/hw/docker-compose.yaml | 15 ++ hw2/hw/shop_api/core/db.py | 44 +++++ hw2/hw/shop_api/core/models.py | 29 +++ hw2/hw/shop_api/core/schemas.py | 9 +- hw2/hw/shop_api/core/storage.py | 322 ++++++++++++++++++++------------ hw2/hw/shop_api/main.py | 9 +- hw2/hw/shop_api/routers/cart.py | 55 +----- hw2/hw/shop_api/routers/item.py | 74 ++------ 8 files changed, 331 insertions(+), 226 deletions(-) create mode 100644 hw2/hw/shop_api/core/db.py create mode 100644 hw2/hw/shop_api/core/models.py diff --git a/hw2/hw/docker-compose.yaml b/hw2/hw/docker-compose.yaml index cca96c41..a25b3682 100644 --- a/hw2/hw/docker-compose.yaml +++ b/hw2/hw/docker-compose.yaml @@ -24,11 +24,26 @@ services: dockerfile: ./Dockerfile target: local restart: always + environment: + - DATABASE_URL=postgresql://shop:shop@postgres:5432/shopdb ports: - 8080:8080 depends_on: - prometheus - grafana + - postgres + + postgres: + image: postgres:15 + environment: + POSTGRES_USER: shop + POSTGRES_PASSWORD: shop + POSTGRES_DB: shopdb + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - 5432:5432 + restart: always volumes: postgres_data: \ No newline at end of file diff --git a/hw2/hw/shop_api/core/db.py b/hw2/hw/shop_api/core/db.py new file mode 100644 index 00000000..9255943d --- /dev/null +++ b/hw2/hw/shop_api/core/db.py @@ -0,0 +1,44 @@ +import os +from typing import AsyncGenerator +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from shop_api.core.models import Base + +# Convert DATABASE_URL to async version if needed +DATABASE_URL = os.environ.get("DATABASE_URL") or os.environ.get( + "DATABASE_URL_LOCAL", +) + +if not DATABASE_URL: + # default to sqlite file for easy local runs/tests + DATABASE_URL = "sqlite+aiosqlite:///./shop.db" +else: + # Convert any PostgreSQL URL to use asyncpg + # Remove any existing dialect (like +psycopg2) if present + if DATABASE_URL.startswith('postgresql'): + if '+' in DATABASE_URL: + DATABASE_URL = DATABASE_URL.split('+')[0] + DATABASE_URL[DATABASE_URL.find('://'):] + DATABASE_URL = DATABASE_URL.replace('postgresql://', 'postgresql+asyncpg://') + +engine = create_async_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} +) + +SessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False +) + +async def init_db() -> None: + """Create database tables if they don't exist.""" + async with engine.begin() as conn: + # Create tables without dropping existing ones + await conn.run_sync(Base.metadata.create_all) + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with SessionLocal() as session: + yield session diff --git a/hw2/hw/shop_api/core/models.py b/hw2/hw/shop_api/core/models.py new file mode 100644 index 00000000..72ea73af --- /dev/null +++ b/hw2/hw/shop_api/core/models.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey +from sqlalchemy.orm import relationship, declarative_base + +Base = declarative_base() + +class Item(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False, nullable=False) + + +class Cart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan") + + +class CartItem(Base): + __tablename__ = "cart_items" + + cart_id = Column(Integer, ForeignKey("carts.id"), primary_key=True) + item_id = Column(Integer, ForeignKey("items.id"), primary_key=True) + quantity = Column(Integer, nullable=False, default=0) + + cart = relationship("Cart", back_populates="items") diff --git a/hw2/hw/shop_api/core/schemas.py b/hw2/hw/shop_api/core/schemas.py index f7547e01..ebe3f999 100644 --- a/hw2/hw/shop_api/core/schemas.py +++ b/hw2/hw/shop_api/core/schemas.py @@ -1,6 +1,6 @@ -# from __future__ import annotations -from typing import Optional, Annotated -from pydantic import BaseModel, Field, ConfigDict +from typing import Annotated, Optional + +from pydantic import BaseModel, ConfigDict, Field ItemName = Annotated[str, Field(description="Наименование товара", min_length=1)] ItemId = Annotated[int, Field(description="Идентификатор корзины", ge=0)] @@ -9,7 +9,6 @@ CartId = Annotated[int, Field(description="Идентификатор корзины")] -# ---- Item ---- class ItemOut(BaseModel): id: ItemId name: ItemName @@ -28,14 +27,12 @@ class ItemPut(BaseModel): class ItemPatch(BaseModel): - # Разрешаем частичное обновление ТОЛЬКО name/price; лишние поля → 422 model_config = ConfigDict(extra="forbid") name: Optional[ItemName] = None price: Optional[ItemPrice] = None -# ---- Cart ---- class CartItemView(BaseModel): id: ItemId name: ItemName diff --git a/hw2/hw/shop_api/core/storage.py b/hw2/hw/shop_api/core/storage.py index bc3c6cb9..37ff6c34 100644 --- a/hw2/hw/shop_api/core/storage.py +++ b/hw2/hw/shop_api/core/storage.py @@ -1,142 +1,230 @@ from __future__ import annotations -from threading import ( - RLock, -) # стандартная библиотека Python: https://docs.python.org/3/library/threading.html -from typing import Optional + +from typing import List, Optional, AsyncGenerator +from contextlib import asynccontextmanager + from fastapi import HTTPException, status -from .schemas import ( - ItemOut, +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from shop_api.core.db import SessionLocal +from shop_api.core.models import Item as ItemORM, Cart as CartORM, CartItem as CartItemORM +from shop_api.core.schemas import ( CartItemView, CartView, ItemCreate, - ItemPut, + ItemOut, ItemPatch, + ItemPut, ) -# ------------------------- -# In-memory хранилище + блокировки -# ------------------------- -_items_lock = RLock() -_carts_lock = RLock() - -_items: dict[int, ItemOut] = {} -_next_item_id = 1 - -# cart_id -> { item_id -> quantity } -_carts: dict[int, dict[int, int]] = {} -_next_cart_id = 1 - -def new_item_id() -> int: - global _next_item_id - with _items_lock: - nid = _next_item_id - _next_item_id += 1 - return nid +@asynccontextmanager +async def _session() -> AsyncGenerator[AsyncSession, None]: + async with SessionLocal() as session: + yield session -def new_cart_id() -> int: - global _next_cart_id - with _carts_lock: - nid = _next_cart_id - _next_cart_id += 1 - return nid - - -def get_item_or_404(item_id: int) -> ItemOut: - with _items_lock: - item = _items.get(item_id) +async def get_item_or_404(item_id: int) -> ItemOut: + async with _session() as s: + result = await s.execute(select(ItemORM).where(ItemORM.id == item_id)) + item = result.scalar_one_or_none() if item is None or item.deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) - return item + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + return ItemOut(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +async def get_item_soft(item_id: int) -> Optional[ItemORM]: + async with _session() as s: + stmt = select(ItemORM).where(ItemORM.id == item_id) + result = await s.execute(stmt) + return result.scalar_one_or_none() + + +async def list_items(offset: int = 0, limit: int = 10, min_price: Optional[float] = None, max_price: Optional[float] = None, show_deleted: bool = False) -> List[ItemOut]: + async with _session() as s: + query = select(ItemORM) + if not show_deleted: + query = query.where(ItemORM.deleted == False) # noqa: E712 + if min_price is not None: + query = query.where(ItemORM.price >= min_price) + if max_price is not None: + query = query.where(ItemORM.price <= max_price) + result = await s.execute(query.offset(offset).limit(limit)) + rows = result.scalars().all() + return [ItemOut(id=r.id, name=r.name, price=r.price, deleted=r.deleted) for r in rows] + + +async def create_item(data: ItemCreate) -> ItemOut: + async with _session() as s: + item = ItemORM(name=data.name, price=data.price, deleted=False) + s.add(item) + await s.commit() + await s.refresh(item) + return ItemOut(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +async def put_item(item_id: int, data: ItemPut) -> ItemOut: + async with _session() as s: + result = await s.execute(select(ItemORM).where(ItemORM.id == item_id)) + item = result.scalar_one_or_none() + if item is None or item.deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + item.name = data.name + item.price = data.price + await s.commit() + await s.refresh(item) + return ItemOut(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +async def patch_item(item_id: int, data: ItemPatch) -> ItemOut: + async with _session() as s: + result = await s.execute(select(ItemORM).where(ItemORM.id == item_id)) + item = result.scalar_one_or_none() + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + if item.deleted: + raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED, detail="Item deleted") + + if data.name is not None: + item.name = data.name + if data.price is not None: + if data.price < 0: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid price") + item.price = data.price + await s.commit() + await s.refresh(item) + return ItemOut(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +async def delete_item(item_id: int) -> dict: + async with _session() as s: + result = await s.execute(select(ItemORM).where(ItemORM.id == item_id)) + item = result.scalar_one_or_none() + if item is not None: + item.deleted = True + await s.commit() + return {"ok": True} -def get_item_soft(item_id: int) -> Optional[ItemOut]: - with _items_lock: - return _items.get(item_id) +async def create_cart() -> int: + async with _session() as s: + c = CartORM() + s.add(c) + await s.commit() + await s.refresh(c) + return c.id -def cart_or_404(cart_id: int) -> dict[int, int]: - with _carts_lock: - cart = _carts.get(cart_id) +async def cart_or_404(cart_id: int) -> CartORM: + async with _session() as s: + result = await s.execute(select(CartORM).where(CartORM.id == cart_id)) + cart = result.scalar_one_or_none() if cart is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found") return cart -def build_cart_view(cart_id: int) -> CartView: - with _carts_lock: - cart = _carts.get(cart_id, {}) - kv = list(cart.items()) - - items = [] - total = 0.0 - for item_id, qty in kv: - item = get_item_soft(item_id) - name = item.name if item else f"item:{item_id}" - available = bool(item and not item.deleted) - items.append( - CartItemView(id=item_id, name=name, quantity=qty, available=available) +async def build_cart_view(cart_id: int) -> CartView: + async with _session() as s: + result = await s.execute( + select(CartORM) + .where(CartORM.id == cart_id) + .options(selectinload(CartORM.items)) ) - if available: - total += item.price * qty - - return CartView(id=cart_id, items=items, price=total) - - -def create_item(data: ItemCreate) -> ItemOut: - item_id = new_item_id() - item = ItemOut(id=item_id, name=data.name, price=data.price, deleted=False) - with _items_lock: - _items[item_id] = item - return item - - -def put_item(item_id: int, data: ItemPut) -> ItemOut: - with _items_lock: - existing = _items.get(item_id) - if existing is None or existing.deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) - existing.name = data.name - existing.price = data.price - return existing + cart = result.scalar_one_or_none() + if cart is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found") + + item_ids = [ci.item_id for ci in cart.items] + if item_ids: + items_result = await s.execute(select(ItemORM).where(ItemORM.id.in_(item_ids))) + items_by_id = {i.id: i for i in items_result.scalars().all()} + else: + items_by_id = {} + + cart_items: List[CartItemView] = [] + total = 0.0 + for ci in cart.items: + item = items_by_id.get(ci.item_id) + name = item.name if item else f"item:{ci.item_id}" + available = bool(item and not item.deleted) + cart_items.append(CartItemView(id=ci.item_id, name=name, quantity=ci.quantity, available=available)) + if available: + total += item.price * ci.quantity + + return CartView(id=cart_id, items=cart_items, price=total) + + +async def list_carts(offset: int = 0, limit: int = 10, min_price: Optional[float] = None, max_price: Optional[float] = None, min_quantity: Optional[int] = None, max_quantity: Optional[int] = None) -> List[CartView]: + async with _session() as s: + result = await s.execute( + select(CartORM) + .options(selectinload(CartORM.items)) + ) + carts = result.scalars().all() + + all_item_ids = {item.item_id for cart in carts for item in cart.items} + + if all_item_ids: + items_result = await s.execute(select(ItemORM).where(ItemORM.id.in_(all_item_ids))) + items_by_id = {i.id: i for i in items_result.scalars().all()} + else: + items_by_id = {} + + views: List[CartView] = [] + for cart in carts: + items: List[CartItemView] = [] + total = 0.0 + for ci in cart.items: + item = items_by_id.get(ci.item_id) + name = item.name if item else f"item:{ci.item_id}" + available = bool(item and not item.deleted) + items.append(CartItemView(id=ci.item_id, name=name, quantity=ci.quantity, available=available)) + if available: + total += item.price * ci.quantity + + cart_view = CartView(id=cart.id, items=items, price=total) + + if not all( + [ + min_price is None or cart_view.price >= min_price, + max_price is None or cart_view.price <= max_price, + min_quantity is None or sum(i.quantity for i in cart_view.items) >= min_quantity, + max_quantity is None or sum(i.quantity for i in cart_view.items) <= max_quantity, + ] + ): + continue + + views.append(cart_view) + + return views[offset : offset + limit] + + +async def add_to_cart(cart_id: int, item_id: int) -> dict: + async with _session() as s: + cart_result = await s.execute(select(CartORM).where(CartORM.id == cart_id)) + cart = cart_result.scalar_one_or_none() + if cart is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found") + item_result = await s.execute(select(ItemORM).where(ItemORM.id == item_id)) + item = item_result.scalar_one_or_none() + if item is None or item.deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") -def patch_item(item_id: int, data: ItemPatch) -> ItemOut: - with _items_lock: - existing = _items.get(item_id) - if existing is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) - if existing.deleted: - # Пробрасываем семантику 304 на верхний уровень - raise HTTPException( - status_code=status.HTTP_304_NOT_MODIFIED, detail="Item deleted" + cart_item_result = await s.execute( + select(CartItemORM).where( + CartItemORM.cart_id == cart_id, + CartItemORM.item_id == item_id ) - - if data.name is not None: - existing.name = data.name - if data.price is not None: - if data.price < 0: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Invalid price", - ) - existing.price = data.price - - return existing - - -def delete_item(item_id: int) -> dict: - with _items_lock: - existing = _items.get(item_id) - if existing is not None: - existing.deleted = True + ) + ci = cart_item_result.scalar_one_or_none() + if ci is None: + ci = CartItemORM(cart_id=cart_id, item_id=item_id, quantity=1) + s.add(ci) + else: + ci.quantity = ci.quantity + 1 + await s.commit() return {"ok": True} diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index af15990b..82744d3a 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,9 +1,16 @@ +from contextlib import asynccontextmanager from fastapi import FastAPI from shop_api.routers.item import router as item from shop_api.routers.cart import router as cart from prometheus_fastapi_instrumentator import Instrumentator +from shop_api.core.db import init_db -app = FastAPI(title="Shop API") +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + +app = FastAPI(title="Shop API", lifespan=lifespan) Instrumentator().instrument(app).expose(app) app.include_router(item) diff --git a/hw2/hw/shop_api/routers/cart.py b/hw2/hw/shop_api/routers/cart.py index 9e040b6f..fc0d59e2 100644 --- a/hw2/hw/shop_api/routers/cart.py +++ b/hw2/hw/shop_api/routers/cart.py @@ -1,43 +1,34 @@ + from typing import List, Optional from fastapi import APIRouter, Query, Response, status from shop_api.core.schemas import CartView -from shop_api.core.storage import ( - _carts, - _carts_lock, - new_cart_id, - cart_or_404, - build_cart_view, - get_item_or_404, -) +from shop_api.core import storage router = APIRouter(prefix="/cart", tags=["cart"]) @router.post("", status_code=status.HTTP_201_CREATED) -def create_cart(response: Response): +async def create_cart(response: Response): """ POST /cart — RPC: создаёт пустую корзину, тело не принимает. Возвращает 201 и JSON {"id": }, а также заголовок Location: /cart/{id}. """ - cart_id = new_cart_id() - with _carts_lock: - _carts[cart_id] = {} + cart_id = await storage.create_cart() response.headers["Location"] = f"/cart/{cart_id}" return {"id": cart_id} @router.get("/{cart_id}", response_model=CartView) -def get_cart(cart_id: int) -> CartView: +async def get_cart(cart_id: int) -> CartView: """ GET /cart/{id} — получить корзину по id. """ - cart_or_404(cart_id) - return build_cart_view(cart_id) + return await storage.build_cart_view(cart_id) @router.get("", response_model=List[CartView]) -def list_carts( +async def list_carts( offset: int = Query(0, ge=0, description="Смещение по списку (offset)"), limit: int = Query(10, gt=0, description="Лимит количества (limit)"), min_price: Optional[float] = Query( @@ -61,39 +52,13 @@ def list_carts( • min_quantity/max_quantity — по суммарному количеству позиций в корзине (включительно). Порядок: фильтрация -> offset/limit. """ - with _carts_lock: - ids = list(_carts.keys()) - - views: List[CartView] = [] - for cid in ids: - v = build_cart_view(cid) - - if min_price is not None and v.price < min_price: - continue - if max_price is not None and v.price > max_price: - continue - - qsum = sum(it.quantity for it in v.items) - if min_quantity is not None and qsum < min_quantity: - continue - if max_quantity is not None and qsum > max_quantity: - continue - - views.append(v) - - return views[offset : offset + limit] + return await storage.list_carts(offset=offset, limit=limit, min_price=min_price, max_price=max_price, min_quantity=min_quantity, max_quantity=max_quantity) @router.post("/{cart_id}/add/{item_id}") -def add_to_cart(cart_id: int, item_id: int): +async def add_to_cart(cart_id: int, item_id: int): """ POST /cart/{cart_id}/add/{item_id} — добавить товар в корзину. Если товар уже есть — увеличивает его количество. """ - cart = cart_or_404(cart_id) - get_item_or_404(item_id) # проверка на товар - - with _carts_lock: - cart[item_id] = cart.get(item_id, 0) + 1 - - return {"ok": True} + return await storage.add_to_cart(cart_id, item_id) diff --git a/hw2/hw/shop_api/routers/item.py b/hw2/hw/shop_api/routers/item.py index acc511f6..f74c1153 100644 --- a/hw2/hw/shop_api/routers/item.py +++ b/hw2/hw/shop_api/routers/item.py @@ -2,33 +2,29 @@ from fastapi import APIRouter, HTTPException, Query, Response, status from shop_api.core.schemas import ItemOut, ItemCreate, ItemPut, ItemPatch -from shop_api.core.storage import _items, _items_lock, get_item_or_404, new_item_id +from shop_api.core import storage router = APIRouter(prefix="/item", tags=["items"]) @router.post("", status_code=status.HTTP_201_CREATED, response_model=ItemOut) -def create_item(body: ItemCreate) -> ItemOut: +async def create_item(body: ItemCreate) -> ItemOut: """ POST /item - добавление нового товара """ - item_id = new_item_id() - obj = ItemOut(id=item_id, name=body.name, price=body.price, deleted=False) - with _items_lock: - _items[item_id] = obj - return obj + return await storage.create_item(body) @router.get("/{item_id}", response_model=ItemOut) -def get_item(item_id: int) -> ItemOut: +async def get_item(item_id: int) -> ItemOut: """ GET /item/{id} - получение товара по id """ - return get_item_or_404(item_id) + return await storage.get_item_or_404(item_id) @router.get("", response_model=List[ItemOut]) -def list_items( +async def list_items( offset: int = Query(0, ge=0, description="Смещение (offset)"), limit: int = Query(10, gt=0, description="Количество (limit)"), min_price: Optional[float] = Query(None, ge=0, description="Мин. цена"), @@ -38,70 +34,34 @@ def list_items( """ GET /item - получение списка товаров с фильтрами и пагинацией """ - with _items_lock: - items = list(_items.values()) - - if not show_deleted: - items = [i for i in items if not i.deleted] - if min_price is not None: - items = [i for i in items if i.price >= min_price] - if max_price is not None: - items = [i for i in items if i.price <= max_price] - - return items[offset : offset + limit] + return await storage.list_items(offset=offset, limit=limit, min_price=min_price, max_price=max_price, show_deleted=show_deleted) @router.put("/{item_id}", response_model=ItemOut) -def put_item(item_id: int, body: ItemPut) -> ItemOut: +async def put_item(item_id: int, body: ItemPut) -> ItemOut: """ PUT /item/{id} - замена товара по id (создание запрещено) """ - with _items_lock: - existing = _items.get(item_id) - if existing is None or existing.deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) - existing.name = body.name - existing.price = body.price - return existing + return await storage.put_item(item_id, body) @router.patch("/{item_id}", response_model=ItemOut) -def patch_item(item_id: int, body: ItemPatch): +async def patch_item(item_id: int, body: ItemPatch): """ PATCH /item/{id} - частичное обновление (разрешено менять всё кроме deleted) Если товар удалён — 304 Not Modified. """ - with _items_lock: - existing = _items.get(item_id) - if existing is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) - if existing.deleted: + try: + return await storage.patch_item(item_id, body) + except HTTPException as e: + if e.status_code == status.HTTP_304_NOT_MODIFIED: return Response(status_code=status.HTTP_304_NOT_MODIFIED) - - if body.name is not None: - existing.name = body.name - if body.price is not None: - if body.price < 0: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Invalid price", - ) - existing.price = body.price - - return existing + raise @router.delete("/{item_id}") -def delete_item(item_id: int): +async def delete_item(item_id: int): """ DELETE /item/{id} - мягкое удаление (deleted=True), идемпотентно """ - with _items_lock: - existing = _items.get(item_id) - if existing is not None: - existing.deleted = True - return {"ok": True} + return await storage.delete_item(item_id) From f1eaaeaec285a88e246412665241885eb236b91b Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 16:51:56 +0000 Subject: [PATCH 06/12] test: Added unit tests, including DB isolation levels and integration ones --- hw2/hw/.github/workflows/test-postgresql.yml | 51 ++ hw2/hw/tests/conftest.py | 4 + hw2/hw/tests/test_isolation_levels.py | 262 +++++++++ hw2/hw/tests/test_shop_api.py | 530 +++++++++++++++++++ hw2/hw/tests/test_storage.py | 352 ++++++++++++ 5 files changed, 1199 insertions(+) create mode 100644 hw2/hw/.github/workflows/test-postgresql.yml create mode 100644 hw2/hw/tests/conftest.py create mode 100644 hw2/hw/tests/test_isolation_levels.py create mode 100644 hw2/hw/tests/test_shop_api.py create mode 100644 hw2/hw/tests/test_storage.py diff --git a/hw2/hw/.github/workflows/test-postgresql.yml b/hw2/hw/.github/workflows/test-postgresql.yml new file mode 100644 index 00000000..bc183079 --- /dev/null +++ b/hw2/hw/.github/workflows/test-postgresql.yml @@ -0,0 +1,51 @@ +name: Tests with PostgreSQL + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: shop_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: | + uv sync + + # Run all tests with PostgreSQL + - name: Run all tests with PostgreSQL + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/shop_test + run: | + cd hw + uv run -m pytest -v \ No newline at end of file diff --git a/hw2/hw/tests/conftest.py b/hw2/hw/tests/conftest.py new file mode 100644 index 00000000..dfa973ea --- /dev/null +++ b/hw2/hw/tests/conftest.py @@ -0,0 +1,4 @@ +from pytest_asyncio.plugin import Mode + +def pytest_configure(config): + config.option.asyncio_mode = Mode.AUTO \ No newline at end of file diff --git a/hw2/hw/tests/test_isolation_levels.py b/hw2/hw/tests/test_isolation_levels.py new file mode 100644 index 00000000..6d94faf9 --- /dev/null +++ b/hw2/hw/tests/test_isolation_levels.py @@ -0,0 +1,262 @@ +import os +import pytest +import pytest_asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import ( + AsyncSession, + AsyncEngine, + create_async_engine, + async_sessionmaker, +) + +# Use PostgreSQL if DATABASE_URL is set, otherwise fallback to SQLite +DATABASE_URL = os.getenv( + "DATABASE_URL", + "sqlite+aiosqlite:///./test.db" +) + +@pytest.fixture(scope="session") +def engine(): + """Create engine - works for both SQLite and PostgreSQL""" + return create_async_engine(DATABASE_URL) + +@pytest_asyncio.fixture(scope="session") +async def async_session_maker(engine: AsyncEngine): + """Create session factory and initialize database""" + from shop_api.core.models import Base + + # Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + yield async_session + + # Drop tables after tests + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + +@pytest_asyncio.fixture(autouse=True) +async def clean_db(async_session_maker): + """Clean database before each test""" + async with async_session_maker() as session: + # Use proper table names from models + await session.execute(text("DELETE FROM cart_items")) + await session.execute(text("DELETE FROM carts")) + await session.execute(text("DELETE FROM items")) + await session.commit() + +async def set_isolation_level(session: AsyncSession, level: str): + """Set isolation level for current transaction""" + if 'sqlite' in str(session.bind.url): + # SQLite only supports SERIALIZABLE and READ UNCOMMITTED + if level in ('SERIALIZABLE', 'REPEATABLE READ', 'READ COMMITTED'): + await session.execute(text("PRAGMA read_uncommitted = 0")) + elif level == 'READ UNCOMMITTED': + await session.execute(text("PRAGMA read_uncommitted = 1")) + else: + # PostgreSQL supports all isolation levels + await session.execute(text(f"SET TRANSACTION ISOLATION LEVEL {level}")) + +@pytest.mark.asyncio +async def test_dirty_read(async_session_maker): + """Test dirty read phenomenon + + 1. Session 1 starts transaction and updates data + 2. Session 2 tries to read the same data + 3. Should NOT see uncommitted changes in READ COMMITTED (default) + """ + # Create test item + async with async_session_maker() as session: + await session.execute( + text("INSERT INTO items (name, price, deleted) VALUES ('test_item', 100, false)") + ) + await session.commit() + + # Start two concurrent sessions + async with async_session_maker() as session1, async_session_maker() as session2: + await session1.begin() + # Set isolation level for session1 if using PostgreSQL + if 'postgresql' in str(session1.bind.url): + await set_isolation_level(session1, "READ UNCOMMITTED") + + # Update price in session1 (uncommitted) + await session1.execute( + text("UPDATE items SET price = 200 WHERE name = 'test_item'") + ) + + # Try to read in session2 + result = await session2.execute( + text("SELECT price FROM items WHERE name = 'test_item'") + ) + price = result.scalar() + + # In READ COMMITTED (default), should still see old price + assert price == 100, "Should not see uncommitted changes (dirty read)" + + # Rollback session1 changes + await session1.rollback() + +@pytest.mark.asyncio +async def test_non_repeatable_read(async_session_maker): + """Test non-repeatable read phenomenon + + 1. Session 1 reads data + 2. Session 2 modifies and commits the same data + 3. Session 1 reads again and sees different results in READ COMMITTED + """ + # Create test item + async with async_session_maker() as session: + await session.execute( + text("INSERT INTO items (name, price, deleted) VALUES ('test_item', 100, false)") + ) + await session.commit() + + # Start two concurrent sessions + async with async_session_maker() as session1, async_session_maker() as session2: + await session1.begin() + if 'postgresql' in str(session1.bind.url): + await set_isolation_level(session1, "READ COMMITTED") + + # First read in session1 + result = await session1.execute( + text("SELECT price FROM items WHERE name = 'test_item'") + ) + price1 = result.scalar() + assert price1 == 100 + + # Session 2 updates and commits + await session2.execute( + text("UPDATE items SET price = 200 WHERE name = 'test_item'") + ) + await session2.commit() + + # Second read in session1 + result = await session1.execute( + text("SELECT price FROM items WHERE name = 'test_item'") + ) + price2 = result.scalar() + + # In READ COMMITTED, should see the new value + assert price2 == 200, "Should see committed changes (non-repeatable read)" + await session1.commit() + +@pytest.mark.asyncio +async def test_repeatable_read(async_session_maker): + """Test prevention of non-repeatable read in REPEATABLE READ + + 1. Session 1 reads data in REPEATABLE READ + 2. Session 2 modifies and commits the same data + 3. Session 1 reads again and should see the same results + """ + if 'sqlite' in str(async_session_maker.kw['bind'].url): + pytest.skip("SQLite doesn't support REPEATABLE READ") + + # Create test item + async with async_session_maker() as session: + await session.execute( + text("INSERT INTO items (name, price, deleted) VALUES ('test_item', 100, false)") + ) + await session.commit() + + # Start two concurrent sessions + async with async_session_maker() as session1, async_session_maker() as session2: + await session1.begin() + await set_isolation_level(session1, "REPEATABLE READ") + + # First read in session1 + result = await session1.execute( + text("SELECT price FROM items WHERE name = 'test_item'") + ) + price1 = result.scalar() + assert price1 == 100 + + # Session 2 updates and commits + await session2.execute( + text("UPDATE items SET price = 200 WHERE name = 'test_item'") + ) + await session2.commit() + + # Second read in session1 + result = await session1.execute( + text("SELECT price FROM items WHERE name = 'test_item'") + ) + price2 = result.scalar() + + # In REPEATABLE READ, should still see the old value + assert price2 == 100, "Should not see changes in REPEATABLE READ" + await session1.commit() + +@pytest.mark.asyncio +async def test_phantom_read(async_session_maker): + """Test phantom read phenomenon + + 1. Session 1 queries a range of records + 2. Session 2 inserts a new record in that range + 3. Session 1 queries again and sees the new record in READ COMMITTED + """ + # Start two concurrent sessions + async with async_session_maker() as session1, async_session_maker() as session2: + await session1.begin() + if 'postgresql' in str(session1.bind.url): + await set_isolation_level(session1, "READ COMMITTED") + + # First count in session1 + result = await session1.execute( + text("SELECT COUNT(*) FROM items WHERE price BETWEEN 50 AND 150") + ) + count1 = result.scalar() + + # Session 2 inserts new record + await session2.execute( + text("INSERT INTO items (name, price, deleted) VALUES ('phantom', 100, false)") + ) + await session2.commit() + + # Second count in session1 + result = await session1.execute( + text("SELECT COUNT(*) FROM items WHERE price BETWEEN 50 AND 150") + ) + count2 = result.scalar() + + # In READ COMMITTED, should see the new record + assert count2 == count1 + 1, "Should see phantom record in READ COMMITTED" + await session1.commit() + +@pytest.mark.asyncio +async def test_serializable(async_session_maker): + """Test serializable isolation level prevents phantom reads + + 1. Session 1 queries a range of records in SERIALIZABLE + 2. Session 2 tries to insert a new record in that range + 3. Session 1 queries again and should not see changes + """ + if 'sqlite' in str(async_session_maker.kw['bind'].url): + pytest.skip("SQLite SERIALIZABLE behavior differs from PostgreSQL") + + # Start two concurrent sessions + async with async_session_maker() as session1, async_session_maker() as session2: + await session1.begin() + await set_isolation_level(session1, "SERIALIZABLE") + + # First count in session1 + result = await session1.execute( + text("SELECT COUNT(*) FROM items WHERE price BETWEEN 50 AND 150") + ) + count1 = result.scalar() + + # Session 2 tries to insert new record + await session2.execute( + text("INSERT INTO items (name, price, deleted) VALUES ('phantom', 100, false)") + ) + await session2.commit() + + # Second count in session1 + result = await session1.execute( + text("SELECT COUNT(*) FROM items WHERE price BETWEEN 50 AND 150") + ) + count2 = result.scalar() + + # In SERIALIZABLE, should not see the new record + assert count2 == count1, "Should not see phantom record in SERIALIZABLE" + await session1.commit() \ No newline at end of file diff --git a/hw2/hw/tests/test_shop_api.py b/hw2/hw/tests/test_shop_api.py new file mode 100644 index 00000000..9ce917d3 --- /dev/null +++ b/hw2/hw/tests/test_shop_api.py @@ -0,0 +1,530 @@ +from http import HTTPStatus +from typing import Any, AsyncGenerator +from uuid import uuid4 + +import pytest +import pytest_asyncio +import httpx +from faker import Faker +from httpx import AsyncClient + +from shop_api.main import app +from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, create_async_engine, async_sessionmaker +from shop_api.core.db import DATABASE_URL + +faker = Faker() + +@pytest_asyncio.fixture(scope="session") +def anyio_backend(): + return "asyncio" + +@pytest.fixture(scope="session") +def engine(): + return create_async_engine(DATABASE_URL) + +@pytest_asyncio.fixture(scope="session") +async def async_session_maker(engine: AsyncEngine): + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + yield async_session + + +@pytest_asyncio.fixture(scope="session") +async def async_client() -> AsyncGenerator: + transport = httpx.ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + +@pytest_asyncio.fixture(autouse=True) +async def clean_db(async_session_maker): + """Clean database before each test""" + from sqlalchemy import text + async with async_session_maker() as session: + await session.execute(text("DELETE FROM cart_items")) + await session.execute(text("DELETE FROM carts")) + await session.execute(text("DELETE FROM items")) + await session.commit() + +@pytest_asyncio.fixture() +async def items() -> list[dict]: + """ + 5 Items for basic tests + """ + return [ + {"name": "Test Item 1", "price": 10.0}, + {"name": "Test Item 2", "price": 20.0}, + {"name": "Test Item 3", "price": 30.0}, + {"name": "Test Item 4", "price": 40.0}, + {"name": "Test Item 5", "price": 50.0}, + ] + +@pytest_asyncio.fixture() +async def existing_items(async_client: AsyncClient, items: list[dict]) -> list[int]: + """Create items in the database and return their IDs""" + result = [] + for item in items: + response = await async_client.post("/item", json=item) + if response.status_code == HTTPStatus.CREATED: + result.append(response.json()["id"]) + return result + +@pytest_asyncio.fixture() +async def existing_empty_cart_id(async_client: AsyncClient) -> int: + response = await async_client.post("/cart") + return response.json()["id"] + +@pytest_asyncio.fixture() +async def existing_not_empty_cart_id( + async_client: AsyncClient, + existing_items: list[int], +) -> int: + # Create a new cart + response = await async_client.post("/cart") + cart_id = response.json()["id"] + + # Add an item to it + for item_id in existing_items[:2]: # Add first two items + await async_client.post(f"/cart/{cart_id}/add/{item_id}") + + return cart_id + + # Add items to it + for item_id in faker.random_elements(existing_items, unique=False, length=3): + await async_client.post(f"/cart/{cart_id}/add/{item_id}") + + return cart_id + + +@pytest_asyncio.fixture() +async def existing_item(async_client: AsyncClient) -> dict[str, Any]: + response = await async_client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ) + return response.json() + + +@pytest_asyncio.fixture() +async def deleted_item(async_client: AsyncClient) -> dict[str, Any]: + # Create a new item + response = await async_client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ) + item = response.json() + + # Delete it + await async_client.delete(f"/item/{item['id']}") + + item["deleted"] = True + return item + + +@pytest.mark.asyncio +async def test_post_cart(async_client: AsyncClient) -> None: + response = await async_client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.asyncio +async def test_get_cart(async_client: AsyncClient, existing_empty_cart_id: int, existing_not_empty_cart_id: int) -> None: + # Test empty cart + response = await async_client.get(f"/cart/{existing_empty_cart_id}") + assert response.status_code == HTTPStatus.OK + response_json = response.json() + assert len(response_json["items"]) == 0 + assert response_json["price"] == 0.0 + + # Test non-empty cart + response = await async_client.get(f"/cart/{existing_not_empty_cart_id}") + assert response.status_code == HTTPStatus.OK + response_json = response.json() + assert len(response_json["items"]) > 0 + + # Verify total price calculation + price = 0 + for item in response_json["items"]: + item_id = item["id"] + item_response = await async_client.get(f"/item/{item_id}") + price += item_response.json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + + +@pytest_asyncio.fixture() +async def cart_list_test_setup(async_client: AsyncClient, existing_items: list[int]) -> None: + """Set up test data for cart list tests""" + # Create a cart with one item + response = await async_client.post("/cart") + cart1_id = response.json()["id"] + await async_client.post(f"/cart/{cart1_id}/add/{existing_items[0]}") # Add Item1 + + # Create a cart with two items + response = await async_client.post("/cart") + cart2_id = response.json()["id"] + await async_client.post(f"/cart/{cart2_id}/add/{existing_items[1]}") # Add Item2 + await async_client.post(f"/cart/{cart2_id}/add/{existing_items[1]}") # Add Item2 again + +@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), + ], +) +@pytest.mark.asyncio +async def test_get_cart_list(async_client: AsyncClient, cart_list_test_setup, query: dict[str, Any], status_code: int): + response = await async_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"] + + +@pytest.mark.asyncio +async def test_post_item(async_client: AsyncClient) -> None: + item = {"name": "test item", "price": 9.99} + response = await async_client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +@pytest.mark.asyncio +async def test_get_item(async_client: AsyncClient, existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = await async_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), + ({"min_price": 1.0, "max_price": 1000.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"show_deleted": False}, 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), + ], +) +@pytest.mark.asyncio +async def test_get_item_list(async_client: AsyncClient, query: dict[str, Any], status_code: int) -> None: + response = await async_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), + ], +) +@pytest.mark.asyncio +async def test_put_item( + async_client: AsyncClient, + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = await async_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_type", "body", "status_code"), + [ + ("deleted", {}, HTTPStatus.NOT_MODIFIED), + ("deleted", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing", {}, HTTPStatus.OK), + ("existing", {"price": 9.99}, HTTPStatus.OK), + ("existing", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +@pytest.mark.asyncio +async def test_patch_item(async_client: AsyncClient, existing_item: dict[str, Any], deleted_item: dict[str, Any], item_type: str, body: dict[str, Any], status_code: int) -> None: + item = deleted_item if item_type == "deleted" else existing_item + item_id = item["id"] + response = await async_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 = await async_client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +@pytest.mark.asyncio +async def test_delete_item(async_client: AsyncClient, existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = await async_client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = await async_client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = await async_client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +async def test_add_to_cart_error_cases(async_client: AsyncClient) -> None: + # Test adding to non-existent cart + response = await async_client.post("/cart/99999/add/1") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["detail"] == "Cart not found" + + # Create a cart for testing + response = await async_client.post("/cart") + cart_id = response.json()["id"] + + # Test adding non-existent item + response = await async_client.post(f"/cart/{cart_id}/add/99999") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["detail"] == "Item not found" + + # Create an item and delete it + response = await async_client.post( + "/item", + json={ + "name": "Deleted Item", + "price": 10.0 + } + ) + item_id = response.json()["id"] + await async_client.delete(f"/item/{item_id}") + + # Test adding deleted item + response = await async_client.post(f"/cart/{cart_id}/add/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["detail"] == "Item not found" + + # Create a new item + response = await async_client.post( + "/item", + json={ + "name": "Test Item", + "price": 10.0 + } + ) + item_id = response.json()["id"] + + # Add same item multiple times and verify quantity increases + response = await async_client.post(f"/cart/{cart_id}/add/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = await async_client.post(f"/cart/{cart_id}/add/{item_id}") + assert response.status_code == HTTPStatus.OK + + # Check the cart contents + response = await async_client.get(f"/cart/{cart_id}") + assert response.status_code == HTTPStatus.OK + data = response.json() + + assert len(data["items"]) == 1 + item = data["items"][0] + assert item["id"] == item_id + assert item["quantity"] == 2 # Should be 2 since we added it twice + + +@pytest.mark.asyncio +async def test_item_list_filters(async_client: AsyncClient) -> None: + # Create items with different prices + items = [ + {"name": "Cheap Item", "price": 5.0}, + {"name": "Mid Item", "price": 10.0}, + {"name": "Expensive Item", "price": 20.0}, + ] + created_items = [] + + for item in items: + response = await async_client.post("/item", json=item) + assert response.status_code == HTTPStatus.CREATED + created_items.append(response.json()) + + # Delete one item + await async_client.delete(f"/item/{created_items[1]['id']}") + + # Test min_price filter + response = await async_client.get("/item?min_price=15.0") + assert response.status_code == HTTPStatus.OK + filtered_items = response.json() + assert len(filtered_items) == 1 + assert filtered_items[0]["price"] == 20.0 + + # Test max_price filter + response = await async_client.get("/item?max_price=7.0") + assert response.status_code == HTTPStatus.OK + filtered_items = response.json() + assert len(filtered_items) == 1 + assert filtered_items[0]["price"] == 5.0 + + # Test price range filter + response = await async_client.get("/item?min_price=4.0&max_price=15.0") + assert response.status_code == HTTPStatus.OK + filtered_items = response.json() + assert len(filtered_items) == 1 + assert filtered_items[0]["price"] == 5.0 + + # Test show_deleted=true + response = await async_client.get("/item?show_deleted=true") + assert response.status_code == HTTPStatus.OK + all_items = response.json() + assert len(all_items) == 3 + assert any(item["deleted"] for item in all_items) + + +@pytest.mark.asyncio +async def test_cart_list_filters(async_client: AsyncClient, existing_items: list[int]) -> None: + # Create three carts with known configurations + # Cart 1: 1x Item1 (10.0) + # Cart 2: 2x Item2 (40.0) + # Cart 3: 1x Item2 + 1x Item3 (50.0) + + # Create Cart 1 (10.0, qty=1) + response = await async_client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + cart1_id = response.json()["id"] + await async_client.post(f"/cart/{cart1_id}/add/{existing_items[0]}") # Add Item1 + + # Create Cart 2 (40.0, qty=2) + response = await async_client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + cart2_id = response.json()["id"] + await async_client.post(f"/cart/{cart2_id}/add/{existing_items[1]}") # Add Item2 + await async_client.post(f"/cart/{cart2_id}/add/{existing_items[1]}") # Add Item2 again + + # Create Cart 3 (50.0, qty=2) + response = await async_client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + cart3_id = response.json()["id"] + await async_client.post(f"/cart/{cart3_id}/add/{existing_items[1]}") # Add Item2 + await async_client.post(f"/cart/{cart3_id}/add/{existing_items[2]}") # Add Item3 + + # Test min_price filter (should get Cart 3 only) + response = await async_client.get("/cart?min_price=45.0") + assert response.status_code == HTTPStatus.OK + filtered_carts = response.json() + assert len(filtered_carts) == 1 + assert filtered_carts[0]["price"] == 50.0 + + # Test max_price filter (should get Cart 1 only) + response = await async_client.get("/cart?max_price=15.0") + assert response.status_code == HTTPStatus.OK + filtered_carts = response.json() + assert len(filtered_carts) == 1 + assert filtered_carts[0]["price"] == 10.0 + + # Test min_quantity filter (should get Cart 2 and Cart 3) + response = await async_client.get("/cart?min_quantity=2") + assert response.status_code == HTTPStatus.OK + filtered_carts = response.json() + assert len(filtered_carts) == 2 + for cart in filtered_carts: + quantity = sum(item["quantity"] for item in cart["items"]) + assert quantity >= 2 + + # Test max_quantity filter (should get Cart 1 only) + response = await async_client.get("/cart?max_quantity=1") + assert response.status_code == HTTPStatus.OK + filtered_carts = response.json() + assert len(filtered_carts) == 1 + cart = filtered_carts[0] + assert sum(item["quantity"] for item in cart["items"]) == 1 + + # Test price and quantity filters combined + # Should get Cart 2 only: price >= 35.0 and quantity <= 2 + response = await async_client.get("/cart?min_price=35.0&max_quantity=2") + assert response.status_code == HTTPStatus.OK + filtered_carts = response.json() + assert len(filtered_carts) == 2 # Cart 2 and Cart 3 both match these criteria + cart = filtered_carts[0] + assert cart["price"] >= 15.0 + assert sum(item["quantity"] for item in cart["items"]) <= 2 \ No newline at end of file diff --git a/hw2/hw/tests/test_storage.py b/hw2/hw/tests/test_storage.py new file mode 100644 index 00000000..b31f7bdc --- /dev/null +++ b/hw2/hw/tests/test_storage.py @@ -0,0 +1,352 @@ +from http import HTTPStatus +from typing import List + +import pytest +import pytest_asyncio +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, create_async_engine, async_sessionmaker + +from shop_api.core.db import DATABASE_URL +from shop_api.core.schemas import ItemCreate, ItemOut, ItemPatch, ItemPut +from shop_api.core.storage import ( + _session, + get_item_or_404, + get_item_soft, + list_items, + create_item, + put_item, + patch_item, + delete_item, + create_cart, + cart_or_404, + build_cart_view, + list_carts, + add_to_cart, +) + +@pytest.fixture(scope="session") +def engine(): + return create_async_engine(DATABASE_URL) + +@pytest_asyncio.fixture(scope="session") +async def async_session_maker(engine: AsyncEngine): + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + yield async_session + +@pytest_asyncio.fixture(autouse=True) +async def clean_db(async_session_maker): + """Clean database before each test""" + from sqlalchemy import text + async with async_session_maker() as session: + await session.execute(text("DELETE FROM cart_items")) + await session.execute(text("DELETE FROM carts")) + await session.execute(text("DELETE FROM items")) + await session.commit() + +@pytest_asyncio.fixture +async def item_data() -> ItemCreate: + return ItemCreate(name="Test Item", price=10.0) + +@pytest_asyncio.fixture +async def existing_item(item_data: ItemCreate) -> ItemOut: + return await create_item(item_data) + +@pytest_asyncio.fixture +async def deleted_item(item_data: ItemCreate) -> ItemOut: + item = await create_item(item_data) + await delete_item(item.id) + item.deleted = True + return item + +@pytest_asyncio.fixture +async def existing_items() -> List[ItemOut]: + """Create multiple items with different prices""" + items = [] + for i, price in enumerate([5.0, 10.0, 20.0, 30.0, 40.0]): + data = ItemCreate(name=f"Test Item {i}", price=price) + items.append(await create_item(data)) + return items + +@pytest_asyncio.fixture +async def existing_cart() -> int: + return await create_cart() + +@pytest_asyncio.fixture +async def cart_with_items(existing_cart: int, existing_items: List[ItemOut]) -> int: + """Create a cart and add some items to it""" + for item in existing_items[:2]: # Add first two items + await add_to_cart(existing_cart, item.id) + return existing_cart + +# Test Item Operations +@pytest.mark.asyncio +async def test_session_context(): + async with _session() as session: + assert isinstance(session, AsyncSession) + +@pytest.mark.asyncio +async def test_get_item_or_404(existing_item: ItemOut): + # Test existing item + item = await get_item_or_404(existing_item.id) + assert item.id == existing_item.id + assert item.name == existing_item.name + assert item.price == existing_item.price + assert not item.deleted + + # Test non-existent item + with pytest.raises(HTTPException) as exc_info: + await get_item_or_404(999) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + # Test deleted item + await delete_item(existing_item.id) + with pytest.raises(HTTPException) as exc_info: + await get_item_or_404(existing_item.id) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + +@pytest.mark.asyncio +async def test_get_item_soft(existing_item: ItemOut, deleted_item: ItemOut): + # Test existing item + item = await get_item_soft(existing_item.id) + assert item is not None + assert item.id == existing_item.id + assert not item.deleted + + # Test deleted item + item = await get_item_soft(deleted_item.id) + assert item is not None + assert item.deleted + + # Test non-existent item + item = await get_item_soft(999) + assert item is None + +@pytest.mark.asyncio +async def test_list_items(existing_items: List[ItemOut]): + # Test default parameters + items = await list_items() + assert len(items) == 5 + assert all(not item.deleted for item in items) + + # Test min_price filter + items = await list_items(min_price=15.0) + assert len(items) == 3 + assert all(item.price >= 15.0 for item in items) + + # Test max_price filter + items = await list_items(max_price=15.0) + assert len(items) == 2 + assert all(item.price <= 15.0 for item in items) + + # Test price range + items = await list_items(min_price=10.0, max_price=30.0) + assert len(items) == 3 + assert all(10.0 <= item.price <= 30.0 for item in items) + + # Test pagination + items = await list_items(offset=2, limit=2) + assert len(items) == 2 + + # Test show_deleted + item_id = existing_items[0].id + await delete_item(item_id) + + items = await list_items(show_deleted=False) + assert len(items) == 4 + assert all(not item.deleted for item in items) + + items = await list_items(show_deleted=True) + assert len(items) == 5 + assert any(item.deleted for item in items) + +@pytest.mark.asyncio +async def test_create_item(item_data: ItemCreate): + item = await create_item(item_data) + assert item.name == item_data.name + assert item.price == item_data.price + assert not item.deleted + assert item.id is not None + +@pytest.mark.asyncio +async def test_put_item(existing_item: ItemOut): + # Test successful update + data = ItemPut(name="Updated Name", price=20.0) + updated = await put_item(existing_item.id, data) + assert updated.name == data.name + assert updated.price == data.price + assert updated.id == existing_item.id + + # Test non-existent item + with pytest.raises(HTTPException) as exc_info: + await put_item(999, data) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + # Test deleted item + await delete_item(existing_item.id) + with pytest.raises(HTTPException) as exc_info: + await put_item(existing_item.id, data) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + +@pytest.mark.asyncio +async def test_patch_item(existing_item: ItemOut, deleted_item: ItemOut): + # Test partial update - only name + data = ItemPatch(name="New Name") + updated = await patch_item(existing_item.id, data) + assert updated.name == "New Name" + assert updated.price == existing_item.price + + # Test partial update - only price + data = ItemPatch(price=25.0) + updated = await patch_item(existing_item.id, data) + assert updated.name == "New Name" # Preserved from previous update + assert updated.price == 25.0 + + # Test invalid price (negative prices are caught by Pydantic validation) + with pytest.raises(Exception) as exc_info: + await patch_item(existing_item.id, ItemPatch(price=-1.0)) + assert "greater than or equal to 0" in str(exc_info.value) + + # Test non-existent item + with pytest.raises(HTTPException) as exc_info: + await patch_item(999, data) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + # Test deleted item + with pytest.raises(HTTPException) as exc_info: + await patch_item(deleted_item.id, data) + assert exc_info.value.status_code == HTTPStatus.NOT_MODIFIED + +@pytest.mark.asyncio +async def test_delete_item(existing_item: ItemOut): + # Test successful delete + result = await delete_item(existing_item.id) + assert result == {"ok": True} + + # Verify item is marked as deleted + item = await get_item_soft(existing_item.id) + assert item is not None + assert item.deleted + + # Test idempotency - deleting again should work + result = await delete_item(existing_item.id) + assert result == {"ok": True} + +# Test Cart Operations +@pytest.mark.asyncio +async def test_create_cart(): + cart_id = await create_cart() + assert cart_id is not None + assert isinstance(cart_id, int) + +@pytest.mark.asyncio +async def test_cart_or_404(existing_cart: int): + # Test existing cart + cart = await cart_or_404(existing_cart) + assert cart.id == existing_cart + + # Test non-existent cart + with pytest.raises(HTTPException) as exc_info: + await cart_or_404(999) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + +@pytest.mark.asyncio +async def test_build_cart_view(cart_with_items: int, existing_items: List[ItemOut]): + # Test cart with items + cart = await build_cart_view(cart_with_items) + assert cart.id == cart_with_items + assert len(cart.items) == 2 + assert cart.price == sum(item.price for item in existing_items[:2]) + + # Test empty cart + empty_cart_id = await create_cart() + cart = await build_cart_view(empty_cart_id) + assert cart.id == empty_cart_id + assert len(cart.items) == 0 + assert cart.price == 0.0 + + # Test non-existent cart + with pytest.raises(HTTPException) as exc_info: + await build_cart_view(999) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + +@pytest.mark.asyncio +async def test_list_carts(existing_items: List[ItemOut]): + # Create carts with different configurations + # Cart 1: 1x Item1 (5.0) + cart1_id = await create_cart() + await add_to_cart(cart1_id, existing_items[0].id) + + # Cart 2: 2x Item2 (20.0) + cart2_id = await create_cart() + await add_to_cart(cart2_id, existing_items[1].id) + await add_to_cart(cart2_id, existing_items[1].id) + + # Cart 3: 1x Item2 + 1x Item3 (30.0) + cart3_id = await create_cart() + await add_to_cart(cart3_id, existing_items[1].id) + await add_to_cart(cart3_id, existing_items[2].id) + + # Test default parameters + carts = await list_carts() + assert len(carts) == 3 + + # Test min_price filter + carts = await list_carts(min_price=25.0) + assert len(carts) == 1 + assert carts[0].price == 30.0 + + # Test max_price filter + carts = await list_carts(max_price=15.0) + assert len(carts) == 1 + assert carts[0].price == 5.0 + + # Test min_quantity filter + carts = await list_carts(min_quantity=2) + assert len(carts) == 2 + for cart in carts: + assert sum(item.quantity for item in cart.items) >= 2 + + # Test max_quantity filter + carts = await list_carts(max_quantity=1) + assert len(carts) == 1 + for cart in carts: + assert sum(item.quantity for item in cart.items) == 1 + + # Test pagination + carts = await list_carts(offset=1, limit=1) + assert len(carts) == 1 + +@pytest.mark.asyncio +async def test_add_to_cart(existing_cart: int, existing_item: ItemOut, deleted_item: ItemOut): + # Test adding new item + result = await add_to_cart(existing_cart, existing_item.id) + assert result == {"ok": True} + + # Verify item was added + cart = await build_cart_view(existing_cart) + assert len(cart.items) == 1 + assert cart.items[0].id == existing_item.id + assert cart.items[0].quantity == 1 + + # Test adding same item again (should increment quantity) + result = await add_to_cart(existing_cart, existing_item.id) + assert result == {"ok": True} + + cart = await build_cart_view(existing_cart) + assert len(cart.items) == 1 + assert cart.items[0].quantity == 2 + + # Test adding to non-existent cart + with pytest.raises(HTTPException) as exc_info: + await add_to_cart(999, existing_item.id) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + # Test adding non-existent item + with pytest.raises(HTTPException) as exc_info: + await add_to_cart(existing_cart, 999) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + # Test adding deleted item + with pytest.raises(HTTPException) as exc_info: + await add_to_cart(existing_cart, deleted_item.id) + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND \ No newline at end of file From 59f1263e09541eef8988c778f7c1e6ad383b07b1 Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 19:57:32 +0300 Subject: [PATCH 07/12] Add GitHub Actions workflow for PostgreSQL tests --- .github/workflows/test-postgresql.yml | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/test-postgresql.yml diff --git a/.github/workflows/test-postgresql.yml b/.github/workflows/test-postgresql.yml new file mode 100644 index 00000000..d263236e --- /dev/null +++ b/.github/workflows/test-postgresql.yml @@ -0,0 +1,52 @@ +name: Tests with PostgreSQL + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: shop_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: | + cd hw + uv sync + + # Run all tests with PostgreSQL + - name: Run all tests with PostgreSQL + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/shop_test + run: | + cd hw + uv run -m pytest -v From cf85b59a1165d0362974928bfb8c3165ce48a17f Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 20:00:24 +0300 Subject: [PATCH 08/12] Update directory path for test execution --- .github/workflows/test-postgresql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-postgresql.yml b/.github/workflows/test-postgresql.yml index d263236e..03981e30 100644 --- a/.github/workflows/test-postgresql.yml +++ b/.github/workflows/test-postgresql.yml @@ -40,7 +40,7 @@ jobs: - name: Install dependencies run: | - cd hw + cd hw2/hw uv sync # Run all tests with PostgreSQL @@ -48,5 +48,5 @@ jobs: env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/shop_test run: | - cd hw + cd hw2/hw uv run -m pytest -v From 409c409cdcd6306e50afbc9cb9268c900b07b2b3 Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 17:21:36 +0000 Subject: [PATCH 09/12] fix: CI testing fixes --- hw2/hw/.github/workflows/test-postgresql.yml | 13 +++----- hw2/hw/tests/conftest.py | 8 ++++- hw2/hw/tests/test_isolation_levels.py | 34 -------------------- 3 files changed, 12 insertions(+), 43 deletions(-) diff --git a/hw2/hw/.github/workflows/test-postgresql.yml b/hw2/hw/.github/workflows/test-postgresql.yml index bc183079..ac1c9964 100644 --- a/hw2/hw/.github/workflows/test-postgresql.yml +++ b/hw2/hw/.github/workflows/test-postgresql.yml @@ -35,17 +35,14 @@ jobs: - name: Install uv run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.cargo/bin" >> $GITHUB_PATH + pip intall uv - name: Install dependencies run: | + cd hw2/hw uv sync - # Run all tests with PostgreSQL - - name: Run all tests with PostgreSQL - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/shop_test + - name: Run all tests with SQLite run: | - cd hw - uv run -m pytest -v \ No newline at end of file + cd hw2/hw + uv run -m pytest -v diff --git a/hw2/hw/tests/conftest.py b/hw2/hw/tests/conftest.py index dfa973ea..81bd2e7f 100644 --- a/hw2/hw/tests/conftest.py +++ b/hw2/hw/tests/conftest.py @@ -1,4 +1,10 @@ +import pytest_asyncio from pytest_asyncio.plugin import Mode +from shop_api.core.db import init_db def pytest_configure(config): - config.option.asyncio_mode = Mode.AUTO \ No newline at end of file + config.option.asyncio_mode = Mode.AUTO + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def initialize_database(): + await init_db() diff --git a/hw2/hw/tests/test_isolation_levels.py b/hw2/hw/tests/test_isolation_levels.py index 6d94faf9..7d90b079 100644 --- a/hw2/hw/tests/test_isolation_levels.py +++ b/hw2/hw/tests/test_isolation_levels.py @@ -9,7 +9,6 @@ async_sessionmaker, ) -# Use PostgreSQL if DATABASE_URL is set, otherwise fallback to SQLite DATABASE_URL = os.getenv( "DATABASE_URL", "sqlite+aiosqlite:///./test.db" @@ -25,14 +24,12 @@ async def async_session_maker(engine: AsyncEngine): """Create session factory and initialize database""" from shop_api.core.models import Base - # Create tables async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) yield async_session - # Drop tables after tests async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) @@ -40,7 +37,6 @@ async def async_session_maker(engine: AsyncEngine): async def clean_db(async_session_maker): """Clean database before each test""" async with async_session_maker() as session: - # Use proper table names from models await session.execute(text("DELETE FROM cart_items")) await session.execute(text("DELETE FROM carts")) await session.execute(text("DELETE FROM items")) @@ -49,13 +45,11 @@ async def clean_db(async_session_maker): async def set_isolation_level(session: AsyncSession, level: str): """Set isolation level for current transaction""" if 'sqlite' in str(session.bind.url): - # SQLite only supports SERIALIZABLE and READ UNCOMMITTED if level in ('SERIALIZABLE', 'REPEATABLE READ', 'READ COMMITTED'): await session.execute(text("PRAGMA read_uncommitted = 0")) elif level == 'READ UNCOMMITTED': await session.execute(text("PRAGMA read_uncommitted = 1")) else: - # PostgreSQL supports all isolation levels await session.execute(text(f"SET TRANSACTION ISOLATION LEVEL {level}")) @pytest.mark.asyncio @@ -66,35 +60,29 @@ async def test_dirty_read(async_session_maker): 2. Session 2 tries to read the same data 3. Should NOT see uncommitted changes in READ COMMITTED (default) """ - # Create test item async with async_session_maker() as session: await session.execute( text("INSERT INTO items (name, price, deleted) VALUES ('test_item', 100, false)") ) await session.commit() - # Start two concurrent sessions async with async_session_maker() as session1, async_session_maker() as session2: await session1.begin() # Set isolation level for session1 if using PostgreSQL if 'postgresql' in str(session1.bind.url): await set_isolation_level(session1, "READ UNCOMMITTED") - # Update price in session1 (uncommitted) await session1.execute( text("UPDATE items SET price = 200 WHERE name = 'test_item'") ) - # Try to read in session2 result = await session2.execute( text("SELECT price FROM items WHERE name = 'test_item'") ) price = result.scalar() - # In READ COMMITTED (default), should still see old price assert price == 100, "Should not see uncommitted changes (dirty read)" - # Rollback session1 changes await session1.rollback() @pytest.mark.asyncio @@ -105,39 +93,33 @@ async def test_non_repeatable_read(async_session_maker): 2. Session 2 modifies and commits the same data 3. Session 1 reads again and sees different results in READ COMMITTED """ - # Create test item async with async_session_maker() as session: await session.execute( text("INSERT INTO items (name, price, deleted) VALUES ('test_item', 100, false)") ) await session.commit() - # Start two concurrent sessions async with async_session_maker() as session1, async_session_maker() as session2: await session1.begin() if 'postgresql' in str(session1.bind.url): await set_isolation_level(session1, "READ COMMITTED") - # First read in session1 result = await session1.execute( text("SELECT price FROM items WHERE name = 'test_item'") ) price1 = result.scalar() assert price1 == 100 - # Session 2 updates and commits await session2.execute( text("UPDATE items SET price = 200 WHERE name = 'test_item'") ) await session2.commit() - # Second read in session1 result = await session1.execute( text("SELECT price FROM items WHERE name = 'test_item'") ) price2 = result.scalar() - # In READ COMMITTED, should see the new value assert price2 == 200, "Should see committed changes (non-repeatable read)" await session1.commit() @@ -152,38 +134,32 @@ async def test_repeatable_read(async_session_maker): if 'sqlite' in str(async_session_maker.kw['bind'].url): pytest.skip("SQLite doesn't support REPEATABLE READ") - # Create test item async with async_session_maker() as session: await session.execute( text("INSERT INTO items (name, price, deleted) VALUES ('test_item', 100, false)") ) await session.commit() - # Start two concurrent sessions async with async_session_maker() as session1, async_session_maker() as session2: await session1.begin() await set_isolation_level(session1, "REPEATABLE READ") - # First read in session1 result = await session1.execute( text("SELECT price FROM items WHERE name = 'test_item'") ) price1 = result.scalar() assert price1 == 100 - # Session 2 updates and commits await session2.execute( text("UPDATE items SET price = 200 WHERE name = 'test_item'") ) await session2.commit() - # Second read in session1 result = await session1.execute( text("SELECT price FROM items WHERE name = 'test_item'") ) price2 = result.scalar() - # In REPEATABLE READ, should still see the old value assert price2 == 100, "Should not see changes in REPEATABLE READ" await session1.commit() @@ -195,31 +171,26 @@ async def test_phantom_read(async_session_maker): 2. Session 2 inserts a new record in that range 3. Session 1 queries again and sees the new record in READ COMMITTED """ - # Start two concurrent sessions async with async_session_maker() as session1, async_session_maker() as session2: await session1.begin() if 'postgresql' in str(session1.bind.url): await set_isolation_level(session1, "READ COMMITTED") - # First count in session1 result = await session1.execute( text("SELECT COUNT(*) FROM items WHERE price BETWEEN 50 AND 150") ) count1 = result.scalar() - # Session 2 inserts new record await session2.execute( text("INSERT INTO items (name, price, deleted) VALUES ('phantom', 100, false)") ) await session2.commit() - # Second count in session1 result = await session1.execute( text("SELECT COUNT(*) FROM items WHERE price BETWEEN 50 AND 150") ) count2 = result.scalar() - # In READ COMMITTED, should see the new record assert count2 == count1 + 1, "Should see phantom record in READ COMMITTED" await session1.commit() @@ -234,29 +205,24 @@ async def test_serializable(async_session_maker): if 'sqlite' in str(async_session_maker.kw['bind'].url): pytest.skip("SQLite SERIALIZABLE behavior differs from PostgreSQL") - # Start two concurrent sessions async with async_session_maker() as session1, async_session_maker() as session2: await session1.begin() await set_isolation_level(session1, "SERIALIZABLE") - # First count in session1 result = await session1.execute( text("SELECT COUNT(*) FROM items WHERE price BETWEEN 50 AND 150") ) count1 = result.scalar() - # Session 2 tries to insert new record await session2.execute( text("INSERT INTO items (name, price, deleted) VALUES ('phantom', 100, false)") ) await session2.commit() - # Second count in session1 result = await session1.execute( text("SELECT COUNT(*) FROM items WHERE price BETWEEN 50 AND 150") ) count2 = result.scalar() - # In SERIALIZABLE, should not see the new record assert count2 == count1, "Should not see phantom record in SERIALIZABLE" await session1.commit() \ No newline at end of file From a022a2261804cda585234a5278396414d82f5a9f Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 20:36:39 +0300 Subject: [PATCH 10/12] Update PostgreSQL connection string and install method --- .github/workflows/test-postgresql.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-postgresql.yml b/.github/workflows/test-postgresql.yml index 03981e30..53231a7f 100644 --- a/.github/workflows/test-postgresql.yml +++ b/.github/workflows/test-postgresql.yml @@ -35,18 +35,16 @@ jobs: - name: Install uv run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.cargo/bin" >> $GITHUB_PATH + pip intall uv - name: Install dependencies run: | cd hw2/hw uv sync - # Run all tests with PostgreSQL - name: Run all tests with PostgreSQL env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/shop_test + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/shop_test run: | cd hw2/hw uv run -m pytest -v From fa3149bcde2be3f6c68d3b9d7dab43bc803f818b Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 20:37:49 +0300 Subject: [PATCH 11/12] Fix typo in pip install command for uv --- .github/workflows/test-postgresql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-postgresql.yml b/.github/workflows/test-postgresql.yml index 53231a7f..802aa0ea 100644 --- a/.github/workflows/test-postgresql.yml +++ b/.github/workflows/test-postgresql.yml @@ -35,7 +35,7 @@ jobs: - name: Install uv run: | - pip intall uv + pip install uv - name: Install dependencies run: | From 28329cf7844084ee9f0a7e576bc435f7cc61204b Mon Sep 17 00:00:00 2001 From: Alexey Laletin Date: Sun, 26 Oct 2025 20:40:18 +0300 Subject: [PATCH 12/12] Change test database from PostgreSQL to SQLite --- .github/workflows/test-postgresql.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/test-postgresql.yml b/.github/workflows/test-postgresql.yml index 802aa0ea..307870e0 100644 --- a/.github/workflows/test-postgresql.yml +++ b/.github/workflows/test-postgresql.yml @@ -42,9 +42,7 @@ jobs: cd hw2/hw uv sync - - name: Run all tests with PostgreSQL - env: - DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/shop_test + - name: Run all tests with SQLite run: | cd hw2/hw uv run -m pytest -v