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"