api development
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-08-08 21:58:36 +09:00
parent d58302c2c8
commit cc87dcc0fa
157 changed files with 14629 additions and 7 deletions

View File

@@ -0,0 +1,19 @@
# ---------- 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

View File

@@ -0,0 +1,24 @@
# ---------- 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

View File

@@ -0,0 +1,24 @@
# ---------- 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

View File

@@ -0,0 +1,105 @@
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:

View File

@@ -0,0 +1,105 @@
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:

View File

View File

@@ -0,0 +1,54 @@
#!/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."

View File

@@ -0,0 +1,486 @@
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:03 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:07 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:11 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:16 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:20 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:24 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:28 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:32 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:36 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:40 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:23:44 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:03 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:07 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:11 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:15 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:20 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:24 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:28 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:32 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:36 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:40 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:44 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:46 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
2025-08-08 21:24:47 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.29.0</center>
</body>
</html>
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"}

View File

View File

View File

View File

View File

View File

@@ -0,0 +1,6 @@
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

View File

@@ -0,0 +1,10 @@
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,68 @@
# Сохраняем фиксер
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

View File

@@ -0,0 +1,50 @@
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

View File

@@ -0,0 +1,60 @@
#!/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/<svc>/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

View File

@@ -0,0 +1,65 @@
#!/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/<svc>/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

View File

@@ -0,0 +1,437 @@
#!/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)

View File

@@ -0,0 +1,416 @@
#!/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)

View File

@@ -0,0 +1,419 @@
#!/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)

View File

@@ -0,0 +1,423 @@
#!/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)

View File

@@ -0,0 +1,423 @@
#!/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)

View File

@@ -0,0 +1,424 @@
#!/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)

View File

@@ -0,0 +1,424 @@
#!/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)

View File

@@ -0,0 +1,424 @@
#!/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)

View File

@@ -0,0 +1,417 @@
#!/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)

View File

View File

@@ -0,0 +1,276 @@
#!/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 <file> <path> (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 <METHOD> <URL> [<TOKEN>] [<JSON_BODY>] -> 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 "<actual>" "<allowed1>|<allowed2>|..."
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 <email> <password> <full_name> <role> -> echoes "<user_id>|<access_token>"
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 <access_token> <gender> <city> <langs_csv> <interests_csv>
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 <<JSON
{"gender":"$G","city":"$CITY","languages":[${langs_json}],"interests":[${intrs_json}]}
JSON
)
local CR CRCODE CRRESP
CR="$(http_req POST "$PROFILES/v1/profiles" "$TOKEN" "$BODY")"
CRCODE="${CR%%|*}"; CRRESP="${CR##*|}"
expect_code "$CRCODE" "201|200" || fail "Create profile failed (${CRCODE}): $(cat "$CRRESP")"
ok "Profile created: $(json_get "$CRRESP" "id")"
echo "$CRRESP" > "${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 "$@"

View File

@@ -0,0 +1,284 @@
#!/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 <file> <path>
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 <METHOD> <URL> [<TOKEN>] [<JSON_BODY>] -> 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 "<actual>" "<allowed1>|<allowed2>|..."
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 <NAME> <URL> [<allowed_codes>|default 200] [<tries>|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 <NAME> <URL> [<tries>|60] (expects 200)
wait_http "$1" "$2" "200" "${3:-60}"
}
register_or_login() {
# register_or_login <email> <password> <full_name> <role> -> echoes "<user_id>|<access_token>"
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 <access_token> <gender> <city> <langs_csv> <interests_csv>
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 <<JSON
{"gender":"$G","city":"$CITY","languages":[${langs_json}],"interests":[${intrs_json}]}
JSON
)
local CR CRCODE CRRESP
CR="$(http_req POST "$PROFILES/v1/profiles" "$TOKEN" "$BODY")"
CRCODE="${CR%%|*}"; CRRESP="${CR##*|}"
expect_code "$CRCODE" "201|200" || fail "Create profile failed (${CRCODE}): $(cat "$CRRESP")"
ok "Profile created: $(json_get "$CRRESP" "id")"
echo "$CRRESP" > "${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 "$@"

