diff --git a/.gitignore b/.gitignore index aa209b4..12e5f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ build/ **/.python_packages/ **/.pytest_cache/ **/.ruff_cache/ +.history/ \ No newline at end of file diff --git a/.history/.env_20250808194630 b/.history/.env_20250808194630 deleted file mode 100644 index 3c1ddb8..0000000 --- a/.history/.env_20250808194630 +++ /dev/null @@ -1,19 +0,0 @@ -# ---------- PostgreSQL ---------- -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres -POSTGRES_DB=postgres -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 - -# ---------- Service Ports ---------- -# Можно переопределять порты хоста (левая часть маппинга ports) -AUTH_PORT=8001 -AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/auth_db -PROFILES_PORT=8002 -PROFILES_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/profiles_db -MATCH_PORT=8003 -MATCH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/match_db -CHAT_PORT=8004 -CHAT_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/chat_db -PAYMENTS_PORT=8005 -PAYMENTS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/payments_db diff --git a/.history/.env_20250808200305 b/.history/.env_20250808200305 deleted file mode 100644 index e180c84..0000000 --- a/.history/.env_20250808200305 +++ /dev/null @@ -1,24 +0,0 @@ -# ---------- PostgreSQL ---------- -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres -POSTGRES_DB=postgres -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 - -# ---------- Service Ports ---------- -# Можно переопределять порты хоста (левая часть маппинга ports) -AUTH_PORT=8001 -AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/auth_db -PROFILES_PORT=8002 -PROFILES_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/profiles_db -MATCH_PORT=8003 -MATCH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/match_db -CHAT_PORT=8004 -CHAT_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/chat_db -PAYMENTS_PORT=8005 -PAYMENTS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/payments_db -# ---------- JWT / Auth ---------- -JWT_SECRET=devsecret_change_me -JWT_ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRES_MIN=15 -REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days \ No newline at end of file diff --git a/.history/.env_20250808200329 b/.history/.env_20250808200329 deleted file mode 100644 index c98c5cf..0000000 --- a/.history/.env_20250808200329 +++ /dev/null @@ -1,24 +0,0 @@ -# ---------- PostgreSQL ---------- -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres -POSTGRES_DB=postgres -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 - -# ---------- Service Ports ---------- -# Можно переопределять порты хоста (левая часть маппинга ports) -AUTH_PORT=8001 -AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/auth_db -PROFILES_PORT=8002 -PROFILES_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/profiles_db -MATCH_PORT=8003 -MATCH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/match_db -CHAT_PORT=8004 -CHAT_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/chat_db -PAYMENTS_PORT=8005 -PAYMENTS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/payments_db -# ---------- JWT / Auth ---------- -JWT_SECRET=NJ6bF1H506VbPNS9TBsRTCZU14laJVTHCevT1FhWvyiNjC39V8 -JWT_ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRES_MIN=15 -REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days \ No newline at end of file diff --git a/.history/docker-compose_20250808194542.yml b/.history/docker-compose_20250808194542.yml deleted file mode 100644 index 63aa1a6..0000000 --- a/.history/docker-compose_20250808194542.yml +++ /dev/null @@ -1,105 +0,0 @@ -version: "3.9" -services: - postgres: - image: postgres:16 - container_name: postgres - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - ./infra/db/init:/docker-entrypoint-initdb.d - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"] - interval: 5s - timeout: 5s - retries: 40 - - gateway: - image: nginx:alpine - container_name: gateway - depends_on: - - auth - - profiles - - match - - chat - - payments - ports: - - "8080:80" - volumes: - - ./infra/gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro - - auth: - build: - context: ./services/auth - container_name: marriage_auth - env_file: - - .env - environment: - DATABASE_URL: ${AUTH_DATABASE_URL} - depends_on: - - postgres - ports: - - "${AUTH_PORT:-8001}:8000" - command: ./docker-entrypoint.sh - - profiles: - build: - context: ./services/profiles - container_name: marriage_profiles - env_file: - - .env - environment: - DATABASE_URL: ${PROFILES_DATABASE_URL} - depends_on: - - postgres - ports: - - "${PROFILES_PORT:-8002}:8000" - command: ./docker-entrypoint.sh - - match: - build: - context: ./services/match - container_name: marriage_match - env_file: - - .env - environment: - DATABASE_URL: ${MATCH_DATABASE_URL} - depends_on: - - postgres - ports: - - "${MATCH_PORT:-8003}:8000" - command: ./docker-entrypoint.sh - - chat: - build: - context: ./services/chat - container_name: marriage_chat - env_file: - - .env - environment: - DATABASE_URL: ${CHAT_DATABASE_URL} - depends_on: - - postgres - ports: - - "${CHAT_PORT:-8004}:8000" - command: ./docker-entrypoint.sh - - payments: - build: - context: ./services/payments - container_name: marriage_payments - env_file: - - .env - environment: - DATABASE_URL: ${PAYMENTS_DATABASE_URL} - depends_on: - - postgres - ports: - - "${PAYMENTS_PORT:-8005}:8000" - command: ./docker-entrypoint.sh - -volumes: - pgdata: diff --git a/.history/docker-compose_20250808201541.yml b/.history/docker-compose_20250808201541.yml deleted file mode 100644 index eb86f45..0000000 --- a/.history/docker-compose_20250808201541.yml +++ /dev/null @@ -1,105 +0,0 @@ - -services: - postgres: - image: postgres:16 - container_name: postgres - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - ./infra/db/init:/docker-entrypoint-initdb.d - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"] - interval: 5s - timeout: 5s - retries: 40 - - gateway: - image: nginx:alpine - container_name: gateway - depends_on: - - auth - - profiles - - match - - chat - - payments - ports: - - "8080:80" - volumes: - - ./infra/gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro - - auth: - build: - context: ./services/auth - container_name: marriage_auth - env_file: - - .env - environment: - DATABASE_URL: ${AUTH_DATABASE_URL} - depends_on: - - postgres - ports: - - "${AUTH_PORT:-8001}:8000" - command: ./docker-entrypoint.sh - - profiles: - build: - context: ./services/profiles - container_name: marriage_profiles - env_file: - - .env - environment: - DATABASE_URL: ${PROFILES_DATABASE_URL} - depends_on: - - postgres - ports: - - "${PROFILES_PORT:-8002}:8000" - command: ./docker-entrypoint.sh - - match: - build: - context: ./services/match - container_name: marriage_match - env_file: - - .env - environment: - DATABASE_URL: ${MATCH_DATABASE_URL} - depends_on: - - postgres - ports: - - "${MATCH_PORT:-8003}:8000" - command: ./docker-entrypoint.sh - - chat: - build: - context: ./services/chat - container_name: marriage_chat - env_file: - - .env - environment: - DATABASE_URL: ${CHAT_DATABASE_URL} - depends_on: - - postgres - ports: - - "${CHAT_PORT:-8004}:8000" - command: ./docker-entrypoint.sh - - payments: - build: - context: ./services/payments - container_name: marriage_payments - env_file: - - .env - environment: - DATABASE_URL: ${PAYMENTS_DATABASE_URL} - depends_on: - - postgres - ports: - - "${PAYMENTS_PORT:-8005}:8000" - command: ./docker-entrypoint.sh - -volumes: - pgdata: diff --git a/.history/fix_alembic_20250808201237.sh b/.history/fix_alembic_20250808201237.sh deleted file mode 100644 index e69de29..0000000 diff --git a/.history/fix_alembic_20250808201241.sh b/.history/fix_alembic_20250808201241.sh deleted file mode 100644 index 7abd070..0000000 --- a/.history/fix_alembic_20250808201241.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SERVICES=(auth profiles match chat payments) - -# Добавим импорт моделей в env.py, если его нет -for s in "${SERVICES[@]}"; do - ENV="services/$s/alembic/env.py" - if ! grep -q "from app import models" "$ENV"; then - # вставим строку сразу после импорта Base - awk ' - /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next} - {print} - ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV" - echo "[fix] added 'from app import models' to $ENV" - fi -done - -# Создадим шаблон mako для Alembic в каждом сервисе (если отсутствует) -for s in "${SERVICES[@]}"; do - TPL="services/$s/alembic/script.py.mako" - if [[ ! -f "$TPL" ]]; then - mkdir -p "$(dirname "$TPL")" - cat > "$TPL" <<'MAKO' -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} -""" - -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = '${up_revision}' -down_revision: Union[str, None] = ${down_revision | repr} -branch_labels: Union[str, Sequence[str], None] = ${branch_labels | repr} -depends_on: Union[str, Sequence[str], None] = ${depends_on | repr} - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass -MAKO - echo "[fix] created $TPL" - fi -done - -echo "✅ Alembic templates fixed." -echo "Совет: предупреждение docker-compose про 'version' можно игнорировать или удалить строку 'version: \"3.9\"' из docker-compose.yml." diff --git a/.history/logs/api_20250808212556.log b/.history/logs/api_20250808212556.log deleted file mode 100644 index 7078563..0000000 --- a/.history/logs/api_20250808212556.log +++ /dev/null @@ -1,486 +0,0 @@ -2025-08-08 21:23:00 | INFO | api_e2e | === API E2E START === -2025-08-08 21:23:00 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev -2025-08-08 21:23:00 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health -2025-08-08 21:23:00 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:00 | DEBUG | api_e2e | ← 200 in 11 ms | body={"status":"ok","service":"auth"} -2025-08-08 21:23:00 | INFO | api_e2e | gateway/auth is healthy -2025-08-08 21:23:00 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health -2025-08-08 21:23:00 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:03 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:03 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:04 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:07 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:07 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:08 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:11 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:11 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:16 | DEBUG | api_e2e | ← -1 in 3096 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:16 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:17 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:20 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:20 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:21 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:24 | DEBUG | api_e2e | ← -1 in 3094 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:24 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:25 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:28 | DEBUG | api_e2e | ← -1 in 3096 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:28 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:29 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:32 | DEBUG | api_e2e | ← -1 in 3094 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:32 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:33 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:36 | DEBUG | api_e2e | ← -1 in 3096 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:36 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:37 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:40 | DEBUG | api_e2e | ← -1 in 3096 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:40 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:41 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:23:44 | DEBUG | api_e2e | ← -1 in 3094 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:44 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:23:45 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:00 | INFO | api_e2e | === API E2E START === -2025-08-08 21:24:00 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev -2025-08-08 21:24:00 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health -2025-08-08 21:24:00 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:00 | DEBUG | api_e2e | ← 200 in 3 ms | body={"status":"ok","service":"auth"} -2025-08-08 21:24:00 | INFO | api_e2e | gateway/auth is healthy -2025-08-08 21:24:00 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health -2025-08-08 21:24:00 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:03 | DEBUG | api_e2e | ← -1 in 3075 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:03 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:04 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:07 | DEBUG | api_e2e | ← -1 in 3096 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:07 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:08 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:11 | DEBUG | api_e2e | ← -1 in 3096 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:11 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:15 | DEBUG | api_e2e | ← -1 in 3094 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:15 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:16 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:20 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:20 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:21 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:24 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:24 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:25 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:28 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:28 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:29 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:32 | DEBUG | api_e2e | ← -1 in 3096 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:32 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:33 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:36 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:36 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:37 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:40 | DEBUG | api_e2e | ← -1 in 3095 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:40 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:41 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:44 | DEBUG | api_e2e | ← -1 in 3094 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:44 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:45 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:46 | DEBUG | api_e2e | ← -1 in 1047 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:46 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:47 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:47 | DEBUG | api_e2e | ← -1 in 1 ms | body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:47 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body= -502 Bad Gateway - -

502 Bad Gateway

