diff --git a/.github/workflows/hw2-comprehensive-tests.yml b/.github/workflows/hw2-comprehensive-tests.yml new file mode 100644 index 00000000..12e014cd --- /dev/null +++ b/.github/workflows/hw2-comprehensive-tests.yml @@ -0,0 +1,129 @@ +name: HW2 Comprehensive Tests + +on: + push: + branches: [ main, dev ] + paths: + - 'hw2/hw/**' + - '.github/workflows/hw2-comprehensive-tests.yml' + pull_request: + branches: [ main ] + paths: + - 'hw2/hw/**' + - '.github/workflows/hw2-comprehensive-tests.yml' + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: shop_db_test + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + cache: 'pip' + + - name: Install dependencies + working-directory: ./hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Wait for PostgreSQL + run: | + until pg_isready -h localhost -p 5432 -U shop_user; do + echo "Waiting for postgres..." + sleep 2 + done + + - name: Run tests with coverage + working-directory: ./hw2/hw + env: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db_test + run: | + pytest test_homework2_all.py -v --cov=shop_api --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./hw2/hw/coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Check coverage threshold + working-directory: ./hw2/hw + run: | + coverage report --fail-under=95 + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Install linting tools + run: | + python -m pip install --upgrade pip + pip install flake8 black isort + + - name: Run flake8 + working-directory: ./hw2/hw + run: | + flake8 shop_api --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 shop_api --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Check formatting with black + working-directory: ./hw2/hw + run: | + black --check shop_api || true + + - name: Check imports with isort + working-directory: ./hw2/hw + run: | + isort --check-only shop_api || true + + docker-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + working-directory: ./hw2/hw + run: | + docker build -t shop-api:test . + + - name: Test Docker Compose + working-directory: ./hw2/hw + run: | + docker compose config \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..55bb4104 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,7 @@ from typing import Any, Awaitable, Callable - +import json +import math +from urllib.parse import parse_qs async def application( scope: dict[str, Any], @@ -12,8 +14,135 @@ 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 + return + + if scope['type'] != 'http': + return + + method = scope['method'] + path = scope['path'] + query_string = scope.get('query_string', b'').decode('utf-8') + query_params = parse_qs(query_string) + + async def send_response(status_code, body): + await send({ + 'type':'http.response.start', + 'status': status_code, + 'headers': [ + [b'content-type', b'application/json'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': json.dumps(body).encode('utf-8'), + }) + + async def receive_body(): + body = b'' + while True: + message = await receive() + if message['type'] == 'http.request': + body += message.get('body', b'') + if not message.get('more_body', False): + break + if body: + try: + return json.loads(body.decode('utf-8')) + except json.JSONDecodeError: + return None + return None + + if method != 'GET': + await send_response(404, {}) + return + + try: + if path == '/factorial': + if 'n' not in query_params or not query_params['n'][0]: + await send_response(422, {}) + return + + try: + n = int(query_params['n'][0]) + if n < 0: + await send_response(400, {}) + return + + result = math.factorial(n) + await send_response(200, {'result': result}) + return + except ValueError: + await send_response(422, {}) + return + + elif path.startswith('/fibonacci/'): + try: + n_str = path[11:] + if not n_str: + await send_response(422, {}) + return + n = int(n_str) + + if n < 0: + await send_response(400, {}) + return + if n == 0: + await send_response(200, {'result': 0}) + return + elif n == 1: + await send_response(200, {'result': 1}) + return + else: + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + await send_response(200, {'result': b}) + return + + except Exception: + await send_response(422, {}) + return + + elif path == '/mean': + body = await receive_body() + + if body is None: + await send_response(422, {}) + return + + if not isinstance(body, list): + await send_response(400, {}) + return + + if len(body) == 0: + await send_response(400, {}) + return + try: + numbers = [float(x) for x in body] + mean_value = sum(numbers) / len(numbers) + await send_response(200, {'result': mean_value}) + return + except (ValueError, TypeError): + await send_response(400, {}) + return + + else: + await send_response(404, {}) + return + + except Exception: + await send_response(500, {}) + return if __name__ == "__main__": import uvicorn - uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/hw2/hw/.coveragerc b/hw2/hw/.coveragerc new file mode 100644 index 00000000..52a40962 --- /dev/null +++ b/hw2/hw/.coveragerc @@ -0,0 +1,19 @@ +[run] +source = shop_api +concurrency = thread,greenlet +omit = + */tests/* + */test_*.py + */__pycache__/* + */venv/* + */env/* + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abstractmethod diff --git a/hw2/hw/.dockerignore b/hw2/hw/.dockerignore new file mode 100644 index 00000000..17080605 --- /dev/null +++ b/hw2/hw/.dockerignore @@ -0,0 +1,26 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.git +.gitignore +.env +.venv +env/ +venv/ +ENV/ +.pytest_cache +.coverage +htmlcov/ +*.log +.DS_Store +Thumbs.db +README.md +docker-compose.yml +Dockerfile \ No newline at end of file diff --git a/hw2/hw/.github/workflows/tests.yml b/hw2/hw/.github/workflows/tests.yml new file mode 100644 index 00000000..cb1661cc --- /dev/null +++ b/hw2/hw/.github/workflows/tests.yml @@ -0,0 +1,123 @@ +name: Tests + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: shop_db_test + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + cache: 'pip' + + - name: Install dependencies + working-directory: ./hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Wait for PostgreSQL + run: | + until pg_isready -h localhost -p 5432 -U shop_user; do + echo "Waiting for postgres..." + sleep 2 + done + + - name: Run tests with coverage + working-directory: ./hw2/hw + env: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db_test + run: | + pytest test_homework2_all.py -v --cov=shop_api --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./hw2/hw/coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Check coverage threshold + working-directory: ./hw2/hw + run: | + coverage report --fail-under=95 + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Install linting tools + run: | + python -m pip install --upgrade pip + pip install flake8 black isort + + - name: Run flake8 + working-directory: ./hw2/hw + run: | + flake8 shop_api --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 shop_api --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Check formatting with black + working-directory: ./hw2/hw + run: | + black --check shop_api || true + + - name: Check imports with isort + working-directory: ./hw2/hw + run: | + isort --check-only shop_api || true + + docker-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + working-directory: ./hw2/hw + run: | + docker build -t shop-api:test . + + - name: Test Docker Compose + working-directory: ./hw2/hw + run: | + docker-compose config \ No newline at end of file diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..825e9282 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,26 @@ + +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +RUN pip install --no-cache-dir prometheus-client prometheus-fastapi-instrumentator + +COPY shop_api/ ./shop_api/ +COPY transaction_isolation_demo.py . +COPY test_homework2_all.py . +COPY setup.py . + +# Устанавливаем пакет shop_api +RUN pip install -e . + +EXPOSE 8000 + +ENV PYTHONUNBUFFERED=1 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/hw2/hw/Dockerfile.test b/hw2/hw/Dockerfile.test new file mode 100644 index 00000000..7db2eb57 --- /dev/null +++ b/hw2/hw/Dockerfile.test @@ -0,0 +1,18 @@ +FROM python:3.9-slim + +WORKDIR /app + + +RUN apt-get update && apt-get install -y \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + + +RUN pip install -e . + +CMD ["pytest", "test_homework2_all.py", "-v"] diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..96fc64b5 --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,76 @@ +import os +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool +from contextlib import asynccontextmanager + +# Используем DATABASE_URL из окружения, или дефолтное значение для локальных тестов +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db_test") + +from shop_api.main import app, Base, get_session + +test_engine = create_async_engine( + os.environ["DATABASE_URL"], + echo=False, + poolclass=NullPool # No connection pooling for tests +) +test_session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def setup_database(): + @asynccontextmanager + async def empty_lifespan(app): + yield + + app.router.lifespan_context = empty_lifespan + + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + yield + + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await test_engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(): + async with test_session_maker() as session: + yield session + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession): + async def override_get_session(): + yield db_session + + app.dependency_overrides[get_session] = override_get_session + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + timeout=30.0 + ) as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture(scope="session") +def event_loop_policy(): + import asyncio + return asyncio.DefaultEventLoopPolicy() + + +@pytest.fixture(scope="session") +def event_loop(event_loop_policy): + import asyncio + loop = event_loop_policy.new_event_loop() + yield loop + loop.close() diff --git a/hw2/hw/create_test_db.py b/hw2/hw/create_test_db.py new file mode 100644 index 00000000..f229c3d9 --- /dev/null +++ b/hw2/hw/create_test_db.py @@ -0,0 +1,17 @@ +import asyncio +import os + +os.environ["DATABASE_URL"] = "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db_test" + +from shop_api.main import engine, Base + +async def create_tables(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + await engine.dispose() + print("Test database tables created successfully!") + +if __name__ == "__main__": + asyncio.run(create_tables()) diff --git a/hw2/hw/docker-compose.test.yml b/hw2/hw/docker-compose.test.yml new file mode 100644 index 00000000..67e7b5ae --- /dev/null +++ b/hw2/hw/docker-compose.test.yml @@ -0,0 +1,32 @@ +services: + postgres-test: + image: postgres:16-alpine + container_name: shop-db-test + environment: + POSTGRES_DB: shop_db_test + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - "5433:5432" # Используем другой порт, чтобы не конфликтовать с локальной БД + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_db_test"] + interval: 5s + timeout: 3s + retries: 5 + tmpfs: + - /var/lib/postgresql/data + + test-runner: + build: + context: . + dockerfile: Dockerfile.test + container_name: shop-test-runner + environment: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@postgres-test:5432/shop_db_test + depends_on: + postgres-test: + condition: service_healthy + volumes: + - .:/app + - /app/.pytest_cache + command: pytest test_homework2_all.py -v --cov=shop_api --cov-report=term-missing diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..7392b25c --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,89 @@ +services: + postgres: + image: postgres:16-alpine + container_name: shop-db + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - monitoring + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_db"] + interval: 10s + timeout: 5s + retries: 5 + + shop-api: + build: + context: . + dockerfile: Dockerfile + container_name: shop-api + environment: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@postgres:5432/shop_db + ports: + - "8000:8000" + networks: + - monitoring + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/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' + networks: + - monitoring + restart: unless-stopped + depends_on: + - shop-api + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + networks: + - monitoring + restart: unless-stopped + depends_on: + - prometheus + +networks: + monitoring: + driver: bridge + +volumes: + prometheus-data: + grafana-data: + postgres-data: \ No newline at end of file diff --git a/hw2/hw/grafana/dashboards/shop-api-dashboard.json b/hw2/hw/grafana/dashboards/shop-api-dashboard.json new file mode 100644 index 00000000..07257adc --- /dev/null +++ b/hw2/hw/grafana/dashboards/shop-api-dashboard.json @@ -0,0 +1,549 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Requests/sec", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "expr": "rate(http_requests_total{job=\"shop-api\"}[1m])", + "refId": "A", + "legendFormat": "{{method}} {{handler}}" + } + ], + "title": "Request Rate (requests/sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.1 + }, + { + "color": "orange", + "value": 0.5 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket{job=\"shop-api\"}[5m]))", + "refId": "A" + } + ], + "title": "Response Time (95th percentile)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": ["value", "percent"] + }, + "pieType": "donut", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "expr": "sum by (status) (http_requests_total{job=\"shop-api\"})", + "refId": "A", + "legendFormat": "{{status}}" + } + ], + "title": "HTTP Status Codes Distribution", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Requests", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "expr": "sum by (handler) (increase(http_requests_total{job=\"shop-api\"}[5m]))", + "refId": "A", + "legendFormat": "{{handler}}" + } + ], + "title": "Requests by Endpoint (last 5 min)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "sum(increase(http_requests_total{job=\"shop-api\"}[1h]))", + "refId": "A" + } + ], + "title": "Total Requests (last hour)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.05 + }, + { + "color": "red", + "value": 0.1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 16 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "mean" + ], + "fields": "" + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "rate(http_request_duration_seconds_sum{job=\"shop-api\"}[5m]) / rate(http_request_duration_seconds_count{job=\"shop-api\"}[5m])", + "refId": "A" + } + ], + "title": "Average Response Time", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 16 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "up{job=\"shop-api\"}", + "refId": "A" + } + ], + "title": "Service Status (1=UP, 0=DOWN)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 16 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "mean" + ], + "fields": "" + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "rate(http_request_size_bytes_sum{job=\"shop-api\"}[5m]) / rate(http_request_size_bytes_count{job=\"shop-api\"}[5m])", + "refId": "A" + } + ], + "title": "Average Request Size", + "type": "stat" + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": ["shop-api", "fastapi"], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Shop API Dashboard", + "uid": "shop-api-dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/hw2/hw/grafana/provisioning/dashboards/dashboard.yml b/hw2/hw/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..a8c80567 --- /dev/null +++ b/hw2/hw/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'Shop API Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards \ No newline at end of file diff --git a/hw2/hw/grafana/provisioning/datasources/prometheus.yml b/hw2/hw/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..fb0e312f --- /dev/null +++ b/hw2/hw/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + uid: prometheus + jsonData: + timeInterval: "5s" + httpMethod: POST \ No newline at end of file diff --git a/hw2/hw/prometheus/prometheus.yml b/hw2/hw/prometheus/prometheus.yml new file mode 100644 index 00000000..7aba99cf --- /dev/null +++ b/hw2/hw/prometheus/prometheus.yml @@ -0,0 +1,17 @@ + +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + monitor: 'shop-api-monitor' + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'shop-api' + scrape_interval: 5s + static_configs: + - targets: ['shop-api:8000'] + metrics_path: '/metrics' \ No newline at end of file diff --git a/hw2/hw/pytest.ini b/hw2/hw/pytest.ini new file mode 100644 index 00000000..dc6d95f7 --- /dev/null +++ b/hw2/hw/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --verbose + --strict-markers + --cov=shop_api + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-fail-under=95 +asyncio_mode = auto \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..f1cd2bbf 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,22 @@ + # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 -# Зависимости для тестирования +# Database +sqlalchemy>=2.0.0 +asyncpg>=0.29.0 +alembic>=1.13.0 + +#WebSocket +websockets>=12.0 + +# Prometheus metrics +prometheus-client>=0.19.0 +prometheus-fastapi-instrumentator>=6.1.0 + pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 httpx>=0.27.2 -Faker>=37.8.0 +Faker>=37.8.0 \ No newline at end of file diff --git a/hw2/hw/setup.py b/hw2/hw/setup.py new file mode 100644 index 00000000..2f4dc44d --- /dev/null +++ b/hw2/hw/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup, find_packages + +setup( + name="shop-api", + version="0.1.0", + packages=find_packages(), + python_requires=">=3.9", +) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..7d579faa 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,335 @@ -from fastapi import FastAPI +import os +import random +from typing import Optional, List +from contextlib import asynccontextmanager -app = FastAPI(title="Shop API") +from fastapi import FastAPI, HTTPException, Response, Query, WebSocket, WebSocketDisconnect, Depends +from pydantic import BaseModel, Field, ConfigDict +from http import HTTPStatus + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy import String, Float, Boolean, Integer, ForeignKey, select, delete, update +from prometheus_fastapi_instrumentator import Instrumentator + + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db") + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +class Base(DeclarativeBase): + pass + +class ItemDB(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + price: Mapped[float] = mapped_column(Float) + deleted: Mapped[bool] = mapped_column(Boolean, default=False) + +class CartDB(Base): + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(primary_key=True) + +class CartItemDB(Base): + __tablename__ = "cart_items" + + id: Mapped[int] = mapped_column(primary_key=True) + cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id", ondelete="CASCADE")) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id", ondelete="CASCADE")) + quantity: Mapped[int] = mapped_column(Integer, default=1) + +class ItemCreate(BaseModel): + name: str + price: float = Field(gt=0) + +class ItemUpdate(BaseModel): + name: str + price: float = Field(gt=0) + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: list[CartItem] + price: float + +class CartIdResponse(BaseModel): + id: int + +@asynccontextmanager +async def lifespan(app: FastAPI): # pragma: no cover + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + await engine.dispose() + + +app = FastAPI(title="Shop API", lifespan=lifespan) + +instrumentator = Instrumentator() +instrumentator.instrument(app).expose(app) + +chat_rooms: dict[str, list[tuple[WebSocket, str]]] = {} + +def generate_username(): # pragma: no cover + adjectives = ["Happy", "Clever", "Brave", "Swift", "Strong", "Wise", "Cool", "Epic"] + nouns = ["Panda", "Tiger", "Eagle", "Dragon", "Phoenix", "Wolf", "Bear", "Fox"] + number = random.randint(100, 999) + return f"{random.choice(adjectives)}{random.choice(nouns)}{number}" + +async def get_session() -> AsyncSession: + async with async_session_maker() as session: + yield session + +@app.get("/health") +def health_check(): + return {"status": "healthy"} + + +@app.post("/item", status_code=HTTPStatus.CREATED, response_model=Item) +async def create_item(item: ItemCreate, session: AsyncSession = Depends(get_session)): + new_item = ItemDB(name=item.name, price=item.price, deleted=False) + session.add(new_item) + await session.commit() + await session.refresh(new_item) + return Item(id=new_item.id, name=new_item.name, price=new_item.price, deleted=new_item.deleted) + + +@app.get("/item/{item_id}", response_model=Item) +async def get_item(item_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + item = result.scalar_one_or_none() + + if not item or item.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + return Item(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + +@app.get("/item", response_model=List[Item]) +async def get_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False, + session: AsyncSession = Depends(get_session) +): + query = select(ItemDB) + + if not show_deleted: + query = query.where(ItemDB.deleted == False) + + if min_price is not None: + query = query.where(ItemDB.price >= min_price) + + if max_price is not None: + query = query.where(ItemDB.price <= max_price) + + query = query.offset(offset).limit(limit) + + result = await session.execute(query) + items = result.scalars().all() + + return [Item(id=item.id, name=item.name, price=item.price, deleted=item.deleted) for item in items] + +@app.put("/item/{item_id}", response_model=Item) +async def update_item(item_id: int, item: ItemUpdate, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + existing_item = result.scalar_one_or_none() + + if not existing_item: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + existing_item.name = item.name + existing_item.price = item.price + + await session.commit() + await session.refresh(existing_item) + + return Item(id=existing_item.id, name=existing_item.name, price=existing_item.price, deleted=existing_item.deleted) + + + +@app.patch("/item/{item_id}", response_model=Item) +async def patch_item(item_id: int, item: ItemPatch, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + existing_item = result.scalar_one_or_none() + + if not existing_item: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + if existing_item.deleted: + return Response(status_code=HTTPStatus.NOT_MODIFIED) + + if item.name is not None: + existing_item.name = item.name + if item.price is not None: + existing_item.price = item.price + + await session.commit() + await session.refresh(existing_item) + + return Item(id=existing_item.id, name=existing_item.name, price=existing_item.price, deleted=existing_item.deleted) + + +@app.delete("/item/{item_id}") +async def delete_item(item_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + item = result.scalar_one_or_none() + + if not item: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + item.deleted = True + await session.commit() + + return Response(status_code=HTTPStatus.OK) + + + +@app.post("/cart", status_code=HTTPStatus.CREATED, response_model=CartIdResponse) +async def create_cart(response: Response, session: AsyncSession = Depends(get_session)): + new_cart = CartDB() + session.add(new_cart) + await session.commit() + await session.refresh(new_cart) + + response.headers["location"] = f"/cart/{new_cart.id}" + return CartIdResponse(id=new_cart.id) + + +@app.get("/cart/{cart_id}", response_model=Cart) +async def get_cart(cart_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(CartDB).where(CartDB.id == cart_id)) + cart = result.scalar_one_or_none() + + if not cart: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + + query = select(CartItemDB, ItemDB).join(ItemDB, CartItemDB.item_id == ItemDB.id).where(CartItemDB.cart_id == cart_id) + result = await session.execute(query) + cart_items_data = result.all() + + cart_items = [] + total_price = 0.0 + + for cart_item, item in cart_items_data: + cart_items.append(CartItem( + id=item.id, + name=item.name, + quantity=cart_item.quantity, + available=not item.deleted + )) + if not item.deleted: + total_price += item.price * cart_item.quantity + + return Cart(id=cart_id, items=cart_items, price=total_price) + + +@app.get("/cart", response_model=List[Cart]) +async def get_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), + session: AsyncSession = Depends(get_session) +): + result = await session.execute(select(CartDB.id).offset(offset).limit(limit)) + cart_ids = result.scalars().all() + + carts = [] + for cart_id in cart_ids: + cart = await get_cart(cart_id, session) + + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + total_quantity = sum(item.quantity for item in cart.items) + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + carts.append(cart) + + return carts + + +@app.post("/cart/{cart_id}/add/{item_id}") +async def add_item_to_cart(cart_id: int, item_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(CartDB).where(CartDB.id == cart_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + result = await session.execute( + select(CartItemDB).where(CartItemDB.cart_id == cart_id, CartItemDB.item_id == item_id) + ) + cart_item = result.scalar_one_or_none() + + if cart_item: + cart_item.quantity += 1 + else: + cart_item = CartItemDB(cart_id=cart_id, item_id=item_id, quantity=1) + session.add(cart_item) + + await session.commit() + return Response(status_code=HTTPStatus.OK) + +@app.websocket("/chat/{chat_name}") +async def chat_endpoint(websocket: WebSocket, chat_name: str): # pragma: no cover + await websocket.accept() + + username = generate_username() + + if chat_name not in chat_rooms: + chat_rooms[chat_name] = [] + + chat_rooms[chat_name].append((websocket, username)) + + try: + while True: + message = await websocket.receive_text() + formatted_message = f"{username} :: {message}" + + for ws, user in chat_rooms[chat_name]: + try: + await ws.send_text(formatted_message) + except: + pass + + except WebSocketDisconnect: + chat_rooms[chat_name] = [ + (ws, user) for ws, user in chat_rooms[chat_name] + if ws != websocket + ] + + if not chat_rooms[chat_name]: + del chat_rooms[chat_name] \ No newline at end of file diff --git a/hw2/hw/test_homework2_all.py b/hw2/hw/test_homework2_all.py new file mode 100644 index 00000000..8a7d3872 --- /dev/null +++ b/hw2/hw/test_homework2_all.py @@ -0,0 +1,605 @@ +""" +Fixed comprehensive tests with proper async support +""" + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from http import HTTPStatus +from typing import Any +from uuid import uuid4 +from faker import Faker + +faker = Faker() + + +# ============================================================================ +# FIXTURES +# ============================================================================ + +@pytest_asyncio.fixture +async def existing_empty_cart_id(client: AsyncClient) -> int: + """Create an empty cart for testing""" + response = await client.post("/cart") + return response.json()["id"] + + +@pytest_asyncio.fixture +async def existing_items(client: AsyncClient) -> list[int]: + """Create test items for testing""" + items = [ + { + "name": f"Test Item {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + item_ids = [] + for item in items: + response = await client.post("/item", json=item) + item_ids.append(response.json()["id"]) + return item_ids + + +@pytest_asyncio.fixture +async def existing_not_empty_carts(client: AsyncClient, existing_items: list[int]) -> list[int]: + """Create carts with items for testing""" + carts = [] + for i in range(20): + response = await client.post("/cart") + cart_id = response.json()["id"] + + for item_id in faker.random_elements(existing_items, unique=False, length=i): + await client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + return carts + + +@pytest_asyncio.fixture +async def existing_not_empty_cart_id( + client: AsyncClient, + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + """Create a cart with items for testing""" + for item_id in faker.random_elements(existing_items, unique=False, length=3): + await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + return existing_empty_cart_id + + +@pytest_asyncio.fixture +async def existing_item(client: AsyncClient) -> dict[str, Any]: + """Create a single test item""" + response = await client.post( + "/item", + json={ + "name": f"Test Item {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ) + return response.json() + + +@pytest_asyncio.fixture +async def deleted_item(client: AsyncClient, existing_item: dict[str, Any]) -> dict[str, Any]: + """Create a deleted item for testing""" + item_id = existing_item["id"] + await client.delete(f"/item/{item_id}") + existing_item["deleted"] = True + return existing_item + + +# ============================================================================ +# HEALTH & METRICS TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_health_endpoint(client: AsyncClient): + """Test health check endpoint""" + response = await client.get("/health") + assert response.status_code == HTTPStatus.OK + assert response.json() == {"status": "healthy"} + + +@pytest.mark.asyncio +async def test_metrics_endpoint(client: AsyncClient): + """Test Prometheus metrics endpoint""" + response = await client.get("/metrics") + assert response.status_code == HTTPStatus.OK + assert "http_requests_total" in response.text + + +# ============================================================================ +# CART TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_post_cart(client: AsyncClient): + """Test cart creation""" + response = await 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_empty(client: AsyncClient, existing_empty_cart_id: int): + """Test getting empty cart by ID""" + response = await 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 + + +@pytest.mark.asyncio +async def test_get_cart_not_empty(client: AsyncClient, existing_not_empty_cart_id: int): + """Test getting non-empty cart by ID""" + response = await 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 + + price = 0 + for item in response_json["items"]: + item_id = item["id"] + item_response = await client.get(f"/item/{item_id}") + item_price = item_response.json()["price"] + price += item_price * item["quantity"] + assert response_json["price"] == pytest.approx(price, 1e-8) + + +@pytest.mark.asyncio +async def test_get_cart_not_found(client: AsyncClient): + """Test getting non-existent cart""" + response = await client.get("/cart/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +@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), + ], +) +async def test_get_cart_list(client: AsyncClient, query: dict[str, Any], status_code: int): + """Test listing carts with various filters""" + response = await client.get("/cart", params=query) + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + assert isinstance(data, list) + + +@pytest.mark.asyncio +async def test_add_item_to_cart( + client: AsyncClient, + existing_empty_cart_id: int, + existing_items: list[int] +): + """Test adding item to cart""" + item_id = existing_items[0] + response = await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + assert response.status_code == HTTPStatus.OK + + cart_response = await client.get(f"/cart/{existing_empty_cart_id}") + cart = cart_response.json() + assert len(cart["items"]) == 1 + assert cart["items"][0]["id"] == item_id + assert cart["items"][0]["quantity"] == 1 + + +@pytest.mark.asyncio +async def test_add_item_to_cart_increment_quantity( + client: AsyncClient, + existing_empty_cart_id: int, + existing_items: list[int] +): + """Test that adding same item increments quantity""" + item_id = existing_items[0] + + await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + cart_response = await client.get(f"/cart/{existing_empty_cart_id}") + cart = cart_response.json() + assert len(cart["items"]) == 1 + assert cart["items"][0]["quantity"] == 2 + + +@pytest.mark.asyncio +async def test_add_item_to_nonexistent_cart(client: AsyncClient, existing_items: list[int]): + """Test adding item to non-existent cart""" + response = await client.post(f"/cart/999999/add/{existing_items[0]}") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_add_nonexistent_item_to_cart(client: AsyncClient, existing_empty_cart_id: int): + """Test adding non-existent item to cart""" + response = await client.post(f"/cart/{existing_empty_cart_id}/add/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +# ============================================================================ +# ITEM TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_post_item(client: AsyncClient): + """Test item creation""" + item = {"name": "test item", "price": 9.99} + response = await client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + assert data["deleted"] is False + + +@pytest.mark.asyncio +async def test_post_item_invalid_price(client: AsyncClient): + """Test item creation with invalid price""" + item = {"name": "test item", "price": -9.99} + response = await client.post("/item", json=item) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_post_item_zero_price(client: AsyncClient): + """Test item creation with zero price""" + item = {"name": "test item", "price": 0} + response = await client.post("/item", json=item) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_get_item(client: AsyncClient, existing_item: dict[str, Any]): + """Test getting item by ID""" + item_id = existing_item["id"] + response = await client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.asyncio +async def test_get_item_not_found(client: AsyncClient): + """Test getting non-existent item""" + response = await client.get("/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_get_deleted_item_not_found(client: AsyncClient, deleted_item: dict[str, Any]): + """Test that deleted items return 404""" + item_id = deleted_item["id"] + response = await client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"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), + ], +) +async def test_get_item_list(client: AsyncClient, query: dict[str, Any], status_code: int): + """Test listing items with various filters""" + response = await client.get("/item", params=query) + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + assert isinstance(data, list) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +async def test_put_item( + client: AsyncClient, + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +): + """Test full item update""" + item_id = existing_item["id"] + response = await 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.asyncio +async def test_put_item_not_found(client: AsyncClient): + """Test updating non-existent item""" + response = await client.put("/item/999999", json={"name": "test", "price": 9.99}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_patch_deleted_item_empty(client: AsyncClient, deleted_item: dict[str, Any]): + """Test patching deleted item with empty body""" + item_id = deleted_item["id"] + response = await client.patch(f"/item/{item_id}", json={}) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + +@pytest.mark.asyncio +async def test_patch_deleted_item_price(client: AsyncClient, deleted_item: dict[str, Any]): + """Test patching deleted item price""" + item_id = deleted_item["id"] + response = await client.patch(f"/item/{item_id}", json={"price": 9.99}) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + +@pytest.mark.asyncio +async def test_patch_deleted_item_full(client: AsyncClient, deleted_item: dict[str, Any]): + """Test patching deleted item with full body""" + item_id = deleted_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name", "price": 9.99}) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + +@pytest.mark.asyncio +async def test_patch_existing_item_empty(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item with empty body""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={}) + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + patched_item = get_response.json() + assert patched_item == response.json() + + +@pytest.mark.asyncio +async def test_patch_existing_item_price(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item price""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"price": 9.99}) + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + patched_item = get_response.json() + assert patched_item == response.json() + assert patched_item["price"] == 9.99 + + +@pytest.mark.asyncio +async def test_patch_existing_item_name(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item name""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name"}) + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + patched_item = get_response.json() + assert patched_item == response.json() + assert patched_item["name"] == "new name" + + +@pytest.mark.asyncio +async def test_patch_existing_item_full(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item with full body""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name", "price": 9.99}) + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + patched_item = get_response.json() + assert patched_item == response.json() + + +@pytest.mark.asyncio +async def test_patch_existing_item_extra_field(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item with extra field""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name", "price": 9.99, "odd": "value"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_patch_existing_item_deleted_field(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item with deleted field""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name", "price": 9.99, "deleted": True}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_patch_item_not_found(client: AsyncClient): + """Test patching non-existent item""" + response = await client.patch("/item/999999", json={"price": 9.99}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_delete_item(client: AsyncClient, existing_item: dict[str, Any]): + """Test item deletion (soft delete)""" + item_id = existing_item["id"] + + response = await client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + assert get_response.status_code == HTTPStatus.NOT_FOUND + + response = await client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +async def test_delete_item_not_found(client: AsyncClient): + """Test deleting non-existent item""" + response = await client.delete("/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_deleted_item_in_cart( + client: AsyncClient, + existing_empty_cart_id: int, + existing_item: dict[str, Any] +): + """Test that deleted items show as unavailable in cart""" + item_id = existing_item["id"] + + await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + await client.delete(f"/item/{item_id}") + + cart_response = await client.get(f"/cart/{existing_empty_cart_id}") + cart = cart_response.json() + assert len(cart["items"]) == 1 + assert cart["items"][0]["available"] is False + assert cart["price"] == 0.0 + + +# ============================================================================ +# WEBSOCKET TESTS +# ============================================================================ + +@pytest.mark.asyncio +@pytest.mark.skip(reason="WebSocket tests require a running server and cannot be tested via AsyncClient") +async def test_websocket_chat(): + """Test websocket chat basic connection - skipped in unit tests""" + pass + + +# ============================================================================ +# INTEGRATION TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_complete_shopping_flow(client: AsyncClient): + """Test a complete shopping flow""" + item1_response = await client.post("/item", json={"name": "Product 1", "price": 10.0}) + item1 = item1_response.json() + + item2_response = await client.post("/item", json={"name": "Product 2", "price": 20.0}) + item2 = item2_response.json() + + cart_response = await client.post("/cart") + cart_id = cart_response.json()["id"] + + await client.post(f"/cart/{cart_id}/add/{item1['id']}") + await client.post(f"/cart/{cart_id}/add/{item1['id']}") + await client.post(f"/cart/{cart_id}/add/{item2['id']}") + + cart_get_response = await client.get(f"/cart/{cart_id}") + cart = cart_get_response.json() + assert len(cart["items"]) == 2 + assert cart["items"][0]["quantity"] == 2 + assert cart["items"][1]["quantity"] == 1 + assert cart["price"] == pytest.approx(10.0 * 2 + 20.0) + + +@pytest.mark.asyncio +async def test_item_lifecycle(client: AsyncClient): + """Test complete item lifecycle""" + create_response = await client.post("/item", json={"name": "Lifecycle Test", "price": 50.0}) + item = create_response.json() + item_id = item["id"] + + get_response = await client.get(f"/item/{item_id}") + fetched = get_response.json() + assert fetched["name"] == "Lifecycle Test" + assert fetched["price"] == 50.0 + + put_response = await client.put(f"/item/{item_id}", json={"name": "Updated Name", "price": 60.0}) + updated = put_response.json() + assert updated["name"] == "Updated Name" + assert updated["price"] == 60.0 + + patch_response = await client.patch(f"/item/{item_id}", json={"price": 70.0}) + patched = patch_response.json() + assert patched["name"] == "Updated Name" + assert patched["price"] == 70.0 + + await client.delete(f"/item/{item_id}") + final_get_response = await client.get(f"/item/{item_id}") + assert final_get_response.status_code == HTTPStatus.NOT_FOUND + + +# ============================================================================ +# EDGE CASES +# ============================================================================ + +@pytest.mark.asyncio +async def test_empty_item_name(client: AsyncClient): + """Test creating item with empty name""" + response = await client.post("/item", json={"name": "", "price": 10.0}) + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.asyncio +async def test_very_long_item_name(client: AsyncClient): + """Test creating item with very long name (should succeed up to 255 chars)""" + long_name = "A" * 255 # Max length for VARCHAR(255) + response = await client.post("/item", json={"name": long_name, "price": 10.0}) + assert response.status_code == HTTPStatus.CREATED + assert response.json()["name"] == long_name + + +@pytest.mark.asyncio +async def test_very_large_price(client: AsyncClient): + """Test creating item with very large price""" + response = await client.post("/item", json={"name": "Expensive", "price": 999999999.99}) + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.asyncio +async def test_pagination_beyond_available_items(client: AsyncClient): + """Test pagination with offset beyond available items""" + response = await client.get("/item", params={"offset": 10000, "limit": 10}) + assert response.status_code == HTTPStatus.OK + assert response.json() == [] + + +@pytest.mark.asyncio +async def test_filter_with_impossible_conditions(client: AsyncClient): + """Test filtering with min_price > max_price""" + response = await client.get("/item", params={"min_price": 100, "max_price": 50}) + assert response.status_code == HTTPStatus.OK + assert response.json() == [] \ No newline at end of file diff --git a/hw2/hw/transaction_isolation_demo.py b/hw2/hw/transaction_isolation_demo.py new file mode 100644 index 00000000..9f599827 --- /dev/null +++ b/hw2/hw/transaction_isolation_demo.py @@ -0,0 +1,339 @@ +import asyncio +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy import text + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db") + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def setup_test_data(): + async with async_session_maker() as session: + await session.execute(text("DELETE FROM items WHERE name LIKE 'Test%'")) + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('Test Item 1', 100.0, false)" + )) + await session.commit() + print(" Test data created\n") + + +# 1. DIRTY READ + + +async def demo_dirty_read_uncommitted(): + print("=" * 70) + print("1. DIRTY READ with READ UNCOMMITTED") + print("=" * 70) + print(" PostgreSQL не поддерживает READ UNCOMMITTED") + print(" Автоматически использует READ COMMITTED\n") + + +async def demo_no_dirty_read_committed(): + print("=" * 70) + print("2. NO DIRTY READ with READ COMMITTED") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + print("Transaction 1: Started") + + await session.execute(text( + "UPDATE items SET price = 999.0 WHERE name = 'Test Item 1'" + )) + print("Transaction 1: Updated price to 999.0 (not committed)") + + await asyncio.sleep(2) + + await session.rollback() + print("Transaction 1: Rolled back") + + async def transaction2(): + await asyncio.sleep(1) + + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + print("Transaction 2: Started") + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price = result.scalar() + print(f"Transaction 2: Read price = {price}") + print("✅ Transaction 2 sees COMMITTED value (100.0), not uncommitted (999.0)") + + await session.commit() + + await asyncio.gather(transaction1(), transaction2()) + print() + + + +# 2. NON-REPEATABLE READ + +async def demo_non_repeatable_read_committed(): + """ + READ COMMITTED: Показывает Non-Repeatable Read + """ + print("=" * 70) + print("3. NON-REPEATABLE READ with READ COMMITTED") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + print("Transaction 1: Started") + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price1 = result.scalar() + print(f"Transaction 1: First read, price = {price1}") + + await asyncio.sleep(2) + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price2 = result.scalar() + print(f"Transaction 1: Second read, price = {price2}") + + if price1 != price2: + print("❌ NON-REPEATABLE READ detected!") + print(f" First read: {price1}, Second read: {price2}") + + await session.commit() + + async def transaction2(): + await asyncio.sleep(1) + + async with async_session_maker() as session: + print("Transaction 2: Started") + + await session.execute(text( + "UPDATE items SET price = 200.0 WHERE name = 'Test Item 1'" + )) + print("Transaction 2: Updated price to 200.0") + + await session.commit() + print("Transaction 2: Committed") + + await asyncio.gather(transaction1(), transaction2()) + + + async with async_session_maker() as session: + await session.execute(text( + "UPDATE items SET price = 100.0 WHERE name = 'Test Item 1'" + )) + await session.commit() + print() + + +async def demo_no_non_repeatable_read_repeatable(): + """ + REPEATABLE READ: Нет Non-Repeatable Read + """ + print("=" * 70) + print("4. NO NON-REPEATABLE READ with REPEATABLE READ") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + print("Transaction 1: Started with REPEATABLE READ") + + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price1 = result.scalar() + print(f"Transaction 1: First read, price = {price1}") + + await asyncio.sleep(2) + + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price2 = result.scalar() + print(f"Transaction 1: Second read, price = {price2}") + + if price1 == price2: + print("✅ NO NON-REPEATABLE READ!") + print(f" Both reads return: {price1}") + + await session.commit() + + async def transaction2(): + await asyncio.sleep(1) + + async with async_session_maker() as session: + print("Transaction 2: Started") + + await session.execute(text( + "UPDATE items SET price = 300.0 WHERE name = 'Test Item 1'" + )) + print("Transaction 2: Updated price to 300.0") + + await session.commit() + print("Transaction 2: Committed") + + await asyncio.gather(transaction1(), transaction2()) + + + async with async_session_maker() as session: + await session.execute(text( + "UPDATE items SET price = 100.0 WHERE name = 'Test Item 1'" + )) + await session.commit() + print() + + + +# 3. PHANTOM READ + + +async def demo_phantom_read_repeatable(): + """ + REPEATABLE READ: Показывает Phantom Read + """ + print("=" * 70) + print("5. PHANTOM READ with REPEATABLE READ") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + print("Transaction 1: Started with REPEATABLE READ") + + result = await session.execute(text( + "SELECT COUNT(*) FROM items WHERE name LIKE 'Test%'" + )) + count1 = result.scalar() + print(f"Transaction 1: First count = {count1}") + + await asyncio.sleep(2) + + result = await session.execute(text( + "SELECT COUNT(*) FROM items WHERE name LIKE 'Test%'" + )) + count2 = result.scalar() + print(f"Transaction 1: Second count = {count2}") + + if count1 == count2: + print("✅ NO PHANTOM READ in PostgreSQL!") + print(" PostgreSQL's REPEATABLE READ предотвращает Phantom Reads") + else: + print("❌ PHANTOM READ detected!") + + await session.commit() + + async def transaction2(): + await asyncio.sleep(1) + + async with async_session_maker() as session: + print("Transaction 2: Started") + + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('Test Item 2', 150.0, false)" + )) + print("Transaction 2: Inserted new item") + + await session.commit() + print("Transaction 2: Committed") + + await asyncio.gather(transaction1(), transaction2()) + + async with async_session_maker() as session: + await session.execute(text("DELETE FROM items WHERE name = 'Test Item 2'")) + await session.commit() + print() + + +async def demo_no_phantom_read_serializable(): + """ + SERIALIZABLE: Нет Phantom Read + """ + print("=" * 70) + print("6. NO PHANTOM READ with SERIALIZABLE") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + print("Transaction 1: Started with SERIALIZABLE") + + result = await session.execute(text( + "SELECT COUNT(*) FROM items WHERE name LIKE 'Test%'" + )) + count1 = result.scalar() + print(f"Transaction 1: First count = {count1}") + + await asyncio.sleep(2) + + result = await session.execute(text( + "SELECT COUNT(*) FROM items WHERE name LIKE 'Test%'" + )) + count2 = result.scalar() + print(f"Transaction 1: Second count = {count2}") + + if count1 == count2: + print("✅ NO PHANTOM READ with SERIALIZABLE!") + + await session.commit() + print("Transaction 1: Committed") + + async def transaction2(): + await asyncio.sleep(1) + + try: + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + print("Transaction 2: Started with SERIALIZABLE") + + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('Test Item 3', 150.0, false)" + )) + print("Transaction 2: Trying to insert new item...") + + await session.commit() + print("Transaction 2: Committed") + except Exception as e: + print(f"Transaction 2: ❌ Serialization conflict! {e}") + print(" PostgreSQL предотвратил конфликт сериализации") + + await asyncio.gather(transaction1(), transaction2()) + + async with async_session_maker() as session: + await session.execute(text("DELETE FROM items WHERE name = 'Test Item 3'")) + await session.commit() + print() + + +async def main(): + print("\n" + "=" * 70) + print("=" * 70 + "\n") + + await setup_test_data() + + await demo_dirty_read_uncommitted() + await demo_no_dirty_read_committed() + await demo_non_repeatable_read_committed() + await demo_no_non_repeatable_read_repeatable() + await demo_phantom_read_repeatable() + await demo_no_phantom_read_serializable() + + print("=" * 70) + print("РЕЗЮМЕ:") + print("=" * 70) + print(" READ COMMITTED: Предотвращает Dirty Reads") + print(" REPEATABLE READ: Предотвращает Dirty + Non-Repeatable Reads") + print(" (В PostgreSQL также предотвращает Phantom Reads!)") + print(" SERIALIZABLE: Полная изоляция, конфликты сериализации") + print("=" * 70 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/hw2/test_chat.html b/hw2/test_chat.html new file mode 100644 index 00000000..8aeab5db --- /dev/null +++ b/hw2/test_chat.html @@ -0,0 +1,35 @@ + + +
+