View File

@@ -0,0 +1,289 @@
#!/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 <file> <path>
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 <METHOD> <URL> [<TOKEN>] [<JSON_BODY>] -> 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 "<actual>" "<allowed1>|<allowed2>|..."
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 <NAME> <URL> [<allowed_codes>|default 200] [<tries>|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 <email> <password> <full_name> <role> -> echoes "<user_id>|<access_token>"
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 <access_token> <gender> <city> <langs_csv> <interests_csv>
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 <<JSON
{"gender":"$G","city":"$CITY","languages":[${langs_json}],"interests":[${intrs_json}]}
JSON
)
local CR CRCODE CRRESP
CR="$(http_req POST "$PROFILES/v1/profiles" "$TOKEN" "$BODY")"
CRCODE="${CR%%|*}"; CRRESP="${CR##*|}"
expect_code "$CRCODE" "201|200" || fail "Create profile failed (${CRCODE}): $(cat "$CRRESP")"
ok "Profile created: $(json_get "$CRRESP" "id")"
echo "$CRRESP" > "${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 "$@"

View File

@@ -0,0 +1,208 @@
#!/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<len(cur) else None
elif isinstance(cur,dict):
cur=cur.get(k)
else: cur=None
if cur is None: break
print("" if cur is None else cur)
PY
}
jwt_get(){ # jwt_get <token> <claim>
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 "<user_id>|<access_token>"
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(){ # <token> <gender> <city> <langs_csv> <interests_csv>
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 <<JSON
{"gender":"$G","city":"$CITY","languages":[${lj}],"interests":[${ij}]}
JSON
)
local CR CRCODE CRRESP; CR="$(http_req POST "$PROFILES/v1/profiles" "$TOKEN" "$BODY")"
CRCODE="${CR%%|*}"; CRRESP="${CR##*|}"
expect_code "$CRCODE" "201|200" || fail "Create profile failed (${CRCODE}): $(cat "$CRRESP")"
ok "Profile created"
}
main(){
echo -e "${B}=== E2E smoke test start ===${NC}" >&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 "$@"

View File

@@ -0,0 +1,18 @@
#!/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

View File

@@ -0,0 +1,10 @@
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

View File

@@ -0,0 +1,6 @@
#!/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

View File

@@ -0,0 +1,68 @@
# Сохраняем фиксер
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

View File

@@ -0,0 +1,123 @@
#!/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

View File

@@ -0,0 +1,33 @@
# 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

View File

@@ -0,0 +1,85 @@
# 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

View File

@@ -0,0 +1,50 @@
# 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

View File

@@ -0,0 +1,50 @@
# 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

View File

@@ -0,0 +1,49 @@
# 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

View File

@@ -0,0 +1,61 @@
# 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

View File

@@ -0,0 +1,31 @@
# 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

View File

@@ -0,0 +1,17 @@
#!/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"

View File

@@ -0,0 +1,29 @@
#!/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 .

View File

@@ -0,0 +1,12 @@
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

View File

@@ -0,0 +1,13 @@
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

View File

@@ -0,0 +1,54 @@
"""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 ###

View File

@@ -0,0 +1,55 @@
"""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 ###

View File

@@ -0,0 +1,6 @@
#!/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

View File

@@ -0,0 +1,6 @@
#!/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

View File

@@ -0,0 +1,22 @@
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)

View File

@@ -0,0 +1,27 @@
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")

View File

@@ -0,0 +1,28 @@
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")

View File

@@ -0,0 +1,29 @@
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")

View File

@@ -0,0 +1,29 @@
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")

View File

@@ -0,0 +1,24 @@
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)

View File

@@ -0,0 +1,29 @@
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")

View File

View File

@@ -0,0 +1,16 @@
#!/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"

View File

@@ -0,0 +1,16 @@
#!/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"

View File

@@ -0,0 +1,16 @@
#!/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"

View File

@@ -0,0 +1,17 @@
#!/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"

View File

@@ -1,4 +1,4 @@
version: "3.9"
services: services:
postgres: postgres:
image: postgres:16 image: postgres:16

View File

View File