-
nginx/1.29.0
- - - -2025-08-08 21:24:48 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:24:58 | ERROR | api_e2e | profiles/health FAILED transport error: HTTPConnectionPool(host='localhost', port=8080): Read timed out. (read timeout=10.0) (10010 ms) -2025-08-08 21:24:59 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:25:09 | ERROR | api_e2e | profiles/health FAILED transport error: HTTPConnectionPool(host='localhost', port=8080): Read timed out. (read timeout=10.0) (10010 ms) -2025-08-08 21:25:56 | INFO | api_e2e | === API E2E START === -2025-08-08 21:25:56 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev -2025-08-08 21:25:56 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 7 ms | body={"status":"ok","service":"auth"} -2025-08-08 21:25:56 | INFO | api_e2e | gateway/auth is healthy -2025-08-08 21:25:56 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"profiles"} -2025-08-08 21:25:56 | INFO | api_e2e | profiles is healthy -2025-08-08 21:25:56 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 5 ms | body={"status":"ok","service":"match"} -2025-08-08 21:25:56 | INFO | api_e2e | match is healthy -2025-08-08 21:25:56 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 6 ms | body={"status":"ok","service":"chat"} -2025-08-08 21:25:56 | INFO | api_e2e | chat is healthy -2025-08-08 21:25:56 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 6 ms | body={"status":"ok","service":"payments"} -2025-08-08 21:25:56 | INFO | api_e2e | payments is healthy -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***'} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 32 ms | body={"detail":"Invalid credentials"} -2025-08-08 21:25:56 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"} -2025-08-08 21:25:56 | INFO | api_e2e | Login failed for admin+1754655956.xaji0y@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Michael Cunningham', 'role': 'ADMIN'} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 257 ms | body=Internal Server Error -2025-08-08 21:25:56 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error -2025-08-08 21:25:56 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***'} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 214 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNzJkNjIwOS0yOGJlLTQyYzAtYmFjMy0yNzBlMWZkNjNmNmMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTU5NTYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU0NjU2ODU2fQ.FUgvIMnAsD-FWP8yjFy0IJS6NKLyAseVyuT6gS2uFLE","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNzJkNjIwOS0yOGJlLTQyYzAtYmFjMy0yNzBlMWZkNjNmNmMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTU5NTYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTc1NzI0Nzk1Nn0.XOx0ehDA4wjIfi9nYdI9iVPsLHS8mXV4L0Be8PvcK5g","token_type":"bearer"} -2025-08-08 21:25:56 | INFO | api_e2e | Registered+Login OK: admin+1754655956.xaji0y@agency.dev -> 172d6209-28be-42c0-bac3-270e1fd63f6c -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***'} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 3 ms | body={"detail":"Invalid credentials"} -2025-08-08 21:25:56 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"} -2025-08-08 21:25:56 | INFO | api_e2e | Login failed for user1+1754655956.6dpbhs@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***', 'full_name': 'Charlotte Porter', 'role': 'CLIENT'} -2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 226 ms | body=Internal Server Error -2025-08-08 21:25:56 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error -2025-08-08 21:25:56 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway -2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***'} -2025-08-08 21:25:57 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOWFhMWMzZS0zN2U4LTRmNWEtODcxNy1kN2FhNDUxMDU0MzUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTU5NTYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1Njg1N30.eeKSArd-im1KjEDUZxzus4e3b3yLuhqMxp065gPZPXE","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOWFhMWMzZS0zN2U4LTRmNWEtODcxNy1kN2FhNDUxMDU0MzUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTU5NTYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDc5NTd9.O7k9PubE1j3BHDw-IgbmXIIfrltA-viHei70j0p92Js","token_type":"bearer"} -2025-08-08 21:25:57 | INFO | api_e2e | Registered+Login OK: user1+1754655956.6dpbhs@agency.dev -> 09aa1c3e-37e8-4f5a-8717-d7aa45105435 -2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***'} -2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 3 ms | body={"detail":"Invalid credentials"} -2025-08-08 21:25:57 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"} -2025-08-08 21:25:57 | INFO | api_e2e | Login failed for user2+1754655957.ahxthv@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register -2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***', 'full_name': 'Denise Hess', 'role': 'CLIENT'} -2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 225 ms | body=Internal Server Error -2025-08-08 21:25:57 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error -2025-08-08 21:25:57 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway -2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***'} -2025-08-08 21:25:57 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwNWRkNjcyMi1hNDAzLTQzYzMtYWViZC0wNjRlOWQ4NTQ1ZDIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTU5NTcuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1Njg1N30.dqvCxPqUX8zhL12dzl1vbstTJgEvMHD43Gppj2Jzllk","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwNWRkNjcyMi1hNDAzLTQzYzMtYWViZC0wNjRlOWQ4NTQ1ZDIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTU5NTcuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDc5NTd9.AIFXUWyrp_BEmmJWmWHnhGOp_b0IZIZoue1PtzhxPCw","token_type":"bearer"} -2025-08-08 21:25:57 | INFO | api_e2e | Registered+Login OK: user2+1754655957.ahxthv@agency.dev -> 05dd6722-a403-43c3-aebd-064e9d8545d2 -2025-08-08 21:25:57 | INFO | api_e2e | [1/3] Ensure profile for admin+1754655956.xaji0y@agency.dev (role=ADMIN) -2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/v1/profiles/me | headers={Authorization: Bearer eyJhbGciOiJI...} | body={} -2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 2 ms | body={"detail":"Not authenticated"} -2025-08-08 21:25:57 | ERROR | api_e2e | profiles/me unexpected status -1, expected [200, 404]; body={"detail":"Not authenticated"} diff --git a/.history/logs/api_20250808212604.log b/.history/logs/api_20250808212604.log deleted file mode 100644 index e69de29..0000000 diff --git a/.history/logs/api_20250808213512.log b/.history/logs/api_20250808213512.log deleted file mode 100644 index e69de29..0000000 diff --git a/.history/logs/api_20250808213904.log b/.history/logs/api_20250808213904.log deleted file mode 100644 index e69de29..0000000 diff --git a/.history/logs/api_20250808213928.log b/.history/logs/api_20250808213928.log deleted file mode 100644 index e69de29..0000000 diff --git a/.history/migrate_20250808200653.sh b/.history/migrate_20250808200653.sh deleted file mode 100644 index e69de29..0000000 diff --git a/.history/migrate_20250808200656.sh b/.history/migrate_20250808200656.sh deleted file mode 100644 index e548ac8..0000000 --- a/.history/migrate_20250808200656.sh +++ /dev/null @@ -1,6 +0,0 @@ -for s in auth profiles match chat payments; do - f="services/$s/alembic/env.py" - # добавим импорт пакета моделей, если его нет - grep -q "from app import models" "$f" || \ - sed -i 's/from app.db.session import Base # noqa/from app.db.session import Base # noqa\nfrom app import models # noqa: F401/' "$f" -done \ No newline at end of file diff --git a/.history/migrate_20250808200715.sh b/.history/migrate_20250808200715.sh deleted file mode 100644 index e99b378..0000000 --- a/.history/migrate_20250808200715.sh +++ /dev/null @@ -1,10 +0,0 @@ -for s in auth profiles match chat payments; do - f="services/$s/alembic/env.py" - # добавим импорт пакета моделей, если его нет - grep -q "from app import models" "$f" || \ - sed -i 's/from app.db.session import Base # noqa/from app.db.session import Base # noqa\nfrom app import models # noqa: F401/' "$f" -done - -for s in auth profiles match chat payments; do - docker compose run --rm $s alembic revision --autogenerate -m "init" -done diff --git a/.history/models_20250808195719.sh b/.history/models_20250808195719.sh deleted file mode 100644 index 57c01de..0000000 --- a/.history/models_20250808195719.sh +++ /dev/null @@ -1,1560 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ------------------------------------------------------------------- -# Apply models + CRUD + API + JWT auth to the existing scaffold -# Requires: the scaffold created earlier (services/* exist) -# ------------------------------------------------------------------- - -ROOT_DIR="." -SERVICES=(auth profiles match chat payments) - -ensure_line() { - # ensure_line - local file="$1" ; shift - local line="$*" - grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file" -} - -write_file() { - # write_file <<'EOF' ... EOF - local path="$1" - mkdir -p "$(dirname "$path")" - # The content will be provided by heredoc by the caller - cat > "$path" -} - -append_file() { - local path="$1" - mkdir -p "$(dirname "$path")" - cat >> "$path" -} - -require_file() { - local path="$1" - if [[ ! -f "$path" ]]; then - echo "ERROR: Missing $path. Run scaffold.sh first." >&2 - exit 1 - fi -} - -# Basic checks -require_file docker-compose.yml - -# ------------------------------------------------------------------- -# 1) .env.example — добавить JWT настройки (общие для всех сервисов) -# ------------------------------------------------------------------- -ENV_FILE=".env.example" -require_file "$ENV_FILE" - -ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------" -ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me" -ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256" -ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15" -ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days" - -# ------------------------------------------------------------------- -# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt] -# ------------------------------------------------------------------- -for s in "${SERVICES[@]}"; do - REQ="services/$s/requirements.txt" - require_file "$REQ" - ensure_line "$REQ" "PyJWT>=2.8" - if [[ "$s" == "auth" ]]; then - ensure_line "$REQ" "passlib[bcrypt]>=1.7" - fi -done - -# ------------------------------------------------------------------- -# 3) Общая безопасность (JWT) для всех сервисов -# В auth добавим + генерацию токенов, в остальных — верификация и RBAC -# ------------------------------------------------------------------- -for s in "${SERVICES[@]}"; do - SEC="services/$s/src/app/core/security.py" - mkdir -p "$(dirname "$SEC")" - if [[ "$s" == "auth" ]]; then - write_file "$SEC" <<'PY' -from __future__ import annotations -import os -from datetime import datetime, timedelta, timezone -from enum import Enum -from typing import Any, Callable, Optional - -import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from pydantic import BaseModel - -JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") -JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") -ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15")) -REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200")) - -class TokenType(str, Enum): - access = "access" - refresh = "refresh" - -class UserClaims(BaseModel): - sub: str - email: str - role: str - type: str - exp: int - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") - -def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str: - now = datetime.now(timezone.utc) - exp = now + timedelta(minutes=expires_minutes) - payload: dict[str, Any] = { - "sub": sub, - "email": email, - "role": role, - "type": token_type.value, - "exp": int(exp.timestamp()), - } - return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) - -def create_access_token(*, sub: str, email: str, role: str) -> str: - return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN) - -def create_refresh_token(*, sub: str, email: str, role: str) -> str: - return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN) - -def decode_token(token: str) -> UserClaims: - try: - payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - return UserClaims(**payload) - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") - except jwt.PyJWTError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - -def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: - return decode_token(token) - -def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]: - def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: - if roles and user.role not in roles: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") - return user - return dep -PY - else - write_file "$SEC" <<'PY' -from __future__ import annotations -import os -from enum import Enum -from typing import Any, Callable - -import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from pydantic import BaseModel - -JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") -JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") - -class UserClaims(BaseModel): - sub: str - email: str - role: str - type: str - exp: int - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") - -def decode_token(token: str) -> UserClaims: - try: - payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - return UserClaims(**payload) - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") - except jwt.PyJWTError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - -def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: - return decode_token(token) - -def require_roles(*roles: str): - def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: - if roles and user.role not in roles: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") - return user - return dep -PY - fi -done - -# ------------------------------------------------------------------- -# 4) AUTH service — модели, CRUD, токены, эндпоинты -# ------------------------------------------------------------------- -# models -write_file services/auth/src/app/models/user.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime -from enum import Enum - -from sqlalchemy import String, Boolean, DateTime -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.session import Base - -class Role(str, Enum): - ADMIN = "ADMIN" - MATCHMAKER = "MATCHMAKER" - CLIENT = "CLIENT" - -class User(Base): - __tablename__ = "users" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - full_name: Mapped[str | None] = mapped_column(String(255), default=None) - role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) -PY - -write_file services/auth/src/app/models/__init__.py <<'PY' -from .user import User, Role # noqa: F401 -PY - -# schemas -write_file services/auth/src/app/schemas/user.py <<'PY' -from __future__ import annotations -from typing import Optional -from pydantic import BaseModel, EmailStr, ConfigDict - -class UserBase(BaseModel): - email: EmailStr - full_name: Optional[str] = None - role: str = "CLIENT" - is_active: bool = True - -class UserCreate(BaseModel): - email: EmailStr - password: str - full_name: Optional[str] = None - role: str = "CLIENT" - -class UserUpdate(BaseModel): - full_name: Optional[str] = None - role: Optional[str] = None - is_active: Optional[bool] = None - password: Optional[str] = None - -class UserRead(BaseModel): - id: str - email: EmailStr - full_name: Optional[str] = None - role: str - is_active: bool - model_config = ConfigDict(from_attributes=True) - -class LoginRequest(BaseModel): - email: EmailStr - password: str - -class TokenPair(BaseModel): - access_token: str - refresh_token: str - token_type: str = "bearer" -PY - -# passwords -write_file services/auth/src/app/core/passwords.py <<'PY' -from passlib.context import CryptContext - -_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") - -def hash_password(p: str) -> str: - return _pwd.hash(p) - -def verify_password(p: str, hashed: str) -> bool: - return _pwd.verify(p, hashed) -PY - -# repositories -write_file services/auth/src/app/repositories/user_repository.py <<'PY' -from __future__ import annotations -from typing import Optional, Sequence -from sqlalchemy.orm import Session -from sqlalchemy import select, update, delete - -from app.models.user import User - -class UserRepository: - def __init__(self, db: Session): - self.db = db - - def get(self, user_id) -> Optional[User]: - return self.db.get(User, user_id) - - def get_by_email(self, email: str) -> Optional[User]: - stmt = select(User).where(User.email == email) - return self.db.execute(stmt).scalar_one_or_none() - - def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]: - stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc()) - return self.db.execute(stmt).scalars().all() - - def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User: - user = User(email=email, password_hash=password_hash, full_name=full_name, role=role) - self.db.add(user) - self.db.commit() - self.db.refresh(user) - return user - - def update(self, user: User, **fields) -> User: - for k, v in fields.items(): - if v is not None: - setattr(user, k, v) - self.db.add(user) - self.db.commit() - self.db.refresh(user) - return user - - def delete(self, user: User) -> None: - self.db.delete(user) - self.db.commit() -PY - -# services -write_file services/auth/src/app/services/user_service.py <<'PY' -from __future__ import annotations -from typing import Optional -from sqlalchemy.orm import Session - -from app.repositories.user_repository import UserRepository -from app.core.passwords import hash_password, verify_password -from app.models.user import User - -class UserService: - def __init__(self, db: Session): - self.repo = UserRepository(db) - - # CRUD - def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User: - if self.repo.get_by_email(email): - raise ValueError("Email already in use") - pwd_hash = hash_password(password) - return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role) - - def get_user(self, user_id) -> Optional[User]: - return self.repo.get(user_id) - - def get_by_email(self, email: str) -> Optional[User]: - return self.repo.get_by_email(email) - - def list_users(self, *, offset: int = 0, limit: int = 50): - return self.repo.list(offset=offset, limit=limit) - - def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None, - is_active: bool | None = None, password: str | None = None) -> User: - fields = {} - if full_name is not None: fields["full_name"] = full_name - if role is not None: fields["role"] = role - if is_active is not None: fields["is_active"] = is_active - if password: fields["password_hash"] = hash_password(password) - return self.repo.update(user, **fields) - - def delete_user(self, user: User) -> None: - self.repo.delete(user) - - # Auth - def authenticate(self, *, email: str, password: str) -> Optional[User]: - user = self.repo.get_by_email(email) - if not user or not user.is_active: - return None - if not verify_password(password, user.password_hash): - return None - return user -PY - -# api routes -write_file services/auth/src/app/api/routes/auth.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead -from app.services.user_service import UserService -from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims - -router = APIRouter(prefix="/v1", tags=["auth"]) - -@router.post("/register", response_model=UserRead, status_code=201) -def register(payload: UserCreate, db: Session = Depends(get_db)): - svc = UserService(db) - try: - user = svc.create_user(email=payload.email, password=payload.password, - full_name=payload.full_name, role=payload.role) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - return user - -@router.post("/token", response_model=TokenPair) -def token(payload: LoginRequest, db: Session = Depends(get_db)): - svc = UserService(db) - user = svc.authenticate(email=payload.email, password=payload.password) - if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") - access = create_access_token(sub=str(user.id), email=user.email, role=user.role) - refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role) - return TokenPair(access_token=access, refresh_token=refresh) - -class RefreshRequest(LoginRequest.__class__): - refresh_token: str # type: ignore - -@router.post("/refresh", response_model=TokenPair) -def refresh_token(req: dict): - # expects: {"refresh_token": ""} - from app.core.security import decode_token - token = req.get("refresh_token") - if not token: - raise HTTPException(status_code=400, detail="Missing refresh_token") - claims = decode_token(token) - if claims.type != "refresh": - raise HTTPException(status_code=400, detail="Not a refresh token") - access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role) - refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role) - return TokenPair(access_token=access, refresh_token=refresh) - -@router.get("/me", response_model=UserRead) -def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)): - svc = UserService(db) - u = svc.get_user(claims.sub) - if not u: - raise HTTPException(status_code=404, detail="User not found") - return u -PY - -write_file services/auth/src/app/api/routes/users.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import require_roles -from app.schemas.user import UserRead, UserUpdate, UserCreate -from app.services.user_service import UserService - -router = APIRouter(prefix="/v1/users", tags=["users"]) - -@router.get("", response_model=list[UserRead]) -def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - return UserService(db).list_users(offset=offset, limit=limit) - -@router.post("", response_model=UserRead, status_code=201) -def create_user(payload: UserCreate, db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - try: - return UserService(db).create_user(email=payload.email, password=payload.password, - full_name=payload.full_name, role=payload.role) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - -@router.get("/{user_id}", response_model=UserRead) -def get_user(user_id: str, db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - u = UserService(db).get_user(user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") - return u - -@router.patch("/{user_id}", response_model=UserRead) -def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - svc = UserService(db) - u = svc.get_user(user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") - return svc.update_user(u, full_name=payload.full_name, role=payload.role, - is_active=payload.is_active, password=payload.password) - -@router.delete("/{user_id}", status_code=204) -def delete_user(user_id: str, db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - svc = UserService(db) - u = svc.get_user(user_id) - if not u: - return - svc.delete_user(u) -PY - -# main.py update for auth -write_file services/auth/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.auth import router as auth_router -from .api.routes.users import router as users_router - -app = FastAPI(title="AUTH Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "auth"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(auth_router) -app.include_router(users_router) -PY - -# ------------------------------------------------------------------- -# 5) PROFILES service — Profile + Photo CRUD + поиск -# ------------------------------------------------------------------- -write_file services/profiles/src/app/models/profile.py <<'PY' -from __future__ import annotations -import uuid -from datetime import date, datetime - -from sqlalchemy import String, Date, DateTime, Text -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Profile(Base): - __tablename__ = "profiles" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other - birthdate: Mapped[date | None] = mapped_column(Date, default=None) - city: Mapped[str | None] = mapped_column(String(120), default=None) - bio: Mapped[str | None] = mapped_column(Text, default=None) - languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"] - interests: Mapped[dict | None] = mapped_column(JSONB, default=list) - preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) - verification_status: Mapped[str] = mapped_column(String(16), default="unverified") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - - photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") -PY - -write_file services/profiles/src/app/models/photo.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime - -from sqlalchemy import String, Boolean, DateTime, ForeignKey -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Photo(Base): - __tablename__ = "photos" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - url: Mapped[str] = mapped_column(String(500), nullable=False) - is_main: Mapped[bool] = mapped_column(Boolean, default=False) - status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - - profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True) -PY - -write_file services/profiles/src/app/models/__init__.py <<'PY' -from .profile import Profile # noqa -from .photo import Photo # noqa -PY - -write_file services/profiles/src/app/schemas/profile.py <<'PY' -from __future__ import annotations -from datetime import date -from typing import Optional, Any -from pydantic import BaseModel, ConfigDict - -class PhotoCreate(BaseModel): - url: str - is_main: bool = False - -class PhotoRead(BaseModel): - id: str - url: str - is_main: bool - status: str - model_config = ConfigDict(from_attributes=True) - -class ProfileCreate(BaseModel): - gender: str - birthdate: Optional[date] = None - city: Optional[str] = None - bio: Optional[str] = None - languages: Optional[list[str]] = None - interests: Optional[list[str]] = None - preferences: Optional[dict[str, Any]] = None - -class ProfileUpdate(BaseModel): - gender: Optional[str] = None - birthdate: Optional[date] = None - city: Optional[str] = None - bio: Optional[str] = None - languages: Optional[list[str]] = None - interests: Optional[list[str]] = None - preferences: Optional[dict[str, Any]] = None - verification_status: Optional[str] = None - -class ProfileRead(BaseModel): - id: str - user_id: str - gender: str - birthdate: Optional[date] = None - city: Optional[str] = None - bio: Optional[str] = None - languages: Optional[list[str]] = None - interests: Optional[list[str]] = None - preferences: Optional[dict] = None - verification_status: str - model_config = ConfigDict(from_attributes=True) -PY - -write_file services/profiles/src/app/repositories/profile_repository.py <<'PY' -from __future__ import annotations -from typing import Optional, Sequence -from datetime import date, timedelta - -from sqlalchemy import select, and_ -from sqlalchemy.orm import Session - -from app.models.profile import Profile -from app.models.photo import Photo - -class ProfileRepository: - def __init__(self, db: Session): - self.db = db - - # Profile CRUD - def create_profile(self, *, user_id, **fields) -> Profile: - p = Profile(user_id=user_id, **fields) - self.db.add(p) - self.db.commit() - self.db.refresh(p) - return p - - def get_profile(self, profile_id) -> Optional[Profile]: - return self.db.get(Profile, profile_id) - - def get_by_user(self, user_id) -> Optional[Profile]: - stmt = select(Profile).where(Profile.user_id == user_id) - return self.db.execute(stmt).scalar_one_or_none() - - def update_profile(self, profile: Profile, **fields) -> Profile: - for k, v in fields.items(): - if v is not None: - setattr(profile, k, v) - self.db.add(profile) - self.db.commit() - self.db.refresh(profile) - return profile - - def delete_profile(self, profile: Profile) -> None: - self.db.delete(profile) - self.db.commit() - - def list_profiles(self, *, gender: str | None = None, city: str | None = None, - age_min: int | None = None, age_max: int | None = None, - offset: int = 0, limit: int = 50) -> Sequence[Profile]: - stmt = select(Profile) - conds = [] - if gender: - conds.append(Profile.gender == gender) - if city: - conds.append(Profile.city == city) - # Age filter -> birthdate between (today - age_max) and (today - age_min) - if age_min is not None or age_max is not None: - today = date.today() - if age_min is not None: - max_birthdate = date(today.year - age_min, today.month, today.day) - conds.append(Profile.birthdate <= max_birthdate) - if age_max is not None: - min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1) - conds.append(Profile.birthdate >= min_birthdate) - if conds: - stmt = stmt.where(and_(*conds)) - stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc()) - return self.db.execute(stmt).scalars().all() - - # Photos - def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo: - photo = Photo(profile_id=profile_id, url=url, is_main=is_main) - self.db.add(photo) - if is_main: - # unset other main photos - self.db.execute(select(Photo).where(Photo.profile_id == profile_id)) - self.db.commit() - self.db.refresh(photo) - return photo - - def list_photos(self, *, profile_id) -> Sequence[Photo]: - stmt = select(Photo).where(Photo.profile_id == profile_id) - return self.db.execute(stmt).scalars().all() - - def get_photo(self, photo_id) -> Optional[Photo]: - return self.db.get(Photo, photo_id) - - def delete_photo(self, photo: Photo) -> None: - self.db.delete(photo) - self.db.commit() -PY - -write_file services/profiles/src/app/services/profile_service.py <<'PY' -from __future__ import annotations -from sqlalchemy.orm import Session -from typing import Optional - -from app.repositories.profile_repository import ProfileRepository -from app.models.profile import Profile -from app.models.photo import Photo - -class ProfileService: - def __init__(self, db: Session): - self.repo = ProfileRepository(db) - - def create_profile(self, *, user_id, **fields) -> Profile: - return self.repo.create_profile(user_id=user_id, **fields) - - def get_profile(self, profile_id) -> Optional[Profile]: - return self.repo.get_profile(profile_id) - - def get_by_user(self, user_id) -> Optional[Profile]: - return self.repo.get_by_user(user_id) - - def update_profile(self, profile: Profile, **fields) -> Profile: - return self.repo.update_profile(profile, **fields) - - def delete_profile(self, profile: Profile) -> None: - return self.repo.delete_profile(profile) - - def list_profiles(self, **filters): - return self.repo.list_profiles(**filters) - - # photos - def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo: - return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main) - - def list_photos(self, profile_id): - return self.repo.list_photos(profile_id=profile_id) - - def get_photo(self, photo_id) -> Photo | None: - return self.repo.get_photo(photo_id) - - def delete_photo(self, photo: Photo) -> None: - self.repo.delete_photo(photo) -PY - -write_file services/profiles/src/app/api/routes/profiles.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import get_current_user, require_roles, UserClaims -from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead -from app.services.profile_service import ProfileService - -router = APIRouter(prefix="/v1", tags=["profiles"]) - -@router.post("/profiles", response_model=ProfileRead, status_code=201) -def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - if svc.get_by_user(user.sub): - raise HTTPException(status_code=400, detail="Profile already exists") - p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True)) - return p - -@router.get("/profiles/me", response_model=ProfileRead) -def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_by_user(user.sub) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - return p - -@router.get("/profiles", response_model=list[ProfileRead]) -def list_profiles(gender: str | None = None, city: str | None = None, - age_min: int | None = Query(None, ge=18, le=120), - age_max: int | None = Query(None, ge=18, le=120), - offset: int = 0, limit: int = Query(50, le=200), - db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit) - -@router.get("/profiles/{profile_id}", response_model=ProfileRead) -def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): - p = ProfileService(db).get_profile(profile_id) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - return p - -@router.patch("/profiles/{profile_id}", response_model=ProfileRead) -def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_profile(profile_id) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): - raise HTTPException(status_code=403, detail="Not allowed") - return svc.update_profile(p, **payload.model_dump(exclude_none=True)) - -@router.delete("/profiles/{profile_id}", status_code=204) -def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_profile(profile_id) - if not p: - return - if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): - raise HTTPException(status_code=403, detail="Not allowed") - svc.delete_profile(p) - -# Photos -@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201) -def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_profile(profile_id) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): - raise HTTPException(status_code=403, detail="Not allowed") - photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main) - return photo - -@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead]) -def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - return svc.list_photos(profile_id) - -@router.delete("/photos/{photo_id}", status_code=204) -def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - photo = svc.get_photo(photo_id) - if not photo: - return - # Lookup profile to check ownership - p = svc.get_profile(photo.profile_id) - if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")): - raise HTTPException(status_code=403, detail="Not allowed") - svc.delete_photo(photo) -PY - -# main.py for profiles -write_file services/profiles/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.profiles import router as profiles_router - -app = FastAPI(title="PROFILES Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "profiles"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(profiles_router) -PY - -# ------------------------------------------------------------------- -# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked) -# ------------------------------------------------------------------- -write_file services/match/src/app/models/pair.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime -from sqlalchemy import String, Float, DateTime -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.session import Base - -class MatchPair(Base): - __tablename__ = "match_pairs" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - # User IDs to validate permissions; profile IDs можно добавить позже - user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked - score: Mapped[float | None] = mapped_column(Float, default=None) - notes: Mapped[str | None] = mapped_column(String(1000), default=None) - created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) -PY - -write_file services/match/src/app/models/__init__.py <<'PY' -from .pair import MatchPair # noqa -PY - -write_file services/match/src/app/schemas/pair.py <<'PY' -from __future__ import annotations -from typing import Optional -from pydantic import BaseModel, ConfigDict - -class PairCreate(BaseModel): - user_id_a: str - user_id_b: str - score: Optional[float] = None - notes: Optional[str] = None - -class PairUpdate(BaseModel): - score: Optional[float] = None - notes: Optional[str] = None - -class PairRead(BaseModel): - id: str - user_id_a: str - user_id_b: str - status: str - score: Optional[float] = None - notes: Optional[str] = None - model_config = ConfigDict(from_attributes=True) -PY - -write_file services/match/src/app/repositories/pair_repository.py <<'PY' -from __future__ import annotations -from typing import Optional, Sequence -from sqlalchemy import select, or_ -from sqlalchemy.orm import Session - -from app.models.pair import MatchPair - -class PairRepository: - def __init__(self, db: Session): - self.db = db - - def create(self, **fields) -> MatchPair: - obj = MatchPair(**fields) - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def get(self, pair_id) -> Optional[MatchPair]: - return self.db.get(MatchPair, pair_id) - - def list(self, *, for_user_id: str | None = None, status: str | None = None, - offset: int = 0, limit: int = 50) -> Sequence[MatchPair]: - stmt = select(MatchPair) - if for_user_id: - stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id)) - if status: - stmt = stmt.where(MatchPair.status == status) - stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc()) - return self.db.execute(stmt).scalars().all() - - def update(self, obj: MatchPair, **fields) -> MatchPair: - for k, v in fields.items(): - if v is not None: - setattr(obj, k, v) - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def delete(self, obj: MatchPair) -> None: - self.db.delete(obj) - self.db.commit() -PY - -write_file services/match/src/app/services/pair_service.py <<'PY' -from __future__ import annotations -from sqlalchemy.orm import Session -from typing import Optional -from app.repositories.pair_repository import PairRepository -from app.models.pair import MatchPair - -class PairService: - def __init__(self, db: Session): - self.repo = PairRepository(db) - - def create(self, **fields) -> MatchPair: - return self.repo.create(**fields) - - def get(self, pair_id) -> Optional[MatchPair]: - return self.repo.get(pair_id) - - def list(self, **filters): - return self.repo.list(**filters) - - def update(self, obj: MatchPair, **fields) -> MatchPair: - return self.repo.update(obj, **fields) - - def delete(self, obj: MatchPair) -> None: - return self.repo.delete(obj) - - def set_status(self, obj: MatchPair, status: str) -> MatchPair: - return self.repo.update(obj, status=status) -PY - -write_file services/match/src/app/api/routes/pairs.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import get_current_user, require_roles, UserClaims -from app.schemas.pair import PairCreate, PairUpdate, PairRead -from app.services.pair_service import PairService - -router = APIRouter(prefix="/v1/pairs", tags=["pairs"]) - -@router.post("", response_model=PairRead, status_code=201) -def create_pair(payload: PairCreate, db: Session = Depends(get_db), - user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PairService(db) - return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b, - score=payload.score, notes=payload.notes, created_by=user.sub) - -@router.get("", response_model=list[PairRead]) -def list_pairs(for_user_id: str | None = None, status: str | None = None, - offset: int = 0, limit: int = Query(50, le=200), - db: Session = Depends(get_db), - _: UserClaims = Depends(get_current_user)): - return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit) - -@router.get("/{pair_id}", response_model=PairRead) -def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): - obj = PairService(db).get(pair_id) - if not obj: - raise HTTPException(status_code=404, detail="Not found") - return obj - -@router.patch("/{pair_id}", response_model=PairRead) -def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PairService(db) - obj = svc.get(pair_id) - if not obj: - raise HTTPException(status_code=404, detail="Not found") - return svc.update(obj, **payload.model_dump(exclude_none=True)) - -@router.post("/{pair_id}/accept", response_model=PairRead) -def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = PairService(db) - obj = svc.get(pair_id) - if not obj: - raise HTTPException(status_code=404, detail="Not found") - # Validate that current user participates - if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): - raise HTTPException(status_code=403, detail="Not allowed") - return svc.set_status(obj, "accepted") - -@router.post("/{pair_id}/reject", response_model=PairRead) -def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = PairService(db) - obj = svc.get(pair_id) - if not obj: - raise HTTPException(status_code=404, detail="Not found") - if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): - raise HTTPException(status_code=403, detail="Not allowed") - return svc.set_status(obj, "rejected") - -@router.delete("/{pair_id}", status_code=204) -def delete_pair(pair_id: str, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PairService(db) - obj = svc.get(pair_id) - if not obj: - return - svc.delete(obj) -PY - -write_file services/match/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.pairs import router as pairs_router - -app = FastAPI(title="MATCH Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "match"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(pairs_router) -PY - -# ------------------------------------------------------------------- -# 7) CHAT service — комнаты и сообщения (REST, без WS) -# ------------------------------------------------------------------- -write_file services/chat/src/app/models/chat.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.session import Base - -class ChatRoom(Base): - __tablename__ = "chat_rooms" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - title: Mapped[str | None] = mapped_column(String(255), default=None) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - -class ChatParticipant(Base): - __tablename__ = "chat_participants" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - is_admin: Mapped[bool] = mapped_column(Boolean, default=False) - -class Message(Base): - __tablename__ = "chat_messages" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) -PY - -write_file services/chat/src/app/models/__init__.py <<'PY' -from .chat import ChatRoom, ChatParticipant, Message # noqa -PY - -write_file services/chat/src/app/schemas/chat.py <<'PY' -from __future__ import annotations -from pydantic import BaseModel, ConfigDict -from typing import Optional - -class RoomCreate(BaseModel): - title: Optional[str] = None - participants: list[str] # user IDs - -class RoomRead(BaseModel): - id: str - title: Optional[str] = None - model_config = ConfigDict(from_attributes=True) - -class MessageCreate(BaseModel): - content: str - -class MessageRead(BaseModel): - id: str - room_id: str - sender_id: str - content: str - model_config = ConfigDict(from_attributes=True) -PY - -write_file services/chat/src/app/repositories/chat_repository.py <<'PY' -from __future__ import annotations -from typing import Sequence, Optional -from sqlalchemy.orm import Session -from sqlalchemy import select, or_ - -from app.models.chat import ChatRoom, ChatParticipant, Message - -class ChatRepository: - def __init__(self, db: Session): - self.db = db - - # Rooms - def create_room(self, title: str | None) -> ChatRoom: - r = ChatRoom(title=title) - self.db.add(r) - self.db.commit() - self.db.refresh(r) - return r - - def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant: - p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin) - self.db.add(p) - self.db.commit() - self.db.refresh(p) - return p - - def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]: - stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\ - .where(ChatParticipant.user_id == user_id) - return self.db.execute(stmt).scalars().all() - - def get_room(self, room_id) -> Optional[ChatRoom]: - return self.db.get(ChatRoom, room_id) - - # Messages - def create_message(self, room_id, sender_id, content: str) -> Message: - m = Message(room_id=room_id, sender_id=sender_id, content=content) - self.db.add(m) - self.db.commit() - self.db.refresh(m) - return m - - def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]: - stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc()) - return self.db.execute(stmt).scalars().all() -PY - -write_file services/chat/src/app/services/chat_service.py <<'PY' -from __future__ import annotations -from sqlalchemy.orm import Session -from typing import Optional, Sequence - -from app.repositories.chat_repository import ChatRepository -from app.models.chat import ChatRoom, ChatParticipant, Message - -class ChatService: - def __init__(self, db: Session): - self.repo = ChatRepository(db) - - def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom: - room = self.repo.create_room(title) - # creator -> admin - self.repo.add_participant(room.id, creator_id, is_admin=True) - for uid in participant_ids: - if uid != creator_id: - self.repo.add_participant(room.id, uid, is_admin=False) - return room - - def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]: - return self.repo.list_rooms_for_user(user_id) - - def get_room(self, room_id: str) -> ChatRoom | None: - return self.repo.get_room(room_id) - - def create_message(self, room_id: str, sender_id: str, content: str) -> Message: - return self.repo.create_message(room_id, sender_id, content) - - def list_messages(self, room_id: str, offset: int = 0, limit: int = 100): - return self.repo.list_messages(room_id, offset=offset, limit=limit) -PY - -write_file services/chat/src/app/api/routes/chat.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import get_current_user, UserClaims -from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead -from app.services.chat_service import ChatService - -router = APIRouter(prefix="/v1", tags=["chat"]) - -@router.post("/rooms", response_model=RoomRead, status_code=201) -def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ChatService(db) - room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub) - return room - -@router.get("/rooms", response_model=list[RoomRead]) -def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - return ChatService(db).list_rooms_for_user(user.sub) - -@router.get("/rooms/{room_id}", response_model=RoomRead) -def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - room = ChatService(db).get_room(room_id) - if not room: - raise HTTPException(status_code=404, detail="Not found") - # NOTE: для простоты опускаем проверку участия (добавьте в проде) - return room - -@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201) -def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ChatService(db) - room = svc.get_room(room_id) - if not room: - raise HTTPException(status_code=404, detail="Room not found") - msg = svc.create_message(room_id, user.sub, payload.content) - return msg - -@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead]) -def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500), - db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ChatService(db) - room = svc.get_room(room_id) - if not room: - raise HTTPException(status_code=404, detail="Room not found") - return svc.list_messages(room_id, offset=offset, limit=limit) -PY - -write_file services/chat/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.chat import router as chat_router - -app = FastAPI(title="CHAT Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "chat"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(chat_router) -PY - -# ------------------------------------------------------------------- -# 8) PAYMENTS service — инвойсы (простая версия) -# ------------------------------------------------------------------- -write_file services/payments/src/app/models/payment.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Numeric -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.session import Base - -class Invoice(Base): - __tablename__ = "invoices" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False) - currency: Mapped[str] = mapped_column(String(3), default="USD") - status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled - description: Mapped[str | None] = mapped_column(String(500), default=None) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) -PY - -write_file services/payments/src/app/models/__init__.py <<'PY' -from .payment import Invoice # noqa -PY - -write_file services/payments/src/app/schemas/payment.py <<'PY' -from __future__ import annotations -from typing import Optional -from pydantic import BaseModel, ConfigDict - -class InvoiceCreate(BaseModel): - client_id: str - amount: float - currency: str = "USD" - description: Optional[str] = None - -class InvoiceUpdate(BaseModel): - amount: Optional[float] = None - currency: Optional[str] = None - description: Optional[str] = None - status: Optional[str] = None - -class InvoiceRead(BaseModel): - id: str - client_id: str - amount: float - currency: str - status: str - description: Optional[str] = None - model_config = ConfigDict(from_attributes=True) -PY - -write_file services/payments/src/app/repositories/payment_repository.py <<'PY' -from __future__ import annotations -from typing import Optional, Sequence -from sqlalchemy.orm import Session -from sqlalchemy import select - -from app.models.payment import Invoice - -class PaymentRepository: - def __init__(self, db: Session): - self.db = db - - def create_invoice(self, **fields) -> Invoice: - obj = Invoice(**fields) - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def get_invoice(self, inv_id) -> Optional[Invoice]: - return self.db.get(Invoice, inv_id) - - def list_invoices(self, *, client_id: str | None = None, status: str | None = None, - offset: int = 0, limit: int = 50) -> Sequence[Invoice]: - stmt = select(Invoice) - if client_id: - stmt = stmt.where(Invoice.client_id == client_id) - if status: - stmt = stmt.where(Invoice.status == status) - stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc()) - return self.db.execute(stmt).scalars().all() - - def update_invoice(self, obj: Invoice, **fields) -> Invoice: - for k, v in fields.items(): - if v is not None: - setattr(obj, k, v) - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def delete_invoice(self, obj: Invoice) -> None: - self.db.delete(obj) - self.db.commit() -PY - -write_file services/payments/src/app/services/payment_service.py <<'PY' -from __future__ import annotations -from sqlalchemy.orm import Session -from typing import Optional -from app.repositories.payment_repository import PaymentRepository -from app.models.payment import Invoice - -class PaymentService: - def __init__(self, db: Session): - self.repo = PaymentRepository(db) - - def create_invoice(self, **fields) -> Invoice: - return self.repo.create_invoice(**fields) - - def get_invoice(self, inv_id) -> Invoice | None: - return self.repo.get_invoice(inv_id) - - def list_invoices(self, **filters): - return self.repo.list_invoices(**filters) - - def update_invoice(self, obj: Invoice, **fields) -> Invoice: - return self.repo.update_invoice(obj, **fields) - - def delete_invoice(self, obj: Invoice) -> None: - return self.repo.delete_invoice(obj) - - def mark_paid(self, obj: Invoice) -> Invoice: - return self.repo.update_invoice(obj, status="paid") -PY - -write_file services/payments/src/app/api/routes/payments.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import get_current_user, require_roles, UserClaims -from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead -from app.services.payment_service import PaymentService - -router = APIRouter(prefix="/v1/invoices", tags=["payments"]) - -@router.post("", response_model=InvoiceRead, status_code=201) -def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True)) - -@router.get("", response_model=list[InvoiceRead]) -def list_invoices(client_id: str | None = None, status: str | None = None, - offset: int = 0, limit: int = Query(50, le=200), - db: Session = Depends(get_db), - user: UserClaims = Depends(get_current_user)): - # Клиент видит только свои инвойсы, админ/матчмейкер — любые - if user.role in ("ADMIN","MATCHMAKER"): - return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit) - else: - return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit) - -@router.get("/{inv_id}", response_model=InvoiceRead) -def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - inv = PaymentService(db).get_invoice(inv_id) - if not inv: - raise HTTPException(status_code=404, detail="Not found") - if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub: - raise HTTPException(status_code=403, detail="Not allowed") - return inv - -@router.patch("/{inv_id}", response_model=InvoiceRead) -def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PaymentService(db) - inv = svc.get_invoice(inv_id) - if not inv: - raise HTTPException(status_code=404, detail="Not found") - return svc.update_invoice(inv, **payload.model_dump(exclude_none=True)) - -@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead) -def mark_paid(inv_id: str, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PaymentService(db) - inv = svc.get_invoice(inv_id) - if not inv: - raise HTTPException(status_code=404, detail="Not found") - return svc.mark_paid(inv) - -@router.delete("/{inv_id}", status_code=204) -def delete_invoice(inv_id: str, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN"))): - svc = PaymentService(db) - inv = svc.get_invoice(inv_id) - if not inv: - return - svc.delete_invoice(inv) -PY - -write_file services/payments/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.payments import router as payments_router - -app = FastAPI(title="PAYMENTS Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "payments"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(payments_router) -PY - -# ------------------------------------------------------------------- -# 9) Обновить __init__.py пакетов (если scaffold создал пустые) -# ------------------------------------------------------------------- -for s in "${SERVICES[@]}"; do - touch "services/$s/src/app/__init__.py" - touch "services/$s/src/app/api/__init__.py" - touch "services/$s/src/app/api/routes/__init__.py" - touch "services/$s/src/app/core/__init__.py" - touch "services/$s/src/app/db/__init__.py" - touch "services/$s/src/app/repositories/__init__.py" - touch "services/$s/src/app/schemas/__init__.py" - touch "services/$s/src/app/services/__init__.py" -done - -echo "✅ Models + CRUD + API + Auth applied." - -cat <<'NEXT' -Next steps: - -1) Сгенерируйте первичные миграции по моделям: - for s in auth profiles match chat payments; do - docker compose run --rm $s alembic revision --autogenerate -m "init" - done - -2) Поднимите окружение (alembic upgrade выполнится в entrypoint): - docker compose up --build - -3) Получите токен: - POST http://localhost:8080/auth/v1/register - POST http://localhost:8080/auth/v1/token - -> Authorization: Bearer - -4) Проверьте CRUD: - - Profiles: GET http://localhost:8080/profiles/v1/profiles/me - - Match: POST http://localhost:8080/match/v1/pairs - - Chat: POST http://localhost:8080/chat/v1/rooms - - Payments: POST http://localhost:8080/payments/v1/invoices - -Замечания по безопасности/продакшену: -- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD. -- Сроки жизни токенов подберите под бизнес-политику. -- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте - хранилище jti/ревокацию. -- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав. -- В PROFILES поля languages/interests/preferences — JSONB; при желании замените - на нормализованные таблицы или ARRAY. -NEXT diff --git a/.history/models_20250808195931.sh b/.history/models_20250808195931.sh deleted file mode 100644 index 1469e6c..0000000 --- a/.history/models_20250808195931.sh +++ /dev/null @@ -1,1564 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ------------------------------------------------------------------- -# Apply models + CRUD + API + JWT auth to the existing scaffold -# Requires: the scaffold created earlier (services/* exist) -# ------------------------------------------------------------------- - -ROOT_DIR="." -SERVICES=(auth profiles match chat payments) - -ensure_line() { - # ensure_line - local file="$1" ; shift - local line="$*" - grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file" -} - -write_file() { - # write_file <<'EOF' ... EOF - local path="$1" - mkdir -p "$(dirname "$path")" - # The content will be provided by heredoc by the caller - cat > "$path" -} - -append_file() { - local path="$1" - mkdir -p "$(dirname "$path")" - cat >> "$path" -} - -require_file() { - local path="$1" - if [[ ! -f "$path" ]]; then - echo "ERROR: Missing $path. Run scaffold.sh first." >&2 - exit 1 - fi -} - -# Basic checks -require_file docker-compose.yml - -# ------------------------------------------------------------------- -# 1) .env.example — добавить JWT настройки (общие для всех сервисов) -# ------------------------------------------------------------------- -ENV_FILE=".env.example" -require_file "$ENV_FILE" - -ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------" -ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me" -ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256" -ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15" -ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days" - -# ------------------------------------------------------------------- -# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt] -# ------------------------------------------------------------------- -for s in "${SERVICES[@]}"; do - REQ="services/$s/requirements.txt" - require_file "$REQ" - ensure_line "$REQ" "PyJWT>=2.8" - if [[ "$s" == "auth" ]]; then - ensure_line "$REQ" "passlib[bcrypt]>=1.7" - fi -done - -# ------------------------------------------------------------------- -# 3) Общая безопасность (JWT) для всех сервисов -# В auth добавим + генерацию токенов, в остальных — верификация и RBAC -# ------------------------------------------------------------------- -for s in "${SERVICES[@]}"; do - SEC="services/$s/src/app/core/security.py" - mkdir -p "$(dirname "$SEC")" - if [[ "$s" == "auth" ]]; then - write_file "$SEC" <<'PY' -from __future__ import annotations -import os -from datetime import datetime, timedelta, timezone -from enum import Enum -from typing import Any, Callable, Optional - -import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from pydantic import BaseModel - -JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") -JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") -ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15")) -REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200")) - -class TokenType(str, Enum): - access = "access" - refresh = "refresh" - -class UserClaims(BaseModel): - sub: str - email: str - role: str - type: str - exp: int - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") - -def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str: - now = datetime.now(timezone.utc) - exp = now + timedelta(minutes=expires_minutes) - payload: dict[str, Any] = { - "sub": sub, - "email": email, - "role": role, - "type": token_type.value, - "exp": int(exp.timestamp()), - } - return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) - -def create_access_token(*, sub: str, email: str, role: str) -> str: - return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN) - -def create_refresh_token(*, sub: str, email: str, role: str) -> str: - return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN) - -def decode_token(token: str) -> UserClaims: - try: - payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - return UserClaims(**payload) - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") - except jwt.PyJWTError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - -def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: - return decode_token(token) - -def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]: - def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: - if roles and user.role not in roles: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") - return user - return dep -PY - else - write_file "$SEC" <<'PY' -from __future__ import annotations -import os -from enum import Enum -from typing import Any, Callable - -import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from pydantic import BaseModel - -JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") -JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") - -class UserClaims(BaseModel): - sub: str - email: str - role: str - type: str - exp: int - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") - -def decode_token(token: str) -> UserClaims: - try: - payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - return UserClaims(**payload) - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") - except jwt.PyJWTError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - -def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: - return decode_token(token) - -def require_roles(*roles: str): - def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: - if roles and user.role not in roles: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") - return user - return dep -PY - fi -done - -# ------------------------------------------------------------------- -# 4) AUTH service — модели, CRUD, токены, эндпоинты -# ------------------------------------------------------------------- -# models -write_file services/auth/src/app/models/user.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime -from enum import Enum - -from sqlalchemy import String, Boolean, DateTime -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.session import Base - -class Role(str, Enum): - ADMIN = "ADMIN" - MATCHMAKER = "MATCHMAKER" - CLIENT = "CLIENT" - -class User(Base): - __tablename__ = "users" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - full_name: Mapped[str | None] = mapped_column(String(255), default=None) - role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) -PY - -write_file services/auth/src/app/models/__init__.py <<'PY' -from .user import User, Role # noqa: F401 -PY - -# schemas -write_file services/auth/src/app/schemas/user.py <<'PY' -from __future__ import annotations -from typing import Optional -from pydantic import BaseModel, EmailStr, ConfigDict - -class UserBase(BaseModel): - email: EmailStr - full_name: Optional[str] = None - role: str = "CLIENT" - is_active: bool = True - -class UserCreate(BaseModel): - email: EmailStr - password: str - full_name: Optional[str] = None - role: str = "CLIENT" - -class UserUpdate(BaseModel): - full_name: Optional[str] = None - role: Optional[str] = None - is_active: Optional[bool] = None - password: Optional[str] = None - -class UserRead(BaseModel): - id: str - email: EmailStr - full_name: Optional[str] = None - role: str - is_active: bool - model_config = ConfigDict(from_attributes=True) - -class LoginRequest(BaseModel): - email: EmailStr - password: str - -class TokenPair(BaseModel): - access_token: str - refresh_token: str - token_type: str = "bearer" -PY - -# passwords -write_file services/auth/src/app/core/passwords.py <<'PY' -from passlib.context import CryptContext - -_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") - -def hash_password(p: str) -> str: - return _pwd.hash(p) - -def verify_password(p: str, hashed: str) -> bool: - return _pwd.verify(p, hashed) -PY - -# repositories -write_file services/auth/src/app/repositories/user_repository.py <<'PY' -from __future__ import annotations -from typing import Optional, Sequence -from sqlalchemy.orm import Session -from sqlalchemy import select, update, delete - -from app.models.user import User - -class UserRepository: - def __init__(self, db: Session): - self.db = db - - def get(self, user_id) -> Optional[User]: - return self.db.get(User, user_id) - - def get_by_email(self, email: str) -> Optional[User]: - stmt = select(User).where(User.email == email) - return self.db.execute(stmt).scalar_one_or_none() - - def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]: - stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc()) - return self.db.execute(stmt).scalars().all() - - def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User: - user = User(email=email, password_hash=password_hash, full_name=full_name, role=role) - self.db.add(user) - self.db.commit() - self.db.refresh(user) - return user - - def update(self, user: User, **fields) -> User: - for k, v in fields.items(): - if v is not None: - setattr(user, k, v) - self.db.add(user) - self.db.commit() - self.db.refresh(user) - return user - - def delete(self, user: User) -> None: - self.db.delete(user) - self.db.commit() -PY - -# services -write_file services/auth/src/app/services/user_service.py <<'PY' -from __future__ import annotations -from typing import Optional -from sqlalchemy.orm import Session - -from app.repositories.user_repository import UserRepository -from app.core.passwords import hash_password, verify_password -from app.models.user import User - -class UserService: - def __init__(self, db: Session): - self.repo = UserRepository(db) - - # CRUD - def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User: - if self.repo.get_by_email(email): - raise ValueError("Email already in use") - pwd_hash = hash_password(password) - return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role) - - def get_user(self, user_id) -> Optional[User]: - return self.repo.get(user_id) - - def get_by_email(self, email: str) -> Optional[User]: - return self.repo.get_by_email(email) - - def list_users(self, *, offset: int = 0, limit: int = 50): - return self.repo.list(offset=offset, limit=limit) - - def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None, - is_active: bool | None = None, password: str | None = None) -> User: - fields = {} - if full_name is not None: fields["full_name"] = full_name - if role is not None: fields["role"] = role - if is_active is not None: fields["is_active"] = is_active - if password: fields["password_hash"] = hash_password(password) - return self.repo.update(user, **fields) - - def delete_user(self, user: User) -> None: - self.repo.delete(user) - - # Auth - def authenticate(self, *, email: str, password: str) -> Optional[User]: - user = self.repo.get_by_email(email) - if not user or not user.is_active: - return None - if not verify_password(password, user.password_hash): - return None - return user -PY - -# api routes -write_file services/auth/src/app/api/routes/auth.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead -from app.services.user_service import UserService -from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims - -router = APIRouter(prefix="/v1", tags=["auth"]) - -@router.post("/register", response_model=UserRead, status_code=201) -def register(payload: UserCreate, db: Session = Depends(get_db)): - svc = UserService(db) - try: - user = svc.create_user(email=payload.email, password=payload.password, - full_name=payload.full_name, role=payload.role) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - return user - -@router.post("/token", response_model=TokenPair) -def token(payload: LoginRequest, db: Session = Depends(get_db)): - svc = UserService(db) - user = svc.authenticate(email=payload.email, password=payload.password) - if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") - access = create_access_token(sub=str(user.id), email=user.email, role=user.role) - refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role) - return TokenPair(access_token=access, refresh_token=refresh) - -class RefreshRequest(LoginRequest.__class__): - refresh_token: str # type: ignore - -@router.post("/refresh", response_model=TokenPair) -def refresh_token(req: dict): - # expects: {"refresh_token": ""} - from app.core.security import decode_token - token = req.get("refresh_token") - if not token: - raise HTTPException(status_code=400, detail="Missing refresh_token") - claims = decode_token(token) - if claims.type != "refresh": - raise HTTPException(status_code=400, detail="Not a refresh token") - access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role) - refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role) - return TokenPair(access_token=access, refresh_token=refresh) - -@router.get("/me", response_model=UserRead) -def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)): - svc = UserService(db) - u = svc.get_user(claims.sub) - if not u: - raise HTTPException(status_code=404, detail="User not found") - return u -PY - -write_file services/auth/src/app/api/routes/users.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import require_roles -from app.schemas.user import UserRead, UserUpdate, UserCreate -from app.services.user_service import UserService - -router = APIRouter(prefix="/v1/users", tags=["users"]) - -@router.get("", response_model=list[UserRead]) -def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - return UserService(db).list_users(offset=offset, limit=limit) - -@router.post("", response_model=UserRead, status_code=201) -def create_user(payload: UserCreate, db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - try: - return UserService(db).create_user(email=payload.email, password=payload.password, - full_name=payload.full_name, role=payload.role) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - -@router.get("/{user_id}", response_model=UserRead) -def get_user(user_id: str, db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - u = UserService(db).get_user(user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") - return u - -@router.patch("/{user_id}", response_model=UserRead) -def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - svc = UserService(db) - u = svc.get_user(user_id) - if not u: - raise HTTPException(status_code=404, detail="User not found") - return svc.update_user(u, full_name=payload.full_name, role=payload.role, - is_active=payload.is_active, password=payload.password) - -@router.delete("/{user_id}", status_code=204) -def delete_user(user_id: str, db: Session = Depends(get_db), - _: dict = Depends(require_roles("ADMIN"))): - svc = UserService(db) - u = svc.get_user(user_id) - if not u: - return - svc.delete_user(u) -PY - -# main.py update for auth -write_file services/auth/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.auth import router as auth_router -from .api.routes.users import router as users_router - -app = FastAPI(title="AUTH Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "auth"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(auth_router) -app.include_router(users_router) -PY - -# ------------------------------------------------------------------- -# 5) PROFILES service — Profile + Photo CRUD + поиск -# ------------------------------------------------------------------- -write_file services/profiles/src/app/models/profile.py <<'PY' -from __future__ import annotations -import uuid -from datetime import date, datetime - -from sqlalchemy import String, Date, DateTime, Text -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Profile(Base): - __tablename__ = "profiles" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other - birthdate: Mapped[date | None] = mapped_column(Date, default=None) - city: Mapped[str | None] = mapped_column(String(120), default=None) - bio: Mapped[str | None] = mapped_column(Text, default=None) - languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"] - interests: Mapped[dict | None] = mapped_column(JSONB, default=list) - preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) - verification_status: Mapped[str] = mapped_column(String(16), default="unverified") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - - photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") -PY - -write_file services/profiles/src/app/models/photo.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime - -from sqlalchemy import String, Boolean, DateTime, ForeignKey -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Photo(Base): - __tablename__ = "photos" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - url: Mapped[str] = mapped_column(String(500), nullable=False) - is_main: Mapped[bool] = mapped_column(Boolean, default=False) - status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - - profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True) -PY - -write_file services/profiles/src/app/models/__init__.py <<'PY' -from .profile import Profile # noqa -from .photo import Photo # noqa -PY - -write_file services/profiles/src/app/schemas/profile.py <<'PY' -from __future__ import annotations -from datetime import date -from typing import Optional, Any -from pydantic import BaseModel, ConfigDict - -class PhotoCreate(BaseModel): - url: str - is_main: bool = False - -class PhotoRead(BaseModel): - id: str - url: str - is_main: bool - status: str - model_config = ConfigDict(from_attributes=True) - -class ProfileCreate(BaseModel): - gender: str - birthdate: Optional[date] = None - city: Optional[str] = None - bio: Optional[str] = None - languages: Optional[list[str]] = None - interests: Optional[list[str]] = None - preferences: Optional[dict[str, Any]] = None - -class ProfileUpdate(BaseModel): - gender: Optional[str] = None - birthdate: Optional[date] = None - city: Optional[str] = None - bio: Optional[str] = None - languages: Optional[list[str]] = None - interests: Optional[list[str]] = None - preferences: Optional[dict[str, Any]] = None - verification_status: Optional[str] = None - -class ProfileRead(BaseModel): - id: str - user_id: str - gender: str - birthdate: Optional[date] = None - city: Optional[str] = None - bio: Optional[str] = None - languages: Optional[list[str]] = None - interests: Optional[list[str]] = None - preferences: Optional[dict] = None - verification_status: str - model_config = ConfigDict(from_attributes=True) -PY - -write_file services/profiles/src/app/repositories/profile_repository.py <<'PY' -from __future__ import annotations -from typing import Optional, Sequence -from datetime import date, timedelta - -from sqlalchemy import select, and_ -from sqlalchemy.orm import Session - -from app.models.profile import Profile -from app.models.photo import Photo - -class ProfileRepository: - def __init__(self, db: Session): - self.db = db - - # Profile CRUD - def create_profile(self, *, user_id, **fields) -> Profile: - p = Profile(user_id=user_id, **fields) - self.db.add(p) - self.db.commit() - self.db.refresh(p) - return p - - def get_profile(self, profile_id) -> Optional[Profile]: - return self.db.get(Profile, profile_id) - - def get_by_user(self, user_id) -> Optional[Profile]: - stmt = select(Profile).where(Profile.user_id == user_id) - return self.db.execute(stmt).scalar_one_or_none() - - def update_profile(self, profile: Profile, **fields) -> Profile: - for k, v in fields.items(): - if v is not None: - setattr(profile, k, v) - self.db.add(profile) - self.db.commit() - self.db.refresh(profile) - return profile - - def delete_profile(self, profile: Profile) -> None: - self.db.delete(profile) - self.db.commit() - - def list_profiles(self, *, gender: str | None = None, city: str | None = None, - age_min: int | None = None, age_max: int | None = None, - offset: int = 0, limit: int = 50) -> Sequence[Profile]: - stmt = select(Profile) - conds = [] - if gender: - conds.append(Profile.gender == gender) - if city: - conds.append(Profile.city == city) - # Age filter -> birthdate between (today - age_max) and (today - age_min) - if age_min is not None or age_max is not None: - today = date.today() - if age_min is not None: - max_birthdate = date(today.year - age_min, today.month, today.day) - conds.append(Profile.birthdate <= max_birthdate) - if age_max is not None: - min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1) - conds.append(Profile.birthdate >= min_birthdate) - if conds: - stmt = stmt.where(and_(*conds)) - stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc()) - return self.db.execute(stmt).scalars().all() - - # Photos - def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo: - photo = Photo(profile_id=profile_id, url=url, is_main=is_main) - self.db.add(photo) - if is_main: - # unset other main photos - self.db.execute(select(Photo).where(Photo.profile_id == profile_id)) - self.db.commit() - self.db.refresh(photo) - return photo - - def list_photos(self, *, profile_id) -> Sequence[Photo]: - stmt = select(Photo).where(Photo.profile_id == profile_id) - return self.db.execute(stmt).scalars().all() - - def get_photo(self, photo_id) -> Optional[Photo]: - return self.db.get(Photo, photo_id) - - def delete_photo(self, photo: Photo) -> None: - self.db.delete(photo) - self.db.commit() -PY - -write_file services/profiles/src/app/services/profile_service.py <<'PY' -from __future__ import annotations -from sqlalchemy.orm import Session -from typing import Optional - -from app.repositories.profile_repository import ProfileRepository -from app.models.profile import Profile -from app.models.photo import Photo - -class ProfileService: - def __init__(self, db: Session): - self.repo = ProfileRepository(db) - - def create_profile(self, *, user_id, **fields) -> Profile: - return self.repo.create_profile(user_id=user_id, **fields) - - def get_profile(self, profile_id) -> Optional[Profile]: - return self.repo.get_profile(profile_id) - - def get_by_user(self, user_id) -> Optional[Profile]: - return self.repo.get_by_user(user_id) - - def update_profile(self, profile: Profile, **fields) -> Profile: - return self.repo.update_profile(profile, **fields) - - def delete_profile(self, profile: Profile) -> None: - return self.repo.delete_profile(profile) - - def list_profiles(self, **filters): - return self.repo.list_profiles(**filters) - - # photos - def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo: - return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main) - - def list_photos(self, profile_id): - return self.repo.list_photos(profile_id=profile_id) - - def get_photo(self, photo_id) -> Photo | None: - return self.repo.get_photo(photo_id) - - def delete_photo(self, photo: Photo) -> None: - self.repo.delete_photo(photo) -PY - -write_file services/profiles/src/app/api/routes/profiles.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import get_current_user, require_roles, UserClaims -from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead -from app.services.profile_service import ProfileService - -router = APIRouter(prefix="/v1", tags=["profiles"]) - -@router.post("/profiles", response_model=ProfileRead, status_code=201) -def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - if svc.get_by_user(user.sub): - raise HTTPException(status_code=400, detail="Profile already exists") - p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True)) - return p - -@router.get("/profiles/me", response_model=ProfileRead) -def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_by_user(user.sub) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - return p - -@router.get("/profiles", response_model=list[ProfileRead]) -def list_profiles(gender: str | None = None, city: str | None = None, - age_min: int | None = Query(None, ge=18, le=120), - age_max: int | None = Query(None, ge=18, le=120), - offset: int = 0, limit: int = Query(50, le=200), - db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit) - -@router.get("/profiles/{profile_id}", response_model=ProfileRead) -def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): - p = ProfileService(db).get_profile(profile_id) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - return p - -@router.patch("/profiles/{profile_id}", response_model=ProfileRead) -def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_profile(profile_id) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): - raise HTTPException(status_code=403, detail="Not allowed") - return svc.update_profile(p, **payload.model_dump(exclude_none=True)) - -@router.delete("/profiles/{profile_id}", status_code=204) -def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_profile(profile_id) - if not p: - return - if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): - raise HTTPException(status_code=403, detail="Not allowed") - svc.delete_profile(p) - -# Photos -@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201) -def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_profile(profile_id) - if not p: - raise HTTPException(status_code=404, detail="Profile not found") - if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): - raise HTTPException(status_code=403, detail="Not allowed") - photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main) - return photo - -@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead]) -def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - return svc.list_photos(profile_id) - -@router.delete("/photos/{photo_id}", status_code=204) -def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ProfileService(db) - photo = svc.get_photo(photo_id) - if not photo: - return - # Lookup profile to check ownership - p = svc.get_profile(photo.profile_id) - if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")): - raise HTTPException(status_code=403, detail="Not allowed") - svc.delete_photo(photo) -PY - -# main.py for profiles -write_file services/profiles/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.profiles import router as profiles_router - -app = FastAPI(title="PROFILES Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "profiles"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(profiles_router) -PY - -# ------------------------------------------------------------------- -# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked) -# ------------------------------------------------------------------- -write_file services/match/src/app/models/pair.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime -from sqlalchemy import String, Float, DateTime -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.session import Base - -class MatchPair(Base): - __tablename__ = "match_pairs" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - # User IDs to validate permissions; profile IDs можно добавить позже - user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked - score: Mapped[float | None] = mapped_column(Float, default=None) - notes: Mapped[str | None] = mapped_column(String(1000), default=None) - created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) -PY - -write_file services/match/src/app/models/__init__.py <<'PY' -from .pair import MatchPair # noqa -PY - -write_file services/match/src/app/schemas/pair.py <<'PY' -from __future__ import annotations -from typing import Optional -from pydantic import BaseModel, ConfigDict - -class PairCreate(BaseModel): - user_id_a: str - user_id_b: str - score: Optional[float] = None - notes: Optional[str] = None - -class PairUpdate(BaseModel): - score: Optional[float] = None - notes: Optional[str] = None - -class PairRead(BaseModel): - id: str - user_id_a: str - user_id_b: str - status: str - score: Optional[float] = None - notes: Optional[str] = None - model_config = ConfigDict(from_attributes=True) -PY - -write_file services/match/src/app/repositories/pair_repository.py <<'PY' -from __future__ import annotations -from typing import Optional, Sequence -from sqlalchemy import select, or_ -from sqlalchemy.orm import Session - -from app.models.pair import MatchPair - -class PairRepository: - def __init__(self, db: Session): - self.db = db - - def create(self, **fields) -> MatchPair: - obj = MatchPair(**fields) - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def get(self, pair_id) -> Optional[MatchPair]: - return self.db.get(MatchPair, pair_id) - - def list(self, *, for_user_id: str | None = None, status: str | None = None, - offset: int = 0, limit: int = 50) -> Sequence[MatchPair]: - stmt = select(MatchPair) - if for_user_id: - stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id)) - if status: - stmt = stmt.where(MatchPair.status == status) - stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc()) - return self.db.execute(stmt).scalars().all() - - def update(self, obj: MatchPair, **fields) -> MatchPair: - for k, v in fields.items(): - if v is not None: - setattr(obj, k, v) - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def delete(self, obj: MatchPair) -> None: - self.db.delete(obj) - self.db.commit() -PY - -write_file services/match/src/app/services/pair_service.py <<'PY' -from __future__ import annotations -from sqlalchemy.orm import Session -from typing import Optional -from app.repositories.pair_repository import PairRepository -from app.models.pair import MatchPair - -class PairService: - def __init__(self, db: Session): - self.repo = PairRepository(db) - - def create(self, **fields) -> MatchPair: - return self.repo.create(**fields) - - def get(self, pair_id) -> Optional[MatchPair]: - return self.repo.get(pair_id) - - def list(self, **filters): - return self.repo.list(**filters) - - def update(self, obj: MatchPair, **fields) -> MatchPair: - return self.repo.update(obj, **fields) - - def delete(self, obj: MatchPair) -> None: - return self.repo.delete(obj) - - def set_status(self, obj: MatchPair, status: str) -> MatchPair: - return self.repo.update(obj, status=status) -PY - -write_file services/match/src/app/api/routes/pairs.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import get_current_user, require_roles, UserClaims -from app.schemas.pair import PairCreate, PairUpdate, PairRead -from app.services.pair_service import PairService - -router = APIRouter(prefix="/v1/pairs", tags=["pairs"]) - -@router.post("", response_model=PairRead, status_code=201) -def create_pair(payload: PairCreate, db: Session = Depends(get_db), - user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PairService(db) - return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b, - score=payload.score, notes=payload.notes, created_by=user.sub) - -@router.get("", response_model=list[PairRead]) -def list_pairs(for_user_id: str | None = None, status: str | None = None, - offset: int = 0, limit: int = Query(50, le=200), - db: Session = Depends(get_db), - _: UserClaims = Depends(get_current_user)): - return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit) - -@router.get("/{pair_id}", response_model=PairRead) -def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): - obj = PairService(db).get(pair_id) - if not obj: - raise HTTPException(status_code=404, detail="Not found") - return obj - -@router.patch("/{pair_id}", response_model=PairRead) -def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PairService(db) - obj = svc.get(pair_id) - if not obj: - raise HTTPException(status_code=404, detail="Not found") - return svc.update(obj, **payload.model_dump(exclude_none=True)) - -@router.post("/{pair_id}/accept", response_model=PairRead) -def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = PairService(db) - obj = svc.get(pair_id) - if not obj: - raise HTTPException(status_code=404, detail="Not found") - # Validate that current user participates - if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): - raise HTTPException(status_code=403, detail="Not allowed") - return svc.set_status(obj, "accepted") - -@router.post("/{pair_id}/reject", response_model=PairRead) -def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = PairService(db) - obj = svc.get(pair_id) - if not obj: - raise HTTPException(status_code=404, detail="Not found") - if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): - raise HTTPException(status_code=403, detail="Not allowed") - return svc.set_status(obj, "rejected") - -@router.delete("/{pair_id}", status_code=204) -def delete_pair(pair_id: str, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PairService(db) - obj = svc.get(pair_id) - if not obj: - return - svc.delete(obj) -PY - -write_file services/match/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.pairs import router as pairs_router - -app = FastAPI(title="MATCH Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "match"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(pairs_router) -PY - -# ------------------------------------------------------------------- -# 7) CHAT service — комнаты и сообщения (REST, без WS) -# ------------------------------------------------------------------- -write_file services/chat/src/app/models/chat.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.session import Base - -class ChatRoom(Base): - __tablename__ = "chat_rooms" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - title: Mapped[str | None] = mapped_column(String(255), default=None) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - -class ChatParticipant(Base): - __tablename__ = "chat_participants" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - is_admin: Mapped[bool] = mapped_column(Boolean, default=False) - -class Message(Base): - __tablename__ = "chat_messages" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) -PY - -write_file services/chat/src/app/models/__init__.py <<'PY' -from .chat import ChatRoom, ChatParticipant, Message # noqa -PY - -write_file services/chat/src/app/schemas/chat.py <<'PY' -from __future__ import annotations -from pydantic import BaseModel, ConfigDict -from typing import Optional - -class RoomCreate(BaseModel): - title: Optional[str] = None - participants: list[str] # user IDs - -class RoomRead(BaseModel): - id: str - title: Optional[str] = None - model_config = ConfigDict(from_attributes=True) - -class MessageCreate(BaseModel): - content: str - -class MessageRead(BaseModel): - id: str - room_id: str - sender_id: str - content: str - model_config = ConfigDict(from_attributes=True) -PY - -write_file services/chat/src/app/repositories/chat_repository.py <<'PY' -from __future__ import annotations -from typing import Sequence, Optional -from sqlalchemy.orm import Session -from sqlalchemy import select, or_ - -from app.models.chat import ChatRoom, ChatParticipant, Message - -class ChatRepository: - def __init__(self, db: Session): - self.db = db - - # Rooms - def create_room(self, title: str | None) -> ChatRoom: - r = ChatRoom(title=title) - self.db.add(r) - self.db.commit() - self.db.refresh(r) - return r - - def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant: - p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin) - self.db.add(p) - self.db.commit() - self.db.refresh(p) - return p - - def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]: - stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\ - .where(ChatParticipant.user_id == user_id) - return self.db.execute(stmt).scalars().all() - - def get_room(self, room_id) -> Optional[ChatRoom]: - return self.db.get(ChatRoom, room_id) - - # Messages - def create_message(self, room_id, sender_id, content: str) -> Message: - m = Message(room_id=room_id, sender_id=sender_id, content=content) - self.db.add(m) - self.db.commit() - self.db.refresh(m) - return m - - def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]: - stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc()) - return self.db.execute(stmt).scalars().all() -PY - -write_file services/chat/src/app/services/chat_service.py <<'PY' -from __future__ import annotations -from sqlalchemy.orm import Session -from typing import Optional, Sequence - -from app.repositories.chat_repository import ChatRepository -from app.models.chat import ChatRoom, ChatParticipant, Message - -class ChatService: - def __init__(self, db: Session): - self.repo = ChatRepository(db) - - def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom: - room = self.repo.create_room(title) - # creator -> admin - self.repo.add_participant(room.id, creator_id, is_admin=True) - for uid in participant_ids: - if uid != creator_id: - self.repo.add_participant(room.id, uid, is_admin=False) - return room - - def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]: - return self.repo.list_rooms_for_user(user_id) - - def get_room(self, room_id: str) -> ChatRoom | None: - return self.repo.get_room(room_id) - - def create_message(self, room_id: str, sender_id: str, content: str) -> Message: - return self.repo.create_message(room_id, sender_id, content) - - def list_messages(self, room_id: str, offset: int = 0, limit: int = 100): - return self.repo.list_messages(room_id, offset=offset, limit=limit) -PY - -write_file services/chat/src/app/api/routes/chat.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import get_current_user, UserClaims -from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead -from app.services.chat_service import ChatService - -router = APIRouter(prefix="/v1", tags=["chat"]) - -@router.post("/rooms", response_model=RoomRead, status_code=201) -def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ChatService(db) - room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub) - return room - -@router.get("/rooms", response_model=list[RoomRead]) -def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - return ChatService(db).list_rooms_for_user(user.sub) - -@router.get("/rooms/{room_id}", response_model=RoomRead) -def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - room = ChatService(db).get_room(room_id) - if not room: - raise HTTPException(status_code=404, detail="Not found") - # NOTE: для простоты опускаем проверку участия (добавьте в проде) - return room - -@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201) -def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ChatService(db) - room = svc.get_room(room_id) - if not room: - raise HTTPException(status_code=404, detail="Room not found") - msg = svc.create_message(room_id, user.sub, payload.content) - return msg - -@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead]) -def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500), - db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - svc = ChatService(db) - room = svc.get_room(room_id) - if not room: - raise HTTPException(status_code=404, detail="Room not found") - return svc.list_messages(room_id, offset=offset, limit=limit) -PY - -write_file services/chat/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.chat import router as chat_router - -app = FastAPI(title="CHAT Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "chat"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(chat_router) -PY - -# ------------------------------------------------------------------- -# 8) PAYMENTS service — инвойсы (простая версия) -# ------------------------------------------------------------------- -write_file services/payments/src/app/models/payment.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Numeric -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.session import Base - -class Invoice(Base): - __tablename__ = "invoices" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False) - currency: Mapped[str] = mapped_column(String(3), default="USD") - status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled - description: Mapped[str | None] = mapped_column(String(500), default=None) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) -PY - -write_file services/payments/src/app/models/__init__.py <<'PY' -from .payment import Invoice # noqa -PY - -write_file services/payments/src/app/schemas/payment.py <<'PY' -from __future__ import annotations -from typing import Optional -from pydantic import BaseModel, ConfigDict - -class InvoiceCreate(BaseModel): - client_id: str - amount: float - currency: str = "USD" - description: Optional[str] = None - -class InvoiceUpdate(BaseModel): - amount: Optional[float] = None - currency: Optional[str] = None - description: Optional[str] = None - status: Optional[str] = None - -class InvoiceRead(BaseModel): - id: str - client_id: str - amount: float - currency: str - status: str - description: Optional[str] = None - model_config = ConfigDict(from_attributes=True) -PY - -write_file services/payments/src/app/repositories/payment_repository.py <<'PY' -from __future__ import annotations -from typing import Optional, Sequence -from sqlalchemy.orm import Session -from sqlalchemy import select - -from app.models.payment import Invoice - -class PaymentRepository: - def __init__(self, db: Session): - self.db = db - - def create_invoice(self, **fields) -> Invoice: - obj = Invoice(**fields) - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def get_invoice(self, inv_id) -> Optional[Invoice]: - return self.db.get(Invoice, inv_id) - - def list_invoices(self, *, client_id: str | None = None, status: str | None = None, - offset: int = 0, limit: int = 50) -> Sequence[Invoice]: - stmt = select(Invoice) - if client_id: - stmt = stmt.where(Invoice.client_id == client_id) - if status: - stmt = stmt.where(Invoice.status == status) - stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc()) - return self.db.execute(stmt).scalars().all() - - def update_invoice(self, obj: Invoice, **fields) -> Invoice: - for k, v in fields.items(): - if v is not None: - setattr(obj, k, v) - self.db.add(obj) - self.db.commit() - self.db.refresh(obj) - return obj - - def delete_invoice(self, obj: Invoice) -> None: - self.db.delete(obj) - self.db.commit() -PY - -write_file services/payments/src/app/services/payment_service.py <<'PY' -from __future__ import annotations -from sqlalchemy.orm import Session -from typing import Optional -from app.repositories.payment_repository import PaymentRepository -from app.models.payment import Invoice - -class PaymentService: - def __init__(self, db: Session): - self.repo = PaymentRepository(db) - - def create_invoice(self, **fields) -> Invoice: - return self.repo.create_invoice(**fields) - - def get_invoice(self, inv_id) -> Invoice | None: - return self.repo.get_invoice(inv_id) - - def list_invoices(self, **filters): - return self.repo.list_invoices(**filters) - - def update_invoice(self, obj: Invoice, **fields) -> Invoice: - return self.repo.update_invoice(obj, **fields) - - def delete_invoice(self, obj: Invoice) -> None: - return self.repo.delete_invoice(obj) - - def mark_paid(self, obj: Invoice) -> Invoice: - return self.repo.update_invoice(obj, status="paid") -PY - -write_file services/payments/src/app/api/routes/payments.py <<'PY' -from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.core.security import get_current_user, require_roles, UserClaims -from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead -from app.services.payment_service import PaymentService - -router = APIRouter(prefix="/v1/invoices", tags=["payments"]) - -@router.post("", response_model=InvoiceRead, status_code=201) -def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True)) - -@router.get("", response_model=list[InvoiceRead]) -def list_invoices(client_id: str | None = None, status: str | None = None, - offset: int = 0, limit: int = Query(50, le=200), - db: Session = Depends(get_db), - user: UserClaims = Depends(get_current_user)): - # Клиент видит только свои инвойсы, админ/матчмейкер — любые - if user.role in ("ADMIN","MATCHMAKER"): - return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit) - else: - return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit) - -@router.get("/{inv_id}", response_model=InvoiceRead) -def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): - inv = PaymentService(db).get_invoice(inv_id) - if not inv: - raise HTTPException(status_code=404, detail="Not found") - if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub: - raise HTTPException(status_code=403, detail="Not allowed") - return inv - -@router.patch("/{inv_id}", response_model=InvoiceRead) -def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PaymentService(db) - inv = svc.get_invoice(inv_id) - if not inv: - raise HTTPException(status_code=404, detail="Not found") - return svc.update_invoice(inv, **payload.model_dump(exclude_none=True)) - -@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead) -def mark_paid(inv_id: str, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): - svc = PaymentService(db) - inv = svc.get_invoice(inv_id) - if not inv: - raise HTTPException(status_code=404, detail="Not found") - return svc.mark_paid(inv) - -@router.delete("/{inv_id}", status_code=204) -def delete_invoice(inv_id: str, db: Session = Depends(get_db), - _: UserClaims = Depends(require_roles("ADMIN"))): - svc = PaymentService(db) - inv = svc.get_invoice(inv_id) - if not inv: - return - svc.delete_invoice(inv) -PY - -write_file services/payments/src/app/main.py <<'PY' -from fastapi import FastAPI -from .api.routes.ping import router as ping_router -from .api.routes.payments import router as payments_router - -app = FastAPI(title="PAYMENTS Service") - -@app.get("/health") -def health(): - return {"status": "ok", "service": "payments"} - -app.include_router(ping_router, prefix="/v1") -app.include_router(payments_router) -PY - -# ------------------------------------------------------------------- -# 9) Обновить __init__.py пакетов (если scaffold создал пустые) -# ------------------------------------------------------------------- -for s in "${SERVICES[@]}"; do - touch "services/$s/src/app/__init__.py" - touch "services/$s/src/app/api/__init__.py" - touch "services/$s/src/app/api/routes/__init__.py" - touch "services/$s/src/app/core/__init__.py" - touch "services/$s/src/app/db/__init__.py" - touch "services/$s/src/app/repositories/__init__.py" - touch "services/$s/src/app/schemas/__init__.py" - touch "services/$s/src/app/services/__init__.py" -done - -for s in auth profiles match chat payments; do - docker compose run --rm $s alembic revision --autogenerate -m "init" -done - -echo "✅ Models + CRUD + API + Auth applied." - -cat <<'NEXT' -Next steps: - -1) Сгенерируйте первичные миграции по моделям: - for s in auth profiles match chat payments; do - docker compose run --rm $s alembic revision --autogenerate -m "init" - done - -2) Поднимите окружение (alembic upgrade выполнится в entrypoint): - docker compose up --build - -3) Получите токен: - POST http://localhost:8080/auth/v1/register - POST http://localhost:8080/auth/v1/token - -> Authorization: Bearer - -4) Проверьте CRUD: - - Profiles: GET http://localhost:8080/profiles/v1/profiles/me - - Match: POST http://localhost:8080/match/v1/pairs - - Chat: POST http://localhost:8080/chat/v1/rooms - - Payments: POST http://localhost:8080/payments/v1/invoices - -Замечания по безопасности/продакшену: -- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD. -- Сроки жизни токенов подберите под бизнес-политику. -- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте - хранилище jti/ревокацию. -- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав. -- В PROFILES поля languages/interests/preferences — JSONB; при желании замените - на нормализованные таблицы или ARRAY. -NEXT diff --git a/.history/patch_20250808204338.sh b/.history/patch_20250808204338.sh deleted file mode 100644 index e69de29..0000000 diff --git a/.history/patch_20250808204342.sh b/.history/patch_20250808204342.sh deleted file mode 100644 index 4c1b211..0000000 --- a/.history/patch_20250808204342.sh +++ /dev/null @@ -1,68 +0,0 @@ -# Сохраняем фиксер -cat > fix_profiles_fk.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -# 1) Обновим модель Photo: добавим ForeignKey + нормальную relationship -cat > services/profiles/src/app/models/photo.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime - -from sqlalchemy import String, Boolean, DateTime, ForeignKey -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Photo(Base): - __tablename__ = "photos" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - profile_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), - ForeignKey("profiles.id", ondelete="CASCADE"), - index=True, - nullable=False, - ) - url: Mapped[str] = mapped_column(String(500), nullable=False) - is_main: Mapped[bool] = mapped_column(Boolean, default=False) - status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - - profile = relationship("Profile", back_populates="photos") -PY - -# (необязательно, но полезно) поправим типы JSONB в Profile -awk ' - {print} - /languages:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed languages type to list[str])"} - /interests:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed interests type to list[str])"} -' services/profiles/src/app/models/profile.py > services/profiles/src/app/models/profile.py.tmp \ - && mv services/profiles/src/app/models/profile.py.tmp services/profiles/src/app/models/profile.py || true - -# 2) Сгенерируем ревизию Alembic (сравнить модели с БД) -docker compose up -d postgres -docker compose run --rm -v "$PWD/services/profiles":/app profiles \ - sh -lc 'alembic revision --autogenerate -m "add FK photos.profile_id -> profiles.id"' - -# 3) Если автогенерация не добавила FK — вживлём вручную в последнюю ревизию -LAST=$(ls -1t services/profiles/alembic/versions/*.py | head -n1) -if ! grep -q "create_foreign_key" "$LAST"; then - # вставим импорт postgresql (на будущее) и create_foreign_key в upgrade() - sed -i '/import sqlalchemy as sa/a from sqlalchemy.dialects import postgresql' "$LAST" - awk ' - BEGIN{done=0} - /def upgrade/ && done==0 {print; print " op.create_foreign_key("; print " '\''fk_photos_profile_id_profiles'\'',"; print " '\''photos'\'', '\''profiles'\'',"; print " ['\''profile_id'\''], ['\''id'\''],"; print " ondelete='\''CASCADE'\''"; print " )"; done=1; next} - {print} - ' "$LAST" > "$LAST.tmp" && mv "$LAST.tmp" "$LAST" -fi - -# 4) Применим миграции и перезапустим сервис -docker compose run --rm profiles alembic upgrade head -docker compose restart profiles -BASH - -chmod +x fix_profiles_fk.sh -./fix_profiles_fk.sh diff --git a/.history/patch_alembic_template_20250808201930.sh b/.history/patch_alembic_template_20250808201930.sh deleted file mode 100644 index e69de29..0000000 diff --git a/.history/patch_alembic_template_20250808201932.sh b/.history/patch_alembic_template_20250808201932.sh deleted file mode 100644 index 32b1e3a..0000000 --- a/.history/patch_alembic_template_20250808201932.sh +++ /dev/null @@ -1,50 +0,0 @@ -cat > patch_alembic_template.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail -SERVICES=(auth profiles match chat payments) - -for s in "${SERVICES[@]}"; do - TPL="services/$s/alembic/script.py.mako" - mkdir -p "services/$s/alembic" - cat > "$TPL" <<'MAKO' -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} -MAKO - echo "[ok] template updated: $TPL" -done - -# Убедимся, что в env.py импортированы модели (для автогенерации) -for s in "${SERVICES[@]}"; do - ENV="services/$s/alembic/env.py" - if ! grep -q "from app import models" "$ENV"; then - awk ' - /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next} - {print} - ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV" - echo "[ok] added 'from app import models' to $ENV" - fi -done -BASH - -chmod +x patch_alembic_template.sh -./patch_alembic_template.sh diff --git a/.history/patch_alembic_template_20250808201952.sh b/.history/patch_alembic_template_20250808201952.sh deleted file mode 100644 index fe35e0b..0000000 --- a/.history/patch_alembic_template_20250808201952.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SERVICES=(auth profiles match chat payments) - -for s in "${SERVICES[@]}"; do - TPL="services/$s/alembic/script.py.mako" - mkdir -p "services/$s/alembic" - cat > "$TPL" <<'MAKO' -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} -MAKO - echo "[ok] template updated: $TPL" -done - -# Убедимся, что в env.py импортированы модели (для автогенерации) -for s in "${SERVICES[@]}"; do - ENV="services/$s/alembic/env.py" - if ! grep -q "from app import models" "$ENV"; then - awk ' - /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next} - {print} - ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV" - echo "[ok] added 'from app import models' to $ENV" - fi -done - -# удалить ревизии, созданные с битым шаблоном -for s in auth profiles match chat payments; do - rm -f services/$s/alembic/versions/*.py -done - -# поднять Postgres (если не запущен) -docker compose up -d postgres - -# автогенерация первичных ревизий (каждая сохранится в services//alembic/versions/) -for s in auth profiles match chat payments; do - echo "[gen] $s" - docker compose run --rm -v "$PWD/services/$s":/app "$s" \ - sh -lc 'alembic revision --autogenerate -m "init"' -done \ No newline at end of file diff --git a/.history/patch_alembic_template_20250808202000.sh b/.history/patch_alembic_template_20250808202000.sh deleted file mode 100644 index 1e5d5ec..0000000 --- a/.history/patch_alembic_template_20250808202000.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SERVICES=(auth profiles match chat payments) - -for s in "${SERVICES[@]}"; do - TPL="services/$s/alembic/script.py.mako" - mkdir -p "services/$s/alembic" - cat > "$TPL" <<'MAKO' -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} -MAKO - echo "[ok] template updated: $TPL" -done - -# Убедимся, что в env.py импортированы модели (для автогенерации) -for s in "${SERVICES[@]}"; do - ENV="services/$s/alembic/env.py" - if ! grep -q "from app import models" "$ENV"; then - awk ' - /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next} - {print} - ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV" - echo "[ok] added 'from app import models' to $ENV" - fi -done - -# удалить ревизии, созданные с битым шаблоном -for s in auth profiles match chat payments; do - rm -f services/$s/alembic/versions/*.py -done - -# поднять Postgres (если не запущен) -docker compose up -d postgres - -# автогенерация первичных ревизий (каждая сохранится в services//alembic/versions/) -for s in auth profiles match chat payments; do - echo "[gen] $s" - docker compose run --rm -v "$PWD/services/$s":/app "$s" \ - sh -lc 'alembic revision --autogenerate -m "init"' -done - -for s in auth profiles match chat payments; do - echo "---- $s" - ls -1 services/$s/alembic/versions/ -done \ No newline at end of file diff --git a/.history/scripts/api_e2e_20250808212121.py b/.history/scripts/api_e2e_20250808212121.py deleted file mode 100644 index e69de29..0000000 diff --git a/.history/scripts/api_e2e_20250808212124.py b/.history/scripts/api_e2e_20250808212124.py deleted file mode 100644 index 9b376e1..0000000 --- a/.history/scripts/api_e2e_20250808212124.py +++ /dev/null @@ -1,437 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - - # --------- низкоуровневый запрос с логированием ---------- - def req( - self, - method: str, - url: str, - token: Optional[str] = None, - body: Optional[Dict[str, Any]] = None, - expected: Iterable[int] = (200,), - name: Optional[str] = None, - ) -> Tuple[int, Dict[str, Any], str]: - """Возвращает (status_code, json_body_or_{} , raw_text). Бросает исключение, если код не из expected.""" - headers = {"Accept": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - # Безопасное логирование тела запроса - log_body = {} - if body: - log_body = dict(body) - # маскируем пароль/токен в логах - for key in list(log_body.keys()): - if key.lower() in ("password", "token", "access_token", "refresh_token"): - log_body[key] = "***hidden***" - - started = now_ms() - self.logger.debug( - f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}" - ) - - resp = None - text = "" - data: Dict[str, Any] = {} - try: - resp = self.sess.request( - method=method, - url=url, - json=body, - timeout=self.timeout, - ) - text = resp.text - try: - data = resp.json() if text else {} - except ValueError: - data = {} - except Exception as e: - duration = now_ms() - started - self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)") - raise - - duration = now_ms() - started - status = resp.status_code if resp else -1 - - self.logger.debug(f"← {status} in {duration} ms | body={text[:2000]}") - - if expected and status not in expected: - msg = f"{name or url} unexpected status {status}, expected {list(expected)}; body={text}" - self.logger.error(msg) - raise RuntimeError(msg) - - return status, data, text - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/api_e2e_20250808213334.py b/.history/scripts/api_e2e_20250808213334.py deleted file mode 100644 index f7179fe..0000000 --- a/.history/scripts/api_e2e_20250808213334.py +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - - # --------- низкоуровневый запрос с логированием ---------- - def req(self, method, url, token=None, body=None, expected=(200,), name=None): - headers = {"Accept": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - log_body = {} - if body: - log_body = dict(body) - for key in list(log_body.keys()): - if key.lower() in ("password", "token", "access_token", "refresh_token"): - log_body[key] = "***hidden***" - - started = now_ms() - self.logger.debug( - f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}" - ) - - try: - resp = self.sess.request(method=method, url=url, json=body, timeout=self.timeout) - except Exception as e: - duration = now_ms() - started - self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)") - raise - - text = resp.text or "" - try: - data = resp.json() if text else {} - except ValueError: - data = {} - - duration = now_ms() - started - self.logger.debug(f"← {resp.status_code} in {duration} ms | body={text[:2000]}") - if expected and resp.status_code not in expected: - msg = f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}" - self.logger.error(msg) - raise RuntimeError(msg) - return resp.status_code, data, text - - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/api_e2e_20250808215311.py b/.history/scripts/api_e2e_20250808215311.py deleted file mode 100644 index c48b626..0000000 --- a/.history/scripts/api_e2e_20250808215311.py +++ /dev/null @@ -1,419 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - import requests, time, json, logging - - self.session = requests.Session() - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - - # --------- низкоуровневый запрос с логированием ---------- - def req(self, method, url, token=None, body=None, expected=(200,), name=None): - headers = {"Accept": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - log_body = {} - if body: - log_body = dict(body) - for key in list(log_body.keys()): - if key.lower() in ("password", "token", "access_token", "refresh_token"): - log_body[key] = "***hidden***" - - started = now_ms() - self.logger.debug( - f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}" - ) - - try: - resp = self.sess.request(method=method, url=url, json=body, timeout=self.timeout) - except Exception as e: - duration = now_ms() - started - self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)") - raise - - text = resp.text or "" - try: - data = resp.json() if text else {} - except ValueError: - data = {} - - duration = now_ms() - started - self.logger.debug(f"← {resp.status_code} in {duration} ms | body={text[:2000]}") - if expected and resp.status_code not in expected: - msg = f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}" - self.logger.error(msg) - raise RuntimeError(msg) - return resp.status_code, data, text - - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/api_e2e_20250808215326.py b/.history/scripts/api_e2e_20250808215326.py deleted file mode 100644 index 360ab82..0000000 --- a/.history/scripts/api_e2e_20250808215326.py +++ /dev/null @@ -1,423 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - import requests, time, json, logging - - self.session = requests.Session() - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - - # --------- низкоуровневый запрос с логированием ---------- - def req(self, method, url, body=None, token=None, expected=(200,), name=""): - headers = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - # Готовим запрос, чтобы увидеть финальные заголовки - req = requests.Request(method, url, - headers=headers, - data=(json.dumps(body) if body is not None else None)) - prep = self.session.prepare_request(req) - - # ЛОГ: какие заголовки действительно уйдут - self.log.debug("HTTP %s %s | headers=%s | body=%s", - method, url, - {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()}, - (body if body is not None else {})) - - t0 = time.time() - resp = self.session.send(prep, - allow_redirects=False, # ВАЖНО - timeout=15) - dt = int((time.time()-t0)*1000) - - # ЛОГ: редиректы, если были - if resp.is_redirect or resp.is_permanent_redirect or resp.history: - self.log.warning("%s got redirect chain: %s", - name or url, - " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp])) - - text = resp.text - self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000]) - - if resp.status_code not in expected: - raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}") - - data = None - try: - data = resp.json() if text else None - except Exception: - pass - return resp.status_code, data, resp.headers - - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/api_e2e_20250808215359.py b/.history/scripts/api_e2e_20250808215359.py deleted file mode 100644 index f5e0329..0000000 --- a/.history/scripts/api_e2e_20250808215359.py +++ /dev/null @@ -1,423 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - import requests, time, json, logging - - - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - self.session = requests.Session() - # --------- низкоуровневый запрос с логированием ---------- - def req(self, method, url, body=None, token=None, expected=(200,), name=""): - headers = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - # Готовим запрос, чтобы увидеть финальные заголовки - req = requests.Request(method, url, - headers=headers, - data=(json.dumps(body) if body is not None else None)) - prep = self.session.prepare_request(req) - - # ЛОГ: какие заголовки действительно уйдут - self.log.debug("HTTP %s %s | headers=%s | body=%s", - method, url, - {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()}, - (body if body is not None else {})) - - t0 = time.time() - resp = self.session.send(prep, - allow_redirects=False, # ВАЖНО - timeout=15) - dt = int((time.time()-t0)*1000) - - # ЛОГ: редиректы, если были - if resp.is_redirect or resp.is_permanent_redirect or resp.history: - self.log.warning("%s got redirect chain: %s", - name or url, - " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp])) - - text = resp.text - self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000]) - - if resp.status_code not in expected: - raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}") - - data = None - try: - data = resp.json() if text else None - except Exception: - pass - return resp.status_code, data, resp.headers - - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/api_e2e_20250808215427.py b/.history/scripts/api_e2e_20250808215427.py deleted file mode 100644 index 42a572e..0000000 --- a/.history/scripts/api_e2e_20250808215427.py +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - import requests, time, json, logging - - - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - self.session = requests.Session() - - # --------- низкоуровневый запрос с логированием ---------- - def req(self, method, url, body=None, token=None, expected=(200,), name=""): - headers = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - # Готовим запрос, чтобы увидеть финальные заголовки - req = requests.Request(method, url, - headers=headers, - data=(json.dumps(body) if body is not None else None)) - prep = self.session.prepare_request(req) - - # ЛОГ: какие заголовки действительно уйдут - self.log.debug("HTTP %s %s | headers=%s | body=%s", - method, url, - {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()}, - (body if body is not None else {})) - - t0 = time.time() - resp = self.session.send(prep, - allow_redirects=False, # ВАЖНО - timeout=15) - dt = int((time.time()-t0)*1000) - - # ЛОГ: редиректы, если были - if resp.is_redirect or resp.is_permanent_redirect or resp.history: - self.log.warning("%s got redirect chain: %s", - name or url, - " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp])) - - text = resp.text - self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000]) - - if resp.status_code not in expected: - raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}") - - data = None - try: - data = resp.json() if text else None - except Exception: - pass - return resp.status_code, data, resp.headers - - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/api_e2e_20250808215516.py b/.history/scripts/api_e2e_20250808215516.py deleted file mode 100644 index 54b9aef..0000000 --- a/.history/scripts/api_e2e_20250808215516.py +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - - - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - import requests, time, json, logging - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - self.session = requests.Session() - - # --------- низкоуровневый запрос с логированием ---------- - def req(self, method, url, body=None, token=None, expected=(200,), name=""): - headers = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - # Готовим запрос, чтобы увидеть финальные заголовки - req = requests.Request(method, url, - headers=headers, - data=(json.dumps(body) if body is not None else None)) - prep = self.session.prepare_request(req) - - # ЛОГ: какие заголовки действительно уйдут - self.log.debug("HTTP %s %s | headers=%s | body=%s", - method, url, - {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()}, - (body if body is not None else {})) - - t0 = time.time() - resp = self.session.send(prep, - allow_redirects=False, # ВАЖНО - timeout=15) - dt = int((time.time()-t0)*1000) - - # ЛОГ: редиректы, если были - if resp.is_redirect or resp.is_permanent_redirect or resp.history: - self.log.warning("%s got redirect chain: %s", - name or url, - " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp])) - - text = resp.text - self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000]) - - if resp.status_code not in expected: - raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}") - - data = None - try: - data = resp.json() if text else None - except Exception: - pass - return resp.status_code, data, resp.headers - - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/api_e2e_20250808215528.py b/.history/scripts/api_e2e_20250808215528.py deleted file mode 100644 index 2eba317..0000000 --- a/.history/scripts/api_e2e_20250808215528.py +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - - - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - - - # --------- низкоуровневый запрос с логированием ---------- - def req(self, method, url, body=None, token=None, expected=(200,), name=""): - headers = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - # Готовим запрос, чтобы увидеть финальные заголовки - req = requests.Request(method, url, - headers=headers, - data=(json.dumps(body) if body is not None else None)) - prep = self.session.prepare_request(req) - - # ЛОГ: какие заголовки действительно уйдут - self.log.debug("HTTP %s %s | headers=%s | body=%s", - method, url, - {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()}, - (body if body is not None else {})) - - t0 = time.time() - resp = self.session.send(prep, - allow_redirects=False, # ВАЖНО - timeout=15) - dt = int((time.time()-t0)*1000) - - # ЛОГ: редиректы, если были - if resp.is_redirect or resp.is_permanent_redirect or resp.history: - self.log.warning("%s got redirect chain: %s", - name or url, - " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp])) - - text = resp.text - self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000]) - - if resp.status_code not in expected: - raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}") - - data = None - try: - data = resp.json() if text else None - except Exception: - pass - return resp.status_code, data, resp.headers - - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/api_e2e_20250808215617.py b/.history/scripts/api_e2e_20250808215617.py deleted file mode 100644 index 7e9f8e5..0000000 --- a/.history/scripts/api_e2e_20250808215617.py +++ /dev/null @@ -1,417 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import base64 -import json -import logging -import os -import random -import string -import sys -import time -from dataclasses import dataclass -from logging.handlers import RotatingFileHandler -from typing import Any, Dict, Iterable, List, Optional, Tuple -from urllib.parse import urljoin - -import requests -from faker import Faker - -# ------------------------- -# Конфигурация по умолчанию -# ------------------------- -DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080") -DEFAULT_PASSWORD = os.getenv("PASS", "secret123") -DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2")) -DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev") -DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log") -DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0")) - -# ------------------------- -# Логирование -# ------------------------- -def setup_logger(path: str) -> logging.Logger: - os.makedirs(os.path.dirname(path), exist_ok=True) - logger = logging.getLogger("api_e2e") - logger.setLevel(logging.DEBUG) - - # Ротация логов: до 5 файлов по 5 МБ - file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logger.addHandler(file_handler) - - # Консоль — INFO и короче - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) - logger.addHandler(console) - return logger - -# ------------------------- -# Утилиты -# ------------------------- -def b64url_json(token_part: str) -> Dict[str, Any]: - """Декодирует часть JWT (payload) без валидации сигнатуры.""" - s = token_part + "=" * (-len(token_part) % 4) - return json.loads(base64.urlsafe_b64decode(s).decode("utf-8")) - -def decode_jwt_sub(token: str) -> str: - try: - payload = b64url_json(token.split(".")[1]) - return str(payload.get("sub", "")) # UUID пользователя - except Exception: - return "" - -def mask_token(token: Optional[str]) -> str: - if not token: - return "" - return token[:12] + "..." if len(token) > 12 else token - -def now_ms() -> int: - return int(time.time() * 1000) - -@dataclass -class UserCreds: - id: str - email: str - access_token: str - role: str - -# ------------------------- -# Класс-клиент -# ------------------------- -class APIE2E: - - def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None: - self.base_url = base_url.rstrip("/") + "/" - self.logger = logger - self.timeout = timeout - self.sess = requests.Session() - - self.urls = { - "auth": urljoin(self.base_url, "auth/"), - "profiles": urljoin(self.base_url, "profiles/"), - "match": urljoin(self.base_url, "match/"), - "chat": urljoin(self.base_url, "chat/"), - "payments": urljoin(self.base_url, "payments/"), - } - - # --------- низкоуровневый запрос с логированием ---------- - def req(self, method, url, token=None, body=None, expected=(200,), name=None): - headers = {"Accept": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - - log_body = {} - if body: - log_body = dict(body) - for key in list(log_body.keys()): - if key.lower() in ("password", "token", "access_token", "refresh_token"): - log_body[key] = "***hidden***" - - started = now_ms() - self.logger.debug( - f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}" - ) - - try: - resp = self.sess.request(method=method, url=url, json=body, timeout=self.timeout) - except Exception as e: - duration = now_ms() - started - self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)") - raise - - text = resp.text or "" - try: - data = resp.json() if text else {} - except ValueError: - data = {} - - duration = now_ms() - started - self.logger.debug(f"← {resp.status_code} in {duration} ms | body={text[:2000]}") - if expected and resp.status_code not in expected: - msg = f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}" - self.logger.error(msg) - raise RuntimeError(msg) - return resp.status_code, data, text - - - # --------- health ---------- - def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None: - self.logger.info(f"Waiting {name} health: {url}") - deadline = time.time() + timeout_sec - while time.time() < deadline: - try: - code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health") - if code == 200: - self.logger.info(f"{name} is healthy") - return - except Exception: - pass - time.sleep(1) - raise TimeoutError(f"{name} not healthy in time: {url}") - - # --------- auth ---------- - def login(self, email: str, password: str) -> Tuple[str, str]: - url = urljoin(self.urls["auth"], "v1/token") - _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login") - token = data.get("access_token", "") - if not token: - raise RuntimeError("access_token is empty") - user_id = decode_jwt_sub(token) - if not user_id: - raise RuntimeError("cannot decode user id (sub) from token") - return user_id, token - - def register(self, email: str, password: str, full_name: str, role: str) -> None: - url = urljoin(self.urls["auth"], "v1/register") - # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации — - # поэтому не падаем на 500 сразу, а логинимся ниже. - try: - self.req( - "POST", - url, - body={"email": email, "password": password, "full_name": full_name, "role": role}, - expected=(200, 201), - name="register", - ) - except RuntimeError as e: - self.logger.warning(f"register returned non-2xx: {e} — will try login anyway") - - def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds: - # 1) пробуем логин - try: - uid, token = self.login(email, password) - self.logger.info(f"Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - except Exception as e: - self.logger.info(f"Login failed for {email}: {e}; will try register") - - # 2) регистрируем (не фатально, если вернулся 500) - self.register(email, password, full_name, role) - - # 3) снова логин - uid, token = self.login(email, password) - self.logger.info(f"Registered+Login OK: {email} -> {uid}") - return UserCreds(id=uid, email=email, access_token=token, role=role) - - # --------- profiles ---------- - def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]: - url = urljoin(self.urls["profiles"], "v1/profiles/me") - code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me") - return code, data - - def create_profile( - self, - token: str, - gender: str, - city: str, - languages: List[str], - interests: List[str], - ) -> Dict[str, Any]: - url = urljoin(self.urls["profiles"], "v1/profiles") - _, data, _ = self.req( - "POST", - url, - token=token, - body={"gender": gender, "city": city, "languages": languages, "interests": interests}, - expected=(200, 201), - name="profiles/create", - ) - return data - - def ensure_profile( - self, token: str, gender: str, city: str, languages: List[str], interests: List[str] - ) -> Dict[str, Any]: - code, p = self.get_my_profile(token) - if code == 200: - self.logger.info(f"Profile exists: id={p.get('id')}") - return p - self.logger.info("Profile not found -> creating") - p = self.create_profile(token, gender, city, languages, interests) - self.logger.info(f"Profile created: id={p.get('id')}") - return p - - # --------- match ---------- - def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]: - url = urljoin(self.urls["match"], "v1/pairs") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes}, - expected=(200, 201), - name="match/create_pair", - ) - return data - - # --------- chat ---------- - def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], "v1/rooms") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"title": title, "participants": participants}, - expected=(200, 201), - name="chat/create_room", - ) - return data - - def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]: - url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"content": content}, - expected=(200, 201), - name="chat/send_message", - ) - return data - - # --------- payments ---------- - def create_invoice( - self, admin_token: str, client_id: str, amount: float, currency: str, description: str - ) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], "v1/invoices") - _, data, _ = self.req( - "POST", - url, - token=admin_token, - body={"client_id": client_id, "amount": amount, "currency": currency, "description": description}, - expected=(200, 201), - name="payments/create_invoice", - ) - return data - - def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]: - url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid") - _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid") - return data - -# ------------------------- -# Генерация данных -# ------------------------- -GENDERS = ["female", "male", "other"] -CITIES = [ - "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar", - "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi", -] -LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"] -INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"] - -def pick_languages(n: int = 2) -> List[str]: - n = max(1, min(n, len(LANG_POOL))) - return sorted(random.sample(LANG_POOL, n)) - -def pick_interests(n: int = 3) -> List[str]: - n = max(1, min(n, len(INTR_POOL))) - return sorted(random.sample(INTR_POOL, n)) - -def random_email(prefix: str, domain: str) -> str: - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - return f"{prefix}+{int(time.time())}.{suffix}@{domain}" - -# ------------------------- -# Основной сценарий -# ------------------------- -def main(): - import argparse - - parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.") - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)") - parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)") - parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей") - parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)") - parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)") - parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)") - args = parser.parse_args() - - random.seed(args.seed) - fake = Faker() - logger = setup_logger(args.log_file) - logger.info("=== API E2E START ===") - logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}") - - if args.clients < 2: - logger.error("Нужно минимум 2 клиента (для пары).") - sys.exit(2) - - api = APIE2E(args.base_url, logger) - - # Health checks через gateway - api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health")) - api.wait_health("profiles", urljoin(api.urls["profiles"], "health")) - api.wait_health("match", urljoin(api.urls["match"], "health")) - api.wait_health("chat", urljoin(api.urls["chat"], "health")) - api.wait_health("payments", urljoin(api.urls["payments"], "health")) - - # Админ - admin_email = random_email("admin", args.email_domain) - admin_full = fake.name() - admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN") - - # Клиенты - clients: List[UserCreds] = [] - for i in range(args.clients): - email = random_email(f"user{i+1}", args.email_domain) - full = fake.name() - u = api.login_or_register(email, args.password, full, role="CLIENT") - clients.append(u) - - # Профили для всех - for i, u in enumerate([admin] + clients, start=1): - gender = random.choice(GENDERS) - city = random.choice(CITIES) - languages = pick_languages(random.choice([1, 2, 3])) - interests = pick_interests(random.choice([2, 3, 4])) - logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})") - api.ensure_profile(u.access_token, gender, city, languages, interests) - - # Match‑пара между двумя случайными клиентами - a, b = random.sample(clients, 2) - score = round(random.uniform(0.6, 0.98), 2) - pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated") - pair_id = str(pair.get("id", "")) - logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}") - - # Чат‑комната и сообщение - room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id]) - room_id = str(room.get("id", "")) - msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)") - msg_id = str(msg.get("id", "")) - logger.info(f"Chat message sent: room={room_id}, msg={msg_id}") - - # Счёт для первого клиента - amount = random.choice([99.0, 199.0, 299.0]) - inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD", - description="Consultation (e2e)") - inv_id = str(inv.get("id", "")) - invp = api.mark_invoice_paid(admin.access_token, inv_id) - logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}") - - # Итог - summary = { - "admin": {"email": admin.email, "id": admin.id}, - "clients": [{"email": c.email, "id": c.id} for c in clients], - "pair_id": pair_id, - "room_id": room_id, - "message_id": msg_id, - "invoice_id": inv_id, - "invoice_status": invp.get("status"), - } - logger.info("=== SUMMARY ===") - logger.info(json.dumps(summary, ensure_ascii=False, indent=2)) - print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nInterrupted.", file=sys.stderr) - sys.exit(130) diff --git a/.history/scripts/e2e_20250808205322.sh b/.history/scripts/e2e_20250808205322.sh deleted file mode 100644 index e69de29..0000000 diff --git a/.history/scripts/e2e_20250808205324.sh b/.history/scripts/e2e_20250808205324.sh deleted file mode 100644 index 0f54893..0000000 --- a/.history/scripts/e2e_20250808205324.sh +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# ------------------------------------------------------------ -# E2E smoke test for the matchmaking microservices -# Services via gateway: /auth, /profiles, /match, /chat, /payments -# ------------------------------------------------------------ - -BASE_URL="${BASE_URL:-http://localhost:8080}" -AUTH="$BASE_URL/auth" -PROFILES="$BASE_URL/profiles" -MATCH="$BASE_URL/match" -CHAT="$BASE_URL/chat" -PAYMENTS="$BASE_URL/payments" - -# Colors -NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m' - -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - -require() { - command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required"; exit 1; } -} -require curl -require python3 - -log() { echo -e "${C}[$(date +%H:%M:%S)]${NC} $*"; } -ok() { echo -e "${G}✔${NC} $*"; } -warn(){ echo -e "${Y}⚠${NC} $*"; } -fail(){ echo -e "${R}✖${NC} $*"; exit 1; } - -json_get() { - # json_get (dot notation; arrays allowed by numeric index) - python3 - "$1" "$2" <<'PY' -import sys, json -f, path = sys.argv[1], sys.argv[2] -with open(f, 'r') as fh: - try: - data = json.load(fh) - except Exception: - print(""); sys.exit(0) -cur = data -for key in path.split('.'): - if isinstance(cur, list): - try: - key = int(key) - except: - print(""); sys.exit(0) - cur = cur[key] if 0 <= key < len(cur) else None - elif isinstance(cur, dict): - cur = cur.get(key) - else: - cur = None - if cur is None: - break -print("" if cur is None else cur) -PY -} - -http_req() { - # http_req [] [] -> prints HTTP code; body to $RESP - local METHOD="$1"; shift - local URL="$1"; shift - local TOKEN="${1:-}"; shift || true - local BODY="${1:-}"; shift || true - local RESP="${TMP_DIR}/resp_$(date +%s%N).json" - - local args=(-sS -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") - if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi - if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi - - local CODE - CODE="$(curl "${args[@]}")" - echo "$CODE|$RESP" -} - -expect_code() { - # expect_code "" "||..." - local ACT="$1"; local ALLOWED="$2" - if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then - return 0 - fi - return 1 -} - -wait_health() { - local NAME="$1"; local URL="$2"; local tries=60 - log "Waiting ${NAME} health: ${URL}" - for ((i=1; i<=tries; i++)); do - local CODE - CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" - if [[ "$CODE" == "200" ]]; then ok "${NAME} is healthy"; return 0; fi - sleep 1 - done - fail "${NAME} not healthy in time: ${URL}" -} - -register_or_login() { - # register_or_login -> echoes "|" - local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" - - local BODY REG RESPCODE RESP REG_ID - BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") - REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" - RESPCODE="${REG%%|*}"; RESP="${REG##*|}" - - if expect_code "$RESPCODE" "201|200"; then - ok "Registered user ${EMAIL}" - else - # maybe already exists - local MSG - MSG="$(json_get "$RESP" "detail")" - if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then - warn "User ${EMAIL} already exists, will login" - else - warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" - fi - fi - - # token - local TOK TOKCODE TOKRESP - BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") - TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")" - TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" - expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" - - local ACCESS REFRESH - ACCESS="$(json_get "$TOKRESP" "access_token")" - REFRESH="$(json_get "$TOKRESP" "refresh_token")" - [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}" - - # resolve user id via /me - local ME MECODE MERESP UID - ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")" - MECODE="${ME%%|*}"; MERESP="${ME##*|}" - expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")" - UID="$(json_get "$MERESP" "id")" - [[ -n "$UID" ]] || fail "Failed to parse user id for ${EMAIL}" - - echo "${UID}|${ACCESS}" -} - -ensure_profile() { - # ensure_profile - local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5" - - # GET /profiles/me: 200 or 404 - local ME MECODE MERESP - ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" - MECODE="${ME%%|*}"; MERESP="${ME##*|}" - if [[ "$MECODE" == "200" ]]; then - ok "Profile already exists" - echo "$MERESP" > "${TMP_DIR}/last_profile.json" - return 0 - elif [[ "$MECODE" != "404" ]]; then - warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")" - fi - - # Create profile - IFS=',' read -r -a langs <<< "$LANGS_CSV" - IFS=',' read -r -a intrs <<< "$INTERESTS_CSV" - local langs_json intrs_json - langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" - intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" - local BODY - BODY=$(cat < "${TMP_DIR}/last_profile.json" -} - -main() { - echo -e "${B}=== E2E smoke test start ===${NC}" - echo "BASE_URL: $BASE_URL" - echo - - # 0) Wait for services - wait_health "gateway" "$BASE_URL/" - wait_health "auth" "$AUTH/health" - wait_health "profiles" "$PROFILES/health" - wait_health "match" "$MATCH/health" - wait_health "chat" "$CHAT/health" - wait_health "payments" "$PAYMENTS/health" - - # 1) Register/login users - TS="$(date +%s)" - ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" - ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" - BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" - PASS="${PASS:-secret123}" - - log "Register/login admin: ${ADMIN_EMAIL}" - IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN") - ok "Admin id: $ADMIN_ID" - - log "Register/login Alice: ${ALICE_EMAIL}" - IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT") - ok "Alice id: $ALICE_ID" - - log "Register/login Bob: ${BOB_EMAIL}" - IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT") - ok "Bob id: $BOB_ID" - - # 2) Ensure profiles for all three - log "Ensure profile for Admin" - ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" - - log "Ensure profile for Alice" - ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" - - log "Ensure profile for Bob" - ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" - - # 3) Create match pair (admin) - log "Create match pair (Alice ↔ Bob)" - BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) - PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")" - PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" - expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" - PAIR_ID="$(json_get "$PRESP" "id")" - ok "Pair created: $PAIR_ID" - - # 4) Create chat room and send a message (admin) - log "Create chat room (Admin + Alice + Bob)" - BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") - ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")" - RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" - expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" - ROOM_ID="$(json_get "$RRESP" "id")" - ok "Room created: $ROOM_ID" - - log "Send message to room" - BODY='{"content":"Hello from admin (e2e)"}' - MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")" - MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" - expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" - MSG_ID="$(json_get "$MRESP" "id")" - ok "Message sent: $MSG_ID" - - # 5) Create invoice for Alice and mark paid (admin) - log "Create invoice for Alice" - BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") - INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")" - INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" - expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" - INV_ID="$(json_get "$INVRESP" "id")" - ok "Invoice created: $INV_ID" - - log "Mark invoice paid" - PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")" - PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" - expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" - STATUS="$(json_get "$PDRESP" "status")" - [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')" - ok "Invoice marked paid" - - echo - echo -e "${B}=== E2E summary ===${NC}" - echo -e "Admin: ${G}${ADMIN_EMAIL}${NC} (id: ${ADMIN_ID})" - echo -e "Alice: ${G}${ALICE_EMAIL}${NC} (id: ${ALICE_ID})" - echo -e "Bob: ${G}${BOB_EMAIL}${NC} (id: ${BOB_ID})" - echo -e "Pair: ${C}${PAIR_ID}${NC}" - echo -e "Room: ${C}${ROOM_ID}${NC} Message: ${C}${MSG_ID}${NC}" - echo -e "Invoice:${C}${INV_ID}${NC} Status: ${G}${STATUS}${NC}" - echo - ok "E2E smoke test finished successfully." -} - -main "$@" diff --git a/.history/scripts/e2e_20250808205905.sh b/.history/scripts/e2e_20250808205905.sh deleted file mode 100644 index a6a3626..0000000 --- a/.history/scripts/e2e_20250808205905.sh +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# ------------------------------------------------------------ -# E2E smoke test for the matchmaking microservices (via gateway) -# ------------------------------------------------------------ - -BASE_URL="${BASE_URL:-http://localhost:8080}" -AUTH="$BASE_URL/auth" -PROFILES="$BASE_URL/profiles" -MATCH="$BASE_URL/match" -CHAT="$BASE_URL/chat" -PAYMENTS="$BASE_URL/payments" - -# Где проверять доступность gateway (по умолчанию /auth/health). -GW_HEALTH_PATH="${GW_HEALTH_PATH:-/auth/health}" - -# Colors -NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m' - -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - -require() { - command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required"; exit 1; } -} -require curl -require python3 - -log() { echo -e "${C}[$(date +%H:%M:%S)]${NC} $*"; } -ok() { echo -e "${G}✔${NC} $*"; } -warn(){ echo -e "${Y}⚠${NC} $*"; } -fail(){ echo -e "${R}✖${NC} $*"; exit 1; } - -json_get() { - # json_get - python3 - "$1" "$2" <<'PY' -import sys, json -f, path = sys.argv[1], sys.argv[2] -with open(f, 'r') as fh: - try: - data = json.load(fh) - except Exception: - print(""); sys.exit(0) -cur = data -for key in path.split('.'): - if isinstance(cur, list): - try: - key = int(key) - except: - print(""); sys.exit(0) - cur = cur[key] if 0 <= key < len(cur) else None - elif isinstance(cur, dict): - cur = cur.get(key) - else: - cur = None - if cur is None: - break -print("" if cur is None else cur) -PY -} - -http_req() { - # http_req [] [] -> prints HTTP code; body to $RESP - local METHOD="$1"; shift - local URL="$1"; shift - local TOKEN="${1:-}"; shift || true - local BODY="${1:-}"; shift || true - local RESP="${TMP_DIR}/resp_$(date +%s%N).json" - - local args=(-sS --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") - if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi - if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi - - local CODE - CODE="$(curl "${args[@]}" || true)" - echo "$CODE|$RESP" -} - -expect_code() { - # expect_code "" "||..." - local ACT="$1"; local ALLOWED="$2" - if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then - return 0 - fi - return 1 -} - -wait_http() { - # wait_http [|default 200] [|60] - local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}" - log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})" - for ((i=1; i<=TRIES; i++)); do - local CODE - CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" - if expect_code "$CODE" "$ALLOWED"; then - ok "${NAME} is ready (${CODE})" - return 0 - fi - sleep 1 - done - fail "${NAME} not ready in time: ${URL}" -} - -wait_health() { - # wait_health [|60] (expects 200) - wait_http "$1" "$2" "200" "${3:-60}" -} - -register_or_login() { - # register_or_login -> echoes "|" - local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" - - local BODY REG RESPCODE RESP - BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") - REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" - RESPCODE="${REG%%|*}"; RESP="${REG##*|}" - - if expect_code "$RESPCODE" "201|200"; then - ok "Registered user ${EMAIL}" - else - local MSG - MSG="$(json_get "$RESP" "detail")" - if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then - warn "User ${EMAIL} already exists, will login" - else - warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" - fi - fi - - local TOK TOKCODE TOKRESP - BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") - TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")" - TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" - expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" - - local ACCESS - ACCESS="$(json_get "$TOKRESP" "access_token")" - [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}" - - local ME MECODE MERESP UID - ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")" - MECODE="${ME%%|*}"; MERESP="${ME##*|}" - expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")" - UID="$(json_get "$MERESP" "id")" - [[ -n "$UID" ]] || fail "Failed to parse user id for ${EMAIL}" - - echo "${UID}|${ACCESS}" -} - -ensure_profile() { - # ensure_profile - local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5" - - local ME MECODE MERESP - ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" - MECODE="${ME%%|*}"; MERESP="${ME##*|}" - if [[ "$MECODE" == "200" ]]; then - ok "Profile already exists" - echo "$MERESP" > "${TMP_DIR}/last_profile.json" - return 0 - elif [[ "$MECODE" != "404" ]]; then - warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")" - fi - - IFS=',' read -r -a langs <<< "$LANGS_CSV" - IFS=',' read -r -a intrs <<< "$INTERESTS_CSV" - local langs_json intrs_json - langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" - intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" - - local BODY - BODY=$(cat < "${TMP_DIR}/last_profile.json" -} - -main() { - echo -e "${B}=== E2E smoke test start ===${NC}" - echo "BASE_URL: $BASE_URL" - echo - - # 0) Wait for gateway by checking proxied /auth/health (root / может отдавать 404/403) - wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}" - - # 1) Wait for services (через gateway) - wait_health "auth" "$AUTH/health" - wait_health "profiles" "$PROFILES/health" - wait_health "match" "$MATCH/health" - wait_health "chat" "$CHAT/health" - wait_health "payments" "$PAYMENTS/health" - - # 2) Register/login users - TS="$(date +%s)" - ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" - ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" - BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" - PASS="${PASS:-secret123}" - - log "Register/login admin: ${ADMIN_EMAIL}" - IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN") - ok "Admin id: $ADMIN_ID" - - log "Register/login Alice: ${ALICE_EMAIL}" - IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT") - ok "Alice id: $ALICE_ID" - - log "Register/login Bob: ${BOB_EMAIL}" - IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT") - ok "Bob id: $BOB_ID" - - # 3) Ensure profiles - log "Ensure profile for Admin" - ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" - - log "Ensure profile for Alice" - ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" - - log "Ensure profile for Bob" - ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" - - # 4) Create match pair - log "Create match pair (Alice ↔ Bob)" - BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) - PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")" - PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" - expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" - PAIR_ID="$(json_get "$PRESP" "id")" - ok "Pair created: $PAIR_ID" - - # 5) Create chat room and send a message - log "Create chat room (Admin + Alice + Bob)" - BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") - ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")" - RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" - expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" - ROOM_ID="$(json_get "$RRESP" "id")" - ok "Room created: $ROOM_ID" - - log "Send message to room" - BODY='{"content":"Hello from admin (e2e)"}' - MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")" - MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" - expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" - MSG_ID="$(json_get "$MRESP" "id")" - ok "Message sent: $MSG_ID" - - # 6) Create invoice for Alice and mark paid - log "Create invoice for Alice" - BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") - INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")" - INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" - expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" - INV_ID="$(json_get "$INVRESP" "id")" - ok "Invoice created: $INV_ID" - - log "Mark invoice paid" - PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")" - PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" - expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" - STATUS="$(json_get "$PDRESP" "status")" - [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')" - ok "Invoice marked paid" - - echo - echo -e "${B}=== E2E summary ===${NC}" - echo -e "Admin: ${G}${ADMIN_EMAIL}${NC} (id: ${ADMIN_ID})" - echo -e "Alice: ${G}${ALICE_EMAIL}${NC} (id: ${ALICE_ID})" - echo -e "Bob: ${G}${BOB_EMAIL}${NC} (id: ${BOB_ID})" - echo -e "Pair: ${C}${PAIR_ID}${NC}" - echo -e "Room: ${C}${ROOM_ID}${NC} Message: ${C}${MSG_ID}${NC}" - echo -e "Invoice:${C}${INV_ID}${NC} Status: ${G}${STATUS}${NC}" - echo - ok "E2E smoke test finished successfully." -} - -main "$@" diff --git a/.history/scripts/e2e_20250808210443.sh b/.history/scripts/e2e_20250808210443.sh deleted file mode 100644 index 8bb0f6e..0000000 --- a/.history/scripts/e2e_20250808210443.sh +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# ------------------------------------------------------------ -# E2E smoke test for the matchmaking microservices (via gateway) -# ------------------------------------------------------------ - -BASE_URL="${BASE_URL:-http://localhost:8080}" -AUTH="$BASE_URL/auth" -PROFILES="$BASE_URL/profiles" -MATCH="$BASE_URL/match" -CHAT="$BASE_URL/chat" -PAYMENTS="$BASE_URL/payments" - -# Где проверять gateway (root / часто не 200) -GW_HEALTH_PATH="${GW_HEALTH_PATH:-/auth/health}" - -# Colors -NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m' - -TMP_DIR="$(mktemp -d)" -cleanup() { rm -rf "$TMP_DIR"; } -trap cleanup EXIT - -require() { - command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required" >&2; exit 1; } -} -require curl -require python3 - -log() { echo -e "${C}[$(date +%H:%M:%S)]${NC} $*" >&2; } -ok() { echo -e "${G}✔${NC} $*" >&2; } -warn() { echo -e "${Y}⚠${NC} $*" >&2; } -fail() { echo -e "${R}✖${NC} $*" >&2; exit 1; } - -json_get() { - # json_get - python3 - "$1" "$2" <<'PY' -import sys, json, os -f, path = sys.argv[1], sys.argv[2] -if not os.path.exists(f): - print(""); sys.exit(0) -with open(f, 'r') as fh: - try: - data = json.load(fh) - except Exception: - print(""); sys.exit(0) -cur = data -for key in path.split('.'): - if isinstance(cur, list): - try: - key = int(key) - except: - print(""); sys.exit(0) - cur = cur[key] if 0 <= key < len(cur) else None - elif isinstance(cur, dict): - cur = cur.get(key) - else: - cur = None - if cur is None: - break -print("" if cur is None else cur) -PY -} - -http_req() { - # http_req [] [] -> prints "HTTP_CODE|/path/to/body.json" - local METHOD="$1"; shift - local URL="$1"; shift - local TOKEN="${1:-}"; shift || true - local BODY="${1:-}"; shift || true - local RESP="${TMP_DIR}/resp_$(date +%s%N).json" - - local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") - if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi - if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi - - local CODE - CODE="$(curl "${args[@]}" || true)" - [[ -e "$RESP" ]] || : > "$RESP" # гарантируем наличие файла - echo "$CODE|$RESP" -} - -expect_code() { - # expect_code "" "||..." - local ACT="$1"; local ALLOWED="$2" - if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then - return 0 - fi - return 1 -} - -wait_http() { - # wait_http [|default 200] [|60] - local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}" - log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})" - for ((i=1; i<=TRIES; i++)); do - local CODE - CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" - if expect_code "$CODE" "$ALLOWED"; then - ok "${NAME} is ready (${CODE})" - return 0 - fi - sleep 1 - done - fail "${NAME} not ready in time: ${URL}" -} - -wait_health() { wait_http "$1" "$2" "200" "${3:-60}"; } - -register_or_login() { - # register_or_login -> echoes "|" - local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" - - # try register (не фатально, даже если 500/409 — дальше попытаемся получить токен) - local BODY REG RESPCODE RESP - BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") - REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" - RESPCODE="${REG%%|*}"; RESP="${REG##*|}" - if expect_code "$RESPCODE" "201|200"; then - ok "Registered user ${EMAIL}" - else - local MSG; MSG="$(json_get "$RESP" "detail")" - if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then - warn "User ${EMAIL} already exists, will login" - else - warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" - fi - fi - - # get token (обязательно) - local TOK TOKCODE TOKRESP - BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") - TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")" - TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" - expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" - - local ACCESS - ACCESS="$(json_get "$TOKRESP" "access_token")" - [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}" - - # resolve user id via /me - local ME MECODE MERESP USER_ID - ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")" - MECODE="${ME%%|*}"; MERESP="${ME##*|}" - expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")" - USER_ID="$(json_get "$MERESP" "id")" - [[ -n "$USER_ID" ]] || fail "Failed to parse user id for ${EMAIL}" - - # ВНИМАНИЕ: в stdout только данные! - echo "${USER_ID}|${ACCESS}" -} - -ensure_profile() { - # ensure_profile - local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5" - [[ -n "$TOKEN" ]] || fail "Empty token passed to ensure_profile" - - local ME MECODE MERESP - ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" - MECODE="${ME%%|*}"; MERESP="${ME##*|}" - if [[ "$MECODE" == "200" ]]; then - ok "Profile already exists" - echo "$MERESP" > "${TMP_DIR}/last_profile.json" - return 0 - elif [[ "$MECODE" != "404" ]]; then - warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")" - fi - - IFS=',' read -r -a langs <<< "$LANGS_CSV" - IFS=',' read -r -a intrs <<< "$INTERESTS_CSV" - local langs_json intrs_json - langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" - intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" - - local BODY - BODY=$(cat < "${TMP_DIR}/last_profile.json" -} - -main() { - echo -e "${B}=== E2E smoke test start ===${NC}" >&2 - echo "BASE_URL: $BASE_URL" >&2 - echo >&2 - - # 0) Gateway health via proxied /auth/health - wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}" - - # 1) Service health (via gateway) - wait_health "auth" "$AUTH/health" - wait_health "profiles" "$PROFILES/health" - wait_health "match" "$MATCH/health" - wait_health "chat" "$CHAT/health" - wait_health "payments" "$PAYMENTS/health" - - # 2) Register/login users - TS="$(date +%s)" - ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" - ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" - BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" - PASS="${PASS:-secret123}" - - log "Register/login admin: ${ADMIN_EMAIL}" - IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN") - ok "Admin id: $ADMIN_ID" - - log "Register/login Alice: ${ALICE_EMAIL}" - IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT") - ok "Alice id: $ALICE_ID" - - log "Register/login Bob: ${BOB_EMAIL}" - IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT") - ok "Bob id: $BOB_ID" - - # 3) Ensure profiles - log "Ensure profile for Admin" - ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" - - log "Ensure profile for Alice" - ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" - - log "Ensure profile for Bob" - ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" - - # 4) Create match pair - log "Create match pair (Alice ↔ Bob)" - BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) - PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")" - PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" - expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" - PAIR_ID="$(json_get "$PRESP" "id")" - ok "Pair created: $PAIR_ID" - - # 5) Create chat room and send a message - log "Create chat room (Admin + Alice + Bob)" - BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") - ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")" - RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" - expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" - ROOM_ID="$(json_get "$RRESP" "id")" - ok "Room created: $ROOM_ID" - - log "Send message to room" - BODY='{"content":"Hello from admin (e2e)"}' - MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")" - MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" - expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" - MSG_ID="$(json_get "$MRESP" "id")" - ok "Message sent: $MSG_ID" - - # 6) Create invoice for Alice and mark paid - log "Create invoice for Alice" - BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") - INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")" - INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" - expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" - INV_ID="$(json_get "$INVRESP" "id")" - ok "Invoice created: $INV_ID" - - log "Mark invoice paid" - PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")" - PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" - expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" - STATUS="$(json_get "$PDRESP" "status")" - [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')" - ok "Invoice marked paid" - - { - echo "=== E2E summary ===" - echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})" - echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})" - echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})" - echo "Pair: ${PAIR_ID}" - echo "Room: ${ROOM_ID} Message: ${MSG_ID}" - echo "Invoice:${INV_ID} Status: ${STATUS}" - } >&2 - - ok "E2E smoke test finished successfully." -} - -main "$@" diff --git a/.history/scripts/e2e_20250808211132.sh b/.history/scripts/e2e_20250808211132.sh deleted file mode 100644 index 85999b4..0000000 --- a/.history/scripts/e2e_20250808211132.sh +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -BASE_URL="${BASE_URL:-http://localhost:8080}" -AUTH="$BASE_URL/auth"; PROFILES="$BASE_URL/profiles"; MATCH="$BASE_URL/match"; CHAT="$BASE_URL/chat"; PAYMENTS="$BASE_URL/payments" -GW_HEALTH_PATH="${GW_HEALTH_PATH:-/auth/health}" - -NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m' -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - -require(){ command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required" >&2; exit 1; }; } -require curl; require python3 -log(){ echo -e "${C}[$(date +%H:%M:%S)]${NC} $*" >&2; } -ok(){ echo -e "${G}✔${NC} $*" >&2; } -warn(){ echo -e "${Y}⚠${NC} $*" >&2; } -fail(){ echo -e "${R}✖${NC} $*" >&2; exit 1; } - -json_get(){ python3 - "$1" "$2" <<'PY' -import sys, json, os -f,p=sys.argv[1],sys.argv[2] -if not os.path.exists(f): print(""); sys.exit(0) -try: data=json.load(open(f)) -except: print(""); sys.exit(0) -cur=data -for k in p.split('.'): - if isinstance(cur,list): - try:k=int(k) - except: print(""); sys.exit(0) - cur=cur[k] if 0<=k -python3 - "$1" "$2" <<'PY' -import sys, json, base64 -t, claim = sys.argv[1], sys.argv[2] -try: - b = t.split('.')[1] - b += '=' * (-len(b) % 4) - payload = json.loads(base64.urlsafe_b64decode(b).decode()) - print(payload.get(claim,"")) -except Exception: - print("") -PY -} - -http_req(){ - local METHOD="$1"; shift; local URL="$1"; shift - local TOKEN="${1:-}"; shift || true - local BODY="${1:-}"; shift || true - local RESP="${TMP_DIR}/resp_$(date +%s%N).json" - local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") - [[ -n "$TOKEN" ]] && args+=(-H "Authorization: Bearer $TOKEN") - [[ -n "$BODY" ]] && args+=(-H "Content-Type: application/json" -d "$BODY") - local CODE; CODE="$(curl "${args[@]}" || true)" - [[ -e "$RESP" ]] || : > "$RESP" - echo "$CODE|$RESP" -} - -expect_code(){ [[ "$2" == *"|${1}|"* || "$2" == "${1}|"* || "$2" == *"|${1}" || "$2" == "${1}" ]]; } - -wait_http(){ - local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}" - log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})" - for((i=1;i<=TRIES;i++)); do - local CODE; CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" - if expect_code "$CODE" "$ALLOWED"; then ok "${NAME} is ready (${CODE})"; return 0; fi - sleep 1 - done; fail "${NAME} not ready in time: ${URL}" -} -wait_health(){ wait_http "$1" "$2" "200" "${3:-60}"; } - -login_or_register(){ # echo "|" - local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" - local BODY TOK TOKCODE TOKRESP ACCESS USER_ID - - # 1) пытаемся логиниться - BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") - TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" - if expect_code "$TOKCODE" "200"; then - ACCESS="$(json_get "$TOKRESP" "access_token")" - USER_ID="$(jwt_get "$ACCESS" sub)" - [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed for $EMAIL" - ok "Login ok for $EMAIL" - echo "${USER_ID}|${ACCESS}"; return 0 - fi - warn "Login failed for $EMAIL ($TOKCODE) → will register" - - # 2) регистрируем - BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") - local REG RESPCODE RESP; REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" - RESPCODE="${REG%%|*}"; RESP="${REG##*|}" - if expect_code "$RESPCODE" "201|200"; then - ok "Registered $EMAIL" - else - local MSG; MSG="$(json_get "$RESP" "detail")" - if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then - warn "Already exists: $EMAIL" - else - warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" - fi - fi - - # 3) снова логин - TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" - expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" - ACCESS="$(json_get "$TOKRESP" "access_token")" - USER_ID="$(jwt_get "$ACCESS" sub)" - [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed after register for $EMAIL" - echo "${USER_ID}|${ACCESS}" -} - -ensure_profile(){ # - local TOKEN="$1" G="$2" CITY="$3" LANGS="$4" INTRS="$5" - [[ -n "$TOKEN" ]] || fail "Empty token in ensure_profile" - - local ME MECODE MERESP; ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" - MECODE="${ME%%|*}"; MERESP="${ME##*|}" - if [[ "$MECODE" == "200" ]]; then ok "Profile exists"; return 0 - elif [[ "$MECODE" != "404" ]]; then warn "Unexpected /profiles/me $MECODE: $(cat "$MERESP")"; fi - - local lj ij; IFS=',' read -r -a _l <<< "$LANGS"; lj="$(printf '%s\n' "${_l[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)" - IFS=',' read -r -a _i <<< "$INTRS"; ij="$(printf '%s\n' "${_i[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)" - local BODY; BODY=$(cat <&2 - echo "BASE_URL: $BASE_URL" >&2; echo >&2 - - wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}" - wait_health "auth" "$AUTH/health"; wait_health "profiles" "$PROFILES/health" - wait_health "match" "$MATCH/health"; wait_health "chat" "$CHAT/health"; wait_health "payments" "$PAYMENTS/health" - - TS="$(date +%s)" - ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" - ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" - BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" - PASS="${PASS:-secret123}" - - log "Admin: ${ADMIN_EMAIL}" - IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(login_or_register "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN"); ok "Admin id: $ADMIN_ID" - - log "Alice: ${ALICE_EMAIL}" - IFS='|' read -r ALICE_ID ALICE_ACCESS < <(login_or_register "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT"); ok "Alice id: $ALICE_ID" - - log "Bob: ${BOB_EMAIL}" - IFS='|' read -r BOB_ID BOB_ACCESS < <(login_or_register "$BOB_EMAIL" "$PASS" "Bob" "CLIENT"); ok "Bob id: $BOB_ID" - - log "Profiles" - ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" - ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" - ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" - - log "Match Alice ↔ Bob" - BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) - PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"; PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" - expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" - PAIR_ID="$(json_get "$PRESP" "id")"; ok "Pair: $PAIR_ID" - - log "Chat" - BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") - ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"; RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" - expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" - ROOM_ID="$(json_get "$RRESP" "id")"; ok "Room: $ROOM_ID" - - BODY='{"content":"Hello from admin (e2e)"}' - MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"; MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" - expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" - MSG_ID="$(json_get "$MRESP" "id")"; ok "Message: $MSG_ID" - - log "Payments" - BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") - INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"; INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" - expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" - INV_ID="$(json_get "$INVRESP" "id")"; ok "Invoice: $INV_ID" - - PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"; PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" - expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" - STATUS="$(json_get "$PDRESP" "status")"; [[ "$STATUS" == "paid" ]] || fail "Invoice not paid" - ok "Invoice status: $STATUS" - - { - echo "=== E2E summary ===" - echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})" - echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})" - echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})" - echo "Pair: ${PAIR_ID}" - echo "Room: ${ROOM_ID} Message: ${MSG_ID}" - echo "Invoice:${INV_ID} Status: ${STATUS}" - } >&2 - - ok "E2E smoke test finished successfully." -} -main "$@" diff --git a/.history/scripts/fix_email_validation_20250808211220.sh b/.history/scripts/fix_email_validation_20250808211220.sh deleted file mode 100644 index e69de29..0000000 diff --git a/.history/scripts/fix_email_validation_20250808211222.sh b/.history/scripts/fix_email_validation_20250808211222.sh deleted file mode 100644 index 2cb5f68..0000000 --- a/.history/scripts/fix_email_validation_20250808211222.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -FILE="services/auth/src/app/schemas/user.py" -[ -f "$FILE" ] || { echo "Not found: $FILE"; exit 1; } - -tmp="$(mktemp)" -awk ' - BEGIN{incls=""} - /^class (UserRead|UserPublic|UserOut|UserResponse)\b/ {incls=$1} - incls!="" && /email: *EmailStr/ { sub(/EmailStr/, "str") } - /^class [A-Za-z_0-9]+\b/ && $2!=incls { incls="" } - { print } -' "$FILE" > "$tmp" && mv "$tmp" "$FILE" - -echo "[auth] rebuilding..." -docker compose build auth -docker compose restart auth \ No newline at end of file diff --git a/.history/scripts/migrate_20250808200714.sh b/.history/scripts/migrate_20250808200714.sh deleted file mode 100644 index e99b378..0000000 --- a/.history/scripts/migrate_20250808200714.sh +++ /dev/null @@ -1,10 +0,0 @@ -for s in auth profiles match chat payments; do - f="services/$s/alembic/env.py" - # добавим импорт пакета моделей, если его нет - grep -q "from app import models" "$f" || \ - sed -i 's/from app.db.session import Base # noqa/from app.db.session import Base # noqa\nfrom app import models # noqa: F401/' "$f" -done - -for s in auth profiles match chat payments; do - docker compose run --rm $s alembic revision --autogenerate -m "init" -done diff --git a/.history/scripts/migrate_20250808214443.sh b/.history/scripts/migrate_20250808214443.sh deleted file mode 100644 index feb04f8..0000000 --- a/.history/scripts/migrate_20250808214443.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -for s in auth profiles match chat payments; do - docker compose run --rm $s alembic revision --autogenerate -m "init" - docker compose run --rm $s alembic upgrade head - -done diff --git a/.history/scripts/patch_20250808204341.sh b/.history/scripts/patch_20250808204341.sh deleted file mode 100644 index 4c1b211..0000000 --- a/.history/scripts/patch_20250808204341.sh +++ /dev/null @@ -1,68 +0,0 @@ -# Сохраняем фиксер -cat > fix_profiles_fk.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -# 1) Обновим модель Photo: добавим ForeignKey + нормальную relationship -cat > services/profiles/src/app/models/photo.py <<'PY' -from __future__ import annotations -import uuid -from datetime import datetime - -from sqlalchemy import String, Boolean, DateTime, ForeignKey -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Photo(Base): - __tablename__ = "photos" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - profile_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), - ForeignKey("profiles.id", ondelete="CASCADE"), - index=True, - nullable=False, - ) - url: Mapped[str] = mapped_column(String(500), nullable=False) - is_main: Mapped[bool] = mapped_column(Boolean, default=False) - status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - - profile = relationship("Profile", back_populates="photos") -PY - -# (необязательно, но полезно) поправим типы JSONB в Profile -awk ' - {print} - /languages:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed languages type to list[str])"} - /interests:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed interests type to list[str])"} -' services/profiles/src/app/models/profile.py > services/profiles/src/app/models/profile.py.tmp \ - && mv services/profiles/src/app/models/profile.py.tmp services/profiles/src/app/models/profile.py || true - -# 2) Сгенерируем ревизию Alembic (сравнить модели с БД) -docker compose up -d postgres -docker compose run --rm -v "$PWD/services/profiles":/app profiles \ - sh -lc 'alembic revision --autogenerate -m "add FK photos.profile_id -> profiles.id"' - -# 3) Если автогенерация не добавила FK — вживлём вручную в последнюю ревизию -LAST=$(ls -1t services/profiles/alembic/versions/*.py | head -n1) -if ! grep -q "create_foreign_key" "$LAST"; then - # вставим импорт postgresql (на будущее) и create_foreign_key в upgrade() - sed -i '/import sqlalchemy as sa/a from sqlalchemy.dialects import postgresql' "$LAST" - awk ' - BEGIN{done=0} - /def upgrade/ && done==0 {print; print " op.create_foreign_key("; print " '\''fk_photos_profile_id_profiles'\'',"; print " '\''photos'\'', '\''profiles'\'',"; print " ['\''profile_id'\''], ['\''id'\''],"; print " ondelete='\''CASCADE'\''"; print " )"; done=1; next} - {print} - ' "$LAST" > "$LAST.tmp" && mv "$LAST.tmp" "$LAST" -fi - -# 4) Применим миграции и перезапустим сервис -docker compose run --rm profiles alembic upgrade head -docker compose restart profiles -BASH - -chmod +x fix_profiles_fk.sh -./fix_profiles_fk.sh diff --git a/.history/scripts/patch_20250808211820.sh b/.history/scripts/patch_20250808211820.sh deleted file mode 100644 index 63c15ec..0000000 --- a/.history/scripts/patch_20250808211820.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# 1) Репозиторий: приводить user_id к uuid.UUID -cat > services/profiles/src/app/repositories/profile_repository.py <<'PY' -import uuid -from typing import Optional - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.models.profile import Profile -from app.schemas.profile import ProfileCreate - -class ProfileRepository: - def __init__(self, db: Session): - self.db = db - - @staticmethod - def _to_uuid(v) -> uuid.UUID: - if isinstance(v, uuid.UUID): - return v - return uuid.UUID(str(v)) - - def get_by_user(self, user_id) -> Optional[Profile]: - uid = self._to_uuid(user_id) - stmt = select(Profile).where(Profile.user_id == uid) - return self.db.execute(stmt).scalar_one_or_none() - - def create(self, user_id, obj: ProfileCreate) -> Profile: - uid = self._to_uuid(user_id) - p = Profile( - user_id=uid, - gender=obj.gender, - city=obj.city, - languages=obj.languages or [], - interests=obj.interests or [], - ) - self.db.add(p) - self.db.commit() - self.db.refresh(p) - return p -PY - -# 2) Схемы: дефолты - пустые списки (чтобы не было None → JSONB) -cat > services/profiles/src/app/schemas/profile.py <<'PY' -from __future__ import annotations -from typing import Optional, List -from pydantic import BaseModel, Field - -class ProfileBase(BaseModel): - gender: str - city: str - languages: List[str] = Field(default_factory=list) - interests: List[str] = Field(default_factory=list) - -class ProfileCreate(ProfileBase): - pass - -class ProfileOut(ProfileBase): - id: str - user_id: str - - class Config: - from_attributes = True -PY - -# 3) Роут: ловим ошибки явно → 400 вместо 500 -cat > services/profiles/src/app/api/routes/profiles.py <<'PY' -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session -from sqlalchemy.exc import IntegrityError, DataError - -from app.db.deps import get_db -from app.schemas.profile import ProfileCreate, ProfileOut -from app.services.profile_service import ProfileService -from app.core.security import get_current_user # возвращает объект с полями sub, email, role - -router = APIRouter(prefix="/v1/profiles", tags=["profiles"]) - -@router.get("/me", response_model=ProfileOut) -def get_my_profile(db: Session = Depends(get_db), user=Depends(get_current_user)): - svc = ProfileService(db) - p = svc.get_by_user(user.sub) - if not p: - # 404, если профиль отсутствует - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") - return p - -@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED) -def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user=Depends(get_current_user)): - svc = ProfileService(db) - try: - existing = svc.get_by_user(user.sub) - if existing: - return existing - p = svc.create(user.sub, payload) - return p - except (IntegrityError, DataError, ValueError) as exc: - db.rollback() - raise HTTPException(status_code=400, detail=f"Invalid data: {exc}") -PY - -# 4) Сервис — тонкая обёртка над репозиторием -cat > services/profiles/src/app/services/profile_service.py <<'PY' -from sqlalchemy.orm import Session -from app.repositories.profile_repository import ProfileRepository -from app.schemas.profile import ProfileCreate - -class ProfileService: - def __init__(self, db: Session): - self.repo = ProfileRepository(db) - - def get_by_user(self, user_id): - return self.repo.get_by_user(user_id) - - def create(self, user_id, obj: ProfileCreate): - return self.repo.create(user_id, obj) -PY - -echo "[profiles] rebuilding..." -docker compose build profiles -docker compose restart profiles \ No newline at end of file diff --git a/.history/scripts/patch_20250808212435.sh b/.history/scripts/patch_20250808212435.sh deleted file mode 100644 index b1b5740..0000000 --- a/.history/scripts/patch_20250808212435.sh +++ /dev/null @@ -1,33 +0,0 @@ -# scripts/fix_profiles_deps.sh -cat > scripts/fix_profiles_deps.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -ROOT="services/profiles/src/app" -mkdir -p "$ROOT/db" - -# __init__.py чтобы пакет точно импортировался -[[ -f "$ROOT/__init__.py" ]] || echo "# app package" > "$ROOT/__init__.py" -[[ -f "$ROOT/db/__init__.py" ]] || echo "# db package" > "$ROOT/db/__init__.py" - -# deps.py с get_db() -cat > "$ROOT/db/deps.py" <<'PY' -from typing import Generator -from sqlalchemy.orm import Session -from app.db.session import SessionLocal # должен существовать в проекте - -def get_db() -> Generator[Session, None, None]: - db = SessionLocal() - try: - yield db - finally: - db.close() -PY - -echo "[profiles] rebuilding..." -docker compose build profiles -docker compose restart profiles -BASH - -chmod +x scripts/fix_profiles_deps.sh -./scripts/fix_profiles_deps.sh diff --git a/.history/scripts/patch_20250808213107.sh b/.history/scripts/patch_20250808213107.sh deleted file mode 100644 index e7295c6..0000000 --- a/.history/scripts/patch_20250808213107.sh +++ /dev/null @@ -1,85 +0,0 @@ -# scripts/patch_profiles_security.sh -cat > scripts/patch_profiles_security.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -REQ="services/profiles/requirements.txt" -PY="services/profiles/src/app/core/security.py" - -# 1) гарантируем зависимость PyJWT -grep -qE '(^|[[:space:]])PyJWT' "$REQ" 2>/dev/null || { - echo "PyJWT>=2.8.0" >> "$REQ" - echo "[profiles] added PyJWT to requirements.txt" -} - -# 2) модуль security.py -mkdir -p "$(dirname "$PY")" -cat > "$PY" <<'PY' -import os -from typing import Optional - -import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from pydantic import BaseModel - -reusable_bearer = HTTPBearer(auto_error=True) - -JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret") -JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") - -# Возможность включить строгую проверку audience/issuer в будущем -JWT_VERIFY_AUD = os.getenv("JWT_VERIFY_AUD", "0") == "1" -JWT_AUDIENCE: Optional[str] = os.getenv("JWT_AUDIENCE") or None -JWT_VERIFY_ISS = os.getenv("JWT_VERIFY_ISS", "0") == "1" -JWT_ISSUER: Optional[str] = os.getenv("JWT_ISSUER") or None - -# Допустимая рассинхронизация часов (сек) -JWT_LEEWAY = int(os.getenv("JWT_LEEWAY", "30")) - -class JwtUser(BaseModel): - sub: str - email: Optional[str] = None - role: Optional[str] = None - -def decode_token(token: str) -> JwtUser: - options = { - "verify_signature": True, - "verify_exp": True, - "verify_aud": JWT_VERIFY_AUD, - "verify_iss": JWT_VERIFY_ISS, - } - kwargs = {"algorithms": [JWT_ALGORITHM], "options": options, "leeway": JWT_LEEWAY} - if JWT_VERIFY_AUD and JWT_AUDIENCE: - kwargs["audience"] = JWT_AUDIENCE - if JWT_VERIFY_ISS and JWT_ISSUER: - kwargs["issuer"] = JWT_ISSUER - - try: - payload = jwt.decode(token, JWT_SECRET, **kwargs) - sub = str(payload.get("sub") or "") - if not sub: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub") - return JwtUser(sub=sub, email=payload.get("email"), role=payload.get("role")) - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") - except jwt.InvalidAudienceError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid audience") - except jwt.InvalidIssuerError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid issuer") - except jwt.InvalidTokenError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(reusable_bearer)) -> JwtUser: - if credentials.scheme.lower() != "bearer": - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth scheme") - return decode_token(credentials.credentials) -PY - -echo "[profiles] rebuilding..." -docker compose build profiles -docker compose restart profiles -BASH - -chmod +x scripts/patch_profiles_security.sh -./scripts/patch_profiles_security.sh diff --git a/.history/scripts/patch_20250808213457.sh b/.history/scripts/patch_20250808213457.sh deleted file mode 100644 index fe650f7..0000000 --- a/.history/scripts/patch_20250808213457.sh +++ /dev/null @@ -1,50 +0,0 @@ -# scripts/fix_profiles_schema_uuid.sh -cat > scripts/fix_profiles_schema_uuid.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -SCHEMA="services/profiles/src/app/schemas/profile.py" -mkdir -p "$(dirname "$SCHEMA")" - -cat > "$SCHEMA" <<'PY' -from __future__ import annotations -from typing import List -from uuid import UUID - -try: - # Pydantic v2 - from pydantic import BaseModel, Field, ConfigDict - _V2 = True -except Exception: - # Pydantic v1 fallback - from pydantic import BaseModel, Field - ConfigDict = None - _V2 = False - -class ProfileBase(BaseModel): - gender: str - city: str - languages: List[str] = Field(default_factory=list) - interests: List[str] = Field(default_factory=list) - -class ProfileCreate(ProfileBase): - pass - -class ProfileOut(ProfileBase): - id: UUID - user_id: UUID - - if _V2: - model_config = ConfigDict(from_attributes=True) - else: - class Config: - orm_mode = True -PY - -echo "[profiles] rebuilding..." -docker compose build profiles -docker compose restart profiles -BASH - -chmod +x scripts/fix_profiles_schema_uuid.sh -./scripts/fix_profiles_schema_uuid.sh diff --git a/.history/scripts/patch_20250808213938.sh b/.history/scripts/patch_20250808213938.sh deleted file mode 100644 index fe650f7..0000000 --- a/.history/scripts/patch_20250808213938.sh +++ /dev/null @@ -1,50 +0,0 @@ -# scripts/fix_profiles_schema_uuid.sh -cat > scripts/fix_profiles_schema_uuid.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -SCHEMA="services/profiles/src/app/schemas/profile.py" -mkdir -p "$(dirname "$SCHEMA")" - -cat > "$SCHEMA" <<'PY' -from __future__ import annotations -from typing import List -from uuid import UUID - -try: - # Pydantic v2 - from pydantic import BaseModel, Field, ConfigDict - _V2 = True -except Exception: - # Pydantic v1 fallback - from pydantic import BaseModel, Field - ConfigDict = None - _V2 = False - -class ProfileBase(BaseModel): - gender: str - city: str - languages: List[str] = Field(default_factory=list) - interests: List[str] = Field(default_factory=list) - -class ProfileCreate(ProfileBase): - pass - -class ProfileOut(ProfileBase): - id: UUID - user_id: UUID - - if _V2: - model_config = ConfigDict(from_attributes=True) - else: - class Config: - orm_mode = True -PY - -echo "[profiles] rebuilding..." -docker compose build profiles -docker compose restart profiles -BASH - -chmod +x scripts/fix_profiles_schema_uuid.sh -./scripts/fix_profiles_schema_uuid.sh diff --git a/.history/scripts/patch_20250808213956.sh b/.history/scripts/patch_20250808213956.sh deleted file mode 100644 index 6f49ac1..0000000 --- a/.history/scripts/patch_20250808213956.sh +++ /dev/null @@ -1,49 +0,0 @@ -# scripts/patch_profiles_router.sh -cat > scripts/patch_profiles_router.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -ROUTER="services/profiles/src/app/api/routes/profiles.py" -mkdir -p "$(dirname "$ROUTER")" - -cat > "$ROUTER" <<'PY' -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from app.db.deps import get_db -from app.core.security import get_current_user, JwtUser -from app.schemas.profile import ProfileCreate, ProfileOut -from app.repositories.profile_repository import ProfileRepository -from app.services.profile_service import ProfileService - -# отключаем авто-редирект /path -> /path/ -router = APIRouter(prefix="/v1/profiles", tags=["profiles"], redirect_slashes=False) - -@router.get("/me", response_model=ProfileOut) -def get_my_profile(current: JwtUser = Depends(get_current_user), - db: Session = Depends(get_db)): - svc = ProfileService(ProfileRepository(db)) - p = svc.get_by_user(current.sub) - if not p: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") - return p - -@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED) -def create_my_profile(payload: ProfileCreate, - current: JwtUser = Depends(get_current_user), - db: Session = Depends(get_db)): - svc = ProfileService(ProfileRepository(db)) - existing = svc.get_by_user(current.sub) - if existing: - # если хотите строго — верните 409; оставлю 200/201 для удобства e2e - return existing - return svc.create(current.sub, payload) -PY - -echo "[profiles] rebuilding..." -docker compose build profiles -docker compose restart profiles -BASH - -chmod +x scripts/patch_profiles_router.sh -./scripts/patch_profiles_router.sh diff --git a/.history/scripts/patch_20250808214013.sh b/.history/scripts/patch_20250808214013.sh deleted file mode 100644 index fc60753..0000000 --- a/.history/scripts/patch_20250808214013.sh +++ /dev/null @@ -1,61 +0,0 @@ -# scripts/patch_profiles_repo_service.sh -cat > scripts/patch_profiles_repo_service.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -REPO="services/profiles/src/app/repositories/profile_repository.py" -SRV="services/profiles/src/app/services/profile_service.py" -mkdir -p "$(dirname "$REPO")" "$(dirname "$SRV")" - -cat > "$REPO" <<'PY' -from typing import Optional -from uuid import UUID -from sqlalchemy.orm import Session -from sqlalchemy import select -from app.models.profile import Profile -from app.schemas.profile import ProfileCreate - -class ProfileRepository: - def __init__(self, db: Session): - self.db = db - - def get_by_user(self, user_id: UUID) -> Optional[Profile]: - return self.db.execute(select(Profile).where(Profile.user_id == user_id)).scalar_one_or_none() - - def create(self, user_id: UUID, data: ProfileCreate) -> Profile: - p = Profile( - user_id=user_id, - gender=data.gender, - city=data.city, - languages=list(data.languages or []), - interests=list(data.interests or []), - ) - self.db.add(p) - self.db.commit() - self.db.refresh(p) - return p -PY - -cat > "$SRV" <<'PY' -from uuid import UUID -from app.schemas.profile import ProfileCreate -from app.repositories.profile_repository import ProfileRepository - -class ProfileService: - def __init__(self, repo: ProfileRepository): - self.repo = repo - - def get_by_user(self, user_id: UUID): - return self.repo.get_by_user(user_id) - - def create(self, user_id: UUID, data: ProfileCreate): - return self.repo.create(user_id, data) -PY - -echo "[profiles] rebuilding..." -docker compose build profiles -docker compose restart profiles -BASH - -chmod +x scripts/patch_profiles_repo_service.sh -./scripts/patch_profiles_repo_service.sh diff --git a/.history/scripts/patch_20250808214025.sh b/.history/scripts/patch_20250808214025.sh deleted file mode 100644 index e5404b1..0000000 --- a/.history/scripts/patch_20250808214025.sh +++ /dev/null @@ -1,31 +0,0 @@ -# scripts/patch_gateway_auth_header.sh -cat > scripts/patch_gateway_auth_header.sh <<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -CFG="infra/gateway/nginx.conf" -[ -f "$CFG" ] || { echo "Not found: $CFG"; exit 1; } - -# Грубая, но надёжная вставка proxy_set_header Authorization во все блоки location к сервисам -awk ' - /location[[:space:]]+\/(auth|profiles|match|chat|payments)\//,/\}/ { - print - if ($0 ~ /proxy_pass/ && !seen_auth) { - print " proxy_set_header Authorization $http_authorization;" - print " proxy_set_header X-Forwarded-Proto $scheme;" - print " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;" - print " proxy_set_header Host $host;" - seen_auth=1 - } - next - } - { print } - /\}/ { seen_auth=0 } -' "$CFG" > "$CFG.tmp" && mv "$CFG.tmp" "$CFG" - -echo "[gateway] restart..." -docker compose restart gateway -BASH - -chmod +x scripts/patch_gateway_auth_header.sh -./scripts/patch_gateway_auth_header.sh diff --git a/.history/scripts/test_20250808204608.sh b/.history/scripts/test_20250808204608.sh deleted file mode 100644 index 92f9447..0000000 --- a/.history/scripts/test_20250808204608.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -# Все запросы будут иметь заголовок Authorization: Bearer $ACCESS -# 404, если профиля ещё нет — это корректно -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" - -# Создание профиля -printf '%s' \ -'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \ -| POST -H "Authorization: Bearer '"$ACCESS"'" \ - -H "Content-Type: application/json" \ - http://localhost:8080/profiles/v1/profiles - -# Теперь должен отдать ваш профиль -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" diff --git a/.history/scripts/test_20250808214044.sh b/.history/scripts/test_20250808214044.sh deleted file mode 100644 index e707b02..0000000 --- a/.history/scripts/test_20250808214044.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -# 1) Здоровье сервисов -curl -sS http://localhost:8080/auth/health -curl -sS http://localhost:8080/profiles/health - -# 2) Токен (любой юзер) -curl -sS -X POST http://localhost:8080/auth/v1/token \ - -H "Content-Type: application/json" \ - -d '{"email":"admin@agency.dev","password":"secret123"}' | tee /tmp/token.json - -ACCESS=$(python3 - <<'PY' /tmp/token.json -import sys, json; print(json.load(open(sys.argv[1]))["access_token"]) -PY -) - -# 3) /me — ожидаемо 404 (если профиля нет), главное НЕ 401 -curl -i -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" - -# 4) Создать профиль — должно быть 201/200, без 500 -curl -i -sS -X POST http://localhost:8080/profiles/v1/profiles \ - -H "Authorization: Bearer $ACCESS" \ - -H "Content-Type: application/json" \ - -d '{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' - -# 5) Снова /me — теперь 200 с JSON (UUIDы как строки) -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" | jq . diff --git a/.history/services/auth/requirements_20250808195758.txt b/.history/services/auth/requirements_20250808195758.txt deleted file mode 100644 index add41b9..0000000 --- a/.history/services/auth/requirements_20250808195758.txt +++ /dev/null @@ -1,12 +0,0 @@ -fastapi -uvicorn[standard] -SQLAlchemy>=2.0 -psycopg2-binary -alembic -pydantic>=2 -pydantic-settings -python-dotenv -httpx>=0.27 -pytest -PyJWT>=2.8 -passlib[bcrypt]>=1.7 diff --git a/.history/services/auth/requirements_20250808200038.txt b/.history/services/auth/requirements_20250808200038.txt deleted file mode 100644 index 04996b5..0000000 --- a/.history/services/auth/requirements_20250808200038.txt +++ /dev/null @@ -1,13 +0,0 @@ -fastapi -uvicorn[standard] -SQLAlchemy>=2.0 -psycopg2-binary -alembic -pydantic>=2 -pydantic-settings -pydantic[email] -python-dotenv -httpx>=0.27 -pytest -PyJWT>=2.8 -passlib[bcrypt]>=1.7 diff --git a/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py deleted file mode 100644 index eada411..0000000 --- a/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py +++ /dev/null @@ -1,54 +0,0 @@ -"""init - -Revision ID: 769f535c9249 -Revises: -Create Date: 2025-08-08 11:20:05.142049+00:00 -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = '769f535c9249' -down_revision = None -branch_labels = None -depends_on = None - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('photos', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('profile_id', sa.UUID(), nullable=False), - sa.Column('url', sa.String(length=500), nullable=False), - sa.Column('is_main', sa.Boolean(), nullable=False), - sa.Column('status', sa.String(length=16), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_photos_profile_id'), 'photos', ['profile_id'], unique=False) - op.create_table('profiles', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('gender', sa.String(length=16), nullable=False), - sa.Column('birthdate', sa.Date(), nullable=True), - sa.Column('city', sa.String(length=120), nullable=True), - sa.Column('bio', sa.Text(), nullable=True), - sa.Column('languages', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('interests', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('verification_status', sa.String(length=16), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_profiles_user_id'), 'profiles', ['user_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_profiles_user_id'), table_name='profiles') - op.drop_table('profiles') - op.drop_index(op.f('ix_photos_profile_id'), table_name='photos') - op.drop_table('photos') - # ### end Alembic commands ### diff --git a/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py deleted file mode 100644 index 6f6ba0c..0000000 --- a/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py +++ /dev/null @@ -1,55 +0,0 @@ -"""init - -Revision ID: 769f535c9249 -Revises: -Create Date: 2025-08-08 11:20:05.142049+00:00 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = '769f535c9249' -down_revision = None -branch_labels = None -depends_on = None - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('photos', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('profile_id', sa.UUID(), nullable=False), - sa.Column('url', sa.String(length=500), nullable=False), - sa.Column('is_main', sa.Boolean(), nullable=False), - sa.Column('status', sa.String(length=16), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_photos_profile_id'), 'photos', ['profile_id'], unique=False) - op.create_table('profiles', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('gender', sa.String(length=16), nullable=False), - sa.Column('birthdate', sa.Date(), nullable=True), - sa.Column('city', sa.String(length=120), nullable=True), - sa.Column('bio', sa.Text(), nullable=True), - sa.Column('languages', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('interests', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('verification_status', sa.String(length=16), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_profiles_user_id'), 'profiles', ['user_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_profiles_user_id'), table_name='profiles') - op.drop_table('profiles') - op.drop_index(op.f('ix_photos_profile_id'), table_name='photos') - op.drop_table('photos') - # ### end Alembic commands ### diff --git a/.history/services/profiles/docker-entrypoint_20250808194542.sh b/.history/services/profiles/docker-entrypoint_20250808194542.sh deleted file mode 100644 index 2828898..0000000 --- a/.history/services/profiles/docker-entrypoint_20250808194542.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env sh -set -e -# Run migrations (no-op if no revisions yet) -alembic -c alembic.ini upgrade head || true -# Start app -exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/.history/services/profiles/docker-entrypoint_20250808203201.sh b/.history/services/profiles/docker-entrypoint_20250808203201.sh deleted file mode 100644 index ae2ee5e..0000000 --- a/.history/services/profiles/docker-entrypoint_20250808203201.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env sh -set -e -# Run migrations (no-op if no revisions yet) -alembic -c alembic.ini upgrade head || true -# Start app -exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level debug diff --git a/.history/services/profiles/src/app/models/photo_20250808195936.py b/.history/services/profiles/src/app/models/photo_20250808195936.py deleted file mode 100644 index d7b2a81..0000000 --- a/.history/services/profiles/src/app/models/photo_20250808195936.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations -import uuid -from datetime import datetime - -from sqlalchemy import String, Boolean, DateTime, ForeignKey -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Photo(Base): - __tablename__ = "photos" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - url: Mapped[str] = mapped_column(String(500), nullable=False) - is_main: Mapped[bool] = mapped_column(Boolean, default=False) - status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - - profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True) diff --git a/.history/services/profiles/src/app/models/photo_20250808204310.py b/.history/services/profiles/src/app/models/photo_20250808204310.py deleted file mode 100644 index 49d3db9..0000000 --- a/.history/services/profiles/src/app/models/photo_20250808204310.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations -import uuid -from datetime import datetime - -from sqlalchemy import String, Boolean, DateTime, ForeignKey -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Photo(Base): - __tablename__ = "photos" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - profile_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), - ForeignKey("profiles.id", ondelete="CASCADE"), - index=True, - nullable=False, - ) - url: Mapped[str] = mapped_column(String(500), nullable=False) - is_main: Mapped[bool] = mapped_column(Boolean, default=False) - status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - - profile = relationship("Profile", back_populates="photos") \ No newline at end of file diff --git a/.history/services/profiles/src/app/models/profile_20250808195936.py b/.history/services/profiles/src/app/models/profile_20250808195936.py deleted file mode 100644 index 23df3d2..0000000 --- a/.history/services/profiles/src/app/models/profile_20250808195936.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations -import uuid -from datetime import date, datetime - -from sqlalchemy import String, Date, DateTime, Text -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.session import Base - -class Profile(Base): - __tablename__ = "profiles" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other - birthdate: Mapped[date | None] = mapped_column(Date, default=None) - city: Mapped[str | None] = mapped_column(String(120), default=None) - bio: Mapped[str | None] = mapped_column(Text, default=None) - languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"] - interests: Mapped[dict | None] = mapped_column(JSONB, default=list) - preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) - verification_status: Mapped[str] = mapped_column(String(16), default="unverified") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - - photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") diff --git a/.history/services/profiles/src/app/models/profile_20250808204008.py b/.history/services/profiles/src/app/models/profile_20250808204008.py deleted file mode 100644 index 652b24c..0000000 --- a/.history/services/profiles/src/app/models/profile_20250808204008.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations -import uuid -from datetime import date, datetime - -from sqlalchemy import String, Date, DateTime, Text -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func -from typing import Optional - -from app.db.session import Base - -class Profile(Base): - __tablename__ = "profiles" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other - birthdate: Mapped[date | None] = mapped_column(Date, default=None) - city: Mapped[str | None] = mapped_column(String(120), default=None) - bio: Mapped[str | None] = mapped_column(Text, default=None) - languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"] - interests: Mapped[dict | None] = mapped_column(JSONB, default=list) - preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) - verification_status: Mapped[str] = mapped_column(String(16), default="unverified") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - - photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") diff --git a/.history/services/profiles/src/app/models/profile_20250808204024.py b/.history/services/profiles/src/app/models/profile_20250808204024.py deleted file mode 100644 index ef84110..0000000 --- a/.history/services/profiles/src/app/models/profile_20250808204024.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations -import uuid -from datetime import date, datetime - -from sqlalchemy import String, Date, DateTime, Text -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func -from typing import Optional - -from app.db.session import Base - -class Profile(Base): - __tablename__ = "profiles" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other - birthdate: Mapped[date | None] = mapped_column(Date, default=None) - city: Mapped[str | None] = mapped_column(String(120), default=None) - bio: Mapped[str | None] = mapped_column(Text, default=None) - languages: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list) - interests: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list) - preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) - verification_status: Mapped[str] = mapped_column(String(16), default="unverified") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - - photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") diff --git a/.history/services/profiles/src/app/models/profile_20250808204059.py b/.history/services/profiles/src/app/models/profile_20250808204059.py deleted file mode 100644 index ee678b1..0000000 --- a/.history/services/profiles/src/app/models/profile_20250808204059.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations -import uuid -from datetime import date, datetime - -from sqlalchemy import String, Date, DateTime, Text -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func -from typing import Optional - -from app.db.session import Base - -class Profile(Base): - __tablename__ = "profiles" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other - birthdate: Mapped[date | None] = mapped_column(Date, default=None) - city: Mapped[str | None] = mapped_column(String(120), default=None) - bio: Mapped[str | None] = mapped_column(Text, default=None) - languages: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list) - interests: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list) - preferences: Mapped[Optional[dict[str, str]]] = mapped_column(JSONB, default=dict) \ No newline at end of file diff --git a/.history/services/profiles/src/app/models/profile_20250808204229.py b/.history/services/profiles/src/app/models/profile_20250808204229.py deleted file mode 100644 index 0c561fd..0000000 --- a/.history/services/profiles/src/app/models/profile_20250808204229.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations -import uuid -from datetime import date, datetime - -from sqlalchemy import String, Date, DateTime, Text -from sqlalchemy.dialects.postgresql import UUID, JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func -from typing import Optional - -from app.db.session import Base - -class Profile(Base): - __tablename__ = "profiles" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) - gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other - birthdate: Mapped[date | None] = mapped_column(Date, default=None) - city: Mapped[str | None] = mapped_column(String(120), default=None) - bio: Mapped[str | None] = mapped_column(Text, default=None) - languages: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list) - interests: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list) - preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) - verification_status: Mapped[str] = mapped_column(String(16), default="unverified") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - - photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") \ No newline at end of file diff --git a/.history/test_20250808204537.sh b/.history/test_20250808204537.sh deleted file mode 100644 index e69de29..0000000 diff --git a/.history/test_20250808204550.sh b/.history/test_20250808204550.sh deleted file mode 100644 index f09d470..0000000 --- a/.history/test_20250808204550.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -# 404, если профиля ещё нет — это корректно -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" - -# Создание профиля -printf '%s' \ -'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \ -| POST -H "Authorization: Bearer '"$ACCESS"'" \ - -H "Content-Type: application/json" \ - http://localhost:8080/profiles/v1/profiles - -# Теперь должен отдать ваш профиль -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" diff --git a/.history/test_20250808204607.sh b/.history/test_20250808204607.sh deleted file mode 100644 index 8ce0151..0000000 --- a/.history/test_20250808204607.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -# 404, если профиля ещё нет — это корректно -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" - -# Создание профиля -printf '%s' \ -'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \ -| POST -H "Authorization: Bearer '"$ACCESS"'" \ - -H "Content-Type: application/json" \ - http://localhost:8080/profiles/v1/profiles - -# Теперь должен отдать ваш профиль -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" diff --git a/.history/test_20250808204608.sh b/.history/test_20250808204608.sh deleted file mode 100644 index 8ce0151..0000000 --- a/.history/test_20250808204608.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -# 404, если профиля ещё нет — это корректно -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" - -# Создание профиля -printf '%s' \ -'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \ -| POST -H "Authorization: Bearer '"$ACCESS"'" \ - -H "Content-Type: application/json" \ - http://localhost:8080/profiles/v1/profiles - -# Теперь должен отдать ваш профиль -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" diff --git a/.history/test_20250808204610.sh b/.history/test_20250808204610.sh deleted file mode 100644 index 92f9447..0000000 --- a/.history/test_20250808204610.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -# Все запросы будут иметь заголовок Authorization: Bearer $ACCESS -# 404, если профиля ещё нет — это корректно -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS" - -# Создание профиля -printf '%s' \ -'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \ -| POST -H "Authorization: Bearer '"$ACCESS"'" \ - -H "Content-Type: application/json" \ - http://localhost:8080/profiles/v1/profiles - -# Теперь должен отдать ваш профиль -curl -sS http://localhost:8080/profiles/v1/profiles/me \ - -H "Authorization: Bearer $ACCESS"