This commit is contained in:
19
.history/.env_20250808194630
Normal file
19
.history/.env_20250808194630
Normal 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
|
||||||
24
.history/.env_20250808200305
Normal file
24
.history/.env_20250808200305
Normal 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
|
||||||
24
.history/.env_20250808200329
Normal file
24
.history/.env_20250808200329
Normal 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
|
||||||
105
.history/docker-compose_20250808194542.yml
Normal file
105
.history/docker-compose_20250808194542.yml
Normal 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:
|
||||||
105
.history/docker-compose_20250808201541.yml
Normal file
105
.history/docker-compose_20250808201541.yml
Normal 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:
|
||||||
0
.history/fix_alembic_20250808201237.sh
Normal file
0
.history/fix_alembic_20250808201237.sh
Normal file
54
.history/fix_alembic_20250808201241.sh
Normal file
54
.history/fix_alembic_20250808201241.sh
Normal 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."
|
||||||
486
.history/logs/api_20250808212556.log
Normal file
486
.history/logs/api_20250808212556.log
Normal 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"}
|
||||||
0
.history/logs/api_20250808212604.log
Normal file
0
.history/logs/api_20250808212604.log
Normal file
0
.history/logs/api_20250808213512.log
Normal file
0
.history/logs/api_20250808213512.log
Normal file
0
.history/logs/api_20250808213904.log
Normal file
0
.history/logs/api_20250808213904.log
Normal file
0
.history/logs/api_20250808213928.log
Normal file
0
.history/logs/api_20250808213928.log
Normal file
0
.history/migrate_20250808200653.sh
Normal file
0
.history/migrate_20250808200653.sh
Normal file
6
.history/migrate_20250808200656.sh
Normal file
6
.history/migrate_20250808200656.sh
Normal 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
|
||||||
10
.history/migrate_20250808200715.sh
Normal file
10
.history/migrate_20250808200715.sh
Normal 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
|
||||||
1560
.history/models_20250808195719.sh
Normal file
1560
.history/models_20250808195719.sh
Normal file
File diff suppressed because it is too large
Load Diff
1564
.history/models_20250808195931.sh
Normal file
1564
.history/models_20250808195931.sh
Normal file
File diff suppressed because it is too large
Load Diff
0
.history/patch_20250808204338.sh
Normal file
0
.history/patch_20250808204338.sh
Normal file
68
.history/patch_20250808204342.sh
Normal file
68
.history/patch_20250808204342.sh
Normal 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
|
||||||
0
.history/patch_alembic_template_20250808201930.sh
Normal file
0
.history/patch_alembic_template_20250808201930.sh
Normal file
50
.history/patch_alembic_template_20250808201932.sh
Normal file
50
.history/patch_alembic_template_20250808201932.sh
Normal 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
|
||||||
60
.history/patch_alembic_template_20250808201952.sh
Normal file
60
.history/patch_alembic_template_20250808201952.sh
Normal 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
|
||||||
65
.history/patch_alembic_template_20250808202000.sh
Normal file
65
.history/patch_alembic_template_20250808202000.sh
Normal 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
|
||||||
0
.history/scripts/api_e2e_20250808212121.py
Normal file
0
.history/scripts/api_e2e_20250808212121.py
Normal file
437
.history/scripts/api_e2e_20250808212124.py
Normal file
437
.history/scripts/api_e2e_20250808212124.py
Normal 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)
|
||||||
416
.history/scripts/api_e2e_20250808213334.py
Normal file
416
.history/scripts/api_e2e_20250808213334.py
Normal 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)
|
||||||
419
.history/scripts/api_e2e_20250808215311.py
Normal file
419
.history/scripts/api_e2e_20250808215311.py
Normal 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)
|
||||||
423
.history/scripts/api_e2e_20250808215326.py
Normal file
423
.history/scripts/api_e2e_20250808215326.py
Normal 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)
|
||||||
423
.history/scripts/api_e2e_20250808215359.py
Normal file
423
.history/scripts/api_e2e_20250808215359.py
Normal 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)
|
||||||
424
.history/scripts/api_e2e_20250808215427.py
Normal file
424
.history/scripts/api_e2e_20250808215427.py
Normal 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)
|
||||||
424
.history/scripts/api_e2e_20250808215516.py
Normal file
424
.history/scripts/api_e2e_20250808215516.py
Normal 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)
|
||||||
424
.history/scripts/api_e2e_20250808215528.py
Normal file
424
.history/scripts/api_e2e_20250808215528.py
Normal 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)
|
||||||
417
.history/scripts/api_e2e_20250808215617.py
Normal file
417
.history/scripts/api_e2e_20250808215617.py
Normal 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)
|
||||||
0
.history/scripts/e2e_20250808205322.sh
Normal file
0
.history/scripts/e2e_20250808205322.sh
Normal file
276
.history/scripts/e2e_20250808205324.sh
Normal file
276
.history/scripts/e2e_20250808205324.sh
Normal 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 "$@"
|
||||||
284
.history/scripts/e2e_20250808205905.sh
Normal file
284
.history/scripts/e2e_20250808205905.sh
Normal 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 "$@"
|
||||||
289
.history/scripts/e2e_20250808210443.sh
Normal file
289
.history/scripts/e2e_20250808210443.sh
Normal 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 "$@"
|
||||||
208
.history/scripts/e2e_20250808211132.sh
Normal file
208
.history/scripts/e2e_20250808211132.sh
Normal 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 "$@"
|
||||||
18
.history/scripts/fix_email_validation_20250808211222.sh
Normal file
18
.history/scripts/fix_email_validation_20250808211222.sh
Normal 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
|
||||||
10
.history/scripts/migrate_20250808200714.sh
Normal file
10
.history/scripts/migrate_20250808200714.sh
Normal 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
|
||||||
6
.history/scripts/migrate_20250808214443.sh
Normal file
6
.history/scripts/migrate_20250808214443.sh
Normal 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
|
||||||
68
.history/scripts/patch_20250808204341.sh
Normal file
68
.history/scripts/patch_20250808204341.sh
Normal 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
|
||||||
123
.history/scripts/patch_20250808211820.sh
Normal file
123
.history/scripts/patch_20250808211820.sh
Normal 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
|
||||||
33
.history/scripts/patch_20250808212435.sh
Normal file
33
.history/scripts/patch_20250808212435.sh
Normal 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
|
||||||
85
.history/scripts/patch_20250808213107.sh
Normal file
85
.history/scripts/patch_20250808213107.sh
Normal 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
|
||||||
50
.history/scripts/patch_20250808213457.sh
Normal file
50
.history/scripts/patch_20250808213457.sh
Normal 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
|
||||||
50
.history/scripts/patch_20250808213938.sh
Normal file
50
.history/scripts/patch_20250808213938.sh
Normal 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
|
||||||
49
.history/scripts/patch_20250808213956.sh
Normal file
49
.history/scripts/patch_20250808213956.sh
Normal 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
|
||||||
61
.history/scripts/patch_20250808214013.sh
Normal file
61
.history/scripts/patch_20250808214013.sh
Normal 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
|
||||||
31
.history/scripts/patch_20250808214025.sh
Normal file
31
.history/scripts/patch_20250808214025.sh
Normal 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
|
||||||
17
.history/scripts/test_20250808204608.sh
Normal file
17
.history/scripts/test_20250808204608.sh
Normal 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"
|
||||||
29
.history/scripts/test_20250808214044.sh
Normal file
29
.history/scripts/test_20250808214044.sh
Normal 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 .
|
||||||
12
.history/services/auth/requirements_20250808195758.txt
Normal file
12
.history/services/auth/requirements_20250808195758.txt
Normal 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
|
||||||
13
.history/services/auth/requirements_20250808200038.txt
Normal file
13
.history/services/auth/requirements_20250808200038.txt
Normal 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
|
||||||
@@ -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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
0
.history/test_20250808204537.sh
Normal file
0
.history/test_20250808204537.sh
Normal file
16
.history/test_20250808204550.sh
Normal file
16
.history/test_20250808204550.sh
Normal 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"
|
||||||
16
.history/test_20250808204607.sh
Normal file
16
.history/test_20250808204607.sh
Normal 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"
|
||||||
16
.history/test_20250808204608.sh
Normal file
16
.history/test_20250808204608.sh
Normal 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"
|
||||||
17
.history/test_20250808204610.sh
Normal file
17
.history/test_20250808204610.sh
Normal 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"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
|
|||||||
0
infra/db/init/02_create_tables.sql
Normal file
0
infra/db/init/02_create_tables.sql
Normal 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
193
logs/api.log
Normal 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
417
scripts/api_e2e.py
Normal 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
208
scripts/e2e.sh
Executable 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
54
scripts/fix_alembic.sh
Executable 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
18
scripts/fix_email_validation.sh
Executable 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
27
scripts/fix_profiles_deps.sh
Executable 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
62
scripts/fix_profiles_fk.sh
Executable 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
|
||||||
44
scripts/fix_profiles_schema_uuid.sh
Executable file
44
scripts/fix_profiles_schema_uuid.sh
Executable 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
6
scripts/migrate.sh
Executable 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
1564
scripts/models.sh
Executable file
File diff suppressed because it is too large
Load Diff
31
scripts/patch.sh
Executable file
31
scripts/patch.sh
Executable 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
|
||||||
65
scripts/patch_alembic_template.sh
Executable file
65
scripts/patch_alembic_template.sh
Executable 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
|
||||||
25
scripts/patch_gateway_auth_header.sh
Executable file
25
scripts/patch_gateway_auth_header.sh
Executable 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
|
||||||
55
scripts/patch_profiles_repo_service.sh
Executable file
55
scripts/patch_profiles_repo_service.sh
Executable 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
|
||||||
43
scripts/patch_profiles_router.sh
Executable file
43
scripts/patch_profiles_router.sh
Executable 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
|
||||||
79
scripts/patch_profiles_security.sh
Executable file
79
scripts/patch_profiles_security.sh
Executable 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
29
scripts/test.sh
Executable 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 .
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
22
services/auth/alembic/script.py.mako
Normal file
22
services/auth/alembic/script.py.mako
Normal 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"}
|
||||||
38
services/auth/alembic/versions/df0effc5d87a_init.py
Normal file
38
services/auth/alembic/versions/df0effc5d87a_init.py
Normal 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 ###
|
||||||
@@ -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
|
||||||
|
|||||||
55
services/auth/src/app/api/routes/auth.py
Normal file
55
services/auth/src/app/api/routes/auth.py
Normal 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
|
||||||
51
services/auth/src/app/api/routes/users.py
Normal file
51
services/auth/src/app/api/routes/users.py
Normal 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)
|
||||||
9
services/auth/src/app/core/passwords.py
Normal file
9
services/auth/src/app/core/passwords.py
Normal 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)
|
||||||
65
services/auth/src/app/core/security.py
Normal file
65
services/auth/src/app/core/security.py
Normal 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
|
||||||
@@ -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
Reference in New Issue
Block a user