@@ -9,6 +9,10 @@ server {
} }
location /auth/ { location /auth/ {
proxy_pass http://auth:8000/; proxy_pass http://auth:8000/;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

193
logs/api.log Normal file
View File

@@ -0,0 +1,193 @@
2025-08-08 21:41:03 | INFO | api_e2e | === API E2E START ===
2025-08-08 21:41:03 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
2025-08-08 21:41:03 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 3 ms | body={"status":"ok","service":"auth"}
2025-08-08 21:41:03 | INFO | api_e2e | gateway/auth is healthy
2025-08-08 21:41:03 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 1 ms | body={"status":"ok","service":"profiles"}
2025-08-08 21:41:03 | INFO | api_e2e | profiles is healthy
2025-08-08 21:41:03 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"match"}
2025-08-08 21:41:03 | INFO | api_e2e | match is healthy
2025-08-08 21:41:03 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"chat"}
2025-08-08 21:41:03 | INFO | api_e2e | chat is healthy
2025-08-08 21:41:03 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"payments"}
2025-08-08 21:41:03 | INFO | api_e2e | payments is healthy
2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754656863.xaji0y@agency.dev', 'password': '***hidden***'}
2025-08-08 21:41:03 | DEBUG | api_e2e | ← 500 in 6 ms | body=Internal Server Error
2025-08-08 21:41:03 | ERROR | api_e2e | login unexpected status 500, expected [200]; body=Internal Server Error
2025-08-08 21:41:03 | INFO | api_e2e | Login failed for admin+1754656863.xaji0y@agency.dev: login unexpected status 500, expected [200]; body=Internal Server Error; will try register
2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754656863.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Corey Briggs', 'role': 'ADMIN'}
2025-08-08 21:41:03 | DEBUG | api_e2e | ← 500 in 7 ms | body=Internal Server Error
2025-08-08 21:41:03 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
2025-08-08 21:41:03 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754656863.xaji0y@agency.dev', 'password': '***hidden***'}
2025-08-08 21:41:03 | DEBUG | api_e2e | ← 500 in 6 ms | body=Internal Server Error
2025-08-08 21:41:03 | ERROR | api_e2e | login unexpected status 500, expected [200]; body=Internal Server Error
2025-08-08 21:43:12 | INFO | api_e2e | === API E2E START ===
2025-08-08 21:43:12 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
2025-08-08 21:43:12 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"auth"}
2025-08-08 21:43:12 | INFO | api_e2e | gateway/auth is healthy
2025-08-08 21:43:12 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"profiles"}
2025-08-08 21:43:12 | INFO | api_e2e | profiles is healthy
2025-08-08 21:43:12 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"match"}
2025-08-08 21:43:12 | INFO | api_e2e | match is healthy
2025-08-08 21:43:12 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"chat"}
2025-08-08 21:43:12 | INFO | api_e2e | chat is healthy
2025-08-08 21:43:12 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"payments"}
2025-08-08 21:43:12 | INFO | api_e2e | payments is healthy
2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754656992.xaji0y@agency.dev', 'password': '***hidden***'}
2025-08-08 21:43:12 | DEBUG | api_e2e | ← 500 in 6 ms | body=Internal Server Error
2025-08-08 21:43:12 | ERROR | api_e2e | login unexpected status 500, expected [200]; body=Internal Server Error
2025-08-08 21:43:12 | INFO | api_e2e | Login failed for admin+1754656992.xaji0y@agency.dev: login unexpected status 500, expected [200]; body=Internal Server Error; will try register
2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754656992.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Heather Franklin', 'role': 'ADMIN'}
2025-08-08 21:43:12 | DEBUG | api_e2e | ← 500 in 7 ms | body=Internal Server Error
2025-08-08 21:43:12 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
2025-08-08 21:43:12 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754656992.xaji0y@agency.dev', 'password': '***hidden***'}
2025-08-08 21:43:12 | DEBUG | api_e2e | ← 500 in 9 ms | body=Internal Server Error
2025-08-08 21:43:12 | ERROR | api_e2e | login unexpected status 500, expected [200]; body=Internal Server Error
2025-08-08 21:45:36 | INFO | api_e2e | === API E2E START ===
2025-08-08 21:45:36 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
2025-08-08 21:45:36 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 3 ms | body={"status":"ok","service":"auth"}
2025-08-08 21:45:36 | INFO | api_e2e | gateway/auth is healthy
2025-08-08 21:45:36 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 1 ms | body={"status":"ok","service":"profiles"}
2025-08-08 21:45:36 | INFO | api_e2e | profiles is healthy
2025-08-08 21:45:36 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"match"}
2025-08-08 21:45:36 | INFO | api_e2e | match is healthy
2025-08-08 21:45:36 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"chat"}
2025-08-08 21:45:36 | INFO | api_e2e | chat is healthy
2025-08-08 21:45:36 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"payments"}
2025-08-08 21:45:36 | INFO | api_e2e | payments is healthy
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754657136.xaji0y@agency.dev', 'password': '***hidden***'}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 401 in 4 ms | body={"detail":"Invalid credentials"}
2025-08-08 21:45:36 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
2025-08-08 21:45:36 | INFO | api_e2e | Login failed for admin+1754657136.xaji0y@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754657136.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Allison Sanders', 'role': 'ADMIN'}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 500 in 227 ms | body=Internal Server Error
2025-08-08 21:45:36 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
2025-08-08 21:45:36 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754657136.xaji0y@agency.dev', 'password': '***hidden***'}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MDEyZDZkNC01ZjgwLTQzMzktOTIxMC0wNzI3ZGU1OTAwOGMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTcxMzYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU0NjU4MDM2fQ.GNe6OFWt4zPlFC-8eGjVEwV-b_mj5AO3HRu75C2oikU","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MDEyZDZkNC01ZjgwLTQzMzktOTIxMC0wNzI3ZGU1OTAwOGMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTcxMzYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTc1NzI0OTEzNn0.OGorK2VQ9KmOTSpnSzN_jJfv5Tvu5QkYldiTlm-sP_Q","token_type":"bearer"}
2025-08-08 21:45:36 | INFO | api_e2e | Registered+Login OK: admin+1754657136.xaji0y@agency.dev -> 5012d6d4-5f80-4339-9210-0727de59008c
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754657136.6dpbhs@agency.dev', 'password': '***hidden***'}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 401 in 3 ms | body={"detail":"Invalid credentials"}
2025-08-08 21:45:36 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
2025-08-08 21:45:36 | INFO | api_e2e | Login failed for user1+1754657136.6dpbhs@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user1+1754657136.6dpbhs@agency.dev', 'password': '***hidden***', 'full_name': 'Joshua Harris', 'role': 'CLIENT'}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 500 in 225 ms | body=Internal Server Error
2025-08-08 21:45:36 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
2025-08-08 21:45:36 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754657136.6dpbhs@agency.dev', 'password': '***hidden***'}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 212 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMDhmODg4MS04ZThhLTRiZDQtODNmNy02NGFjN2MwYjQzODQiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTcxMzYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1ODAzNn0.JYz3xrtGtQ6V0g14CWinTVj1P1cz8cWDQSNz_Z-e64k","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMDhmODg4MS04ZThhLTRiZDQtODNmNy02NGFjN2MwYjQzODQiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTcxMzYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDkxMzZ9.IfmVSwhWJbQjFj1mSmhN9qV20CYvHwy4aUuaaictCEI","token_type":"bearer"}
2025-08-08 21:45:36 | INFO | api_e2e | Registered+Login OK: user1+1754657136.6dpbhs@agency.dev -> a08f8881-8e8a-4bd4-83f7-64ac7c0b4384
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754657136.ahxthv@agency.dev', 'password': '***hidden***'}
2025-08-08 21:45:36 | DEBUG | api_e2e | ← 401 in 4 ms | body={"detail":"Invalid credentials"}
2025-08-08 21:45:36 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
2025-08-08 21:45:36 | INFO | api_e2e | Login failed for user2+1754657136.ahxthv@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user2+1754657136.ahxthv@agency.dev', 'password': '***hidden***', 'full_name': 'Adrian Taylor', 'role': 'CLIENT'}
2025-08-08 21:45:37 | DEBUG | api_e2e | ← 500 in 225 ms | body=Internal Server Error
2025-08-08 21:45:37 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
2025-08-08 21:45:37 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
2025-08-08 21:45:37 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754657136.ahxthv@agency.dev', 'password': '***hidden***'}
2025-08-08 21:45:37 | DEBUG | api_e2e | ← 200 in 212 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2MDkyMTc5Zi03MmUwLTQ0NGMtYmI1YS0yNDRjYzVjMjNiMGIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTcxMzYuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1ODAzN30.zhLgSUtLDDuisejsK-vIsxsplwEfXmSqtDdLuuOG6xY","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2MDkyMTc5Zi03MmUwLTQ0NGMtYmI1YS0yNDRjYzVjMjNiMGIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTcxMzYuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDkxMzd9.FFHAxduf-mGYZwFeP-SkLlRYtBV3U5v31hvtrAHbo30","token_type":"bearer"}
2025-08-08 21:45:37 | INFO | api_e2e | Registered+Login OK: user2+1754657136.ahxthv@agency.dev -> 6092179f-72e0-444c-bb5a-244cc5c23b0b
2025-08-08 21:45:37 | INFO | api_e2e | [1/3] Ensure profile for admin+1754657136.xaji0y@agency.dev (role=ADMIN)
2025-08-08 21:45:37 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/v1/profiles/me | headers={Authorization: Bearer eyJhbGciOiJI...} | body={}
2025-08-08 21:45:37 | DEBUG | api_e2e | ← 403 in 1 ms | body={"detail":"Not authenticated"}
2025-08-08 21:45:37 | ERROR | api_e2e | profiles/me unexpected status 403, expected [200, 404]; body={"detail":"Not authenticated"}
2025-08-08 21:54:30 | INFO | api_e2e | === API E2E START ===
2025-08-08 21:54:30 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
2025-08-08 21:54:30 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
2025-08-08 21:55:18 | INFO | api_e2e | === API E2E START ===
2025-08-08 21:55:18 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
2025-08-08 21:55:18 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
2025-08-08 21:55:29 | INFO | api_e2e | === API E2E START ===
2025-08-08 21:55:29 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
2025-08-08 21:55:29 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
2025-08-08 21:56:19 | INFO | api_e2e | === API E2E START ===
2025-08-08 21:56:19 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
2025-08-08 21:56:19 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 14 ms | body={"status":"ok","service":"auth"}
2025-08-08 21:56:19 | INFO | api_e2e | gateway/auth is healthy
2025-08-08 21:56:19 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 5 ms | body={"status":"ok","service":"profiles"}
2025-08-08 21:56:19 | INFO | api_e2e | profiles is healthy
2025-08-08 21:56:19 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 3 ms | body={"status":"ok","service":"match"}
2025-08-08 21:56:19 | INFO | api_e2e | match is healthy
2025-08-08 21:56:19 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"chat"}
2025-08-08 21:56:19 | INFO | api_e2e | chat is healthy
2025-08-08 21:56:19 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"payments"}
2025-08-08 21:56:19 | INFO | api_e2e | payments is healthy
2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754657779.xaji0y@agency.dev', 'password': '***hidden***'}
2025-08-08 21:56:19 | DEBUG | api_e2e | ← 401 in 10 ms | body={"detail":"Invalid credentials"}
2025-08-08 21:56:19 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
2025-08-08 21:56:19 | INFO | api_e2e | Login failed for admin+1754657779.xaji0y@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754657779.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Eric Roberson', 'role': 'ADMIN'}
2025-08-08 21:56:20 | DEBUG | api_e2e | ← 500 in 230 ms | body=Internal Server Error
2025-08-08 21:56:20 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
2025-08-08 21:56:20 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754657779.xaji0y@agency.dev', 'password': '***hidden***'}
2025-08-08 21:56:20 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MDZhYmI3OS1kOWI2LTRjMzAtOGQ0ZC0xOTUwYWI2MTE5ZGUiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTc3NzkueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU0NjU4NjgwfQ.fopkkb3_QSCoDCkyYDVeQRCJse2VFP2cHDYx8QkZ6eY","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MDZhYmI3OS1kOWI2LTRjMzAtOGQ0ZC0xOTUwYWI2MTE5ZGUiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTc3NzkueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTc1NzI0OTc4MH0.vegho7J95DF_XuJqKaoTa79RcQHEhM-O4Uo-iDF4s2M","token_type":"bearer"}
2025-08-08 21:56:20 | INFO | api_e2e | Registered+Login OK: admin+1754657779.xaji0y@agency.dev -> 406abb79-d9b6-4c30-8d4d-1950ab6119de
2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754657780.6dpbhs@agency.dev', 'password': '***hidden***'}
2025-08-08 21:56:20 | DEBUG | api_e2e | ← 401 in 3 ms | body={"detail":"Invalid credentials"}
2025-08-08 21:56:20 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
2025-08-08 21:56:20 | INFO | api_e2e | Login failed for user1+1754657780.6dpbhs@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user1+1754657780.6dpbhs@agency.dev', 'password': '***hidden***', 'full_name': 'Stephen Garcia', 'role': 'CLIENT'}
2025-08-08 21:56:20 | DEBUG | api_e2e | ← 500 in 224 ms | body=Internal Server Error
2025-08-08 21:56:20 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
2025-08-08 21:56:20 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754657780.6dpbhs@agency.dev', 'password': '***hidden***'}
2025-08-08 21:56:20 | DEBUG | api_e2e | ← 200 in 212 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjM2YzNkZS01YzllLTRiNGMtODE5My1hZjIwODI0MDgxZGUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTc3ODAuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1ODY4MH0.8lkRa-uwaI5MD5Bz-NQPYuxuFb84lAroVX9nqwIwSWU","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjM2YzNkZS01YzllLTRiNGMtODE5My1hZjIwODI0MDgxZGUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTc3ODAuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDk3ODB9.bMMXN_To2KRtLME0NiY1BkHfpQXkkPm4WOv4KUD3PYs","token_type":"bearer"}
2025-08-08 21:56:20 | INFO | api_e2e | Registered+Login OK: user1+1754657780.6dpbhs@agency.dev -> 2236c3de-5c9e-4b4c-8193-af20824081de
2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754657780.ahxthv@agency.dev', 'password': '***hidden***'}
2025-08-08 21:56:20 | DEBUG | api_e2e | ← 401 in 4 ms | body={"detail":"Invalid credentials"}
2025-08-08 21:56:20 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
2025-08-08 21:56:20 | INFO | api_e2e | Login failed for user2+1754657780.ahxthv@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user2+1754657780.ahxthv@agency.dev', 'password': '***hidden***', 'full_name': 'Colleen Morrow', 'role': 'CLIENT'}
2025-08-08 21:56:21 | DEBUG | api_e2e | ← 500 in 226 ms | body=Internal Server Error
2025-08-08 21:56:21 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
2025-08-08 21:56:21 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
2025-08-08 21:56:21 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754657780.ahxthv@agency.dev', 'password': '***hidden***'}
2025-08-08 21:56:21 | DEBUG | api_e2e | ← 200 in 212 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4NzFlZTBjMi05ZTIyLTQ1OTYtYWZhOS03YWJiZmQxMzBlODYiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTc3ODAuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1ODY4MX0.Xx_ASHRjT8B_4EBpndcQoKcys8lJ_uJIN0log_f-2Ss","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4NzFlZTBjMi05ZTIyLTQ1OTYtYWZhOS03YWJiZmQxMzBlODYiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTc3ODAuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDk3ODF9.hK54H_2APL3FgjfUNdmoHxJaC5BMfw3XokhticH5pKQ","token_type":"bearer"}
2025-08-08 21:56:21 | INFO | api_e2e | Registered+Login OK: user2+1754657780.ahxthv@agency.dev -> 871ee0c2-9e22-4596-afa9-7abbfd130e86
2025-08-08 21:56:21 | INFO | api_e2e | [1/3] Ensure profile for admin+1754657779.xaji0y@agency.dev (role=ADMIN)
2025-08-08 21:56:21 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/v1/profiles/me | headers={Authorization: Bearer eyJhbGciOiJI...} | body={}
2025-08-08 21:56:21 | DEBUG | api_e2e | ← 403 in 2 ms | body={"detail":"Not authenticated"}
2025-08-08 21:56:21 | ERROR | api_e2e | profiles/me unexpected status 403, expected [200, 404]; body={"detail":"Not authenticated"}

