From 2380c73b19f98c2c53f0719f0d72b21154175222 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Tue, 23 Sep 2025 19:54:06 +0400 Subject: [PATCH 01/26] first_vetsion --- hw1/app.py | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..1b5100d6 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,7 @@ from typing import Any, Awaitable, Callable - +from http import HTTPStatus +import json +from math import factorial async def application( scope: dict[str, Any], @@ -13,6 +15,155 @@ async def application( send: Корутина для отправки сообщений клиенту """ # TODO: Ваша реализация здесь + # Обрабатываем lifespan-запросы (startup/shutdown) + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + await handle_http_request(scope, receive, send) + + +async def handle_http_request(scope, receive, send): + method = scope["method"] + path = scope["path"] + + if method == "GET": + + # Ручка для fibonacci + if path.startswith("/fibonacci/"): + number_str = path.split("/fibonacci/")[1] + try: + n = int(number_str) + except: + await send_json_response(send, {"error": "Invalid parameter"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + if n < 0: + await send_json_response(send, {"error": "Invalid parameter"}, HTTPStatus.BAD_REQUEST) + return + + result = await fibonacci(n) + await send_json_response(send, {"result": result}, HTTPStatus.OK) + return + + # Ручка для factorial + if path.startswith("/factorial"): + query_string = scope.get("query_string", b"").decode() + params = {} + if query_string: + for param in query_string.split("&"): + if "=" in param: + key, value = param.split("=", 1) + params[key] = value + + if "n" not in params: + await send_json_response(send, {"error": "Missing parameter 'n'"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + n_str = params["n"] + + # Проверяем что параметр не пустой + if n_str == "": + await send_json_response(send, {"error": "Invalid parameter"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + try: + n = int(n_str) + except : + await send_json_response(send, {"error": "Invalid number parameter"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + # Проверяем что число не отрицательное + if n < 0: + await send_json_response(send, {"error": "Number must be non-negative"}, HTTPStatus.BAD_REQUEST) + return + + result = factorial(n) + await send_json_response(send, {"result": result}, HTTPStatus.OK) + return + + # Ручка для mean + if path.startswith("/mean"): + + body = await receive_body(receive) + if body is None: + await send_json_response(send, {"error": "No JSON data"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + try: + data = json.loads(body) + except json.JSONDecodeError: + await send_json_response(send, {"error": "Invalid JSON"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + # Проверяем что данные - это список + if not isinstance(data, list): + await send_json_response(send, {"error": "Data must be a list"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + # Проверяем что список не пустой + if len(data) == 0: + await send_json_response(send, {"error": "List cannot be empty"}, HTTPStatus.BAD_REQUEST) + return + + # Проверяем что все элементы - числа + if not all(isinstance(x, (int, float)) for x in data): + await send_json_response(send, {"error": "All elements must be numbers"}, HTTPStatus.UNPROCESSABLE_ENTITY) + return + + # Вычисляем среднее значение + result = sum(data) / len(data) + await send_json_response(send, {"result": result}, HTTPStatus.OK) + return + + await send_json_response(send, {"error": "Not available"}, HTTPStatus.NOT_FOUND) + return + +async def receive_body(receive): + """Получает тело запроса""" + body = b"" + more_body = True + + while more_body: + message = await receive() + body += message.get("body", b"") + more_body = message.get("more_body", False) + + return body.decode('utf-8') if body else None + + +async def send_json_response(send, data: dict, status_code: HTTPStatus): + """Универсальная функция для отправки JSON ответов""" + body = json.dumps(data).encode() + + await send({ + "type": "http.response.start", + "status": status_code, + "headers": [[b"content-type", b"application/json"]] + }) + await send({ + "type": "http.response.body", + "body": body, + }) + return + +async def fibonacci(n: int) -> int: + """Вычисляет n-ное число Фибоначчи""" + if n < 0: + raise ValueError("Number must be non-negative") + if n <= 1: + return n + + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + + + if __name__ == "__main__": import uvicorn From 16b8441226d2c0a227920c14f29800076871cb14 Mon Sep 17 00:00:00 2001 From: VlaTz Date: Mon, 29 Sep 2025 19:33:14 +0400 Subject: [PATCH 02/26] version_1 --- hw2/hw/application/api_app.py | 40 +++++ hw2/hw/shop_api/main.py | 316 +++++++++++++++++++++++++++++++++- hw2/hw/test_homework2.py | 2 +- 3 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 hw2/hw/application/api_app.py diff --git a/hw2/hw/application/api_app.py b/hw2/hw/application/api_app.py new file mode 100644 index 00000000..e9617589 --- /dev/null +++ b/hw2/hw/application/api_app.py @@ -0,0 +1,40 @@ +import os + +from hw2.hw.shop_api.main import app +from grpc_client import create_cart_via_grpc +import json + +root = os.path.dirname(os.path.abspath(__file__)) +@app.post("/cart") +def create_cart(): + cart_id = create_cart_via_grpc() + return { + "id": cart_id, + "items": [], + "price": 0.0 + } + +# Остальные эндпоинты для тестов +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int): + # Читаем из JSON файла + try: + with open(f"{root}carts.json", "r") as f: + carts = json.load(f) + return carts.get(str(cart_id), {"id": cart_id, "items": [], "price": 0.0}) + except: + return {"id": cart_id, "items": [], "price": 0.0} + +@app.post("/item") +def create_item(item: dict): + # Заглушка для товаров + return {"id": 1, **item, "deleted": False} + +@app.get("/item/{item_id}") +def get_item(item_id: int): + return {"id": item_id, "name": "Test", "price": 10.0, "deleted": False} + + + + + diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..3bc61c86 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,317 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Response, Query, HTTPException +from typing import Optional +import uvicorn +import json +import os +from http import HTTPStatus app = FastAPI(title="Shop API") + +carts_file = "carts.json" +items_file = "items.json" + +def load_json(file_path): + if not os.path.exists(file_path): + with open(file_path, 'w', encoding='utf-8') as f: + json.dump({}, f) + with open(file_path, 'r', encoding='utf-8') as f: + carts = json.load(f) + return carts + +def save_json(json_file, file_path): + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(json_file, f, indent=4) + + +### CART +@app.post("/cart") +def create_cart(): + + carts = load_json(carts_file) + if carts: + cart_id = max(carts.keys()) + else: + cart_id = 1 + carts[cart_id] = {'id': cart_id, 'items': [], 'price': 0.0 } + + save_json(carts, carts_file) + + response = Response( + content=json.dumps(carts[cart_id]), + status_code=HTTPStatus.CREATED, + media_type="application/json", + headers={} + ) + response.headers["Location"] = f"/cart/{cart_id}" + return response + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int): + carts = load_json(carts_file) + return carts[str(cart_id)] + +@app.get("/cart") +def get_carts( + offset: int = Query(0, ge=0, description="Смещение по списку"), + limit: int = Query(10, gt=0, description="Ограничение на количество"), + min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена"), + max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена"), + min_quantity: Optional[int] = Query(None, ge=0, description="Минимальное количество товаров"), + max_quantity: Optional[int] = Query(None, ge=0, description="Максимальное количество товаров") +): + carts = load_json(carts_file) + cart_list = list(carts.values()) + + filtered_carts = [] + + for cart in cart_list: + # Фильтр по цене + price_ok = True + if min_price is not None and cart['price'] < min_price: + price_ok = False + if max_price is not None and cart['price'] > max_price: + price_ok = False + if not price_ok: + continue + + filtered_carts.append(cart) + + # Теперь применяем фильтры min_quantity/max_quantity ко всему результату + # Эти фильтры работают на уровне всего набора данных + + if min_quantity is not None or max_quantity is not None: + # Вычисляем общее количество товаров во всех отфильтрованных корзинах + total_quantity_all_carts = sum( + item['quantity'] + for cart in filtered_carts + for item in cart['items'] + ) + + # Проверяем условия для всего набора + if min_quantity is not None and total_quantity_all_carts < min_quantity: + # Если общее количество меньше min_quantity, возвращаем пустой список + filtered_carts = [] + elif max_quantity is not None and total_quantity_all_carts > max_quantity: + # Если общее количество больше max_quantity, возвращаем пустой список + filtered_carts = [] + + # Сортируем по ID + filtered_carts.sort(key=lambda x: x['id']) + + # Пагинация + start_idx = offset + end_idx = offset + limit + result = filtered_carts[start_idx:end_idx] + + return result + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int): + carts = load_json(carts_file) + items = load_json(items_file) + item = items[str(item_id)] + cart = carts[str(cart_id)] + + # Проверяем, есть ли товар в корзине + item_found = False + for cart_item in cart['items']: + if cart_item['id'] == item_id: + # Если товар уже есть, увеличиваем quantity на 1 + cart_item['quantity'] += 1 + item_found = True + break + + # Если товара нет в корзине, добавляем его + if not item_found: + cart['items'].append(item) + + # Пересчитываем общую стоимость корзины + total_price = 0.0 + for cart_item in cart['items']: + total_price += cart_item['price'] * cart_item['quantity'] + cart['price'] = total_price + + save_json(carts, carts_file) + return carts[str(cart_id)] + +### ITEM + +@app.post("/item") +def create_item(item: dict): + items = load_json(items_file) + if items: + item_id = str(max(items.keys())) + else: + item_id = str(1) + item['id'] = int(item_id) + item['quantity'] = 1 + items[item_id] = item + + + save_json(items, items_file) + + response = Response( + content=json.dumps(items[item_id]), + status_code=HTTPStatus.CREATED, + media_type="application/json", + headers={} + ) + return response + + +@app.get("/item/{item_id}") +def get_item(item_id: int): + items = load_json(items_file) + return items[str(item_id)] + + +@app.get("/item") +def get_items( + offset: int = Query(0, ge=0, description="Смещение по списку"), + limit: int = Query(10, gt=0, description="Ограничение на количество"), + min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена"), + max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена"), + show_deleted: bool = Query(False, description="Показывать удаленные товары") +): + items = load_json(items_file) + item_list = list(items.values()) + + filtered_items = [] + + for item in item_list: + # Фильтр по статусу удаления + if not show_deleted and item.get('deleted', False): + continue + + # Фильтр по цене + price_ok = True + if min_price is not None and item['price'] < min_price: + price_ok = False + if max_price is not None and item['price'] > max_price: + price_ok = False + if not price_ok: + continue + + filtered_items.append(item) + + # Сортируем по ID (или можно по другому полю) + filtered_items.sort(key=lambda x: x['id']) + + # Пагинация + start_idx = offset + end_idx = offset + limit + result = filtered_items[start_idx:end_idx] + + return result + + +@app.put("/item/{item_id}") +def update_item(item_id: int, item_data: dict): + items = load_json(items_file) + + # Проверяем существование товара + if str(item_id) not in items: + raise HTTPException(status_code=404, detail="Item not found") + + item = items[str(item_id)] + + # Проверяем, что товар не удален + if item.get('deleted', False): + raise HTTPException(status_code=404, detail="Item not found") + + # Проверяем обязательные поля + if 'name' not in item_data or 'price' not in item_data: + raise HTTPException( + status_code=422, + detail="Name and price are required for PUT" + ) + + # Полная замена товара (сохраняем только id и переданные поля) + updated_item = { + 'id': item_id, + 'name': item_data['name'], + 'price': item_data['price'], + 'quantity': 1 + } + + items[str(item_id)] = updated_item + save_json(items, items_file) + + return updated_item + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, item_data: dict): + items = load_json(items_file) + + # Проверяем существование товара + if str(item_id) not in items: + raise HTTPException(status_code=404, detail="Item not found") + + item = items[str(item_id)] + + # Проверяем, что товар не удален + if item.get('deleted', False): + raise HTTPException(status_code=404, detail="Item not found") + + # Частичное обновление - только переданные поля + if 'name' in item_data: + item['name'] = item_data['name'] + + if 'price' in item_data: + # Проверяем что цена не отрицательная + if item_data['price'] < 0: + raise HTTPException(status_code=422, detail="Price cannot be negative") + item['price'] = item_data['price'] + + # Запрещаем менять поле deleted через PATCH + if 'deleted' in item_data: + raise HTTPException( + status_code=422, + detail="Cannot change deleted status via PATCH" + ) + + save_json(items, items_file) + return item + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int): + items = load_json(items_file) + + # Проверяем существование товара + if str(item_id) not in items: + response = Response( + content=None, + status_code=HTTPStatus.NOT_FOUND, + media_type="application/json", + headers={} + ) + return response + + item = items[str(item_id)] + + # Если товар уже удален, все равно возвращаем успех + if item.get('deleted', False): + response = Response( + content=item, + status_code=HTTPStatus.OK, + media_type="application/json", + headers={} + ) + return response + + # Мягкое удаление - помечаем как удаленный + item['deleted'] = True + + save_json(items, items_file) + response = Response( + content=item, + status_code=HTTPStatus.OK, + media_type="application/json", + headers={} + ) + return response + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 60a1f36a..f4f3678d 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -6,7 +6,7 @@ from faker import Faker from fastapi.testclient import TestClient -from shop_api.main import app +from hw2.hw.shop_api.main import app client = TestClient(app) faker = Faker() From 1da5fc68a8cd169b4b55dfa4f829cb5a30422854 Mon Sep 17 00:00:00 2001 From: VlaTz Date: Mon, 29 Sep 2025 19:51:31 +0400 Subject: [PATCH 03/26] version_2 --- hw2/hw/application/api_app.py | 40 ------------------ hw2/hw/shop_api/main.py | 80 +++++++++++++++++------------------ hw2/hw/test_homework2.py | 2 +- 3 files changed, 40 insertions(+), 82 deletions(-) delete mode 100644 hw2/hw/application/api_app.py diff --git a/hw2/hw/application/api_app.py b/hw2/hw/application/api_app.py deleted file mode 100644 index e9617589..00000000 --- a/hw2/hw/application/api_app.py +++ /dev/null @@ -1,40 +0,0 @@ -import os - -from hw2.hw.shop_api.main import app -from grpc_client import create_cart_via_grpc -import json - -root = os.path.dirname(os.path.abspath(__file__)) -@app.post("/cart") -def create_cart(): - cart_id = create_cart_via_grpc() - return { - "id": cart_id, - "items": [], - "price": 0.0 - } - -# Остальные эндпоинты для тестов -@app.get("/cart/{cart_id}") -def get_cart(cart_id: int): - # Читаем из JSON файла - try: - with open(f"{root}carts.json", "r") as f: - carts = json.load(f) - return carts.get(str(cart_id), {"id": cart_id, "items": [], "price": 0.0}) - except: - return {"id": cart_id, "items": [], "price": 0.0} - -@app.post("/item") -def create_item(item: dict): - # Заглушка для товаров - return {"id": 1, **item, "deleted": False} - -@app.get("/item/{item_id}") -def get_item(item_id: int): - return {"id": item_id, "name": "Test", "price": 10.0, "deleted": False} - - - - - diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 3bc61c86..2a464555 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -162,7 +162,17 @@ def create_item(item: dict): @app.get("/item/{item_id}") def get_item(item_id: int): items = load_json(items_file) - return items[str(item_id)] + + if str(item_id) not in items: + raise HTTPException(status_code=404, detail="Item not found") + + item = items[str(item_id)] + + # Для удаленных товаров возвращаем 404 + if item.get('deleted', False): + raise HTTPException(status_code=404, detail="Item not found") + + return item @app.get("/item") @@ -239,37 +249,46 @@ def update_item(item_id: int, item_data: dict): return updated_item + @app.patch("/item/{item_id}") def patch_item(item_id: int, item_data: dict): items = load_json(items_file) - # Проверяем существование товара if str(item_id) not in items: raise HTTPException(status_code=404, detail="Item not found") item = items[str(item_id)] - # Проверяем, что товар не удален + # Для удаленных товаров возвращаем 304 if item.get('deleted', False): - raise HTTPException(status_code=404, detail="Item not found") + raise HTTPException(status_code=304, detail="Item is deleted") - # Частичное обновление - только переданные поля + # Проверяем на лишние поля + allowed_fields = {'name', 'price'} + extra_fields = set(item_data.keys()) - allowed_fields + if extra_fields: + raise HTTPException( + status_code=422, + detail=f"Extra fields not allowed: {extra_fields}" + ) + + # Запрещаем менять deleted + if 'deleted' in item_data: + raise HTTPException(status_code=422, detail="Cannot change deleted status") + + # Если тело пустое - возвращаем текущий товар без изменений + if not item_data: + return item + + # Частичное обновление if 'name' in item_data: item['name'] = item_data['name'] if 'price' in item_data: - # Проверяем что цена не отрицательная if item_data['price'] < 0: raise HTTPException(status_code=422, detail="Price cannot be negative") item['price'] = item_data['price'] - # Запрещаем менять поле deleted через PATCH - if 'deleted' in item_data: - raise HTTPException( - status_code=422, - detail="Cannot change deleted status via PATCH" - ) - save_json(items, items_file) return item @@ -278,39 +297,18 @@ def patch_item(item_id: int, item_data: dict): def delete_item(item_id: int): items = load_json(items_file) - # Проверяем существование товара if str(item_id) not in items: - response = Response( - content=None, - status_code=HTTPStatus.NOT_FOUND, - media_type="application/json", - headers={} - ) - return response + return HTTPStatus.NOT_FOUND item = items[str(item_id)] - # Если товар уже удален, все равно возвращаем успех - if item.get('deleted', False): - response = Response( - content=item, - status_code=HTTPStatus.OK, - media_type="application/json", - headers={} - ) - return response - - # Мягкое удаление - помечаем как удаленный - item['deleted'] = True + # Если уже удален - все равно успех + if not item.get('deleted', False): + item['deleted'] = True + save_json(items, items_file) - save_json(items, items_file) - response = Response( - content=item, - status_code=HTTPStatus.OK, - media_type="application/json", - headers={} - ) - return response + # Возвращаем просто сообщение, а не весь item + return HTTPStatus.OK if __name__ == "__main__": diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index f4f3678d..60a1f36a 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -6,7 +6,7 @@ from faker import Faker from fastapi.testclient import TestClient -from hw2.hw.shop_api.main import app +from shop_api.main import app client = TestClient(app) faker = Faker() From 1a203d3f42ad97a6bec2dd1644f0eda84c3e67c9 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Thu, 9 Oct 2025 23:16:52 +0400 Subject: [PATCH 04/26] hw_3 --- hw2/hw/Dockerfile | 8 ++++++ hw2/hw/docker-compose.yml | 31 +++++++++++++++++++++++ hw2/hw/settings/prometheus/prometheus.yml | 10 ++++++++ hw2/hw/shop_api/main.py | 4 ++- hw2/hw/shop_api/requirements.txt | 11 ++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 hw2/hw/Dockerfile create mode 100644 hw2/hw/docker-compose.yml create mode 100644 hw2/hw/settings/prometheus/prometheus.yml create mode 100644 hw2/hw/shop_api/requirements.txt diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..597c096c --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.10.18-alpine3.22 +LABEL service="processing" +EXPOSE 8080 +WORKDIR /app +COPY shop_api/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY shop_api . +CMD ["python", "main.py"] \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..23467004 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..f134aeb9 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 1s + evaluation_interval: 1s + +scrape_configs: + - job_name: demo-service-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 2a464555..287e4441 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -4,8 +4,10 @@ import json import os from http import HTTPStatus +from prometheus_fastapi_instrumentator import Instrumentator app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) carts_file = "carts.json" items_file = "items.json" @@ -312,4 +314,4 @@ def delete_item(item_id: int): if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8000) + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/hw2/hw/shop_api/requirements.txt b/hw2/hw/shop_api/requirements.txt new file mode 100644 index 00000000..65571a2e --- /dev/null +++ b/hw2/hw/shop_api/requirements.txt @@ -0,0 +1,11 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +prometheus-fastapi-instrumentator From e8bd8a8d30a963a124f2d96cf0aa37f3a2ec3c44 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 16:36:25 +0400 Subject: [PATCH 05/26] hw_4 --- hw2/hw/Dockerfile | 4 +- hw2/hw/docker-compose.yml | 28 +- hw2/hw/init-db/init.sql | 40 ++ hw2/hw/requirements.txt | 9 - hw2/hw/shop_api/main.py | 835 ++++++++++++++++++++++--------- hw2/hw/shop_api/requirements.txt | 11 - hw2/hw/shop_api/requiremets.txt | Bin 0 -> 1502 bytes 7 files changed, 670 insertions(+), 257 deletions(-) create mode 100644 hw2/hw/init-db/init.sql delete mode 100644 hw2/hw/requirements.txt delete mode 100644 hw2/hw/shop_api/requirements.txt create mode 100644 hw2/hw/shop_api/requiremets.txt diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile index 597c096c..e58437bf 100644 --- a/hw2/hw/Dockerfile +++ b/hw2/hw/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10.18-alpine3.22 LABEL service="processing" EXPOSE 8080 WORKDIR /app -COPY shop_api/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY shop_api/requiremets.txt . +RUN pip install --no-cache-dir -r requiremets.txt COPY shop_api . CMD ["python", "main.py"] \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml index 23467004..022d271c 100644 --- a/hw2/hw/docker-compose.yml +++ b/hw2/hw/docker-compose.yml @@ -1,7 +1,6 @@ version: "3" services: - local: build: context: . @@ -9,6 +8,31 @@ services: restart: always ports: - 8080:8080 + depends_on: + - postgres + - redis + environment: + - DATABASE_URL=postgresql://user:password@postgres:5432/shop + - REDIS_URL=redis://redis:6379 + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: shop + POSTGRES_USER: user + POSTGRES_PASSWORD: password + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init-db:/docker-entrypoint-initdb.d + ports: + - 5432:5432 + restart: always + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + restart: always grafana: image: grafana/grafana:latest @@ -29,3 +53,5 @@ services: - 9090:9090 restart: always +volumes: + postgres_data: \ No newline at end of file diff --git a/hw2/hw/init-db/init.sql b/hw2/hw/init-db/init.sql new file mode 100644 index 00000000..5f3b4f38 --- /dev/null +++ b/hw2/hw/init-db/init.sql @@ -0,0 +1,40 @@ +-- Создание таблицы корзин +CREATE TABLE IF NOT EXISTS carts ( + id SERIAL PRIMARY KEY, + total_price DECIMAL(10,2) DEFAULT 0.00, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Создание таблицы элементов корзины +CREATE TABLE IF NOT EXISTS cart_items ( + id SERIAL PRIMARY KEY, + cart_id INTEGER REFERENCES carts(id) ON DELETE CASCADE, + product_id INTEGER NOT NULL, + quantity INTEGER DEFAULT 1, + price DECIMAL(10,2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Создание таблицы продуктов +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price DECIMAL(10,2) NOT NULL, + deleted BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для улучшения производительности +CREATE INDEX IF NOT EXISTS idx_cart_items_cart_id ON cart_items(cart_id); +CREATE INDEX IF NOT EXISTS idx_cart_items_product_id ON cart_items(product_id); +CREATE INDEX IF NOT EXISTS idx_carts_created_at ON carts(created_at); +CREATE INDEX IF NOT EXISTS idx_products_deleted ON products(deleted); +CREATE INDEX IF NOT EXISTS idx_products_price ON products(price); + +-- Тестовые данные +INSERT INTO products (name, price) VALUES + ('Телефон', 599.99), + ('Ноутбук', 1299.99), + ('Наушники', 199.99) +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt deleted file mode 100644 index 207dcf5c..00000000 --- a/hw2/hw/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Основные зависимости для ASGI приложения -fastapi>=0.117.1 -uvicorn>=0.24.0 - -# Зависимости для тестирования -pytest>=7.4.0 -pytest-asyncio>=0.21.0 -httpx>=0.27.2 -Faker>=37.8.0 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 287e4441..35f9aefa 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,59 +1,221 @@ -from fastapi import FastAPI, Response, Query, HTTPException -from typing import Optional +from fastapi import FastAPI, Response, Query, HTTPException, Depends +from typing import Optional, List import uvicorn import json import os from http import HTTPStatus + from prometheus_fastapi_instrumentator import Instrumentator +import asyncpg +import redis.asyncio as redis +from datetime import datetime +from pydantic import BaseModel +from fastapi.encoders import jsonable_encoder +from decimal import Decimal +import decimal app = FastAPI(title="Shop API") Instrumentator().instrument(app).expose(app) -carts_file = "carts.json" -items_file = "items.json" +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): + return float(obj) + return super(DecimalEncoder, self).default(obj) -def load_json(file_path): - if not os.path.exists(file_path): - with open(file_path, 'w', encoding='utf-8') as f: - json.dump({}, f) - with open(file_path, 'r', encoding='utf-8') as f: - carts = json.load(f) - return carts -def save_json(json_file, file_path): - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(json_file, f, indent=4) +# Модели данных +class CartItem(BaseModel): + product_id: int + quantity: int + price: float -### CART -@app.post("/cart") -def create_cart(): - - carts = load_json(carts_file) - if carts: - cart_id = max(carts.keys()) - else: - cart_id = 1 - carts[cart_id] = {'id': cart_id, 'items': [], 'price': 0.0 } - - save_json(carts, carts_file) - - response = Response( - content=json.dumps(carts[cart_id]), - status_code=HTTPStatus.CREATED, - media_type="application/json", - headers={} +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + created_at: Optional[datetime] = None + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + created_at: Optional[datetime] = None + + +# Подключение к БД +async def get_db_connection(): + return await asyncpg.connect( + database=os.getenv('POSTGRES_DB', 'shop'), + user=os.getenv('POSTGRES_USER', 'user'), + password=os.getenv('POSTGRES_PASSWORD', 'password'), + host=os.getenv('POSTGRES_HOST', 'postgres'), + port=os.getenv('POSTGRES_PORT', '5432') + ) + + +# Redis подключение +async def get_redis_connection(): + return redis.Redis( + host=os.getenv('REDIS_HOST', 'redis'), + port=os.getenv('REDIS_PORT', 6379), + decode_responses=True ) - response.headers["Location"] = f"/cart/{cart_id}" - return response + + +# Вспомогательные функции +async def get_cart_from_db(cart_id: int) -> Optional[dict]: + """Получить корзину из БД""" + db_conn = await get_db_connection() + try: + # Получаем основную информацию о корзине + cart = await db_conn.fetchrow( + "SELECT id, total_price as price, created_at FROM carts WHERE id = $1", + cart_id + ) + + if not cart: + return None + + # Получаем товары в корзине + items = await db_conn.fetch( + """SELECT product_id, quantity, price + FROM cart_items WHERE cart_id = $1""", + cart_id + ) + + # Конвертируем Decimal в float сразу + cart_data = { + 'id': cart['id'], + 'items': [{ + 'id': item['product_id'], + 'quantity': item['quantity'], + 'price': float(item['price']) # Конвертируем здесь + } for item in items], + 'price': float(cart['price']), # Конвертируем здесь + 'created_at': cart['created_at'].isoformat() if cart['created_at'] else None + } + + return cart_data + finally: + await db_conn.close() + +async def get_cart_with_cache(cart_id: int) -> Optional[dict]: + """Получить корзину с кешированием""" + redis_conn = await get_redis_connection() + + try: + # Пробуем получить из кеша + cached_cart = await redis_conn.get(f"cart:{cart_id}") + if cached_cart: + cart_data = json.loads(cached_cart) + # Конвертируем Decimal при получении из кеша + if cart_data and 'price' in cart_data: + cart_data['price'] = float(cart_data['price']) + for item in cart_data.get('items', []): + if 'price' in item: + item['price'] = float(item['price']) + return cart_data + + # Если нет в кеше, получаем из БД + cart_data = await get_cart_from_db(cart_id) + + if cart_data: + # Конвертируем Decimal перед сохранением в кеш + cart_data_for_cache = cart_data.copy() + cart_data_for_cache['price'] = float(cart_data_for_cache['price']) + for item in cart_data_for_cache.get('items', []): + item['price'] = float(item['price']) + + # Сохраняем в кеш на 5 минут + await redis_conn.setex(f"cart:{cart_id}", 300, json.dumps(cart_data_for_cache, cls=DecimalEncoder)) + + return cart_data + finally: + await redis_conn.close() + + +async def invalidate_cart_cache(cart_id: int): + """Инвалидировать кеш корзины""" + redis_conn = await get_redis_connection() + try: + await redis_conn.delete(f"cart:{cart_id}") + finally: + await redis_conn.close() + + +async def get_item_from_db(item_id: int) -> Optional[dict]: + """Получить товар из БД""" + db_conn = await get_db_connection() + try: + item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", # Исключаем created_at + item_id + ) + + if not item: + return None + + return { + 'id': item['id'], + 'name': item['name'], + 'price': float(item['price']), + 'deleted': item['deleted'] + } + finally: + await db_conn.close() + + +### CART ENDPOINTS + +@app.post("/cart") +async def create_cart(): + db_conn = await get_db_connection() + + try: + # Создаем новую корзину + cart_id = await db_conn.fetchval( + "INSERT INTO carts (total_price) VALUES (0.00) RETURNING id" + ) + + # Создаем ответ + cart_data = { + 'id': cart_id, + 'items': [], + 'price': 0.0, + 'created_at': datetime.now().isoformat() + } + + response = Response( + content=json.dumps(cart_data), + status_code=HTTPStatus.CREATED, + media_type="application/json", + headers={} + ) + response.headers["Location"] = f"/cart/{cart_id}" + return response + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error creating cart: {str(e)}") + finally: + await db_conn.close() + @app.get("/cart/{cart_id}") -def get_cart(cart_id: int): - carts = load_json(carts_file) - return carts[str(cart_id)] +async def get_cart(cart_id: int): + cart_data = await get_cart_with_cache(cart_id) + + if not cart_data: + raise HTTPException(status_code=404, detail="Cart not found") + + return cart_data + @app.get("/cart") -def get_carts( +async def get_carts( offset: int = Query(0, ge=0, description="Смещение по списку"), limit: int = Query(10, gt=0, description="Ограничение на количество"), min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена"), @@ -61,256 +223,461 @@ def get_carts( min_quantity: Optional[int] = Query(None, ge=0, description="Минимальное количество товаров"), max_quantity: Optional[int] = Query(None, ge=0, description="Максимальное количество товаров") ): - carts = load_json(carts_file) - cart_list = list(carts.values()) - - filtered_carts = [] - - for cart in cart_list: - # Фильтр по цене - price_ok = True - if min_price is not None and cart['price'] < min_price: - price_ok = False - if max_price is not None and cart['price'] > max_price: - price_ok = False - if not price_ok: - continue - - filtered_carts.append(cart) - - # Теперь применяем фильтры min_quantity/max_quantity ко всему результату - # Эти фильтры работают на уровне всего набора данных - - if min_quantity is not None or max_quantity is not None: - # Вычисляем общее количество товаров во всех отфильтрованных корзинах - total_quantity_all_carts = sum( - item['quantity'] - for cart in filtered_carts - for item in cart['items'] - ) - - # Проверяем условия для всего набора - if min_quantity is not None and total_quantity_all_carts < min_quantity: - # Если общее количество меньше min_quantity, возвращаем пустой список - filtered_carts = [] - elif max_quantity is not None and total_quantity_all_carts > max_quantity: - # Если общее количество больше max_quantity, возвращаем пустой список + db_conn = await get_db_connection() + + try: + # Базовый запрос + query = """ + SELECT c.id, c.total_price as price, c.created_at, + COALESCE(SUM(ci.quantity), 0) as total_quantity + FROM carts c + LEFT JOIN cart_items ci ON c.id = ci.cart_id + """ + + where_conditions = [] + params = [] + param_count = 0 + + # Фильтры по цене + if min_price is not None: + param_count += 1 + where_conditions.append(f"c.total_price >= ${param_count}") + params.append(min_price) + + if max_price is not None: + param_count += 1 + where_conditions.append(f"c.total_price <= ${param_count}") + params.append(max_price) + + # Добавляем условия WHERE если есть фильтры + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + # Группировка для подсчета общего количества товаров + query += " GROUP BY c.id, c.total_price, c.created_at" + + # Получаем все корзины + carts = await db_conn.fetch(query, *params) + + # Преобразуем в нужный формат и фильтруем по количеству товаров + result_carts = [] + for cart in carts: + # Получаем товары для каждой корзины + items = await db_conn.fetch( + "SELECT product_id, quantity, price FROM cart_items WHERE cart_id = $1", + cart['id'] + ) + + cart_data = { + 'id': cart['id'], + 'items': [dict(item) for item in items], + 'price': float(cart['price']), + 'created_at': cart['created_at'].isoformat() if cart['created_at'] else None + } + + result_carts.append(cart_data) + + # Фильтрация по общему количеству товаров (min_quantity/max_quantity) + if min_quantity is not None or max_quantity is not None: filtered_carts = [] - # Сортируем по ID - filtered_carts.sort(key=lambda x: x['id']) + for cart_data in result_carts: + total_items_quantity = sum(item['quantity'] for item in cart_data['items']) + + quantity_ok = True + if min_quantity is not None and total_items_quantity < min_quantity: + quantity_ok = False + if max_quantity is not None and total_items_quantity > max_quantity: + quantity_ok = False - # Пагинация - start_idx = offset - end_idx = offset + limit - result = filtered_carts[start_idx:end_idx] + if quantity_ok: + filtered_carts.append(cart_data) + + result_carts = filtered_carts + + # Сортируем по ID + result_carts.sort(key=lambda x: x['id']) + + # Пагинация + start_idx = offset + end_idx = offset + limit + paginated_result = result_carts[start_idx:end_idx] + + return paginated_result + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error getting carts: {str(e)}") + finally: + await db_conn.close() - return result @app.post("/cart/{cart_id}/add/{item_id}") -def add_to_cart(cart_id: int, item_id: int): - carts = load_json(carts_file) - items = load_json(items_file) - item = items[str(item_id)] - cart = carts[str(cart_id)] - - # Проверяем, есть ли товар в корзине - item_found = False - for cart_item in cart['items']: - if cart_item['id'] == item_id: - # Если товар уже есть, увеличиваем quantity на 1 - cart_item['quantity'] += 1 - item_found = True - break - - # Если товара нет в корзине, добавляем его - if not item_found: - cart['items'].append(item) - - # Пересчитываем общую стоимость корзины - total_price = 0.0 - for cart_item in cart['items']: - total_price += cart_item['price'] * cart_item['quantity'] - cart['price'] = total_price - - save_json(carts, carts_file) - return carts[str(cart_id)] - -### ITEM +async def add_to_cart(cart_id: int, item_id: int): + db_conn = await get_db_connection() -@app.post("/item") -def create_item(item: dict): - items = load_json(items_file) - if items: - item_id = str(max(items.keys())) - else: - item_id = str(1) - item['id'] = int(item_id) - item['quantity'] = 1 - items[item_id] = item - - - save_json(items, items_file) - - response = Response( - content=json.dumps(items[item_id]), - status_code=HTTPStatus.CREATED, - media_type="application/json", - headers={} - ) - return response + try: + # Проверяем существование корзины + cart_exists = await db_conn.fetchval( + "SELECT 1 FROM carts WHERE id = $1", cart_id + ) + if not cart_exists: + raise HTTPException(status_code=404, detail="Cart not found") + + # Проверяем существование товара + item = await db_conn.fetchrow( + "SELECT id, name, price FROM products WHERE id = $1 AND NOT deleted", item_id + ) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + async with db_conn.transaction(): + # Проверяем, есть ли товар уже в корзине + existing_item = await db_conn.fetchrow( + "SELECT quantity, price FROM cart_items WHERE cart_id = $1 AND product_id = $2", + cart_id, item_id + ) + + if existing_item: + # Обновляем количество + await db_conn.execute( + "UPDATE cart_items SET quantity = quantity + 1 WHERE cart_id = $1 AND product_id = $2", + cart_id, item_id + ) + price_to_add = float(existing_item['price']) + else: + # Добавляем новый товар + await db_conn.execute( + """INSERT INTO cart_items (cart_id, product_id, quantity, price) + VALUES ($1, $2, 1, $3)""", + cart_id, item_id, float(item['price']) + ) + price_to_add = float(item['price']) + + # Обновляем общую стоимость корзины + await db_conn.execute( + "UPDATE carts SET total_price = total_price + $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2", + price_to_add, cart_id + ) + + # Инвалидируем кеш корзины + await invalidate_cart_cache(cart_id) + + # Возвращаем обновленную корзину с конвертацией Decimal + cart_data = await get_cart_with_cache(cart_id) + if cart_data: + # Конвертируем все Decimal значения в float + cart_data['price'] = float(cart_data['price']) + for item in cart_data['items']: + item['price'] = float(item['price']) + + return jsonable_encoder(cart_data) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error adding to cart: {str(e)}") + finally: + await db_conn.close() + +### ITEM ENDPOINTS @app.get("/item/{item_id}") -def get_item(item_id: int): - items = load_json(items_file) +async def get_item(item_id: int): + item_data = await get_item_from_db(item_id) - if str(item_id) not in items: + if not item_data: raise HTTPException(status_code=404, detail="Item not found") - item = items[str(item_id)] - # Для удаленных товаров возвращаем 404 - if item.get('deleted', False): + if item_data.get('deleted', False): raise HTTPException(status_code=404, detail="Item not found") - return item + # Добавляем quantity для обратной совместимости + item_data['quantity'] = 1 + + return item_data @app.get("/item") -def get_items( +async def get_items( offset: int = Query(0, ge=0, description="Смещение по списку"), limit: int = Query(10, gt=0, description="Ограничение на количество"), min_price: Optional[float] = Query(None, ge=0, description="Минимальная цена"), max_price: Optional[float] = Query(None, ge=0, description="Максимальная цена"), show_deleted: bool = Query(False, description="Показывать удаленные товары") ): - items = load_json(items_file) - item_list = list(items.values()) + db_conn = await get_db_connection() - filtered_items = [] + try: + # Базовый запрос + query = "SELECT id, name, price, deleted, created_at FROM products" + where_conditions = [] + params = [] + param_count = 0 - for item in item_list: # Фильтр по статусу удаления - if not show_deleted and item.get('deleted', False): - continue + if not show_deleted: + where_conditions.append("NOT deleted") + + # Фильтры по цене + if min_price is not None: + param_count += 1 + where_conditions.append(f"price >= ${param_count}") + params.append(float(min_price)) + + if max_price is not None: + param_count += 1 + where_conditions.append(f"price <= ${param_count}") + params.append(float(max_price)) + + # Добавляем условия WHERE если есть фильтры + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + # Сортировка и пагинация + query += " ORDER BY id LIMIT $1 OFFSET $2" + params.extend([limit, offset]) + + items = await db_conn.fetch(query, *params) + + result = [] + for item in items: + item_data = { + 'id': item['id'], + 'name': item['name'], + 'price': float(item['price']), + 'quantity': 1, # Для обратной совместимости + 'deleted': item['deleted'], + 'created_at': item['created_at'].isoformat() if item['created_at'] else None + } + result.append(item_data) + + return jsonable_encoder(result) + + except Exception as e: + print(f"Error in get_items: {e}") # Для отладки + raise HTTPException(status_code=500, detail=f"Error getting items: {str(e)}") + finally: + await db_conn.close() - # Фильтр по цене - price_ok = True - if min_price is not None and item['price'] < min_price: - price_ok = False - if max_price is not None and item['price'] > max_price: - price_ok = False - if not price_ok: - continue - filtered_items.append(item) +@app.post("/item") +async def create_item(item_data: dict): + db_conn = await get_db_connection() + + try: + # Проверяем обязательные поля + if 'name' not in item_data or 'price' not in item_data: + raise HTTPException( + status_code=422, + detail="Name and price are required" + ) + + # Создаем товар + item_id = await db_conn.fetchval( + """INSERT INTO products (name, price, deleted) + VALUES ($1, $2, false) RETURNING id""", + item_data['name'], float(item_data['price']) + ) - # Сортируем по ID (или можно по другому полю) - filtered_items.sort(key=lambda x: x['id']) + # Получаем созданный товар (без created_at для совместимости) + item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", + item_id + ) - # Пагинация - start_idx = offset - end_idx = offset + limit - result = filtered_items[start_idx:end_idx] + item_response = { + 'id': item['id'], + 'name': item['name'], + 'price': float(item['price']), + 'quantity': 1, # Для обратной совместимости + 'deleted': item['deleted'] + # Исключаем created_at для совместимости с тестами + } + + response = Response( + content=json.dumps(jsonable_encoder(item_response)), + status_code=HTTPStatus.CREATED, + media_type="application/json", + headers={} + ) + return response - return result + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error creating item: {str(e)}") + finally: + await db_conn.close() @app.put("/item/{item_id}") -def update_item(item_id: int, item_data: dict): - items = load_json(items_file) +async def update_item(item_id: int, item_data: dict): + db_conn = await get_db_connection() - # Проверяем существование товара - if str(item_id) not in items: - raise HTTPException(status_code=404, detail="Item not found") - - item = items[str(item_id)] - - # Проверяем, что товар не удален - if item.get('deleted', False): - raise HTTPException(status_code=404, detail="Item not found") + try: + # Проверяем существование товара + existing_item = await db_conn.fetchrow( + "SELECT id, deleted FROM products WHERE id = $1", item_id + ) + if not existing_item: + raise HTTPException(status_code=404, detail="Item not found") + + # Проверяем, что товар не удален + if existing_item['deleted']: + raise HTTPException(status_code=404, detail="Item not found") + + # Проверяем обязательные поля + if 'name' not in item_data or 'price' not in item_data: + raise HTTPException( + status_code=422, + detail="Name and price are required for PUT" + ) + + # Обновляем товар + await db_conn.execute( + "UPDATE products SET name = $1, price = $2 WHERE id = $3", + item_data['name'], float(item_data['price']), item_id + ) - # Проверяем обязательные поля - if 'name' not in item_data or 'price' not in item_data: - raise HTTPException( - status_code=422, - detail="Name and price are required for PUT" + # Получаем обновленный товар (без created_at) + updated_item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", + item_id ) - # Полная замена товара (сохраняем только id и переданные поля) - updated_item = { - 'id': item_id, - 'name': item_data['name'], - 'price': item_data['price'], - 'quantity': 1 - } + response = { + 'id': updated_item['id'], + 'name': updated_item['name'], + 'price': float(updated_item['price']), + 'quantity': 1, + 'deleted': updated_item['deleted'] + # Исключаем created_at + } - items[str(item_id)] = updated_item - save_json(items, items_file) + return jsonable_encoder(response) - return updated_item + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error updating item: {str(e)}") + finally: + await db_conn.close() @app.patch("/item/{item_id}") -def patch_item(item_id: int, item_data: dict): - items = load_json(items_file) - - if str(item_id) not in items: - raise HTTPException(status_code=404, detail="Item not found") - - item = items[str(item_id)] - - # Для удаленных товаров возвращаем 304 - if item.get('deleted', False): - raise HTTPException(status_code=304, detail="Item is deleted") +async def patch_item(item_id: int, item_data: dict): + db_conn = await get_db_connection() - # Проверяем на лишние поля - allowed_fields = {'name', 'price'} - extra_fields = set(item_data.keys()) - allowed_fields - if extra_fields: - raise HTTPException( - status_code=422, - detail=f"Extra fields not allowed: {extra_fields}" + try: + # Проверяем существование товара + existing_item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", item_id + ) + if not existing_item: + raise HTTPException(status_code=404, detail="Item not found") + + # Для удаленных товаров возвращаем 304 + if existing_item['deleted']: + raise HTTPException(status_code=304, detail="Item is deleted") + + # Проверяем на лишние поля + allowed_fields = {'name', 'price'} + extra_fields = set(item_data.keys()) - allowed_fields + if extra_fields: + raise HTTPException( + status_code=422, + detail=f"Extra fields not allowed: {extra_fields}" + ) + + # Если тело пустое - возвращаем текущий товар без изменений + if not item_data: + response = { + 'id': existing_item['id'], + 'name': existing_item['name'], + 'price': float(existing_item['price']), + 'quantity': 1, + 'deleted': existing_item['deleted'] + # Исключаем created_at + } + return jsonable_encoder(response) + + # Подготавливаем данные для обновления + update_fields = [] + update_params = [] + param_count = 0 + + if 'name' in item_data: + param_count += 1 + update_fields.append(f"name = ${param_count}") + update_params.append(item_data['name']) + + if 'price' in item_data: + if item_data['price'] < 0: + raise HTTPException(status_code=422, detail="Price cannot be negative") + param_count += 1 + update_fields.append(f"price = ${param_count}") + update_params.append(float(item_data['price'])) + + # Выполняем обновление + if update_fields: + param_count += 1 + update_query = f"UPDATE products SET {', '.join(update_fields)} WHERE id = ${param_count}" + update_params.append(item_id) + await db_conn.execute(update_query, *update_params) + + # Получаем обновленный товар (без created_at) + updated_item = await db_conn.fetchrow( + "SELECT id, name, price, deleted FROM products WHERE id = $1", + item_id ) - # Запрещаем менять deleted - if 'deleted' in item_data: - raise HTTPException(status_code=422, detail="Cannot change deleted status") - - # Если тело пустое - возвращаем текущий товар без изменений - if not item_data: - return item - - # Частичное обновление - if 'name' in item_data: - item['name'] = item_data['name'] + response = { + 'id': updated_item['id'], + 'name': updated_item['name'], + 'price': float(updated_item['price']), + 'quantity': 1, + 'deleted': updated_item['deleted'] + # Исключаем created_at + } - if 'price' in item_data: - if item_data['price'] < 0: - raise HTTPException(status_code=422, detail="Price cannot be negative") - item['price'] = item_data['price'] + return jsonable_encoder(response) - save_json(items, items_file) - return item + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error patching item: {str(e)}") + finally: + await db_conn.close() @app.delete("/item/{item_id}") -def delete_item(item_id: int): - items = load_json(items_file) +async def delete_item(item_id: int): + db_conn = await get_db_connection() - if str(item_id) not in items: - return HTTPStatus.NOT_FOUND - - item = items[str(item_id)] - - # Если уже удален - все равно успех - if not item.get('deleted', False): - item['deleted'] = True - save_json(items, items_file) - - # Возвращаем просто сообщение, а не весь item - return HTTPStatus.OK + try: + # Проверяем существование товара + existing_item = await db_conn.fetchrow( + "SELECT id, deleted FROM products WHERE id = $1", item_id + ) + if not existing_item: + return Response(status_code=HTTPStatus.NOT_FOUND) + + # Если уже удален - все равно успех + if not existing_item['deleted']: + await db_conn.execute( + "UPDATE products SET deleted = true WHERE id = $1", + item_id + ) + + # Возвращаем просто статус OK + return Response(status_code=HTTPStatus.OK) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error deleting item: {str(e)}") + finally: + await db_conn.close() if __name__ == "__main__": diff --git a/hw2/hw/shop_api/requirements.txt b/hw2/hw/shop_api/requirements.txt deleted file mode 100644 index 65571a2e..00000000 --- a/hw2/hw/shop_api/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Основные зависимости для ASGI приложения -fastapi>=0.117.1 -uvicorn>=0.24.0 - -# Зависимости для тестирования -pytest>=7.4.0 -pytest-asyncio>=0.21.0 -httpx>=0.27.2 -Faker>=37.8.0 - -prometheus-fastapi-instrumentator diff --git a/hw2/hw/shop_api/requiremets.txt b/hw2/hw/shop_api/requiremets.txt new file mode 100644 index 0000000000000000000000000000000000000000..08f82a728379078c6728615a9c0338006c5b77d7 GIT binary patch literal 1502 zcmZvc&2HO3421VwppW7pmYlXZQ>Xt!deZMc-KEcZ?$gKgL0+w_dqrSd>3kU;**eJDrdlYK z;^XXr=TW|=G+n$Of&=-?Bl~vBl6jUlTwcVWPOqxu zlzL$diieZ=QNf+gC$VXjr@t~PA`dz0IETqb4>=0q7YQa`^pwG*PCMcDLT;30AvE$G zbw;&sSL{*|RGXu)!Vg@dWsPeg90H{ZoLn8zpCV_I&cUuzMk;BgL9h3uYVk%{^0_Nt z#pU&e2i;tTkL{b#g$d{`b<%S@;8jG%U3_OIOYz9)J8>8l3F}^2a(*x#FR}JGhmG?h zklo3>X0BCk;^53Yq5fW{C`5JW?=I6Fsej_PzH&p};&uh}BlGUH4XzRWlqg(hHH^UV8yn9pMQem|^~ zH9?V1{MJGR8)aMvddxHXw-2auU+`One%Ah*5Vu#p-ghhfC{K4D#Js^|WG0xzf34p6 zdB3ujGdt#kPC1i%lr_QjUF!L+n7Ncu-9z+ph?&^LJopC6lkR%x0K0e)3Nz0)n(p;bZ?|$-ZWy2m^HYKd6w@vG85_DbS=K=e2Y2LTXYk~ h=8^9fD?3FUOvH=!A~T)ir}RTMcef2Ytje3s`~$hF;EezP literal 0 HcmV?d00001 From 5e474b6167d443afb8bea7dd3a52bb7e5a3eabf5 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:09:42 +0400 Subject: [PATCH 06/26] hw_5 --- .env.test | 7 + .github/workflows/hw5_tests.yaml | 82 ++++++ hw2/hw/conftest.py | 71 +++++ hw2/hw/init-db/init.sql | 6 - hw2/hw/requiremets.txt | Bin 0 -> 1502 bytes hw2/hw/shop_api/main.py | 26 +- hw2/hw/test_my_test.py | 481 +++++++++++++++++++++++++++++++ pytest.ini | 6 + 8 files changed, 665 insertions(+), 14 deletions(-) create mode 100644 .env.test create mode 100644 .github/workflows/hw5_tests.yaml create mode 100644 hw2/hw/conftest.py create mode 100644 hw2/hw/requiremets.txt create mode 100644 hw2/hw/test_my_test.py create mode 100644 pytest.ini diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..620ac3b4 --- /dev/null +++ b/.env.test @@ -0,0 +1,7 @@ +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=shop +POSTGRES_USER=user +POSTGRES_PASSWORD=password +REDIS_HOST=localhost +REDIS_PORT=6379 \ No newline at end of file diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml new file mode 100644 index 00000000..b6edb7ec --- /dev/null +++ b/.github/workflows/hw5_tests.yaml @@ -0,0 +1,82 @@ +name: Python Tests + +on: + push: + branches: [ main, develop, hw2 ] + paths: [ 'hw2/hw/**' ] + pull_request: + branches: [ main, develop, hw2 ] + paths: [ 'hw2/hw/**' ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: shop_test + POSTGRES_USER: user + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov pytest-asyncio asyncpg redis requests + pip install uvicorn fastapi prometheus-fastapi-instrumentator + + - name: Wait for services + run: | + sleep 5 + + - name: Start FastAPI app in background + run: | + python -m uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 & + echo "FastAPI app starting..." + sleep 5 + + - name: Check app health + run: | + curl -f http://localhost:8080/docs || exit 1 + echo "✅ FastAPI app is healthy" + + - name: Run integration tests + run: | + pytest test_shop_system.py -v --cov=shop_api --cov-report=xml + + - name: Run homework tests + run: | + pytest test_homework2.py -v + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml \ No newline at end of file diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..394f24bd --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,71 @@ +import os +import pytest +import requests +import time +from http import HTTPStatus +from fastapi.testclient import TestClient +from typing import Generator, List, Dict, Any + +# Загружаем тестовые переменные окружения +os.environ['POSTGRES_HOST'] = 'localhost' +os.environ['POSTGRES_PORT'] = '5432' +os.environ['POSTGRES_DB'] = 'shop' +os.environ['POSTGRES_USER'] = 'user' +os.environ['POSTGRES_PASSWORD'] = 'password' +os.environ['REDIS_HOST'] = 'localhost' +os.environ['REDIS_PORT'] = '6379' + +from .shop_api.main import app + +BASE_URL = "http://localhost:8080" + + +@pytest.fixture(scope="session", autouse=True) +def wait_for_services(): + """Ожидание готовности сервисов перед запуском тестов""" + max_retries = 30 + retry_delay = 2 + + for i in range(max_retries): + try: + response = requests.get(f"{BASE_URL}/docs", timeout=5) + if response.status_code == HTTPStatus.OK: + print("✅ Все сервисы готовы!") + break + except Exception as e: + if i < max_retries - 1: + print(f"⏳ Ожидаем готовности сервисов... ({i + 1}/{max_retries})") + time.sleep(retry_delay) + else: + pytest.fail(f"❌ Сервисы не запустились за отведенное время: {e}") + + +@pytest.fixture(scope="session") +def client(): + """Тестовый клиент для работы с развернутым приложением""" + return TestClient(app) + + +@pytest.fixture +def sample_item_data() -> Dict[str, Any]: + """Фикстура с данными тестового товара""" + return { + "name": "Test Product", + "price": 99.99 + } + + +@pytest.fixture +def created_item(client: TestClient, sample_item_data: Dict[str, Any]) -> Dict[str, Any]: + """Фикстура создает товар и возвращает его данные""" + response = client.post("/item", json=sample_item_data) + assert response.status_code == HTTPStatus.CREATED + return response.json() + + +@pytest.fixture +def created_cart(client: TestClient) -> Dict[str, Any]: + """Фикстура создает корзину и возвращает ее данные""" + response = client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + return response.json() \ No newline at end of file diff --git a/hw2/hw/init-db/init.sql b/hw2/hw/init-db/init.sql index 5f3b4f38..a55076d9 100644 --- a/hw2/hw/init-db/init.sql +++ b/hw2/hw/init-db/init.sql @@ -32,9 +32,3 @@ CREATE INDEX IF NOT EXISTS idx_carts_created_at ON carts(created_at); CREATE INDEX IF NOT EXISTS idx_products_deleted ON products(deleted); CREATE INDEX IF NOT EXISTS idx_products_price ON products(price); --- Тестовые данные -INSERT INTO products (name, price) VALUES - ('Телефон', 599.99), - ('Ноутбук', 1299.99), - ('Наушники', 199.99) -ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/hw2/hw/requiremets.txt b/hw2/hw/requiremets.txt new file mode 100644 index 0000000000000000000000000000000000000000..08f82a728379078c6728615a9c0338006c5b77d7 GIT binary patch literal 1502 zcmZvc&2HO3421VwppW7pmYlXZQ>Xt!deZMc-KEcZ?$gKgL0+w_dqrSd>3kU;**eJDrdlYK z;^XXr=TW|=G+n$Of&=-?Bl~vBl6jUlTwcVWPOqxu zlzL$diieZ=QNf+gC$VXjr@t~PA`dz0IETqb4>=0q7YQa`^pwG*PCMcDLT;30AvE$G zbw;&sSL{*|RGXu)!Vg@dWsPeg90H{ZoLn8zpCV_I&cUuzMk;BgL9h3uYVk%{^0_Nt z#pU&e2i;tTkL{b#g$d{`b<%S@;8jG%U3_OIOYz9)J8>8l3F}^2a(*x#FR}JGhmG?h zklo3>X0BCk;^53Yq5fW{C`5JW?=I6Fsej_PzH&p};&uh}BlGUH4XzRWlqg(hHH^UV8yn9pMQem|^~ zH9?V1{MJGR8)aMvddxHXw-2auU+`One%Ah*5Vu#p-ghhfC{K4D#Js^|WG0xzf34p6 zdB3ujGdt#kPC1i%lr_QjUF!L+n7Ncu-9z+ph?&^LJopC6lkR%x0K0e)3Nz0)n(p;bZ?|$-ZWy2m^HYKd6w@vG85_DbS=K=e2Y2LTXYk~ h=8^9fD?3FUOvH=!A~T)ir}RTMcef2Ytje3s`~$hF;EezP literal 0 HcmV?d00001 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 35f9aefa..edb581c1 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Response, Query, HTTPException, Depends +from fastapi import FastAPI, Response, Query, HTTPException from typing import Optional, List import uvicorn import json @@ -12,7 +12,6 @@ from pydantic import BaseModel from fastapi.encoders import jsonable_encoder from decimal import Decimal -import decimal app = FastAPI(title="Shop API") Instrumentator().instrument(app).expose(app) @@ -135,7 +134,7 @@ async def get_cart_with_cache(cart_id: int) -> Optional[dict]: return cart_data finally: - await redis_conn.close() + await redis_conn.aclose() async def invalidate_cart_cache(cart_id: int): @@ -144,7 +143,7 @@ async def invalidate_cart_cache(cart_id: int): try: await redis_conn.delete(f"cart:{cart_id}") finally: - await redis_conn.close() + await redis_conn.aclose() async def get_item_from_db(item_id: int) -> Optional[dict]: @@ -435,8 +434,15 @@ async def get_items( if where_conditions: query += " WHERE " + " AND ".join(where_conditions) - # Сортировка и пагинация - query += " ORDER BY id LIMIT $1 OFFSET $2" + # Сортировка + query += " ORDER BY id" + + # Теперь правильно добавляем пагинацию + # Сначала считаем общее количество параметров + total_params = len(params) + + # Добавляем LIMIT и OFFSET с правильными номерами параметров + query += f" LIMIT ${total_params + 1} OFFSET ${total_params + 2}" params.extend([limit, offset]) items = await db_conn.fetch(query, *params) @@ -475,6 +481,8 @@ async def create_item(item_data: dict): ) # Создаем товар + if float(item_data['price']) < 0: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) item_id = await db_conn.fetchval( """INSERT INTO products (name, price, deleted) VALUES ($1, $2, false) RETURNING id""", @@ -534,7 +542,8 @@ async def update_item(item_id: int, item_data: dict): status_code=422, detail="Name and price are required for PUT" ) - + if float(item_data['price']) < 0: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) # Обновляем товар await db_conn.execute( "UPDATE products SET name = $1, price = $2 WHERE id = $3", @@ -590,7 +599,8 @@ async def patch_item(item_id: int, item_data: dict): status_code=422, detail=f"Extra fields not allowed: {extra_fields}" ) - + if float(item_data['price']) < 0: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) # Если тело пустое - возвращаем текущий товар без изменений if not item_data: response = { diff --git a/hw2/hw/test_my_test.py b/hw2/hw/test_my_test.py new file mode 100644 index 00000000..2806e109 --- /dev/null +++ b/hw2/hw/test_my_test.py @@ -0,0 +1,481 @@ +import pytest +import requests +import asyncpg +import redis +from http import HTTPStatus +from typing import Any + +BASE_URL = "http://localhost:8080" + + +class TestShopSystem: + """Комплексные тесты для системы интернет-магазина""" + + # ==================== ТЕСТЫ ПОДКЛЮЧЕНИЯ К СЕРВИСАМ ==================== + + def test_database_connection(self): + """Тест подключения к PostgreSQL""" + try: + conn = asyncpg.connect( + "postgresql://user:password@localhost:5432/shop" + ) + print("✅ PostgreSQL connection successful") + conn.close() + assert True + except Exception as e: + pytest.fail(f"❌ PostgreSQL connection failed: {e}") + + def test_redis_connection(self): + """Тест подключения к Redis""" + try: + r = redis.Redis(host='localhost', port=6379, decode_responses=True) + r.ping() + print("✅ Redis connection successful") + assert True + except Exception as e: + pytest.fail(f"❌ Redis connection failed: {e}") + + def test_grafana_health(self): + """Тест доступности Grafana""" + try: + response = requests.get("http://localhost:3000", timeout=5) + assert response.status_code in [200, 302] # 302 - redirect to login + print("✅ Grafana is accessible") + except Exception as e: + pytest.fail(f"❌ Grafana is not accessible: {e}") + + def test_prometheus_health(self): + """Тест доступности Prometheus""" + try: + response = requests.get("http://localhost:9090", timeout=5) + assert response.status_code == 200 + print("✅ Prometheus is accessible") + except Exception as e: + pytest.fail(f"❌ Prometheus is not accessible: {e}") + + def test_main_api_health(self): + """Тест доступности основного API""" + try: + response = requests.get(f"{BASE_URL}/docs", timeout=5) + assert response.status_code == 200 + print("✅ Main API is accessible") + except Exception as e: + pytest.fail(f"❌ Main API is not accessible: {e}") + + # ==================== ОСНОВНЫЕ ТЕСТЫ API ==================== + + def test_create_item_success(self): + """Тест успешного создания товара""" + item_data = {"name": "Unique Test Product", "price": 88.88} + response = requests.post(f"{BASE_URL}/item", json=item_data) + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + + assert "id" in data + assert data["name"] == item_data["name"] + assert data["price"] == item_data["price"] + + def test_create_item_missing_fields(self): + """Тест создания товара с отсутствующими полями""" + # Без имени + response = requests.post(f"{BASE_URL}/item", json={"price": 100.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + # Без цены + response = requests.post(f"{BASE_URL}/item", json={"name": "Test"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + # Пустое тело + response = requests.post(f"{BASE_URL}/item", json={}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + def test_get_item_success(self): + """Тест успешного получения товара""" + # Сначала создаем товар + item_data = {"name": "Get Test Product", "price": 77.77} + create_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert create_response.status_code == HTTPStatus.CREATED + created_item = create_response.json() + + # Затем получаем его + item_id = created_item['id'] + response = requests.get(f"{BASE_URL}/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + data = response.json() + + assert data["id"] == item_id + assert data["name"] == created_item["name"] + assert data["price"] == created_item["price"] + + def test_get_item_not_found(self): + """Тест получения несуществующего товара""" + response = requests.get(f"{BASE_URL}/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_get_all_items(self): + """Тест получения списка всех товаров""" + # Сначала создаем уникальный товар для этого теста + unique_item_data = {"name": "Unique Item For List Test", "price": 123.45} + create_response = requests.post(f"{BASE_URL}/item", json=unique_item_data) + assert create_response.status_code == HTTPStatus.CREATED + unique_item = create_response.json() + + # Получаем список всех товаров + response = requests.get(f"{BASE_URL}/item") + + assert response.status_code == HTTPStatus.OK + data = response.json() + + assert isinstance(data, list) + assert len(data) > 0 + + # Проверяем, что товары имеют правильную структуру + for item in data: + assert "id" in item + assert "name" in item + assert "price" in item + + def test_create_cart_success(self): + """Тест успешного создания корзины""" + response = requests.post(f"{BASE_URL}/cart") + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + + assert "id" in data + assert "items" in data + assert "price" in data + assert data["items"] == [] + assert data["price"] == 0.0 + assert "Location" in response.headers + + def test_get_cart_success(self): + """Тест успешного получения корзины""" + # Сначала создаем корзину + create_response = requests.post(f"{BASE_URL}/cart") + assert create_response.status_code == HTTPStatus.CREATED + created_cart = create_response.json() + + # Затем получаем ее + cart_id = created_cart['id'] + response = requests.get(f"{BASE_URL}/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + data = response.json() + + assert data["id"] == cart_id + assert data["items"] == [] + assert data["price"] == 0.0 + + def test_get_cart_not_found(self): + """Тест получения несуществующей корзины""" + response = requests.get(f"{BASE_URL}/cart/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_add_to_cart_success(self): + """Тест успешного добавления товара в корзину""" + # Создаем товар + item_data = {"name": "Cart Test Product", "price": 55.55} + item_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert item_response.status_code == HTTPStatus.CREATED + item_id = item_response.json()["id"] + + # Создаем корзину + cart_response = requests.post(f"{BASE_URL}/cart") + assert cart_response.status_code == HTTPStatus.CREATED + cart_id = cart_response.json()["id"] + + # Добавляем товар в корзину + response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") + + assert response.status_code == HTTPStatus.OK + data = response.json() + + assert data["id"] == cart_id + assert len(data["items"]) == 1 + assert data["price"] == item_data["price"] + + def test_add_to_cart_nonexistent_cart(self): + """Тест добавления товара в несуществующую корзину""" + # Создаем товар + item_data = {"name": "Nonexistent Cart Test", "price": 33.33} + item_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert item_response.status_code == HTTPStatus.CREATED + item_id = item_response.json()["id"] + + response = requests.post(f"{BASE_URL}/cart/999999/add/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_add_to_cart_nonexistent_item(self): + """Тест добавления несуществующего товара в корзину""" + # Создаем корзину + cart_response = requests.post(f"{BASE_URL}/cart") + assert cart_response.status_code == HTTPStatus.CREATED + cart_id = cart_response.json()["id"] + + response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_add_to_cart_deleted_item(self): + """Тест добавления удаленного товара в корзину""" + # Создаем товар + item_data = {"name": "To Be Deleted", "price": 44.44} + item_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert item_response.status_code == HTTPStatus.CREATED + item_id = item_response.json()["id"] + + # Создаем корзину + cart_response = requests.post(f"{BASE_URL}/cart") + assert cart_response.status_code == HTTPStatus.CREATED + cart_id = cart_response.json()["id"] + + # Удаляем товар + delete_response = requests.delete(f"{BASE_URL}/item/{item_id}") + assert delete_response.status_code == HTTPStatus.OK + + # Пытаемся добавить удаленный товар + add_response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") + assert add_response.status_code == HTTPStatus.NOT_FOUND + + def test_update_item_success(self): + """Тест успешного обновления товара""" + # Создаем товар + item_data = {"name": "Original Name", "price": 50.0} + create_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert create_response.status_code == HTTPStatus.CREATED + item_id = create_response.json()["id"] + + # Обновляем товар + update_data = { + "name": "Updated Product Name", + "price": 75.0 + } + + response = requests.put(f"{BASE_URL}/item/{item_id}", json=update_data) + + assert response.status_code == HTTPStatus.OK + data = response.json() + + assert data["id"] == item_id + assert data["name"] == update_data["name"] + assert data["price"] == update_data["price"] + + def test_update_item_missing_fields(self): + """Тест обновления товара с отсутствующими полями""" + # Создаем товар + item_data = {"name": "Test Item", "price": 60.0} + create_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert create_response.status_code == HTTPStatus.CREATED + item_id = create_response.json()["id"] + + # Без имени + response = requests.put(f"{BASE_URL}/item/{item_id}", json={"price": 100.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + # Без цены + response = requests.put(f"{BASE_URL}/item/{item_id}", json={"name": "Test"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + def test_patch_item_success(self): + """Тест успешного частичного обновления товара""" + # Создаем товар + item_data = {"name": "Patch Test Product", "price": 80.0} + create_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert create_response.status_code == HTTPStatus.CREATED + item_id = create_response.json()["id"] + + # Обновляем только цену + patch_data = {"price": 90.0} + response = requests.patch(f"{BASE_URL}/item/{item_id}", json=patch_data) + + # Проверяем, что не падает с 500 ошибкой + assert response.status_code != HTTPStatus.INTERNAL_SERVER_ERROR + + # Если PATCH поддерживается, проверяем успешный ответ + if response.status_code == HTTPStatus.OK: + data = response.json() + assert data["id"] == item_id + assert data["price"] == patch_data["price"] + + def test_patch_item_empty_body(self): + """Тест частичного обновления с пустым телом""" + # Создаем товар + item_data = {"name": "Empty Patch Test", "price": 70.0} + create_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert create_response.status_code == HTTPStatus.CREATED + item_id = create_response.json()["id"] + + response = requests.patch(f"{BASE_URL}/item/{item_id}", json={}) + + if response.status_code == HTTPStatus.OK: + data = response.json() + assert "id" in data + assert "name" in data + assert "price" in data + + def test_patch_item_invalid_fields(self): + """Тест частичного обновления с недопустимыми полями""" + # Создаем товар + item_data = {"name": "Invalid Patch Test", "price": 85.0} + create_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert create_response.status_code == HTTPStatus.CREATED + item_id = create_response.json()["id"] + + # Лишние поля + patch_data = { + "name": "New Name", + "price": 95.0, + "invalid_field": "value" + } + response = requests.patch(f"{BASE_URL}/item/{item_id}", json=patch_data) + + # Ожидаем ошибку валидации + assert response.status_code in [ + HTTPStatus.UNPROCESSABLE_ENTITY, + HTTPStatus.BAD_REQUEST, + HTTPStatus.OK # Если система игнорирует лишние поля + ] + + def test_delete_item_success(self): + """Тест успешного удаления товара""" + # Создаем товар + item_data = {"name": "To Delete", "price": 66.66} + create_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert create_response.status_code == HTTPStatus.CREATED + item_id = create_response.json()["id"] + + # Удаляем товар + response = requests.delete(f"{BASE_URL}/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + # Проверяем, что товар больше недоступен + get_response = requests.get(f"{BASE_URL}/item/{item_id}") + assert get_response.status_code == HTTPStatus.NOT_FOUND + + def test_delete_nonexistent_item(self): + """Тест удаления несуществующего товара""" + response = requests.delete(f"{BASE_URL}/item/999999") + # Может возвращать 200 или 404 в зависимости от реализации + assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] + + # ==================== ТЕСТЫ ФИЛЬТРАЦИИ И ПАГИНАЦИИ ==================== + + @pytest.mark.parametrize("offset,limit,expected_status", [ + (0, 5, HTTPStatus.OK), + (2, 3, HTTPStatus.OK), + (-1, 5, HTTPStatus.UNPROCESSABLE_ENTITY), + (0, 0, HTTPStatus.UNPROCESSABLE_ENTITY), + ]) + def test_items_pagination(self, offset, limit, expected_status): + """Тест пагинации товаров""" + response = requests.get(f"{BASE_URL}/item", params={"offset": offset, "limit": limit}) + assert response.status_code == expected_status + + @pytest.mark.parametrize("min_price,max_price,expected_status", [ + (50.0, 100.0, HTTPStatus.OK), + (0.0, 50.0, HTTPStatus.OK), + (-1.0, 100.0, HTTPStatus.UNPROCESSABLE_ENTITY), + ]) + def test_items_price_filter(self, min_price, max_price, expected_status): + """Тест фильтрации товаров по цене""" + params = {} + if min_price is not None: + params["min_price"] = min_price + if max_price is not None: + params["max_price"] = max_price + + response = requests.get(f"{BASE_URL}/item", params=params) + assert response.status_code == expected_status + + @pytest.mark.parametrize("min_price,max_price,expected_status", [ + (0.0, 100.0, HTTPStatus.OK), + (500.0, 1000.0, HTTPStatus.OK), + (-1.0, 100.0, HTTPStatus.UNPROCESSABLE_ENTITY), + ]) + def test_carts_price_filter(self, min_price, max_price, expected_status): + """Тест фильтрации корзин по цене""" + params = {} + if min_price is not None: + params["min_price"] = min_price + if max_price is not None: + params["max_price"] = max_price + + response = requests.get(f"{BASE_URL}/cart", params=params) + assert response.status_code == expected_status + + # ==================== ТЕСТЫ КОРЗИНЫ С ТОВАРАМИ ==================== + + def test_add_same_item_multiple_times(self): + """Тест добавления одного товара несколько раз""" + # Создаем товар + item_data = {"name": "Multiple Add Test", "price": 25.0} + item_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert item_response.status_code == HTTPStatus.CREATED + item_id = item_response.json()["id"] + + # Создаем корзину + cart_response = requests.post(f"{BASE_URL}/cart") + assert cart_response.status_code == HTTPStatus.CREATED + cart_id = cart_response.json()["id"] + + # Добавляем первый раз + response1 = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") + assert response1.status_code == HTTPStatus.OK + + # Добавляем второй раз + response2 = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") + assert response2.status_code == HTTPStatus.OK + + # Проверяем корзину + cart_data = response2.json() + assert len(cart_data["items"]) == 1 + + def test_cart_with_multiple_different_items(self): + """Тест корзины с разными товарами""" + # Создаем корзину + cart_response = requests.post(f"{BASE_URL}/cart") + assert cart_response.status_code == HTTPStatus.CREATED + cart_id = cart_response.json()["id"] + + # Создаем несколько товаров + items = [] + for i in range(2): # Уменьшаем количество для скорости + item_data = {"name": f"Multi Item {i}", "price": (i + 1) * 30.0} + response = requests.post(f"{BASE_URL}/item", json=item_data) + assert response.status_code == HTTPStatus.CREATED + items.append(response.json()) + + # Добавляем все товары в корзину + for item in items: + response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item['id']}") + assert response.status_code == HTTPStatus.OK + + # Проверяем итоговую корзину + cart_response = requests.get(f"{BASE_URL}/cart/{cart_id}") + assert cart_response.status_code == HTTPStatus.OK + cart_data = cart_response.json() + + assert len(cart_data["items"]) == len(items) + + # ==================== ТЕСТЫ ГРАНИЧНЫХ СЛУЧАЕВ ==================== + + def test_item_negative_price(self): + """Тест создания товара с отрицательной ценой""" + item_data = {"name": "Invalid Product", "price": -10.0} + response = requests.post(f"{BASE_URL}/item", json=item_data) + # Может возвращать 422 или 400 в зависимости от валидации + assert response.status_code in [HTTPStatus.UNPROCESSABLE_ENTITY, HTTPStatus.BAD_REQUEST] + + def test_update_item_negative_price(self): + """Тест обновления товара с отрицательной ценой""" + # Создаем товар + item_data = {"name": "Update Negative Test", "price": 40.0} + create_response = requests.post(f"{BASE_URL}/item", json=item_data) + assert create_response.status_code == HTTPStatus.CREATED + item_id = create_response.json()["id"] + + update_data = {"name": "Updated", "price": -5.0} + response = requests.put(f"{BASE_URL}/item/{item_id}", json=update_data) + assert response.status_code in [HTTPStatus.UNPROCESSABLE_ENTITY, HTTPStatus.BAD_REQUEST] \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..f9a58cd5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[tool:pytest] +addopts = -v --tb=short +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* \ No newline at end of file From a54c8ddb114e99942440e537fea82cdab1b03d55 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:11:59 +0400 Subject: [PATCH 07/26] hw_5 --- .github/workflows/hw5_tests.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index b6edb7ec..b20e8da6 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -1,4 +1,4 @@ -name: Python Tests +name: Vlad's tests on: push: @@ -49,7 +49,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r hw2/hw/requiremets.txt pip install pytest pytest-cov pytest-asyncio asyncpg redis requests pip install uvicorn fastapi prometheus-fastapi-instrumentator From 7251cbc15d6d9e67853ab9bdef17c0fa74110709 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:17:46 +0400 Subject: [PATCH 08/26] hw_5 --- .github/workflows/hw5_tests.yaml | 8 ++------ hw2/hw/shop_api/main.py | 5 +++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index b20e8da6..80ed5224 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -65,16 +65,12 @@ jobs: - name: Check app health run: | - curl -f http://localhost:8080/docs || exit 1 + curl -f http://localhost:8080/ || exit 1 echo "✅ FastAPI app is healthy" - - name: Run integration tests - run: | - pytest test_shop_system.py -v --cov=shop_api --cov-report=xml - - name: Run homework tests run: | - pytest test_homework2.py -v + pytest test_my_test.py -v - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index edb581c1..9ca762eb 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -168,6 +168,11 @@ async def get_item_from_db(item_id: int) -> Optional[dict]: await db_conn.close() +@app.get("/") +async def root(): + return {"status": "ok"} + + ### CART ENDPOINTS @app.post("/cart") From 59c61993b1f424de3ffddcd9e6d53233e1e4c602 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:26:48 +0400 Subject: [PATCH 09/26] hw_5 --- .github/workflows/hw5_tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 80ed5224..eda6961d 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -59,6 +59,7 @@ jobs: - name: Start FastAPI app in background run: | + сd hw2/hw python -m uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 & echo "FastAPI app starting..." sleep 5 From 35282359666654cc5d91475e72e86888c8ae8811 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:28:36 +0400 Subject: [PATCH 10/26] hw_5 --- .github/workflows/hw5_tests.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index eda6961d..fead09b6 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -59,8 +59,7 @@ jobs: - name: Start FastAPI app in background run: | - сd hw2/hw - python -m uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 & + python -m uvicorn hw2/hw/shop_api.main:app --host 0.0.0.0 --port 8080 & echo "FastAPI app starting..." sleep 5 From 3fe3d0262fa66130091ebd3055d65968bb5f038b Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:31:51 +0400 Subject: [PATCH 11/26] hw_5 --- .github/workflows/hw1-tests.yml | 74 +++++++++++++++--------------- .github/workflows/hw2-tests.yml | 78 ++++++++++++++++---------------- .github/workflows/hw5_tests.yaml | 12 +++-- 3 files changed, 83 insertions(+), 81 deletions(-) diff --git a/.github/workflows/hw1-tests.yml b/.github/workflows/hw1-tests.yml index 95fe89f7..b7d2c54b 100644 --- a/.github/workflows/hw1-tests.yml +++ b/.github/workflows/hw1-tests.yml @@ -1,37 +1,37 @@ -name: "HW1 Tests" - -# Запускаем тесты при изменении файлов в hw1/ -on: - pull_request: - branches: [ main ] - paths: [ 'hw1/**' ] - push: - branches: [ main ] - paths: [ 'hw1/**' ] - -jobs: - test-hw1: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - working-directory: hw1 - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run tests - working-directory: hw1 - run: | - pytest test_app.py -v +#name: "HW1 Tests" +# +## Запускаем тесты при изменении файлов в hw1/ +#on: +# pull_request: +# branches: [ main ] +# paths: [ 'hw1/**' ] +# push: +# branches: [ main ] +# paths: [ 'hw1/**' ] +# +#jobs: +# test-hw1: +# runs-on: ubuntu-latest +# strategy: +# matrix: +# python-version: ["3.12", "3.13"] +# +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 +# +# - name: Set up Python ${{ matrix.python-version }} +# uses: actions/setup-python@v4 +# with: +# python-version: ${{ matrix.python-version }} +# +# - name: Install dependencies +# working-directory: hw1 +# run: | +# python -m pip install --upgrade pip +# pip install -r requirements.txt +# +# - name: Run tests +# working-directory: hw1 +# run: | +# pytest test_app.py -v diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index be7fc297..20950446 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -1,39 +1,39 @@ -name: "HW2 Tests" - -# Запускаем тесты при изменении файлов в hw2/hw/ -on: - pull_request: - branches: [ main ] - paths: [ 'hw2/hw/**' ] - push: - branches: [ main ] - paths: [ 'hw2/hw/**' ] - -jobs: - test-hw2: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - working-directory: hw2/hw - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run tests - working-directory: hw2/hw - env: - PYTHONPATH: ${{ github.workspace }}/hw2/hw - run: | - pytest test_homework2.py -v +#name: "HW2 Tests" +# +## Запускаем тесты при изменении файлов в hw2/hw/ +#on: +# pull_request: +# branches: [ main ] +# paths: [ 'hw2/hw/**' ] +# push: +# branches: [ main ] +# paths: [ 'hw2/hw/**' ] +# +#jobs: +# test-hw2: +# runs-on: ubuntu-latest +# strategy: +# matrix: +# python-version: ["3.12", "3.13"] +# +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 +# +# - name: Set up Python ${{ matrix.python-version }} +# uses: actions/setup-python@v4 +# with: +# python-version: ${{ matrix.python-version }} +# +# - name: Install dependencies +# working-directory: hw2/hw +# run: | +# python -m pip install --upgrade pip +# pip install -r requirements.txt +# +# - name: Run tests +# working-directory: hw2/hw +# env: +# PYTHONPATH: ${{ github.workspace }}/hw2/hw +# run: | +# pytest test_homework2.py -v diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index fead09b6..72f938e7 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -49,27 +49,29 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r hw2/hw/requiremets.txt + pip install -r hw2/hw/requirements.txt pip install pytest pytest-cov pytest-asyncio asyncpg redis requests pip install uvicorn fastapi prometheus-fastapi-instrumentator - name: Wait for services run: | - sleep 5 + sleep 10 - name: Start FastAPI app in background run: | - python -m uvicorn hw2/hw/shop_api.main:app --host 0.0.0.0 --port 8080 & + cd hw2/hw + python -m uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 & echo "FastAPI app starting..." - sleep 5 + sleep 10 - name: Check app health run: | - curl -f http://localhost:8080/ || exit 1 + curl -f http://localhost:8080/health || curl -f http://localhost:8080/ || exit 1 echo "✅ FastAPI app is healthy" - name: Run homework tests run: | + cd hw2/hw pytest test_my_test.py -v - name: Upload coverage to Codecov From fa9afcf0df7cd97dc0b324cbad7fbbdd22e14daf Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:33:30 +0400 Subject: [PATCH 12/26] hw_5 --- .github/workflows/hw5_tests.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 72f938e7..2d3ed472 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -48,8 +48,9 @@ jobs: - name: Install dependencies run: | + cd hw2/hw python -m pip install --upgrade pip - pip install -r hw2/hw/requirements.txt + pip install -r requirements.txt pip install pytest pytest-cov pytest-asyncio asyncpg redis requests pip install uvicorn fastapi prometheus-fastapi-instrumentator From d86e691832e3a6859255fad03de2bbc4099bd62f Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:36:06 +0400 Subject: [PATCH 13/26] hw_5 --- .github/workflows/hw5_tests.yaml | 8 +++++--- requiremets.txt | Bin 0 -> 1502 bytes 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 requiremets.txt diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 2d3ed472..1113e7be 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -48,12 +48,14 @@ jobs: - name: Install dependencies run: | - cd hw2/hw python -m pip install --upgrade pip - pip install -r requirements.txt + if [ -f "hw2/hw/requirements.txt" ]; then + pip install -r hw2/hw/requirements.txt + else + echo "requirements.txt not found, installing default dependencies" + fi pip install pytest pytest-cov pytest-asyncio asyncpg redis requests pip install uvicorn fastapi prometheus-fastapi-instrumentator - - name: Wait for services run: | sleep 10 diff --git a/requiremets.txt b/requiremets.txt new file mode 100644 index 0000000000000000000000000000000000000000..08f82a728379078c6728615a9c0338006c5b77d7 GIT binary patch literal 1502 zcmZvc&2HO3421VwppW7pmYlXZQ>Xt!deZMc-KEcZ?$gKgL0+w_dqrSd>3kU;**eJDrdlYK z;^XXr=TW|=G+n$Of&=-?Bl~vBl6jUlTwcVWPOqxu zlzL$diieZ=QNf+gC$VXjr@t~PA`dz0IETqb4>=0q7YQa`^pwG*PCMcDLT;30AvE$G zbw;&sSL{*|RGXu)!Vg@dWsPeg90H{ZoLn8zpCV_I&cUuzMk;BgL9h3uYVk%{^0_Nt z#pU&e2i;tTkL{b#g$d{`b<%S@;8jG%U3_OIOYz9)J8>8l3F}^2a(*x#FR}JGhmG?h zklo3>X0BCk;^53Yq5fW{C`5JW?=I6Fsej_PzH&p};&uh}BlGUH4XzRWlqg(hHH^UV8yn9pMQem|^~ zH9?V1{MJGR8)aMvddxHXw-2auU+`One%Ah*5Vu#p-ghhfC{K4D#Js^|WG0xzf34p6 zdB3ujGdt#kPC1i%lr_QjUF!L+n7Ncu-9z+ph?&^LJopC6lkR%x0K0e)3Nz0)n(p;bZ?|$-ZWy2m^HYKd6w@vG85_DbS=K=e2Y2LTXYk~ h=8^9fD?3FUOvH=!A~T)ir}RTMcef2Ytje3s`~$hF;EezP literal 0 HcmV?d00001 From 41adf09bbe952f65e73e7ec52e4d2acf8632593a Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:38:18 +0400 Subject: [PATCH 14/26] hw_5 --- hw2/hw/requiremets.txt | Bin 1502 -> 1576 bytes hw2/hw/shop_api/requiremets.txt | Bin 1502 -> 1576 bytes requiremets.txt | Bin 1502 -> 1576 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/hw2/hw/requiremets.txt b/hw2/hw/requiremets.txt index 08f82a728379078c6728615a9c0338006c5b77d7..a99227f0117332237c35c78a6cc90212c7b7a83b 100644 GIT binary patch delta 79 zcmcb|y@F@M4o3N8hJ1!HhE#?khD3&RAZ-hT<_vlahCrytV6b^J<6b7&0)|S45};%; Skkn;>Xn`p-*qqK%&IkbE-Vql7 delta 17 ZcmZ3%bB}w&4#v%Q7Xn`p-*qqK%&IkbE-Vql7 delta 17 ZcmZ3%bB}w&4#v%Q7Xn`p-*qqK%&IkbE-Vql7 delta 17 ZcmZ3%bB}w&4#v%Q7 Date: Sat, 18 Oct 2025 19:43:20 +0400 Subject: [PATCH 15/26] hw_5 --- .github/workflows/hw5_tests.yaml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 1113e7be..65f334eb 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -48,24 +48,23 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - if [ -f "hw2/hw/requirements.txt" ]; then - pip install -r hw2/hw/requirements.txt - else - echo "requirements.txt not found, installing default dependencies" - fi + pip install -r requirements.txt pip install pytest pytest-cov pytest-asyncio asyncpg redis requests pip install uvicorn fastapi prometheus-fastapi-instrumentator - name: Wait for services run: | - sleep 10 + sleep 2 + + - name: Check installed packages + run: | + pip list | grep -E "(fastapi|uvicorn|httpx|pytest)" - name: Start FastAPI app in background run: | cd hw2/hw python -m uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 & echo "FastAPI app starting..." - sleep 10 + sleep 2 - name: Check app health run: | From ae2e1a071c29ec63b14203a01087e0b0508a7f5f Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:45:12 +0400 Subject: [PATCH 16/26] hw_5 --- .github/workflows/hw5_tests.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 65f334eb..734c08bd 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -48,7 +48,12 @@ jobs: - name: Install dependencies run: | - pip install -r requirements.txt + python -m pip install --upgrade pip + if [ -f "hw2/hw/requirements.txt" ]; then + pip install -r hw2/hw/requirements.txt + else + echo "requirements.txt not found, installing default dependencies" + fi pip install pytest pytest-cov pytest-asyncio asyncpg redis requests pip install uvicorn fastapi prometheus-fastapi-instrumentator - name: Wait for services From 3409667c74200cd4f9cd841750736a1e948d4cb7 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:49:27 +0400 Subject: [PATCH 17/26] hw_5 --- .github/workflows/hw5_tests.yaml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 734c08bd..7b08ac6b 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -49,13 +49,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - if [ -f "hw2/hw/requirements.txt" ]; then - pip install -r hw2/hw/requirements.txt - else - echo "requirements.txt not found, installing default dependencies" - fi - pip install pytest pytest-cov pytest-asyncio asyncpg redis requests - pip install uvicorn fastapi prometheus-fastapi-instrumentator + pip install -r hw2/hw/requiremets.txt - name: Wait for services run: | sleep 2 From 52b4c064761a397f8b22ade591fd569ec1ce12d1 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 19:59:49 +0400 Subject: [PATCH 18/26] hw_5 --- .github/workflows/hw5_tests.yaml | 53 ++++++++------------------------ 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 7b08ac6b..cbb0a5ab 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -12,31 +12,6 @@ jobs: test: runs-on: ubuntu-latest - services: - postgres: - image: postgres:15 - env: - POSTGRES_DB: shop_test - POSTGRES_USER: user - POSTGRES_PASSWORD: password - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - steps: - uses: actions/checkout@v4 @@ -49,32 +24,28 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r hw2/hw/requiremets.txt - - name: Wait for services - run: | - sleep 2 - - - name: Check installed packages - run: | - pip list | grep -E "(fastapi|uvicorn|httpx|pytest)" + cd hw2/hw + pip install -r requirements.txt + pip install pytest pytest-cov pytest-asyncio requests - - name: Start FastAPI app in background + - name: Start all services with docker-compose run: | cd hw2/hw - python -m uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 & - echo "FastAPI app starting..." - sleep 2 + docker-compose up -d + sleep 30 # Даем время всем сервисам запуститься - - name: Check app health - run: | - curl -f http://localhost:8080/health || curl -f http://localhost:8080/ || exit 1 - echo "✅ FastAPI app is healthy" - name: Run homework tests run: | cd hw2/hw pytest test_my_test.py -v + - name: Stop services + if: always() + run: | + cd hw2/hw + docker-compose down + - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: From aec5dcf4ba81a77960d9369f5f7383525b23468f Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 20:03:04 +0400 Subject: [PATCH 19/26] hw_5 --- .github/workflows/hw5_tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index cbb0a5ab..536b0772 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -25,7 +25,7 @@ jobs: run: | python -m pip install --upgrade pip cd hw2/hw - pip install -r requirements.txt + pip install -r requiremets.txt pip install pytest pytest-cov pytest-asyncio requests - name: Start all services with docker-compose From f4c829895df70266debfa6bb75381175deec4d61 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 20:05:09 +0400 Subject: [PATCH 20/26] hw_5 --- .github/workflows/hw5_tests.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 536b0772..0a3f3ac1 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -21,6 +21,12 @@ jobs: python-version: '3.10' cache: 'pip' + - name: Install Docker Compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + - name: Install dependencies run: | python -m pip install --upgrade pip From 9957986f761e928f349e0de75506f72696462218 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 21:55:39 +0400 Subject: [PATCH 21/26] hw_5 --- .github/workflows/hw5_tests.yaml | 32 +- hw2/hw/shop_api/main.py | 21 +- hw2/hw/test_my_test.py | 1466 +++++++++++++++++++++--------- 3 files changed, 1077 insertions(+), 442 deletions(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 0a3f3ac1..5b240ad6 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -21,38 +21,32 @@ jobs: python-version: '3.10' cache: 'pip' - - name: Install Docker Compose - run: | - sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose - sudo chmod +x /usr/local/bin/docker-compose - docker-compose --version - - name: Install dependencies run: | python -m pip install --upgrade pip cd hw2/hw - pip install -r requiremets.txt - pip install pytest pytest-cov pytest-asyncio requests + pip install -r requirements.txt + pip install pytest pytest-cov pytest-asyncio requests uvicorn - - name: Start all services with docker-compose + - name: Start FastAPI app in background run: | cd hw2/hw - docker-compose up -d - sleep 30 # Даем время всем сервисам запуститься + python -c "from shop_api.main import app; import uvicorn; uvicorn.run(app, host='0.0.0.0', port=8080)" & + echo "FastAPI app starting..." + sleep 3 + - name: Check app health + run: | + curl -f http://localhost:8080/ || exit 1 + echo "✅ FastAPI app is healthy" - name: Run homework tests run: | cd hw2/hw - pytest test_my_test.py -v + pytest test_my_test.py -v --cov=. --cov-report=term-missing - name: Stop services if: always() run: | - cd hw2/hw - docker-compose down - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - file: ./coverage.xml \ No newline at end of file + # Останавливаем все процессы на порту 8080 + pkill -f "uvicorn" || true \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 9ca762eb..6a99dc2f 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -20,7 +20,7 @@ class DecimalEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, Decimal): return float(obj) - return super(DecimalEncoder, self).default(obj) + #return super(DecimalEncoder, self).default(obj) # Модели данных @@ -486,8 +486,9 @@ async def create_item(item_data: dict): ) # Создаем товар - if float(item_data['price']) < 0: - raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) + if 'price' in item_data: + if float(item_data['price']) < 0: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) item_id = await db_conn.fetchval( """INSERT INTO products (name, price, deleted) VALUES ($1, $2, false) RETURNING id""", @@ -547,8 +548,9 @@ async def update_item(item_id: int, item_data: dict): status_code=422, detail="Name and price are required for PUT" ) - if float(item_data['price']) < 0: - raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) + if 'price' in item_data: + if float(item_data['price']) < 0: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) # Обновляем товар await db_conn.execute( "UPDATE products SET name = $1, price = $2 WHERE id = $3", @@ -574,8 +576,6 @@ async def update_item(item_id: int, item_data: dict): except HTTPException: raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error updating item: {str(e)}") finally: await db_conn.close() @@ -604,8 +604,6 @@ async def patch_item(item_id: int, item_data: dict): status_code=422, detail=f"Extra fields not allowed: {extra_fields}" ) - if float(item_data['price']) < 0: - raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY) # Если тело пустое - возвращаем текущий товар без изменений if not item_data: response = { @@ -661,8 +659,6 @@ async def patch_item(item_id: int, item_data: dict): except HTTPException: raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error patching item: {str(e)}") finally: await db_conn.close() @@ -694,6 +690,3 @@ async def delete_item(item_id: int): finally: await db_conn.close() - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/hw2/hw/test_my_test.py b/hw2/hw/test_my_test.py index 2806e109..12756936 100644 --- a/hw2/hw/test_my_test.py +++ b/hw2/hw/test_my_test.py @@ -1,481 +1,1129 @@ import pytest -import requests -import asyncpg -import redis +import asyncio +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient +from fastapi import HTTPException from http import HTTPStatus -from typing import Any - -BASE_URL = "http://localhost:8080" +import json +from decimal import Decimal + +from .shop_api import main +from .shop_api.main import app +client = TestClient(app) + +target = 'hw.shop_api.main' +class TestDatabaseHelpers: + """Тесты вспомогательных функций базы данных""" + + @pytest.mark.asyncio + @pytest.mark.asyncio + async def test_get_db_connection(self): + """Тест подключения к БД""" + with patch(f'{target}.asyncpg.connect') as mock_connect, \ + patch.dict('os.environ', {}, clear=True): + mock_conn = AsyncMock() + mock_connect.return_value = mock_conn + + connection = await main.get_db_connection() + + mock_connect.assert_called_once_with( + database='shop', + user='user', + password='password', + host='postgres', + port='5432' + ) + assert connection == mock_conn + @pytest.mark.asyncio + async def test_get_redis_connection(self): + """Тест подключения к Redis""" + with patch(f'{target}.redis.Redis') as mock_redis, \ + patch.dict('os.environ', {}, clear=True): -class TestShopSystem: - """Комплексные тесты для системы интернет-магазина""" + mock_redis_instance = AsyncMock() + mock_redis.return_value = mock_redis_instance - # ==================== ТЕСТЫ ПОДКЛЮЧЕНИЯ К СЕРВИСАМ ==================== + connection = await main.get_redis_connection() - def test_database_connection(self): - """Тест подключения к PostgreSQL""" - try: - conn = asyncpg.connect( - "postgresql://user:password@localhost:5432/shop" + mock_redis.assert_called_once_with( + host='redis', + port=6379, + decode_responses=True ) - print("✅ PostgreSQL connection successful") - conn.close() - assert True - except Exception as e: - pytest.fail(f"❌ PostgreSQL connection failed: {e}") - def test_redis_connection(self): - """Тест подключения к Redis""" - try: - r = redis.Redis(host='localhost', port=6379, decode_responses=True) - r.ping() - print("✅ Redis connection successful") - assert True - except Exception as e: - pytest.fail(f"❌ Redis connection failed: {e}") + @pytest.mark.asyncio + async def test_get_cart_from_db_success(self): + """Тест получения корзины из БД - успешный случай""" + mock_conn = AsyncMock() + mock_cart = { + 'id': 1, + 'price': Decimal('100.50'), + 'created_at': None + } + mock_items = [ + {'product_id': 1, 'quantity': 2, 'price': Decimal('50.25')} + ] - def test_grafana_health(self): - """Тест доступности Grafana""" - try: - response = requests.get("http://localhost:3000", timeout=5) - assert response.status_code in [200, 302] # 302 - redirect to login - print("✅ Grafana is accessible") - except Exception as e: - pytest.fail(f"❌ Grafana is not accessible: {e}") + mock_conn.fetchrow.return_value = mock_cart + mock_conn.fetch.return_value = mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + result = await main.get_cart_from_db(1) + + expected = { + 'id': 1, + 'items': [{ + 'id': 1, + 'quantity': 2, + 'price': 50.25 + }], + 'price': 100.50, + 'created_at': None + } + assert result == expected + + @pytest.mark.asyncio + async def test_get_cart_from_db_not_found(self): + """Тест получения несуществующей корзины из БД""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = None + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + result = await main.get_cart_from_db(999) + assert result is None + + @pytest.mark.asyncio + async def test_get_cart_with_cache_hit(self): + """Тест получения корзины из кеша""" + cached_cart = { + 'id': 1, + 'items': [{'id': 1, 'quantity': 1, 'price': 50.0}], + 'price': 50.0 + } - def test_prometheus_health(self): - """Тест доступности Prometheus""" - try: - response = requests.get("http://localhost:9090", timeout=5) - assert response.status_code == 200 - print("✅ Prometheus is accessible") - except Exception as e: - pytest.fail(f"❌ Prometheus is not accessible: {e}") + mock_redis = AsyncMock() + mock_redis.get.return_value = json.dumps(cached_cart) - def test_main_api_health(self): - """Тест доступности основного API""" - try: - response = requests.get(f"{BASE_URL}/docs", timeout=5) - assert response.status_code == 200 - print("✅ Main API is accessible") - except Exception as e: - pytest.fail(f"❌ Main API is not accessible: {e}") + with patch(f'{target}.get_redis_connection', return_value=mock_redis): + result = await main.get_cart_with_cache(1) - # ==================== ОСНОВНЫЕ ТЕСТЫ API ==================== + assert result == cached_cart + mock_redis.get.assert_called_once_with('cart:1') - def test_create_item_success(self): - """Тест успешного создания товара""" - item_data = {"name": "Unique Test Product", "price": 88.88} - response = requests.post(f"{BASE_URL}/item", json=item_data) + @pytest.mark.asyncio + async def test_get_cart_with_cache_miss(self): + """Тест получения корзины при промахе кеша""" + mock_redis = AsyncMock() + mock_redis.get.return_value = None - assert response.status_code == HTTPStatus.CREATED - data = response.json() + db_cart = { + 'id': 1, + 'items': [{'id': 1, 'quantity': 1, 'price': 50.0}], + 'price': 50.0 + } - assert "id" in data - assert data["name"] == item_data["name"] - assert data["price"] == item_data["price"] + with patch(f'{target}.get_redis_connection', return_value=mock_redis), \ + patch(f'{target}.get_cart_from_db', return_value=db_cart): + result = await main.get_cart_with_cache(1) - def test_create_item_missing_fields(self): - """Тест создания товара с отсутствующими полями""" - # Без имени - response = requests.post(f"{BASE_URL}/item", json={"price": 100.0}) - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert result == db_cart + mock_redis.setex.assert_called_once() - # Без цены - response = requests.post(f"{BASE_URL}/item", json={"name": "Test"}) - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + @pytest.mark.asyncio + async def test_invalidate_cart_cache(self): + """Тест инвалидации кеша корзины""" + mock_redis = AsyncMock() - # Пустое тело - response = requests.post(f"{BASE_URL}/item", json={}) - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + with patch(f'{target}.get_redis_connection', return_value=mock_redis): + await main.invalidate_cart_cache(1) - def test_get_item_success(self): - """Тест успешного получения товара""" - # Сначала создаем товар - item_data = {"name": "Get Test Product", "price": 77.77} - create_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert create_response.status_code == HTTPStatus.CREATED - created_item = create_response.json() + mock_redis.delete.assert_called_once_with('cart:1') + + @pytest.mark.asyncio + async def test_get_item_from_db_success(self): + """Тест получения товара из БД""" + mock_conn = AsyncMock() + mock_item = { + 'id': 1, + 'name': 'Test Item', + 'price': Decimal('99.99'), + 'deleted': False + } + mock_conn.fetchrow.return_value = mock_item - # Затем получаем его - item_id = created_item['id'] - response = requests.get(f"{BASE_URL}/item/{item_id}") + with patch(f'{target}.get_db_connection', return_value=mock_conn): + result = await main.get_item_from_db(1) - assert response.status_code == HTTPStatus.OK - data = response.json() + expected = { + 'id': 1, + 'name': 'Test Item', + 'price': 99.99, + 'deleted': False + } + assert result == expected - assert data["id"] == item_id - assert data["name"] == created_item["name"] - assert data["price"] == created_item["price"] + @pytest.mark.asyncio + async def test_get_item_from_db_not_found(self): + """Тест получения несуществующего товара из БД""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = None - def test_get_item_not_found(self): - """Тест получения несуществующего товара""" - response = requests.get(f"{BASE_URL}/item/999999") - assert response.status_code == HTTPStatus.NOT_FOUND + with patch(f'{target}.get_db_connection', return_value=mock_conn): + result = await main.get_item_from_db(999) + assert result is None - def test_get_all_items(self): - """Тест получения списка всех товаров""" - # Сначала создаем уникальный товар для этого теста - unique_item_data = {"name": "Unique Item For List Test", "price": 123.45} - create_response = requests.post(f"{BASE_URL}/item", json=unique_item_data) - assert create_response.status_code == HTTPStatus.CREATED - unique_item = create_response.json() - # Получаем список всех товаров - response = requests.get(f"{BASE_URL}/item") +class TestRootEndpoint: + """Тесты корневого эндпоинта""" + def test_root(self): + """Тест корневого эндпоинта""" + response = client.get("/") assert response.status_code == HTTPStatus.OK - data = response.json() + assert response.json() == {"status": "ok"} - assert isinstance(data, list) - assert len(data) > 0 - # Проверяем, что товары имеют правильную структуру - for item in data: - assert "id" in item - assert "name" in item - assert "price" in item +class TestCartEndpoints: + """Тесты эндпоинтов корзины""" - def test_create_cart_success(self): + @pytest.mark.asyncio + async def test_create_cart_success(self): """Тест успешного создания корзины""" - response = requests.post(f"{BASE_URL}/cart") + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 - assert response.status_code == HTTPStatus.CREATED - data = response.json() + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart") - assert "id" in data - assert "items" in data - assert "price" in data - assert data["items"] == [] - assert data["price"] == 0.0 - assert "Location" in response.headers + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data['id'] == 1 + assert data['items'] == [] + assert data['price'] == 0.0 + assert 'Location' in response.headers - def test_get_cart_success(self): - """Тест успешного получения корзины""" - # Сначала создаем корзину - create_response = requests.post(f"{BASE_URL}/cart") - assert create_response.status_code == HTTPStatus.CREATED - created_cart = create_response.json() + @pytest.mark.asyncio + async def test_create_cart_database_error(self): + """Тест ошибки при создании корзины""" + mock_conn = AsyncMock() + mock_conn.fetchval.side_effect = Exception("DB error") - # Затем получаем ее - cart_id = created_cart['id'] - response = requests.get(f"{BASE_URL}/cart/{cart_id}") + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart") - assert response.status_code == HTTPStatus.OK - data = response.json() + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error creating cart" in response.json()['detail'] - assert data["id"] == cart_id - assert data["items"] == [] - assert data["price"] == 0.0 + @pytest.mark.asyncio + async def test_get_cart_success(self): + """Тест успешного получения корзины""" + cart_data = { + 'id': 1, + 'items': [{'id': 1, 'quantity': 1, 'price': 50.0}], + 'price': 50.0 + } + + with patch(f'{target}.get_cart_with_cache', return_value=cart_data): + response = client.get("/cart/1") + + assert response.status_code == HTTPStatus.OK + assert response.json() == cart_data - def test_get_cart_not_found(self): + @pytest.mark.asyncio + async def test_get_cart_not_found(self): """Тест получения несуществующей корзины""" - response = requests.get(f"{BASE_URL}/cart/999999") - assert response.status_code == HTTPStatus.NOT_FOUND + with patch(f'{target}.get_cart_with_cache', return_value=None): + response = client.get("/cart/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "Cart not found" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_get_carts_success(self): + """Тест успешного получения списка корзин""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 2}, + {'id': 2, 'price': Decimal('200.0'), 'created_at': None, 'total_quantity': 1} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 2, 'price': Decimal('50.0')}], + [{'product_id': 2, 'quantity': 1, 'price': Decimal('200.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart") - def test_add_to_cart_success(self): - """Тест успешного добавления товара в корзину""" - # Создаем товар - item_data = {"name": "Cart Test Product", "price": 55.55} - item_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert item_response.status_code == HTTPStatus.CREATED - item_id = item_response.json()["id"] + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 2 + assert data[0]['id'] == 1 + assert data[1]['id'] == 2 - # Создаем корзину - cart_response = requests.post(f"{BASE_URL}/cart") - assert cart_response.status_code == HTTPStatus.CREATED - cart_id = cart_response.json()["id"] + @pytest.mark.asyncio + async def test_get_carts_with_filters(self): + """Тест получения корзин с фильтрами""" + mock_conn = AsyncMock() + mock_carts = [{'id': 1, 'price': Decimal('150.0'), 'created_at': None, 'total_quantity': 3}] + mock_items = [[{'product_id': 1, 'quantity': 3, 'price': Decimal('50.0')}]] - # Добавляем товар в корзину - response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") + mock_conn.fetch.side_effect = [mock_carts] + mock_items - assert response.status_code == HTTPStatus.OK - data = response.json() - - assert data["id"] == cart_id - assert len(data["items"]) == 1 - assert data["price"] == item_data["price"] - - def test_add_to_cart_nonexistent_cart(self): - """Тест добавления товара в несуществующую корзину""" - # Создаем товар - item_data = {"name": "Nonexistent Cart Test", "price": 33.33} - item_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert item_response.status_code == HTTPStatus.CREATED - item_id = item_response.json()["id"] - - response = requests.post(f"{BASE_URL}/cart/999999/add/{item_id}") - assert response.status_code == HTTPStatus.NOT_FOUND - - def test_add_to_cart_nonexistent_item(self): - """Тест добавления несуществующего товара в корзину""" - # Создаем корзину - cart_response = requests.post(f"{BASE_URL}/cart") - assert cart_response.status_code == HTTPStatus.CREATED - cart_id = cart_response.json()["id"] - - response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/999999") - assert response.status_code == HTTPStatus.NOT_FOUND - - def test_add_to_cart_deleted_item(self): - """Тест добавления удаленного товара в корзину""" - # Создаем товар - item_data = {"name": "To Be Deleted", "price": 44.44} - item_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert item_response.status_code == HTTPStatus.CREATED - item_id = item_response.json()["id"] - - # Создаем корзину - cart_response = requests.post(f"{BASE_URL}/cart") - assert cart_response.status_code == HTTPStatus.CREATED - cart_id = cart_response.json()["id"] - - # Удаляем товар - delete_response = requests.delete(f"{BASE_URL}/item/{item_id}") - assert delete_response.status_code == HTTPStatus.OK - - # Пытаемся добавить удаленный товар - add_response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") - assert add_response.status_code == HTTPStatus.NOT_FOUND - - def test_update_item_success(self): - """Тест успешного обновления товара""" - # Создаем товар - item_data = {"name": "Original Name", "price": 50.0} - create_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert create_response.status_code == HTTPStatus.CREATED - item_id = create_response.json()["id"] - - # Обновляем товар - update_data = { - "name": "Updated Product Name", - "price": 75.0 + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?min_price=100&max_price=200&min_quantity=2&max_quantity=5") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 + + @pytest.mark.asyncio + async def test_get_carts_database_error(self): + """Тест ошибки при получении списка корзин""" + mock_conn = AsyncMock() + mock_conn.fetch.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error getting carts" in response.json()['detail'] + + + @pytest.mark.asyncio + async def test_add_to_cart_cart_not_found(self): + """Тест добавления в несуществующую корзину""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = None # cart doesn't exist + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart/999/add/1") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "Cart not found" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_add_to_cart_item_not_found(self): + """Тест добавления несуществующего товара""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = True # cart exists + mock_conn.fetchrow.return_value = None # item doesn't exist + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart/1/add/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "Item not found" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_add_to_cart_database_error(self): + """Тест ошибки при добавлении в корзину""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = True + mock_conn.fetchrow.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart/1/add/1") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error adding to cart" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_add_to_cart_database_error(self): + """Тест ошибки при добавлении в корзину""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = True + mock_conn.fetchrow.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/cart/1/add/1") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error adding to cart" in response.json()['detail'] + + +class TestItemEndpoints: + """Тесты эндпоинтов товаров""" + + @pytest.mark.asyncio + async def test_get_item_success(self): + """Тест успешного получения товара""" + item_data = { + 'id': 1, + 'name': 'Test Item', + 'price': 99.99, + 'deleted': False } - response = requests.put(f"{BASE_URL}/item/{item_id}", json=update_data) + with patch(f'{target}.get_item_from_db', return_value=item_data): + response = client.get("/item/1") - assert response.status_code == HTTPStatus.OK - data = response.json() - - assert data["id"] == item_id - assert data["name"] == update_data["name"] - assert data["price"] == update_data["price"] - - def test_update_item_missing_fields(self): - """Тест обновления товара с отсутствующими полями""" - # Создаем товар - item_data = {"name": "Test Item", "price": 60.0} - create_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert create_response.status_code == HTTPStatus.CREATED - item_id = create_response.json()["id"] - - # Без имени - response = requests.put(f"{BASE_URL}/item/{item_id}", json={"price": 100.0}) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['id'] == 1 + assert data['quantity'] == 1 # Добавлено для обратной совместимости + + @pytest.mark.asyncio + async def test_get_item_not_found(self): + """Тест получения несуществующего товара""" + with patch(f'{target}.get_item_from_db', return_value=None): + response = client.get("/item/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_get_item_deleted(self): + """Тест получения удаленного товара""" + item_data = { + 'id': 1, + 'name': 'Deleted Item', + 'price': 99.99, + 'deleted': True + } + + with patch(f'{target}.get_item_from_db', return_value=item_data): + response = client.get("/item/1") + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_get_items_success(self): + """Тест успешного получения списка товаров""" + mock_conn = AsyncMock() + mock_items = [ + { + 'id': 1, + 'name': 'Item 1', + 'price': Decimal('50.0'), + 'deleted': False, + 'created_at': None + } + ] + mock_conn.fetch.return_value = mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 + assert data[0]['name'] == 'Item 1' + + @pytest.mark.asyncio + async def test_get_items_with_filters(self): + """Тест получения товаров с фильтрами""" + mock_conn = AsyncMock() + mock_items = [ + { + 'id': 1, + 'name': 'Item 1', + 'price': Decimal('75.0'), + 'deleted': False, + 'created_at': None + } + ] + mock_conn.fetch.return_value = mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item?min_price=50&max_price=100&show_deleted=true&offset=0&limit=10") + + assert response.status_code == HTTPStatus.OK + + @pytest.mark.asyncio + async def test_get_items_database_error(self): + """Тест ошибки при получении списка товаров""" + mock_conn = AsyncMock() + mock_conn.fetch.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error getting items" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_create_item_success(self): + """Тест успешного создания товара""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetchrow.return_value = { + 'id': 1, + 'name': 'New Item', + 'price': Decimal('99.99'), + 'deleted': False + } + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/item", json={"name": "New Item", "price": 99.99}) + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data['id'] == 1 + assert data['name'] == 'New Item' + assert data['price'] == 99.99 + + @pytest.mark.asyncio + async def test_create_item_missing_fields(self): + """Тест создания товара без обязательных полей""" + response = client.post("/item", json={"name": "Only Name"}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - # Без цены - response = requests.put(f"{BASE_URL}/item/{item_id}", json={"name": "Test"}) + response = client.post("/item", json={"price": 100.0}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - def test_patch_item_success(self): - """Тест успешного частичного обновления товара""" - # Создаем товар - item_data = {"name": "Patch Test Product", "price": 80.0} - create_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert create_response.status_code == HTTPStatus.CREATED - item_id = create_response.json()["id"] + @pytest.mark.asyncio + async def test_create_item_negative_price(self): + """Тест создания товара с отрицательной ценой""" + response = client.post("/item", json={"name": "Test", "price": -10.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - # Обновляем только цену - patch_data = {"price": 90.0} - response = requests.patch(f"{BASE_URL}/item/{item_id}", json=patch_data) + @pytest.mark.asyncio + async def test_create_item_database_error(self): + """Тест ошибки при создании товара""" + mock_conn = AsyncMock() + mock_conn.fetchval.side_effect = Exception("DB error") - # Проверяем, что не падает с 500 ошибкой - assert response.status_code != HTTPStatus.INTERNAL_SERVER_ERROR + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/item", json={"name": "Test", "price": 100.0}) - # Если PATCH поддерживается, проверяем успешный ответ - if response.status_code == HTTPStatus.OK: - data = response.json() - assert data["id"] == item_id - assert data["price"] == patch_data["price"] + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error creating item" in response.json()['detail'] - def test_patch_item_empty_body(self): - """Тест частичного обновления с пустым телом""" - # Создаем товар - item_data = {"name": "Empty Patch Test", "price": 70.0} - create_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert create_response.status_code == HTTPStatus.CREATED - item_id = create_response.json()["id"] + @pytest.mark.asyncio + async def test_update_item_success(self): + """Тест успешного обновления товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'deleted': False}, # existing item check + {'id': 1, 'name': 'Updated', 'price': Decimal('150.0'), 'deleted': False} # updated item + ] - response = requests.patch(f"{BASE_URL}/item/{item_id}", json={}) + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Updated", "price": 150.0}) - if response.status_code == HTTPStatus.OK: + assert response.status_code == HTTPStatus.OK data = response.json() - assert "id" in data - assert "name" in data - assert "price" in data - - def test_patch_item_invalid_fields(self): - """Тест частичного обновления с недопустимыми полями""" - # Создаем товар - item_data = {"name": "Invalid Patch Test", "price": 85.0} - create_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert create_response.status_code == HTTPStatus.CREATED - item_id = create_response.json()["id"] - - # Лишние поля - patch_data = { - "name": "New Name", - "price": 95.0, - "invalid_field": "value" - } - response = requests.patch(f"{BASE_URL}/item/{item_id}", json=patch_data) + assert data['name'] == 'Updated' + assert data['price'] == 150.0 + + @pytest.mark.asyncio + async def test_update_item_not_found(self): + """Тест обновления несуществующего товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = None + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/999", json={"name": "Test", "price": 100.0}) + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_update_item_deleted(self): + """Тест обновления удаленного товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': True} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Test", "price": 100.0}) + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_update_item_missing_fields(self): + """Тест обновления товара без обязательных полей""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Only Name"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + response = client.put("/item/1", json={"price": 100.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - # Ожидаем ошибку валидации - assert response.status_code in [ - HTTPStatus.UNPROCESSABLE_ENTITY, - HTTPStatus.BAD_REQUEST, - HTTPStatus.OK # Если система игнорирует лишние поля + @pytest.mark.asyncio + async def test_update_item_negative_price(self): + """Тест обновления товара с отрицательной ценой""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Test", "price": -10.0}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_patch_item_success(self): + """Тест успешного частичного обновления товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False}, + {'id': 1, 'name': 'Patched', 'price': Decimal('120.0'), 'deleted': False} ] - def test_delete_item_success(self): + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "Patched", "price": 120.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['name'] == 'Patched' + assert data['price'] == 120.0 + + + @pytest.mark.asyncio + async def test_patch_item_deleted(self): + """Тест частичного обновления удаленного товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'name': 'Deleted', 'price': Decimal('100.0'), 'deleted': True} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "Updated"}) + + assert response.status_code == HTTPStatus.NOT_MODIFIED + + @pytest.mark.asyncio + async def test_patch_item_extra_fields(self): + """Тест частичного обновления с лишними полями""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "Test", "invalid_field": "value"}) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_patch_item_negative_price(self): + """Тест частичного обновления с отрицательной ценой""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"price": -10.0}) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_delete_item_success(self): """Тест успешного удаления товара""" - # Создаем товар - item_data = {"name": "To Delete", "price": 66.66} - create_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert create_response.status_code == HTTPStatus.CREATED - item_id = create_response.json()["id"] - - # Удаляем товар - response = requests.delete(f"{BASE_URL}/item/{item_id}") - assert response.status_code == HTTPStatus.OK + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': False} - # Проверяем, что товар больше недоступен - get_response = requests.get(f"{BASE_URL}/item/{item_id}") - assert get_response.status_code == HTTPStatus.NOT_FOUND + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.delete("/item/1") - def test_delete_nonexistent_item(self): + assert response.status_code == HTTPStatus.OK + + @pytest.mark.asyncio + async def test_delete_item_not_found(self): """Тест удаления несуществующего товара""" - response = requests.delete(f"{BASE_URL}/item/999999") - # Может возвращать 200 или 404 в зависимости от реализации - assert response.status_code in [HTTPStatus.OK, HTTPStatus.NOT_FOUND] - - # ==================== ТЕСТЫ ФИЛЬТРАЦИИ И ПАГИНАЦИИ ==================== - - @pytest.mark.parametrize("offset,limit,expected_status", [ - (0, 5, HTTPStatus.OK), - (2, 3, HTTPStatus.OK), - (-1, 5, HTTPStatus.UNPROCESSABLE_ENTITY), - (0, 0, HTTPStatus.UNPROCESSABLE_ENTITY), - ]) - def test_items_pagination(self, offset, limit, expected_status): - """Тест пагинации товаров""" - response = requests.get(f"{BASE_URL}/item", params={"offset": offset, "limit": limit}) - assert response.status_code == expected_status - - @pytest.mark.parametrize("min_price,max_price,expected_status", [ - (50.0, 100.0, HTTPStatus.OK), - (0.0, 50.0, HTTPStatus.OK), - (-1.0, 100.0, HTTPStatus.UNPROCESSABLE_ENTITY), - ]) - def test_items_price_filter(self, min_price, max_price, expected_status): - """Тест фильтрации товаров по цене""" - params = {} - if min_price is not None: - params["min_price"] = min_price - if max_price is not None: - params["max_price"] = max_price - - response = requests.get(f"{BASE_URL}/item", params=params) - assert response.status_code == expected_status - - @pytest.mark.parametrize("min_price,max_price,expected_status", [ - (0.0, 100.0, HTTPStatus.OK), - (500.0, 1000.0, HTTPStatus.OK), - (-1.0, 100.0, HTTPStatus.UNPROCESSABLE_ENTITY), - ]) - def test_carts_price_filter(self, min_price, max_price, expected_status): - """Тест фильтрации корзин по цене""" - params = {} - if min_price is not None: - params["min_price"] = min_price - if max_price is not None: - params["max_price"] = max_price - - response = requests.get(f"{BASE_URL}/cart", params=params) - assert response.status_code == expected_status - - # ==================== ТЕСТЫ КОРЗИНЫ С ТОВАРАМИ ==================== - - def test_add_same_item_multiple_times(self): - """Тест добавления одного товара несколько раз""" - # Создаем товар - item_data = {"name": "Multiple Add Test", "price": 25.0} - item_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert item_response.status_code == HTTPStatus.CREATED - item_id = item_response.json()["id"] - - # Создаем корзину - cart_response = requests.post(f"{BASE_URL}/cart") - assert cart_response.status_code == HTTPStatus.CREATED - cart_id = cart_response.json()["id"] - - # Добавляем первый раз - response1 = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") - assert response1.status_code == HTTPStatus.OK - - # Добавляем второй раз - response2 = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") - assert response2.status_code == HTTPStatus.OK - - # Проверяем корзину - cart_data = response2.json() - assert len(cart_data["items"]) == 1 - - def test_cart_with_multiple_different_items(self): - """Тест корзины с разными товарами""" - # Создаем корзину - cart_response = requests.post(f"{BASE_URL}/cart") - assert cart_response.status_code == HTTPStatus.CREATED - cart_id = cart_response.json()["id"] - - # Создаем несколько товаров - items = [] - for i in range(2): # Уменьшаем количество для скорости - item_data = {"name": f"Multi Item {i}", "price": (i + 1) * 30.0} - response = requests.post(f"{BASE_URL}/item", json=item_data) + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = None + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.delete("/item/999") + + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.asyncio + async def test_delete_item_already_deleted(self): + """Тест удаления уже удаленного товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'deleted': True} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.delete("/item/1") + + assert response.status_code == HTTPStatus.OK # Все равно успех + + @pytest.mark.asyncio + async def test_delete_item_database_error(self): + """Тест ошибки при удалении товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = Exception("DB error") + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.delete("/item/1") + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Error deleting item" in response.json()['detail'] + + + @pytest.mark.asyncio + async def test_get_cart_with_cache_empty_cart_data(self): + """Тест получения корзины когда cart_data is None""" + mock_redis = AsyncMock() + mock_redis.get.return_value = None # Кеш пустой + mock_redis.aclose = AsyncMock() + + with patch(f'{target}.get_redis_connection', return_value=mock_redis), \ + patch(f'{target}.get_cart_from_db', return_value=None): + result = await main.get_cart_with_cache(1) + + assert result is None + # Не должен вызывать setex если cart_data is None + + + @pytest.mark.asyncio + async def test_get_carts_with_quantity_filters_only_min(self): + """Тест фильтрации корзин только по min_quantity""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 3}, + {'id': 2, 'price': Decimal('50.0'), 'created_at': None, 'total_quantity': 1} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 3, 'price': Decimal('33.33')}], + [{'product_id': 2, 'quantity': 1, 'price': Decimal('50.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?min_quantity=2") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 # Только корзина с quantity >= 2 + assert data[0]['id'] == 1 + + @pytest.mark.asyncio + async def test_get_carts_with_quantity_filters_only_max(self): + """Тест фильтрации корзин только по max_quantity""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 3}, + {'id': 2, 'price': Decimal('50.0'), 'created_at': None, 'total_quantity': 1} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 3, 'price': Decimal('33.33')}], + [{'product_id': 2, 'quantity': 1, 'price': Decimal('50.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?max_quantity=2") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 # Только корзина с quantity <= 2 + assert data[0]['id'] == 2 + + @pytest.mark.asyncio + async def test_get_carts_with_quantity_filters_both(self): + """Тест фильтрации корзин по min_quantity и max_quantity""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 3}, + {'id': 2, 'price': Decimal('50.0'), 'created_at': None, 'total_quantity': 2}, + {'id': 3, 'price': Decimal('25.0'), 'created_at': None, 'total_quantity': 1} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 3, 'price': Decimal('33.33')}], + [{'product_id': 2, 'quantity': 2, 'price': Decimal('25.0')}], + [{'product_id': 3, 'quantity': 1, 'price': Decimal('25.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?min_quantity=2&max_quantity=2") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 # Только корзина с quantity = 2 + assert data[0]['id'] == 2 + + @pytest.mark.asyncio + async def test_get_carts_with_quantity_filters_no_matches(self): + """Тест фильтрации корзин когда нет совпадений""" + mock_conn = AsyncMock() + mock_carts = [ + {'id': 1, 'price': Decimal('100.0'), 'created_at': None, 'total_quantity': 5}, + {'id': 2, 'price': Decimal('50.0'), 'created_at': None, 'total_quantity': 10} + ] + mock_items = [ + [{'product_id': 1, 'quantity': 5, 'price': Decimal('20.0')}], + [{'product_id': 2, 'quantity': 10, 'price': Decimal('5.0')}] + ] + + mock_conn.fetch.side_effect = [mock_carts] + mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart?min_quantity=20") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 0 # Нет корзин с quantity >= 20 + + + @pytest.mark.asyncio + async def test_create_item_price_zero(self): + """Тест создания товара с ценой 0""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetchrow.return_value = { + 'id': 1, + 'name': 'Free Item', + 'price': Decimal('0.0'), + 'deleted': False + } + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/item", json={"name": "Free Item", "price": 0.0}) + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert data['price'] == 0.0 + + @pytest.mark.asyncio + async def test_create_item_price_float_conversion(self): + """Тест создания товара с преобразованием float цены""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetchrow.return_value = { + 'id': 1, + 'name': 'Test Item', + 'price': Decimal('99.99'), + 'deleted': False + } + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.post("/item", json={"name": "Test Item", "price": 99.99}) + assert response.status_code == HTTPStatus.CREATED - items.append(response.json()) + data = response.json() + assert data['price'] == 99.99 + + @pytest.mark.asyncio + async def test_update_item_price_zero(self): + """Тест обновления товара с ценой 0""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'deleted': False}, # existing item check + {'id': 1, 'name': 'Free Item', 'price': Decimal('0.0'), 'deleted': False} # updated item + ] + mock_conn.execute = AsyncMock() + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Free Item", "price": 0.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['price'] == 0.0 + + @pytest.mark.asyncio + async def test_update_item_same_price(self): + """Тест обновления товара с той же ценой""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'deleted': False}, + {'id': 1, 'name': 'Same Price', 'price': Decimal('100.0'), 'deleted': False} + ] + mock_conn.execute = AsyncMock() + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.put("/item/1", json={"name": "Same Price", "price": 100.0}) - # Добавляем все товары в корзину - for item in items: - response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item['id']}") assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['price'] == 100.0 + + @pytest.mark.asyncio + async def test_patch_item_only_name(self): + """Тест частичного обновления только имени товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False}, + {'id': 1, 'name': 'Updated Name', 'price': Decimal('100.0'), 'deleted': False} + ] + mock_conn.execute = AsyncMock() - # Проверяем итоговую корзину - cart_response = requests.get(f"{BASE_URL}/cart/{cart_id}") - assert cart_response.status_code == HTTPStatus.OK - cart_data = cart_response.json() + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "Updated Name"}) - assert len(cart_data["items"]) == len(items) + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['name'] == 'Updated Name' + assert data['price'] == 100.0 # Цена не изменилась + + @pytest.mark.asyncio + async def test_patch_item_only_price(self): + """Тест частичного обновления только цены товара""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Test Item', 'price': Decimal('50.0'), 'deleted': False}, + {'id': 1, 'name': 'Test Item', 'price': Decimal('75.0'), 'deleted': False} + ] + mock_conn.execute = AsyncMock() - # ==================== ТЕСТЫ ГРАНИЧНЫХ СЛУЧАЕВ ==================== + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"price": 75.0}) - def test_item_negative_price(self): - """Тест создания товара с отрицательной ценой""" - item_data = {"name": "Invalid Product", "price": -10.0} - response = requests.post(f"{BASE_URL}/item", json=item_data) - # Может возвращать 422 или 400 в зависимости от валидации - assert response.status_code in [HTTPStatus.UNPROCESSABLE_ENTITY, HTTPStatus.BAD_REQUEST] + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['name'] == 'Test Item' # Имя не изменилось + assert data['price'] == 75.0 + + @pytest.mark.asyncio + async def test_patch_item_price_zero(self): + """Тест частичного обновления цены на 0""" + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Test Item', 'price': Decimal('100.0'), 'deleted': False}, + {'id': 1, 'name': 'Test Item', 'price': Decimal('0.0'), 'deleted': False} + ] + mock_conn.execute = AsyncMock() - def test_update_item_negative_price(self): - """Тест обновления товара с отрицательной ценой""" - # Создаем товар - item_data = {"name": "Update Negative Test", "price": 40.0} - create_response = requests.post(f"{BASE_URL}/item", json=item_data) - assert create_response.status_code == HTTPStatus.CREATED - item_id = create_response.json()["id"] - - update_data = {"name": "Updated", "price": -5.0} - response = requests.put(f"{BASE_URL}/item/{item_id}", json=update_data) - assert response.status_code in [HTTPStatus.UNPROCESSABLE_ENTITY, HTTPStatus.BAD_REQUEST] \ No newline at end of file + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"price": 0.0}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data['price'] == 0.0 + + def test_decimal_encoder_other_types(self): + """Тест DecimalEncoder с другими типами данных""" + encoder = main.DecimalEncoder() + + # Тест с обычными типами + test_data = { + 'string': 'test', + 'integer': 123, + 'float': 45.67, + 'list': [1, 2, 3], + 'none': None, + 'boolean': True + } + + result = encoder.encode(test_data) + parsed = json.loads(result) + + assert parsed['string'] == 'test' + assert parsed['integer'] == 123 + assert parsed['float'] == 45.67 + assert parsed['list'] == [1, 2, 3] + assert parsed['none'] is None + assert parsed['boolean'] is True + + def test_decimal_encoder_nested_decimal(self): + """Тест DecimalEncoder с вложенными Decimal""" + encoder = main.DecimalEncoder() + + test_data = { + 'prices': [Decimal('10.50'), Decimal('20.75')], + 'nested': { + 'subprice': Decimal('30.25') + } + } + + result = encoder.encode(test_data) + parsed = json.loads(result) + + assert parsed['prices'] == [10.50, 20.75] + assert parsed['nested']['subprice'] == 30.25 + + @pytest.mark.asyncio + async def test_add_to_cart_success_existing_item(self): + """Тест успешного добавления существующего товара в корзину""" + with patch(f'{target}.get_db_connection') as mock_get_db, \ + patch(f'{target}.get_redis_connection'), \ + patch(f'{target}.get_cart_with_cache') as mock_get_cart, \ + patch(f'{target}.invalidate_cart_cache', new_callable=AsyncMock) as mock_invalidate: + mock_conn = AsyncMock() + mock_get_db.return_value = mock_conn + + # Базовые моки + mock_conn.fetchval.return_value = True + mock_conn.fetchrow.side_effect = [ + {'id': 1, 'name': 'Test', 'price': Decimal('50.0')}, + {'quantity': 1, 'price': Decimal('50.0')} + ] + mock_conn.execute = AsyncMock() + + # Transaction как простой async context manager + mock_conn.transaction = MagicMock() + mock_conn.transaction.return_value.__aenter__ = AsyncMock(return_value=None) + mock_conn.transaction.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_get_cart.return_value = { + 'id': 1, + 'items': [{'id': 1, 'quantity': 2, 'price': 50.0}], + 'price': 100.0 + } + + response = client.post("/cart/1/add/1") + + assert response.status_code == HTTPStatus.OK + mock_invalidate.assert_called_once_with(1) + + @pytest.mark.asyncio + async def test_patch_item_empty_body_fields(self): + """Тест частичного обновления с пустыми полями в теле""" + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = {'id': 1, 'name': 'Original', 'price': Decimal('100.0'), 'deleted': False} + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.patch("/item/1", json={"name": "", "price": 0.0}) + + assert response.status_code == HTTPStatus.OK + # Должен обработать пустые значения без ошибки + + @pytest.mark.asyncio + async def test_get_carts_empty_result(self): + """Тест получения пустого списка корзин""" + mock_conn = AsyncMock() + mock_conn.fetch.return_value = [] # Нет корзин + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/cart") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data == [] + + @pytest.mark.asyncio + async def test_get_items_empty_result(self): + """Тест получения пустого списка товаров""" + mock_conn = AsyncMock() + mock_conn.fetch.return_value = [] # Нет товаров + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data == [] + + @pytest.mark.asyncio + async def test_get_items_show_deleted_false(self): + """Тест получения товаров с show_deleted=false""" + mock_conn = AsyncMock() + mock_items = [ + { + 'id': 1, + 'name': 'Active Item', + 'price': Decimal('50.0'), + 'deleted': False, + 'created_at': None + } + ] + mock_conn.fetch.return_value = mock_items + + with patch(f'{target}.get_db_connection', return_value=mock_conn): + response = client.get("/item?show_deleted=false") + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == 1 + assert data[0]['deleted'] is False + + @pytest.mark.asyncio + async def test_patch_item_not_found(self): + """Тест частичного обновления несуществующего товара""" + with patch(f'{target}.get_db_connection') as mock_get_db: + mock_conn = AsyncMock() + mock_get_db.return_value = mock_conn + + # Товар не существует + mock_conn.fetchrow.return_value = None + + response = client.patch("/item/999", json={"name": "Updated Name"}) + + assert response.status_code == HTTPStatus.NOT_FOUND + assert "Item not found" in response.json()['detail'] + + @pytest.mark.asyncio + async def test_patch_item_empty_body(self): + """Тест частичного обновления с пустым телом""" + with patch(f'{target}.get_db_connection') as mock_get_db: + mock_conn = AsyncMock() + mock_get_db.return_value = mock_conn + + # Существующий товар + existing_item = { + 'id': 1, + 'name': 'Original Name', + 'price': Decimal('100.0'), + 'deleted': False + } + mock_conn.fetchrow.return_value = existing_item + + # Не вызываем execute, так как обновления не должно быть + + response = client.patch("/item/1", json={}) + + assert response.status_code == HTTPStatus.OK + data = response.json() + + # Проверяем что вернулся товар без изменений + assert data['id'] == 1 + assert data['name'] == 'Original Name' + assert data['price'] == 100.0 + assert data['quantity'] == 1 + assert data['deleted'] is False + + # Проверяем что execute не вызывался (нет обновления в БД) + mock_conn.execute.assert_not_called() + + @pytest.mark.asyncio + async def test_add_to_cart_success_new_item(self): + """Тест успешного добавления нового товара в корзину""" + try: + mock_conn = AsyncMock() + + # Включаем отладку для всех методов + def debug_call(*args, **kwargs): + print(f"Called: {args}, {kwargs}") + return AsyncMock() + + mock_conn.fetchval = AsyncMock(return_value=True) + mock_conn.fetchrow = AsyncMock(side_effect=[ + {'id': 1, 'name': 'New Product', 'price': Decimal('75.50')}, + None + ]) + mock_conn.execute = AsyncMock() + + # Детальная настройка transaction + mock_transaction = MagicMock() + mock_transaction.__aenter__ = AsyncMock(return_value=None) + mock_transaction.__aexit__ = AsyncMock(return_value=None) + mock_conn.transaction = MagicMock(return_value=mock_transaction) + + with patch(f'{target}.get_db_connection', return_value=mock_conn), \ + patch(f'{target}.get_redis_connection'), \ + patch(f'{target}.get_cart_with_cache', return_value={'id': 1, 'items': [], 'price': 75.50}), \ + patch(f'{target}.invalidate_cart_cache', new_callable=AsyncMock) as mock_invalidate: + + print("Before making request...") + response = client.post("/cart/1/add/1") + print(f"Response: {response.status_code}, {response.text}") + + assert response.status_code == HTTPStatus.OK + + except Exception as e: + print(f"ERROR: {e}") + print(f"Error type: {type(e)}") + import traceback + traceback.print_exc() + raise +class TestDecimalEncoder: + """Тесты для кастомного JSON энкодера""" + + def test_decimal_encoder(self): + """Тест кодирования Decimal в JSON""" + + encoder = main.DecimalEncoder() + + # Тест с Decimal + decimal_value = Decimal('123.45') + result = encoder.encode({'price': decimal_value}) + assert '123.45' in result + + # Тест с обычными типами + regular_data = {'name': 'test', 'number': 100} + result = encoder.encode(regular_data) + assert 'test' in result + assert '100' in result From 6817bb3efee83d2ede7cd97a6f56fa1324d46a95 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 21:57:10 +0400 Subject: [PATCH 22/26] hw_5 --- hw2/hw/Dockerfile | 2 +- hw2/hw/{requiremets.txt => requirements.txt} | Bin .../shop_api/{requiremets.txt => requirements.txt} | Bin requiremets.txt => requirements.txt | Bin 4 files changed, 1 insertion(+), 1 deletion(-) rename hw2/hw/{requiremets.txt => requirements.txt} (100%) rename hw2/hw/shop_api/{requiremets.txt => requirements.txt} (100%) rename requiremets.txt => requirements.txt (100%) diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile index e58437bf..21f49a4e 100644 --- a/hw2/hw/Dockerfile +++ b/hw2/hw/Dockerfile @@ -3,6 +3,6 @@ LABEL service="processing" EXPOSE 8080 WORKDIR /app COPY shop_api/requiremets.txt . -RUN pip install --no-cache-dir -r requiremets.txt +RUN pip install --no-cache-dir -r requirements.txt COPY shop_api . CMD ["python", "main.py"] \ No newline at end of file diff --git a/hw2/hw/requiremets.txt b/hw2/hw/requirements.txt similarity index 100% rename from hw2/hw/requiremets.txt rename to hw2/hw/requirements.txt diff --git a/hw2/hw/shop_api/requiremets.txt b/hw2/hw/shop_api/requirements.txt similarity index 100% rename from hw2/hw/shop_api/requiremets.txt rename to hw2/hw/shop_api/requirements.txt diff --git a/requiremets.txt b/requirements.txt similarity index 100% rename from requiremets.txt rename to requirements.txt From dbed6c957b15b0ed09a8909b7171d1fbec7c21e6 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 22:03:41 +0400 Subject: [PATCH 23/26] hw_5 --- .github/workflows/hw5_tests.yaml | 68 ++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 5b240ad6..f779fcd6 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -12,6 +12,51 @@ jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: shop + POSTGRES_USER: user + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + prometheus: + image: prom/prometheus + ports: + - 9090:9090 + options: >- + --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + grafana: + image: grafana/grafana + ports: + - 3000:3000 + options: >- + --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 @@ -26,27 +71,36 @@ jobs: python -m pip install --upgrade pip cd hw2/hw pip install -r requirements.txt - pip install pytest pytest-cov pytest-asyncio requests uvicorn + pip install pytest pytest-cov pytest-asyncio requests uvicorn asyncpg redis + + - name: Wait for databases to be ready + run: | + echo "Waiting for PostgreSQL..." + until pg_isready -h localhost -p 5432; do sleep 1; done + echo "Waiting for Redis..." + until redis-cli -h localhost ping | grep -q PONG; do sleep 1; done + echo "✅ All databases are ready" - name: Start FastAPI app in background run: | cd hw2/hw - python -c "from shop_api.main import app; import uvicorn; uvicorn.run(app, host='0.0.0.0', port=8080)" & + python -m uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 & echo "FastAPI app starting..." - sleep 3 + sleep 15 - name: Check app health run: | - curl -f http://localhost:8080/ || exit 1 + echo "Checking if FastAPI app is healthy..." + curl -f http://localhost:8080/ || (echo "❌ FastAPI app not healthy"; exit 1) echo "✅ FastAPI app is healthy" - name: Run homework tests run: | cd hw2/hw - pytest test_my_test.py -v --cov=. --cov-report=term-missing + pytest test_my_test.py -v - - name: Stop services + - name: Stop FastAPI app if: always() run: | - # Останавливаем все процессы на порту 8080 + echo "Stopping FastAPI app..." pkill -f "uvicorn" || true \ No newline at end of file From ade77e4be9c742480c84b71fdc7359fca7c3fab6 Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 22:12:10 +0400 Subject: [PATCH 24/26] hw_5 --- .github/workflows/hw5_tests.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index f779fcd6..a00bed65 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -73,6 +73,11 @@ jobs: pip install -r requirements.txt pip install pytest pytest-cov pytest-asyncio requests uvicorn asyncpg redis + - name: Install redis-cli + run: | + sudo apt-get update + sudo apt-get install -y redis-tools + - name: Wait for databases to be ready run: | echo "Waiting for PostgreSQL..." From 5d684154b215824149d2a9a7ea7284ca94e8824c Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 22:15:53 +0400 Subject: [PATCH 25/26] hw_5 --- .github/workflows/hw5_tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index a00bed65..27307d9c 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -102,7 +102,7 @@ jobs: - name: Run homework tests run: | cd hw2/hw - pytest test_my_test.py -v + pytest pytest test_my_test.py -v --cov=. --cov-report=term-missing - name: Stop FastAPI app if: always() From 1440b19575c9216072afed46f995e21b2c393e9d Mon Sep 17 00:00:00 2001 From: Vlad Tzybin Date: Sat, 18 Oct 2025 22:21:38 +0400 Subject: [PATCH 26/26] hw_5 --- .github/workflows/hw5_tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hw5_tests.yaml b/.github/workflows/hw5_tests.yaml index 27307d9c..3f46f4ba 100644 --- a/.github/workflows/hw5_tests.yaml +++ b/.github/workflows/hw5_tests.yaml @@ -102,7 +102,7 @@ jobs: - name: Run homework tests run: | cd hw2/hw - pytest pytest test_my_test.py -v --cov=. --cov-report=term-missing + pytest test_my_test.py -v --cov=. --cov-report=term-missing - name: Stop FastAPI app if: always()