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