417
scripts/api_e2e.py Normal file
View File

@@ -0,0 +1,417 @@
#!/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)

208
scripts/e2e.sh Executable file
View File

@@ -0,0 +1,208 @@
#!/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<len(cur) else None
elif isinstance(cur,dict):
cur=cur.get(k)
else: cur=None
if cur is None: break
print("" if cur is None else cur)
PY
}
jwt_get(){ # jwt_get <token> <claim>
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 "<user_id>|<access_token>"
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(){ # <token> <gender> <city> <langs_csv> <interests_csv>
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 <<JSON
{"gender":"$G","city":"$CITY","languages":[${lj}],"interests":[${ij}]}
JSON
)
local CR CRCODE CRRESP; CR="$(http_req POST "$PROFILES/v1/profiles" "$TOKEN" "$BODY")"
CRCODE="${CR%%|*}"; CRRESP="${CR##*|}"
expect_code "$CRCODE" "201|200" || fail "Create profile failed (${CRCODE}): $(cat "$CRRESP")"
ok "Profile created"
}
main(){
echo -e "${B}=== E2E smoke test start ===${NC}" >&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 "$@"

54
scripts/fix_alembic.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/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."

18
scripts/fix_email_validation.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/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

27
scripts/fix_profiles_deps.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/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

62
scripts/fix_profiles_fk.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/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

View File

@@ -0,0 +1,44 @@
#!/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

6
scripts/migrate.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/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

1564
scripts/models.sh Executable file

File diff suppressed because it is too large Load Diff

31
scripts/patch.sh Executable file
View File

@@ -0,0 +1,31 @@
# 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

View File

@@ -0,0 +1,65 @@
#!/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/<svc>/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

View File

@@ -0,0 +1,25 @@
#!/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

View File

@@ -0,0 +1,55 @@
#!/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

View File

@@ -0,0 +1,43 @@
#!/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

View File

@@ -0,0 +1,79 @@
#!/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

29
scripts/test.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/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 .

View File

@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR) sys.path.append(SRC_DIR)
from app.db.session import Base # noqa from app.db.session import Base # noqa
from app import models # noqa: F401
config = context.config config = context.config

View File

@@ -0,0 +1,22 @@
"""${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"}

View File

@@ -0,0 +1,38 @@
"""init
Revision ID: df0effc5d87a
Revises:
Create Date: 2025-08-08 11:20:03.816755+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'df0effc5d87a'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.Column('role', sa.String(length=32), nullable=False),
sa.Column('is_active', sa.Boolean(), 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_users_email'), 'users', ['email'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -5,6 +5,9 @@ psycopg2-binary
alembic alembic
pydantic>=2 pydantic>=2
pydantic-settings pydantic-settings
pydantic[email]
python-dotenv python-dotenv
httpx>=0.27 httpx>=0.27
pytest pytest
PyJWT>=2.8
passlib[bcrypt]>=1.7

View File

@@ -0,0 +1,55 @@
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": "<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

View File

@@ -0,0 +1,51 @@
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)

View File

@@ -0,0 +1,9 @@
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)

View File

@@ -0,0 +1,65 @@
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

View File

@@ -1,5 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from .api.routes.ping import router as ping_router 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 = FastAPI(title="AUTH Service")
@@ -7,5 +9,6 @@ app = FastAPI(title="AUTH Service")
def health(): def health():
return {"status": "ok", "service": "auth"} return {"status": "ok", "service": "auth"}
# v1 API
app.include_router(ping_router, prefix="/v1") app.include_router(ping_router, prefix="/v1")
app.include_router(auth_router)
app.include_router(users_router)

Some files were not shown because too many files have changed in this diff Show More