From cc87dcc0fa61a1a9846e390414f99bdcc091d2fd Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Fri, 8 Aug 2025 21:58:36 +0900 Subject: [PATCH] api development --- .history/.env_20250808194630 | 19 + .history/.env_20250808200305 | 24 + .history/.env_20250808200329 | 24 + .history/docker-compose_20250808194542.yml | 105 ++ .history/docker-compose_20250808201541.yml | 105 ++ .history/fix_alembic_20250808201237.sh | 0 .history/fix_alembic_20250808201241.sh | 54 + .history/logs/api_20250808212556.log | 486 +++++ .history/logs/api_20250808212604.log | 0 .history/logs/api_20250808213512.log | 0 .history/logs/api_20250808213904.log | 0 .history/logs/api_20250808213928.log | 0 .history/migrate_20250808200653.sh | 0 .history/migrate_20250808200656.sh | 6 + .history/migrate_20250808200715.sh | 10 + .history/models_20250808195719.sh | 1560 ++++++++++++++++ .history/models_20250808195931.sh | 1564 +++++++++++++++++ .history/patch_20250808204338.sh | 0 .history/patch_20250808204342.sh | 68 + .../patch_alembic_template_20250808201930.sh | 0 .../patch_alembic_template_20250808201932.sh | 50 + .../patch_alembic_template_20250808201952.sh | 60 + .../patch_alembic_template_20250808202000.sh | 65 + .history/scripts/api_e2e_20250808212121.py | 0 .history/scripts/api_e2e_20250808212124.py | 437 +++++ .history/scripts/api_e2e_20250808213334.py | 416 +++++ .history/scripts/api_e2e_20250808215311.py | 419 +++++ .history/scripts/api_e2e_20250808215326.py | 423 +++++ .history/scripts/api_e2e_20250808215359.py | 423 +++++ .history/scripts/api_e2e_20250808215427.py | 424 +++++ .history/scripts/api_e2e_20250808215516.py | 424 +++++ .history/scripts/api_e2e_20250808215528.py | 424 +++++ .history/scripts/api_e2e_20250808215617.py | 417 +++++ .history/scripts/e2e_20250808205322.sh | 0 .history/scripts/e2e_20250808205324.sh | 276 +++ .history/scripts/e2e_20250808205905.sh | 284 +++ .history/scripts/e2e_20250808210443.sh | 289 +++ .history/scripts/e2e_20250808211132.sh | 208 +++ .../fix_email_validation_20250808211220.sh | 0 .../fix_email_validation_20250808211222.sh | 18 + .history/scripts/migrate_20250808200714.sh | 10 + .history/scripts/migrate_20250808214443.sh | 6 + .history/scripts/patch_20250808204341.sh | 68 + .history/scripts/patch_20250808211820.sh | 123 ++ .history/scripts/patch_20250808212435.sh | 33 + .history/scripts/patch_20250808213107.sh | 85 + .history/scripts/patch_20250808213457.sh | 50 + .history/scripts/patch_20250808213938.sh | 50 + .history/scripts/patch_20250808213956.sh | 49 + .history/scripts/patch_20250808214013.sh | 61 + .history/scripts/patch_20250808214025.sh | 31 + .history/scripts/test_20250808204608.sh | 17 + .history/scripts/test_20250808214044.sh | 29 + .../auth/requirements_20250808195758.txt | 12 + .../auth/requirements_20250808200038.txt | 13 + .../769f535c9249_init_20250808202004.py | 54 + .../769f535c9249_init_20250808203551.py | 55 + .../docker-entrypoint_20250808194542.sh | 6 + .../docker-entrypoint_20250808203201.sh | 6 + .../src/app/models/photo_20250808195936.py | 22 + .../src/app/models/photo_20250808204310.py | 27 + .../src/app/models/profile_20250808195936.py | 28 + .../src/app/models/profile_20250808204008.py | 29 + .../src/app/models/profile_20250808204024.py | 29 + .../src/app/models/profile_20250808204059.py | 24 + .../src/app/models/profile_20250808204229.py | 29 + .history/test_20250808204537.sh | 0 .history/test_20250808204550.sh | 16 + .history/test_20250808204607.sh | 16 + .history/test_20250808204608.sh | 16 + .history/test_20250808204610.sh | 17 + docker-compose.yml | 2 +- infra/db/init/02_create_tables.sql | 0 infra/gateway/nginx.conf | 4 + logs/api.log | 193 ++ scripts/api_e2e.py | 417 +++++ scripts/e2e.sh | 208 +++ scripts/fix_alembic.sh | 54 + scripts/fix_email_validation.sh | 18 + scripts/fix_profiles_deps.sh | 27 + scripts/fix_profiles_fk.sh | 62 + scripts/fix_profiles_schema_uuid.sh | 44 + scripts/migrate.sh | 6 + scripts/models.sh | 1564 +++++++++++++++++ scripts/patch.sh | 31 + scripts/patch_alembic_template.sh | 65 + scripts/patch_gateway_auth_header.sh | 25 + scripts/patch_profiles_repo_service.sh | 55 + scripts/patch_profiles_router.sh | 43 + scripts/patch_profiles_security.sh | 79 + scripts/test.sh | 29 + services/auth/alembic/env.py | 1 + services/auth/alembic/script.py.mako | 22 + .../alembic/versions/df0effc5d87a_init.py | 38 + services/auth/requirements.txt | 3 + services/auth/src/app/api/routes/auth.py | 55 + services/auth/src/app/api/routes/users.py | 51 + services/auth/src/app/core/passwords.py | 9 + services/auth/src/app/core/security.py | 65 + services/auth/src/app/main.py | 5 +- services/auth/src/app/models/__init__.py | 1 + services/auth/src/app/models/user.py | 28 + .../src/app/repositories/user_repository.py | 41 + services/auth/src/app/schemas/user.py | 38 + .../auth/src/app/services/user_service.py | 48 + services/chat/alembic/env.py | 1 + services/chat/alembic/script.py.mako | 22 + .../alembic/versions/8cc8115aaf0e_init.py | 56 + services/chat/requirements.txt | 1 + services/chat/src/app/api/routes/chat.py | 46 + services/chat/src/app/core/security.py | 40 + services/chat/src/app/main.py | 3 +- services/chat/src/app/models/__init__.py | 1 + services/chat/src/app/models/chat.py | 30 + .../src/app/repositories/chat_repository.py | 45 + services/chat/src/app/schemas/chat.py | 22 + .../chat/src/app/services/chat_service.py | 31 + services/match/alembic/env.py | 1 + services/match/alembic/script.py.mako | 22 + .../alembic/versions/00ce87deada6_init.py | 41 + services/match/requirements.txt | 1 + services/match/src/app/api/routes/pairs.py | 70 + services/match/src/app/core/security.py | 40 + services/match/src/app/main.py | 3 +- services/match/src/app/models/__init__.py | 1 + services/match/src/app/models/pair.py | 22 + .../src/app/repositories/pair_repository.py | 43 + services/match/src/app/schemas/pair.py | 22 + .../match/src/app/services/pair_service.py | 27 + services/payments/alembic/env.py | 1 + services/payments/alembic/script.py.mako | 22 + .../alembic/versions/6641523a6967_init.py | 38 + services/payments/requirements.txt | 1 + .../payments/src/app/api/routes/payments.py | 62 + services/payments/src/app/core/security.py | 40 + services/payments/src/app/main.py | 3 +- services/payments/src/app/models/__init__.py | 1 + services/payments/src/app/models/payment.py | 20 + .../app/repositories/payment_repository.py | 43 + services/payments/src/app/schemas/payment.py | 24 + .../src/app/services/payment_service.py | 27 + services/profiles/alembic/env.py | 1 + services/profiles/alembic/script.py.mako | 22 + ...13_add_fk_photos_profile_id_profiles_id.py | 26 + .../alembic/versions/769f535c9249_init.py | 55 + services/profiles/docker-entrypoint.sh | 2 +- services/profiles/requirements.txt | 1 + .../profiles/src/app/api/routes/profiles.py | 31 + services/profiles/src/app/core/security.py | 59 + services/profiles/src/app/db/deps.py | 10 + services/profiles/src/app/main.py | 3 +- services/profiles/src/app/models/__init__.py | 2 + services/profiles/src/app/models/photo.py | 27 + services/profiles/src/app/models/profile.py | 29 + .../app/repositories/profile_repository.py | 26 + services/profiles/src/app/schemas/profile.py | 32 + .../src/app/services/profile_service.py | 13 + 157 files changed, 14629 insertions(+), 7 deletions(-) create mode 100644 .history/.env_20250808194630 create mode 100644 .history/.env_20250808200305 create mode 100644 .history/.env_20250808200329 create mode 100644 .history/docker-compose_20250808194542.yml create mode 100644 .history/docker-compose_20250808201541.yml create mode 100644 .history/fix_alembic_20250808201237.sh create mode 100644 .history/fix_alembic_20250808201241.sh create mode 100644 .history/logs/api_20250808212556.log create mode 100644 .history/logs/api_20250808212604.log create mode 100644 .history/logs/api_20250808213512.log create mode 100644 .history/logs/api_20250808213904.log create mode 100644 .history/logs/api_20250808213928.log create mode 100644 .history/migrate_20250808200653.sh create mode 100644 .history/migrate_20250808200656.sh create mode 100644 .history/migrate_20250808200715.sh create mode 100644 .history/models_20250808195719.sh create mode 100644 .history/models_20250808195931.sh create mode 100644 .history/patch_20250808204338.sh create mode 100644 .history/patch_20250808204342.sh create mode 100644 .history/patch_alembic_template_20250808201930.sh create mode 100644 .history/patch_alembic_template_20250808201932.sh create mode 100644 .history/patch_alembic_template_20250808201952.sh create mode 100644 .history/patch_alembic_template_20250808202000.sh create mode 100644 .history/scripts/api_e2e_20250808212121.py create mode 100644 .history/scripts/api_e2e_20250808212124.py create mode 100644 .history/scripts/api_e2e_20250808213334.py create mode 100644 .history/scripts/api_e2e_20250808215311.py create mode 100644 .history/scripts/api_e2e_20250808215326.py create mode 100644 .history/scripts/api_e2e_20250808215359.py create mode 100644 .history/scripts/api_e2e_20250808215427.py create mode 100644 .history/scripts/api_e2e_20250808215516.py create mode 100644 .history/scripts/api_e2e_20250808215528.py create mode 100644 .history/scripts/api_e2e_20250808215617.py create mode 100644 .history/scripts/e2e_20250808205322.sh create mode 100644 .history/scripts/e2e_20250808205324.sh create mode 100644 .history/scripts/e2e_20250808205905.sh create mode 100644 .history/scripts/e2e_20250808210443.sh create mode 100644 .history/scripts/e2e_20250808211132.sh create mode 100644 .history/scripts/fix_email_validation_20250808211220.sh create mode 100644 .history/scripts/fix_email_validation_20250808211222.sh create mode 100644 .history/scripts/migrate_20250808200714.sh create mode 100644 .history/scripts/migrate_20250808214443.sh create mode 100644 .history/scripts/patch_20250808204341.sh create mode 100644 .history/scripts/patch_20250808211820.sh create mode 100644 .history/scripts/patch_20250808212435.sh create mode 100644 .history/scripts/patch_20250808213107.sh create mode 100644 .history/scripts/patch_20250808213457.sh create mode 100644 .history/scripts/patch_20250808213938.sh create mode 100644 .history/scripts/patch_20250808213956.sh create mode 100644 .history/scripts/patch_20250808214013.sh create mode 100644 .history/scripts/patch_20250808214025.sh create mode 100644 .history/scripts/test_20250808204608.sh create mode 100644 .history/scripts/test_20250808214044.sh create mode 100644 .history/services/auth/requirements_20250808195758.txt create mode 100644 .history/services/auth/requirements_20250808200038.txt create mode 100644 .history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py create mode 100644 .history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py create mode 100644 .history/services/profiles/docker-entrypoint_20250808194542.sh create mode 100644 .history/services/profiles/docker-entrypoint_20250808203201.sh create mode 100644 .history/services/profiles/src/app/models/photo_20250808195936.py create mode 100644 .history/services/profiles/src/app/models/photo_20250808204310.py create mode 100644 .history/services/profiles/src/app/models/profile_20250808195936.py create mode 100644 .history/services/profiles/src/app/models/profile_20250808204008.py create mode 100644 .history/services/profiles/src/app/models/profile_20250808204024.py create mode 100644 .history/services/profiles/src/app/models/profile_20250808204059.py create mode 100644 .history/services/profiles/src/app/models/profile_20250808204229.py create mode 100644 .history/test_20250808204537.sh create mode 100644 .history/test_20250808204550.sh create mode 100644 .history/test_20250808204607.sh create mode 100644 .history/test_20250808204608.sh create mode 100644 .history/test_20250808204610.sh create mode 100644 infra/db/init/02_create_tables.sql create mode 100644 logs/api.log create mode 100644 scripts/api_e2e.py create mode 100755 scripts/e2e.sh create mode 100755 scripts/fix_alembic.sh create mode 100755 scripts/fix_email_validation.sh create mode 100755 scripts/fix_profiles_deps.sh create mode 100755 scripts/fix_profiles_fk.sh create mode 100755 scripts/fix_profiles_schema_uuid.sh create mode 100755 scripts/migrate.sh create mode 100755 scripts/models.sh create mode 100755 scripts/patch.sh create mode 100755 scripts/patch_alembic_template.sh create mode 100755 scripts/patch_gateway_auth_header.sh create mode 100755 scripts/patch_profiles_repo_service.sh create mode 100755 scripts/patch_profiles_router.sh create mode 100755 scripts/patch_profiles_security.sh create mode 100755 scripts/test.sh create mode 100644 services/auth/alembic/script.py.mako create mode 100644 services/auth/alembic/versions/df0effc5d87a_init.py create mode 100644 services/auth/src/app/api/routes/auth.py create mode 100644 services/auth/src/app/api/routes/users.py create mode 100644 services/auth/src/app/core/passwords.py create mode 100644 services/auth/src/app/core/security.py create mode 100644 services/auth/src/app/models/user.py create mode 100644 services/auth/src/app/repositories/user_repository.py create mode 100644 services/auth/src/app/schemas/user.py create mode 100644 services/auth/src/app/services/user_service.py create mode 100644 services/chat/alembic/script.py.mako create mode 100644 services/chat/alembic/versions/8cc8115aaf0e_init.py create mode 100644 services/chat/src/app/api/routes/chat.py create mode 100644 services/chat/src/app/core/security.py create mode 100644 services/chat/src/app/models/chat.py create mode 100644 services/chat/src/app/repositories/chat_repository.py create mode 100644 services/chat/src/app/schemas/chat.py create mode 100644 services/chat/src/app/services/chat_service.py create mode 100644 services/match/alembic/script.py.mako create mode 100644 services/match/alembic/versions/00ce87deada6_init.py create mode 100644 services/match/src/app/api/routes/pairs.py create mode 100644 services/match/src/app/core/security.py create mode 100644 services/match/src/app/models/pair.py create mode 100644 services/match/src/app/repositories/pair_repository.py create mode 100644 services/match/src/app/schemas/pair.py create mode 100644 services/match/src/app/services/pair_service.py create mode 100644 services/payments/alembic/script.py.mako create mode 100644 services/payments/alembic/versions/6641523a6967_init.py create mode 100644 services/payments/src/app/api/routes/payments.py create mode 100644 services/payments/src/app/core/security.py create mode 100644 services/payments/src/app/models/payment.py create mode 100644 services/payments/src/app/repositories/payment_repository.py create mode 100644 services/payments/src/app/schemas/payment.py create mode 100644 services/payments/src/app/services/payment_service.py create mode 100644 services/profiles/alembic/script.py.mako create mode 100644 services/profiles/alembic/versions/5c69d1403313_add_fk_photos_profile_id_profiles_id.py create mode 100644 services/profiles/alembic/versions/769f535c9249_init.py create mode 100644 services/profiles/src/app/api/routes/profiles.py create mode 100644 services/profiles/src/app/core/security.py create mode 100644 services/profiles/src/app/db/deps.py create mode 100644 services/profiles/src/app/models/photo.py create mode 100644 services/profiles/src/app/models/profile.py create mode 100644 services/profiles/src/app/repositories/profile_repository.py create mode 100644 services/profiles/src/app/schemas/profile.py create mode 100644 services/profiles/src/app/services/profile_service.py diff --git a/.history/.env_20250808194630 b/.history/.env_20250808194630 new file mode 100644 index 0000000..3c1ddb8 --- /dev/null +++ b/.history/.env_20250808194630 @@ -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 diff --git a/.history/.env_20250808200305 b/.history/.env_20250808200305 new file mode 100644 index 0000000..e180c84 --- /dev/null +++ b/.history/.env_20250808200305 @@ -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 \ No newline at end of file diff --git a/.history/.env_20250808200329 b/.history/.env_20250808200329 new file mode 100644 index 0000000..c98c5cf --- /dev/null +++ b/.history/.env_20250808200329 @@ -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 \ No newline at end of file diff --git a/.history/docker-compose_20250808194542.yml b/.history/docker-compose_20250808194542.yml new file mode 100644 index 0000000..63aa1a6 --- /dev/null +++ b/.history/docker-compose_20250808194542.yml @@ -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: diff --git a/.history/docker-compose_20250808201541.yml b/.history/docker-compose_20250808201541.yml new file mode 100644 index 0000000..eb86f45 --- /dev/null +++ b/.history/docker-compose_20250808201541.yml @@ -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: diff --git a/.history/fix_alembic_20250808201237.sh b/.history/fix_alembic_20250808201237.sh new file mode 100644 index 0000000..e69de29 diff --git a/.history/fix_alembic_20250808201241.sh b/.history/fix_alembic_20250808201241.sh new file mode 100644 index 0000000..7abd070 --- /dev/null +++ b/.history/fix_alembic_20250808201241.sh @@ -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." diff --git a/.history/logs/api_20250808212556.log b/.history/logs/api_20250808212556.log new file mode 100644 index 0000000..7078563 --- /dev/null +++ b/.history/logs/api_20250808212556.log @@ -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= +502 Bad Gateway + +

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

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

502 Bad Gateway

+
nginx/1.29.0
+ + + +2025-08-08 21:24:48 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} +2025-08-08 21:24:58 | ERROR | api_e2e | profiles/health FAILED transport error: HTTPConnectionPool(host='localhost', port=8080): Read timed out. (read timeout=10.0) (10010 ms) +2025-08-08 21:24:59 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} +2025-08-08 21:25:09 | ERROR | api_e2e | profiles/health FAILED transport error: HTTPConnectionPool(host='localhost', port=8080): Read timed out. (read timeout=10.0) (10010 ms) +2025-08-08 21:25:56 | INFO | api_e2e | === API E2E START === +2025-08-08 21:25:56 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev +2025-08-08 21:25:56 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 7 ms | body={"status":"ok","service":"auth"} +2025-08-08 21:25:56 | INFO | api_e2e | gateway/auth is healthy +2025-08-08 21:25:56 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"profiles"} +2025-08-08 21:25:56 | INFO | api_e2e | profiles is healthy +2025-08-08 21:25:56 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 5 ms | body={"status":"ok","service":"match"} +2025-08-08 21:25:56 | INFO | api_e2e | match is healthy +2025-08-08 21:25:56 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 6 ms | body={"status":"ok","service":"chat"} +2025-08-08 21:25:56 | INFO | api_e2e | chat is healthy +2025-08-08 21:25:56 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 6 ms | body={"status":"ok","service":"payments"} +2025-08-08 21:25:56 | INFO | api_e2e | payments is healthy +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***'} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 32 ms | body={"detail":"Invalid credentials"} +2025-08-08 21:25:56 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"} +2025-08-08 21:25:56 | INFO | api_e2e | Login failed for admin+1754655956.xaji0y@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Michael Cunningham', 'role': 'ADMIN'} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 257 ms | body=Internal Server Error +2025-08-08 21:25:56 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error +2025-08-08 21:25:56 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***'} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 214 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNzJkNjIwOS0yOGJlLTQyYzAtYmFjMy0yNzBlMWZkNjNmNmMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTU5NTYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU0NjU2ODU2fQ.FUgvIMnAsD-FWP8yjFy0IJS6NKLyAseVyuT6gS2uFLE","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNzJkNjIwOS0yOGJlLTQyYzAtYmFjMy0yNzBlMWZkNjNmNmMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTU5NTYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTc1NzI0Nzk1Nn0.XOx0ehDA4wjIfi9nYdI9iVPsLHS8mXV4L0Be8PvcK5g","token_type":"bearer"} +2025-08-08 21:25:56 | INFO | api_e2e | Registered+Login OK: admin+1754655956.xaji0y@agency.dev -> 172d6209-28be-42c0-bac3-270e1fd63f6c +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***'} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 3 ms | body={"detail":"Invalid credentials"} +2025-08-08 21:25:56 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"} +2025-08-08 21:25:56 | INFO | api_e2e | Login failed for user1+1754655956.6dpbhs@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***', 'full_name': 'Charlotte Porter', 'role': 'CLIENT'} +2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 226 ms | body=Internal Server Error +2025-08-08 21:25:56 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error +2025-08-08 21:25:56 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway +2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***'} +2025-08-08 21:25:57 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOWFhMWMzZS0zN2U4LTRmNWEtODcxNy1kN2FhNDUxMDU0MzUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTU5NTYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1Njg1N30.eeKSArd-im1KjEDUZxzus4e3b3yLuhqMxp065gPZPXE","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOWFhMWMzZS0zN2U4LTRmNWEtODcxNy1kN2FhNDUxMDU0MzUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTU5NTYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDc5NTd9.O7k9PubE1j3BHDw-IgbmXIIfrltA-viHei70j0p92Js","token_type":"bearer"} +2025-08-08 21:25:57 | INFO | api_e2e | Registered+Login OK: user1+1754655956.6dpbhs@agency.dev -> 09aa1c3e-37e8-4f5a-8717-d7aa45105435 +2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***'} +2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 3 ms | body={"detail":"Invalid credentials"} +2025-08-08 21:25:57 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"} +2025-08-08 21:25:57 | INFO | api_e2e | Login failed for user2+1754655957.ahxthv@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register +2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***', 'full_name': 'Denise Hess', 'role': 'CLIENT'} +2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 225 ms | body=Internal Server Error +2025-08-08 21:25:57 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error +2025-08-08 21:25:57 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway +2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***'} +2025-08-08 21:25:57 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwNWRkNjcyMi1hNDAzLTQzYzMtYWViZC0wNjRlOWQ4NTQ1ZDIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTU5NTcuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1Njg1N30.dqvCxPqUX8zhL12dzl1vbstTJgEvMHD43Gppj2Jzllk","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwNWRkNjcyMi1hNDAzLTQzYzMtYWViZC0wNjRlOWQ4NTQ1ZDIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTU5NTcuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDc5NTd9.AIFXUWyrp_BEmmJWmWHnhGOp_b0IZIZoue1PtzhxPCw","token_type":"bearer"} +2025-08-08 21:25:57 | INFO | api_e2e | Registered+Login OK: user2+1754655957.ahxthv@agency.dev -> 05dd6722-a403-43c3-aebd-064e9d8545d2 +2025-08-08 21:25:57 | INFO | api_e2e | [1/3] Ensure profile for admin+1754655956.xaji0y@agency.dev (role=ADMIN) +2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/v1/profiles/me | headers={Authorization: Bearer eyJhbGciOiJI...} | body={} +2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 2 ms | body={"detail":"Not authenticated"} +2025-08-08 21:25:57 | ERROR | api_e2e | profiles/me unexpected status -1, expected [200, 404]; body={"detail":"Not authenticated"} diff --git a/.history/logs/api_20250808212604.log b/.history/logs/api_20250808212604.log new file mode 100644 index 0000000..e69de29 diff --git a/.history/logs/api_20250808213512.log b/.history/logs/api_20250808213512.log new file mode 100644 index 0000000..e69de29 diff --git a/.history/logs/api_20250808213904.log b/.history/logs/api_20250808213904.log new file mode 100644 index 0000000..e69de29 diff --git a/.history/logs/api_20250808213928.log b/.history/logs/api_20250808213928.log new file mode 100644 index 0000000..e69de29 diff --git a/.history/migrate_20250808200653.sh b/.history/migrate_20250808200653.sh new file mode 100644 index 0000000..e69de29 diff --git a/.history/migrate_20250808200656.sh b/.history/migrate_20250808200656.sh new file mode 100644 index 0000000..e548ac8 --- /dev/null +++ b/.history/migrate_20250808200656.sh @@ -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 \ No newline at end of file diff --git a/.history/migrate_20250808200715.sh b/.history/migrate_20250808200715.sh new file mode 100644 index 0000000..e99b378 --- /dev/null +++ b/.history/migrate_20250808200715.sh @@ -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 diff --git a/.history/models_20250808195719.sh b/.history/models_20250808195719.sh new file mode 100644 index 0000000..57c01de --- /dev/null +++ b/.history/models_20250808195719.sh @@ -0,0 +1,1560 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ------------------------------------------------------------------- +# Apply models + CRUD + API + JWT auth to the existing scaffold +# Requires: the scaffold created earlier (services/* exist) +# ------------------------------------------------------------------- + +ROOT_DIR="." +SERVICES=(auth profiles match chat payments) + +ensure_line() { + # ensure_line + local file="$1" ; shift + local line="$*" + grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file" +} + +write_file() { + # write_file <<'EOF' ... EOF + local path="$1" + mkdir -p "$(dirname "$path")" + # The content will be provided by heredoc by the caller + cat > "$path" +} + +append_file() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat >> "$path" +} + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "ERROR: Missing $path. Run scaffold.sh first." >&2 + exit 1 + fi +} + +# Basic checks +require_file docker-compose.yml + +# ------------------------------------------------------------------- +# 1) .env.example — добавить JWT настройки (общие для всех сервисов) +# ------------------------------------------------------------------- +ENV_FILE=".env.example" +require_file "$ENV_FILE" + +ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------" +ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me" +ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256" +ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15" +ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days" + +# ------------------------------------------------------------------- +# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt] +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + REQ="services/$s/requirements.txt" + require_file "$REQ" + ensure_line "$REQ" "PyJWT>=2.8" + if [[ "$s" == "auth" ]]; then + ensure_line "$REQ" "passlib[bcrypt]>=1.7" + fi +done + +# ------------------------------------------------------------------- +# 3) Общая безопасность (JWT) для всех сервисов +# В auth добавим + генерацию токенов, в остальных — верификация и RBAC +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + SEC="services/$s/src/app/core/security.py" + mkdir -p "$(dirname "$SEC")" + if [[ "$s" == "auth" ]]; then + write_file "$SEC" <<'PY' +from __future__ import annotations +import os +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Any, Callable, Optional + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15")) +REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200")) + +class TokenType(str, Enum): + access = "access" + refresh = "refresh" + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str: + now = datetime.now(timezone.utc) + exp = now + timedelta(minutes=expires_minutes) + payload: dict[str, Any] = { + "sub": sub, + "email": email, + "role": role, + "type": token_type.value, + "exp": int(exp.timestamp()), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + +def create_access_token(*, sub: str, email: str, role: str) -> str: + return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN) + +def create_refresh_token(*, sub: str, email: str, role: str) -> str: + return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN) + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]: + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep +PY + else + write_file "$SEC" <<'PY' +from __future__ import annotations +import os +from enum import Enum +from typing import Any, Callable + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str): + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep +PY + fi +done + +# ------------------------------------------------------------------- +# 4) AUTH service — модели, CRUD, токены, эндпоинты +# ------------------------------------------------------------------- +# models +write_file services/auth/src/app/models/user.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, Boolean, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class Role(str, Enum): + ADMIN = "ADMIN" + MATCHMAKER = "MATCHMAKER" + CLIENT = "CLIENT" + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str | None] = mapped_column(String(255), default=None) + role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/auth/src/app/models/__init__.py <<'PY' +from .user import User, Role # noqa: F401 +PY + +# schemas +write_file services/auth/src/app/schemas/user.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, EmailStr, ConfigDict + +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + role: str = "CLIENT" + is_active: bool = True + +class UserCreate(BaseModel): + email: EmailStr + password: str + full_name: Optional[str] = None + role: str = "CLIENT" + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + password: Optional[str] = None + +class UserRead(BaseModel): + id: str + email: EmailStr + full_name: Optional[str] = None + role: str + is_active: bool + model_config = ConfigDict(from_attributes=True) + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" +PY + +# passwords +write_file services/auth/src/app/core/passwords.py <<'PY' +from passlib.context import CryptContext + +_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(p: str) -> str: + return _pwd.hash(p) + +def verify_password(p: str, hashed: str) -> bool: + return _pwd.verify(p, hashed) +PY + +# repositories +write_file services/auth/src/app/repositories/user_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy.orm import Session +from sqlalchemy import select, update, delete + +from app.models.user import User + +class UserRepository: + def __init__(self, db: Session): + self.db = db + + def get(self, user_id) -> Optional[User]: + return self.db.get(User, user_id) + + def get_by_email(self, email: str) -> Optional[User]: + stmt = select(User).where(User.email == email) + return self.db.execute(stmt).scalar_one_or_none() + + def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]: + stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User: + user = User(email=email, password_hash=password_hash, full_name=full_name, role=role) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def update(self, user: User, **fields) -> User: + for k, v in fields.items(): + if v is not None: + setattr(user, k, v) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def delete(self, user: User) -> None: + self.db.delete(user) + self.db.commit() +PY + +# services +write_file services/auth/src/app/services/user_service.py <<'PY' +from __future__ import annotations +from typing import Optional +from sqlalchemy.orm import Session + +from app.repositories.user_repository import UserRepository +from app.core.passwords import hash_password, verify_password +from app.models.user import User + +class UserService: + def __init__(self, db: Session): + self.repo = UserRepository(db) + + # CRUD + def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User: + if self.repo.get_by_email(email): + raise ValueError("Email already in use") + pwd_hash = hash_password(password) + return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role) + + def get_user(self, user_id) -> Optional[User]: + return self.repo.get(user_id) + + def get_by_email(self, email: str) -> Optional[User]: + return self.repo.get_by_email(email) + + def list_users(self, *, offset: int = 0, limit: int = 50): + return self.repo.list(offset=offset, limit=limit) + + def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None, + is_active: bool | None = None, password: str | None = None) -> User: + fields = {} + if full_name is not None: fields["full_name"] = full_name + if role is not None: fields["role"] = role + if is_active is not None: fields["is_active"] = is_active + if password: fields["password_hash"] = hash_password(password) + return self.repo.update(user, **fields) + + def delete_user(self, user: User) -> None: + self.repo.delete(user) + + # Auth + def authenticate(self, *, email: str, password: str) -> Optional[User]: + user = self.repo.get_by_email(email) + if not user or not user.is_active: + return None + if not verify_password(password, user.password_hash): + return None + return user +PY + +# api routes +write_file services/auth/src/app/api/routes/auth.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead +from app.services.user_service import UserService +from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims + +router = APIRouter(prefix="/v1", tags=["auth"]) + +@router.post("/register", response_model=UserRead, status_code=201) +def register(payload: UserCreate, db: Session = Depends(get_db)): + svc = UserService(db) + try: + user = svc.create_user(email=payload.email, password=payload.password, + full_name=payload.full_name, role=payload.role) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return user + +@router.post("/token", response_model=TokenPair) +def token(payload: LoginRequest, db: Session = Depends(get_db)): + svc = UserService(db) + user = svc.authenticate(email=payload.email, password=payload.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + access = create_access_token(sub=str(user.id), email=user.email, role=user.role) + refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role) + return TokenPair(access_token=access, refresh_token=refresh) + +class RefreshRequest(LoginRequest.__class__): + refresh_token: str # type: ignore + +@router.post("/refresh", response_model=TokenPair) +def refresh_token(req: dict): + # expects: {"refresh_token": ""} + from app.core.security import decode_token + token = req.get("refresh_token") + if not token: + raise HTTPException(status_code=400, detail="Missing refresh_token") + claims = decode_token(token) + if claims.type != "refresh": + raise HTTPException(status_code=400, detail="Not a refresh token") + access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role) + refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role) + return TokenPair(access_token=access, refresh_token=refresh) + +@router.get("/me", response_model=UserRead) +def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)): + svc = UserService(db) + u = svc.get_user(claims.sub) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return u +PY + +write_file services/auth/src/app/api/routes/users.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import require_roles +from app.schemas.user import UserRead, UserUpdate, UserCreate +from app.services.user_service import UserService + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +@router.get("", response_model=list[UserRead]) +def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + return UserService(db).list_users(offset=offset, limit=limit) + +@router.post("", response_model=UserRead, status_code=201) +def create_user(payload: UserCreate, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + try: + return UserService(db).create_user(email=payload.email, password=payload.password, + full_name=payload.full_name, role=payload.role) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/{user_id}", response_model=UserRead) +def get_user(user_id: str, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + u = UserService(db).get_user(user_id) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return u + +@router.patch("/{user_id}", response_model=UserRead) +def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + svc = UserService(db) + u = svc.get_user(user_id) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return svc.update_user(u, full_name=payload.full_name, role=payload.role, + is_active=payload.is_active, password=payload.password) + +@router.delete("/{user_id}", status_code=204) +def delete_user(user_id: str, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + svc = UserService(db) + u = svc.get_user(user_id) + if not u: + return + svc.delete_user(u) +PY + +# main.py update for auth +write_file services/auth/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.auth import router as auth_router +from .api.routes.users import router as users_router + +app = FastAPI(title="AUTH Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "auth"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(auth_router) +app.include_router(users_router) +PY + +# ------------------------------------------------------------------- +# 5) PROFILES service — Profile + Photo CRUD + поиск +# ------------------------------------------------------------------- +write_file services/profiles/src/app/models/profile.py <<'PY' +from __future__ import annotations +import uuid +from datetime import date, datetime + +from sqlalchemy import String, Date, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.session import Base + +class Profile(Base): + __tablename__ = "profiles" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other + birthdate: Mapped[date | None] = mapped_column(Date, default=None) + city: Mapped[str | None] = mapped_column(String(120), default=None) + bio: Mapped[str | None] = mapped_column(Text, default=None) + languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"] + interests: Mapped[dict | None] = mapped_column(JSONB, default=list) + preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) + verification_status: Mapped[str] = mapped_column(String(16), default="unverified") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") +PY + +write_file services/profiles/src/app/models/photo.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.session import Base + +class Photo(Base): + __tablename__ = "photos" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + url: Mapped[str] = mapped_column(String(500), nullable=False) + is_main: Mapped[bool] = mapped_column(Boolean, default=False) + status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True) +PY + +write_file services/profiles/src/app/models/__init__.py <<'PY' +from .profile import Profile # noqa +from .photo import Photo # noqa +PY + +write_file services/profiles/src/app/schemas/profile.py <<'PY' +from __future__ import annotations +from datetime import date +from typing import Optional, Any +from pydantic import BaseModel, ConfigDict + +class PhotoCreate(BaseModel): + url: str + is_main: bool = False + +class PhotoRead(BaseModel): + id: str + url: str + is_main: bool + status: str + model_config = ConfigDict(from_attributes=True) + +class ProfileCreate(BaseModel): + gender: str + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict[str, Any]] = None + +class ProfileUpdate(BaseModel): + gender: Optional[str] = None + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict[str, Any]] = None + verification_status: Optional[str] = None + +class ProfileRead(BaseModel): + id: str + user_id: str + gender: str + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict] = None + verification_status: str + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/profiles/src/app/repositories/profile_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from datetime import date, timedelta + +from sqlalchemy import select, and_ +from sqlalchemy.orm import Session + +from app.models.profile import Profile +from app.models.photo import Photo + +class ProfileRepository: + def __init__(self, db: Session): + self.db = db + + # Profile CRUD + def create_profile(self, *, user_id, **fields) -> Profile: + p = Profile(user_id=user_id, **fields) + self.db.add(p) + self.db.commit() + self.db.refresh(p) + return p + + def get_profile(self, profile_id) -> Optional[Profile]: + return self.db.get(Profile, profile_id) + + def get_by_user(self, user_id) -> Optional[Profile]: + stmt = select(Profile).where(Profile.user_id == user_id) + return self.db.execute(stmt).scalar_one_or_none() + + def update_profile(self, profile: Profile, **fields) -> Profile: + for k, v in fields.items(): + if v is not None: + setattr(profile, k, v) + self.db.add(profile) + self.db.commit() + self.db.refresh(profile) + return profile + + def delete_profile(self, profile: Profile) -> None: + self.db.delete(profile) + self.db.commit() + + def list_profiles(self, *, gender: str | None = None, city: str | None = None, + age_min: int | None = None, age_max: int | None = None, + offset: int = 0, limit: int = 50) -> Sequence[Profile]: + stmt = select(Profile) + conds = [] + if gender: + conds.append(Profile.gender == gender) + if city: + conds.append(Profile.city == city) + # Age filter -> birthdate between (today - age_max) and (today - age_min) + if age_min is not None or age_max is not None: + today = date.today() + if age_min is not None: + max_birthdate = date(today.year - age_min, today.month, today.day) + conds.append(Profile.birthdate <= max_birthdate) + if age_max is not None: + min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1) + conds.append(Profile.birthdate >= min_birthdate) + if conds: + stmt = stmt.where(and_(*conds)) + stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + # Photos + def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo: + photo = Photo(profile_id=profile_id, url=url, is_main=is_main) + self.db.add(photo) + if is_main: + # unset other main photos + self.db.execute(select(Photo).where(Photo.profile_id == profile_id)) + self.db.commit() + self.db.refresh(photo) + return photo + + def list_photos(self, *, profile_id) -> Sequence[Photo]: + stmt = select(Photo).where(Photo.profile_id == profile_id) + return self.db.execute(stmt).scalars().all() + + def get_photo(self, photo_id) -> Optional[Photo]: + return self.db.get(Photo, photo_id) + + def delete_photo(self, photo: Photo) -> None: + self.db.delete(photo) + self.db.commit() +PY + +write_file services/profiles/src/app/services/profile_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional + +from app.repositories.profile_repository import ProfileRepository +from app.models.profile import Profile +from app.models.photo import Photo + +class ProfileService: + def __init__(self, db: Session): + self.repo = ProfileRepository(db) + + def create_profile(self, *, user_id, **fields) -> Profile: + return self.repo.create_profile(user_id=user_id, **fields) + + def get_profile(self, profile_id) -> Optional[Profile]: + return self.repo.get_profile(profile_id) + + def get_by_user(self, user_id) -> Optional[Profile]: + return self.repo.get_by_user(user_id) + + def update_profile(self, profile: Profile, **fields) -> Profile: + return self.repo.update_profile(profile, **fields) + + def delete_profile(self, profile: Profile) -> None: + return self.repo.delete_profile(profile) + + def list_profiles(self, **filters): + return self.repo.list_profiles(**filters) + + # photos + def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo: + return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main) + + def list_photos(self, profile_id): + return self.repo.list_photos(profile_id=profile_id) + + def get_photo(self, photo_id) -> Photo | None: + return self.repo.get_photo(photo_id) + + def delete_photo(self, photo: Photo) -> None: + self.repo.delete_photo(photo) +PY + +write_file services/profiles/src/app/api/routes/profiles.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead +from app.services.profile_service import ProfileService + +router = APIRouter(prefix="/v1", tags=["profiles"]) + +@router.post("/profiles", response_model=ProfileRead, status_code=201) +def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + if svc.get_by_user(user.sub): + raise HTTPException(status_code=400, detail="Profile already exists") + p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True)) + return p + +@router.get("/profiles/me", response_model=ProfileRead) +def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_by_user(user.sub) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + return p + +@router.get("/profiles", response_model=list[ProfileRead]) +def list_profiles(gender: str | None = None, city: str | None = None, + age_min: int | None = Query(None, ge=18, le=120), + age_max: int | None = Query(None, ge=18, le=120), + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit) + +@router.get("/profiles/{profile_id}", response_model=ProfileRead) +def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + p = ProfileService(db).get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + return p + +@router.patch("/profiles/{profile_id}", response_model=ProfileRead) +def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.update_profile(p, **payload.model_dump(exclude_none=True)) + +@router.delete("/profiles/{profile_id}", status_code=204) +def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + return + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + svc.delete_profile(p) + +# Photos +@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201) +def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main) + return photo + +@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead]) +def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + return svc.list_photos(profile_id) + +@router.delete("/photos/{photo_id}", status_code=204) +def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + photo = svc.get_photo(photo_id) + if not photo: + return + # Lookup profile to check ownership + p = svc.get_profile(photo.profile_id) + if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")): + raise HTTPException(status_code=403, detail="Not allowed") + svc.delete_photo(photo) +PY + +# main.py for profiles +write_file services/profiles/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.profiles import router as profiles_router + +app = FastAPI(title="PROFILES Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "profiles"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(profiles_router) +PY + +# ------------------------------------------------------------------- +# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked) +# ------------------------------------------------------------------- +write_file services/match/src/app/models/pair.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, Float, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class MatchPair(Base): + __tablename__ = "match_pairs" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + # User IDs to validate permissions; profile IDs можно добавить позже + user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked + score: Mapped[float | None] = mapped_column(Float, default=None) + notes: Mapped[str | None] = mapped_column(String(1000), default=None) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/match/src/app/models/__init__.py <<'PY' +from .pair import MatchPair # noqa +PY + +write_file services/match/src/app/schemas/pair.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class PairCreate(BaseModel): + user_id_a: str + user_id_b: str + score: Optional[float] = None + notes: Optional[str] = None + +class PairUpdate(BaseModel): + score: Optional[float] = None + notes: Optional[str] = None + +class PairRead(BaseModel): + id: str + user_id_a: str + user_id_b: str + status: str + score: Optional[float] = None + notes: Optional[str] = None + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/match/src/app/repositories/pair_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy import select, or_ +from sqlalchemy.orm import Session + +from app.models.pair import MatchPair + +class PairRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, **fields) -> MatchPair: + obj = MatchPair(**fields) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get(self, pair_id) -> Optional[MatchPair]: + return self.db.get(MatchPair, pair_id) + + def list(self, *, for_user_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = 50) -> Sequence[MatchPair]: + stmt = select(MatchPair) + if for_user_id: + stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id)) + if status: + stmt = stmt.where(MatchPair.status == status) + stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def update(self, obj: MatchPair, **fields) -> MatchPair: + for k, v in fields.items(): + if v is not None: + setattr(obj, k, v) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, obj: MatchPair) -> None: + self.db.delete(obj) + self.db.commit() +PY + +write_file services/match/src/app/services/pair_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional +from app.repositories.pair_repository import PairRepository +from app.models.pair import MatchPair + +class PairService: + def __init__(self, db: Session): + self.repo = PairRepository(db) + + def create(self, **fields) -> MatchPair: + return self.repo.create(**fields) + + def get(self, pair_id) -> Optional[MatchPair]: + return self.repo.get(pair_id) + + def list(self, **filters): + return self.repo.list(**filters) + + def update(self, obj: MatchPair, **fields) -> MatchPair: + return self.repo.update(obj, **fields) + + def delete(self, obj: MatchPair) -> None: + return self.repo.delete(obj) + + def set_status(self, obj: MatchPair, status: str) -> MatchPair: + return self.repo.update(obj, status=status) +PY + +write_file services/match/src/app/api/routes/pairs.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.pair import PairCreate, PairUpdate, PairRead +from app.services.pair_service import PairService + +router = APIRouter(prefix="/v1/pairs", tags=["pairs"]) + +@router.post("", response_model=PairRead, status_code=201) +def create_pair(payload: PairCreate, db: Session = Depends(get_db), + user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b, + score=payload.score, notes=payload.notes, created_by=user.sub) + +@router.get("", response_model=list[PairRead]) +def list_pairs(for_user_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + _: UserClaims = Depends(get_current_user)): + return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit) + +@router.get("/{pair_id}", response_model=PairRead) +def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + obj = PairService(db).get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return obj + +@router.patch("/{pair_id}", response_model=PairRead) +def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return svc.update(obj, **payload.model_dump(exclude_none=True)) + +@router.post("/{pair_id}/accept", response_model=PairRead) +def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + # Validate that current user participates + if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.set_status(obj, "accepted") + +@router.post("/{pair_id}/reject", response_model=PairRead) +def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.set_status(obj, "rejected") + +@router.delete("/{pair_id}", status_code=204) +def delete_pair(pair_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + return + svc.delete(obj) +PY + +write_file services/match/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.pairs import router as pairs_router + +app = FastAPI(title="MATCH Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "match"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(pairs_router) +PY + +# ------------------------------------------------------------------- +# 7) CHAT service — комнаты и сообщения (REST, без WS) +# ------------------------------------------------------------------- +write_file services/chat/src/app/models/chat.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class ChatRoom(Base): + __tablename__ = "chat_rooms" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title: Mapped[str | None] = mapped_column(String(255), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + +class ChatParticipant(Base): + __tablename__ = "chat_participants" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + +class Message(Base): + __tablename__ = "chat_messages" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) +PY + +write_file services/chat/src/app/models/__init__.py <<'PY' +from .chat import ChatRoom, ChatParticipant, Message # noqa +PY + +write_file services/chat/src/app/schemas/chat.py <<'PY' +from __future__ import annotations +from pydantic import BaseModel, ConfigDict +from typing import Optional + +class RoomCreate(BaseModel): + title: Optional[str] = None + participants: list[str] # user IDs + +class RoomRead(BaseModel): + id: str + title: Optional[str] = None + model_config = ConfigDict(from_attributes=True) + +class MessageCreate(BaseModel): + content: str + +class MessageRead(BaseModel): + id: str + room_id: str + sender_id: str + content: str + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/chat/src/app/repositories/chat_repository.py <<'PY' +from __future__ import annotations +from typing import Sequence, Optional +from sqlalchemy.orm import Session +from sqlalchemy import select, or_ + +from app.models.chat import ChatRoom, ChatParticipant, Message + +class ChatRepository: + def __init__(self, db: Session): + self.db = db + + # Rooms + def create_room(self, title: str | None) -> ChatRoom: + r = ChatRoom(title=title) + self.db.add(r) + self.db.commit() + self.db.refresh(r) + return r + + def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant: + p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin) + self.db.add(p) + self.db.commit() + self.db.refresh(p) + return p + + def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]: + stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\ + .where(ChatParticipant.user_id == user_id) + return self.db.execute(stmt).scalars().all() + + def get_room(self, room_id) -> Optional[ChatRoom]: + return self.db.get(ChatRoom, room_id) + + # Messages + def create_message(self, room_id, sender_id, content: str) -> Message: + m = Message(room_id=room_id, sender_id=sender_id, content=content) + self.db.add(m) + self.db.commit() + self.db.refresh(m) + return m + + def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]: + stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc()) + return self.db.execute(stmt).scalars().all() +PY + +write_file services/chat/src/app/services/chat_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional, Sequence + +from app.repositories.chat_repository import ChatRepository +from app.models.chat import ChatRoom, ChatParticipant, Message + +class ChatService: + def __init__(self, db: Session): + self.repo = ChatRepository(db) + + def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom: + room = self.repo.create_room(title) + # creator -> admin + self.repo.add_participant(room.id, creator_id, is_admin=True) + for uid in participant_ids: + if uid != creator_id: + self.repo.add_participant(room.id, uid, is_admin=False) + return room + + def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]: + return self.repo.list_rooms_for_user(user_id) + + def get_room(self, room_id: str) -> ChatRoom | None: + return self.repo.get_room(room_id) + + def create_message(self, room_id: str, sender_id: str, content: str) -> Message: + return self.repo.create_message(room_id, sender_id, content) + + def list_messages(self, room_id: str, offset: int = 0, limit: int = 100): + return self.repo.list_messages(room_id, offset=offset, limit=limit) +PY + +write_file services/chat/src/app/api/routes/chat.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, UserClaims +from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead +from app.services.chat_service import ChatService + +router = APIRouter(prefix="/v1", tags=["chat"]) + +@router.post("/rooms", response_model=RoomRead, status_code=201) +def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub) + return room + +@router.get("/rooms", response_model=list[RoomRead]) +def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + return ChatService(db).list_rooms_for_user(user.sub) + +@router.get("/rooms/{room_id}", response_model=RoomRead) +def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + room = ChatService(db).get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Not found") + # NOTE: для простоты опускаем проверку участия (добавьте в проде) + return room + +@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201) +def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + msg = svc.create_message(room_id, user.sub, payload.content) + return msg + +@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead]) +def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500), + db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + return svc.list_messages(room_id, offset=offset, limit=limit) +PY + +write_file services/chat/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.chat import router as chat_router + +app = FastAPI(title="CHAT Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "chat"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(chat_router) +PY + +# ------------------------------------------------------------------- +# 8) PAYMENTS service — инвойсы (простая версия) +# ------------------------------------------------------------------- +write_file services/payments/src/app/models/payment.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Numeric +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class Invoice(Base): + __tablename__ = "invoices" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="USD") + status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled + description: Mapped[str | None] = mapped_column(String(500), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/payments/src/app/models/__init__.py <<'PY' +from .payment import Invoice # noqa +PY + +write_file services/payments/src/app/schemas/payment.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class InvoiceCreate(BaseModel): + client_id: str + amount: float + currency: str = "USD" + description: Optional[str] = None + +class InvoiceUpdate(BaseModel): + amount: Optional[float] = None + currency: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + +class InvoiceRead(BaseModel): + id: str + client_id: str + amount: float + currency: str + status: str + description: Optional[str] = None + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/payments/src/app/repositories/payment_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy.orm import Session +from sqlalchemy import select + +from app.models.payment import Invoice + +class PaymentRepository: + def __init__(self, db: Session): + self.db = db + + def create_invoice(self, **fields) -> Invoice: + obj = Invoice(**fields) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_invoice(self, inv_id) -> Optional[Invoice]: + return self.db.get(Invoice, inv_id) + + def list_invoices(self, *, client_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = 50) -> Sequence[Invoice]: + stmt = select(Invoice) + if client_id: + stmt = stmt.where(Invoice.client_id == client_id) + if status: + stmt = stmt.where(Invoice.status == status) + stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def update_invoice(self, obj: Invoice, **fields) -> Invoice: + for k, v in fields.items(): + if v is not None: + setattr(obj, k, v) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete_invoice(self, obj: Invoice) -> None: + self.db.delete(obj) + self.db.commit() +PY + +write_file services/payments/src/app/services/payment_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional +from app.repositories.payment_repository import PaymentRepository +from app.models.payment import Invoice + +class PaymentService: + def __init__(self, db: Session): + self.repo = PaymentRepository(db) + + def create_invoice(self, **fields) -> Invoice: + return self.repo.create_invoice(**fields) + + def get_invoice(self, inv_id) -> Invoice | None: + return self.repo.get_invoice(inv_id) + + def list_invoices(self, **filters): + return self.repo.list_invoices(**filters) + + def update_invoice(self, obj: Invoice, **fields) -> Invoice: + return self.repo.update_invoice(obj, **fields) + + def delete_invoice(self, obj: Invoice) -> None: + return self.repo.delete_invoice(obj) + + def mark_paid(self, obj: Invoice) -> Invoice: + return self.repo.update_invoice(obj, status="paid") +PY + +write_file services/payments/src/app/api/routes/payments.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead +from app.services.payment_service import PaymentService + +router = APIRouter(prefix="/v1/invoices", tags=["payments"]) + +@router.post("", response_model=InvoiceRead, status_code=201) +def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True)) + +@router.get("", response_model=list[InvoiceRead]) +def list_invoices(client_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + user: UserClaims = Depends(get_current_user)): + # Клиент видит только свои инвойсы, админ/матчмейкер — любые + if user.role in ("ADMIN","MATCHMAKER"): + return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit) + else: + return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit) + +@router.get("/{inv_id}", response_model=InvoiceRead) +def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + inv = PaymentService(db).get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub: + raise HTTPException(status_code=403, detail="Not allowed") + return inv + +@router.patch("/{inv_id}", response_model=InvoiceRead) +def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + return svc.update_invoice(inv, **payload.model_dump(exclude_none=True)) + +@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead) +def mark_paid(inv_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + return svc.mark_paid(inv) + +@router.delete("/{inv_id}", status_code=204) +def delete_invoice(inv_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + return + svc.delete_invoice(inv) +PY + +write_file services/payments/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.payments import router as payments_router + +app = FastAPI(title="PAYMENTS Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "payments"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(payments_router) +PY + +# ------------------------------------------------------------------- +# 9) Обновить __init__.py пакетов (если scaffold создал пустые) +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + touch "services/$s/src/app/__init__.py" + touch "services/$s/src/app/api/__init__.py" + touch "services/$s/src/app/api/routes/__init__.py" + touch "services/$s/src/app/core/__init__.py" + touch "services/$s/src/app/db/__init__.py" + touch "services/$s/src/app/repositories/__init__.py" + touch "services/$s/src/app/schemas/__init__.py" + touch "services/$s/src/app/services/__init__.py" +done + +echo "✅ Models + CRUD + API + Auth applied." + +cat <<'NEXT' +Next steps: + +1) Сгенерируйте первичные миграции по моделям: + for s in auth profiles match chat payments; do + docker compose run --rm $s alembic revision --autogenerate -m "init" + done + +2) Поднимите окружение (alembic upgrade выполнится в entrypoint): + docker compose up --build + +3) Получите токен: + POST http://localhost:8080/auth/v1/register + POST http://localhost:8080/auth/v1/token + -> Authorization: Bearer + +4) Проверьте CRUD: + - Profiles: GET http://localhost:8080/profiles/v1/profiles/me + - Match: POST http://localhost:8080/match/v1/pairs + - Chat: POST http://localhost:8080/chat/v1/rooms + - Payments: POST http://localhost:8080/payments/v1/invoices + +Замечания по безопасности/продакшену: +- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD. +- Сроки жизни токенов подберите под бизнес-политику. +- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте + хранилище jti/ревокацию. +- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав. +- В PROFILES поля languages/interests/preferences — JSONB; при желании замените + на нормализованные таблицы или ARRAY. +NEXT diff --git a/.history/models_20250808195931.sh b/.history/models_20250808195931.sh new file mode 100644 index 0000000..1469e6c --- /dev/null +++ b/.history/models_20250808195931.sh @@ -0,0 +1,1564 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ------------------------------------------------------------------- +# Apply models + CRUD + API + JWT auth to the existing scaffold +# Requires: the scaffold created earlier (services/* exist) +# ------------------------------------------------------------------- + +ROOT_DIR="." +SERVICES=(auth profiles match chat payments) + +ensure_line() { + # ensure_line + local file="$1" ; shift + local line="$*" + grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file" +} + +write_file() { + # write_file <<'EOF' ... EOF + local path="$1" + mkdir -p "$(dirname "$path")" + # The content will be provided by heredoc by the caller + cat > "$path" +} + +append_file() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat >> "$path" +} + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "ERROR: Missing $path. Run scaffold.sh first." >&2 + exit 1 + fi +} + +# Basic checks +require_file docker-compose.yml + +# ------------------------------------------------------------------- +# 1) .env.example — добавить JWT настройки (общие для всех сервисов) +# ------------------------------------------------------------------- +ENV_FILE=".env.example" +require_file "$ENV_FILE" + +ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------" +ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me" +ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256" +ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15" +ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days" + +# ------------------------------------------------------------------- +# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt] +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + REQ="services/$s/requirements.txt" + require_file "$REQ" + ensure_line "$REQ" "PyJWT>=2.8" + if [[ "$s" == "auth" ]]; then + ensure_line "$REQ" "passlib[bcrypt]>=1.7" + fi +done + +# ------------------------------------------------------------------- +# 3) Общая безопасность (JWT) для всех сервисов +# В auth добавим + генерацию токенов, в остальных — верификация и RBAC +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + SEC="services/$s/src/app/core/security.py" + mkdir -p "$(dirname "$SEC")" + if [[ "$s" == "auth" ]]; then + write_file "$SEC" <<'PY' +from __future__ import annotations +import os +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Any, Callable, Optional + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15")) +REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200")) + +class TokenType(str, Enum): + access = "access" + refresh = "refresh" + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str: + now = datetime.now(timezone.utc) + exp = now + timedelta(minutes=expires_minutes) + payload: dict[str, Any] = { + "sub": sub, + "email": email, + "role": role, + "type": token_type.value, + "exp": int(exp.timestamp()), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + +def create_access_token(*, sub: str, email: str, role: str) -> str: + return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN) + +def create_refresh_token(*, sub: str, email: str, role: str) -> str: + return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN) + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]: + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep +PY + else + write_file "$SEC" <<'PY' +from __future__ import annotations +import os +from enum import Enum +from typing import Any, Callable + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str): + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep +PY + fi +done + +# ------------------------------------------------------------------- +# 4) AUTH service — модели, CRUD, токены, эндпоинты +# ------------------------------------------------------------------- +# models +write_file services/auth/src/app/models/user.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, Boolean, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class Role(str, Enum): + ADMIN = "ADMIN" + MATCHMAKER = "MATCHMAKER" + CLIENT = "CLIENT" + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str | None] = mapped_column(String(255), default=None) + role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/auth/src/app/models/__init__.py <<'PY' +from .user import User, Role # noqa: F401 +PY + +# schemas +write_file services/auth/src/app/schemas/user.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, EmailStr, ConfigDict + +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + role: str = "CLIENT" + is_active: bool = True + +class UserCreate(BaseModel): + email: EmailStr + password: str + full_name: Optional[str] = None + role: str = "CLIENT" + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + password: Optional[str] = None + +class UserRead(BaseModel): + id: str + email: EmailStr + full_name: Optional[str] = None + role: str + is_active: bool + model_config = ConfigDict(from_attributes=True) + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" +PY + +# passwords +write_file services/auth/src/app/core/passwords.py <<'PY' +from passlib.context import CryptContext + +_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(p: str) -> str: + return _pwd.hash(p) + +def verify_password(p: str, hashed: str) -> bool: + return _pwd.verify(p, hashed) +PY + +# repositories +write_file services/auth/src/app/repositories/user_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy.orm import Session +from sqlalchemy import select, update, delete + +from app.models.user import User + +class UserRepository: + def __init__(self, db: Session): + self.db = db + + def get(self, user_id) -> Optional[User]: + return self.db.get(User, user_id) + + def get_by_email(self, email: str) -> Optional[User]: + stmt = select(User).where(User.email == email) + return self.db.execute(stmt).scalar_one_or_none() + + def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]: + stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User: + user = User(email=email, password_hash=password_hash, full_name=full_name, role=role) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def update(self, user: User, **fields) -> User: + for k, v in fields.items(): + if v is not None: + setattr(user, k, v) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def delete(self, user: User) -> None: + self.db.delete(user) + self.db.commit() +PY + +# services +write_file services/auth/src/app/services/user_service.py <<'PY' +from __future__ import annotations +from typing import Optional +from sqlalchemy.orm import Session + +from app.repositories.user_repository import UserRepository +from app.core.passwords import hash_password, verify_password +from app.models.user import User + +class UserService: + def __init__(self, db: Session): + self.repo = UserRepository(db) + + # CRUD + def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User: + if self.repo.get_by_email(email): + raise ValueError("Email already in use") + pwd_hash = hash_password(password) + return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role) + + def get_user(self, user_id) -> Optional[User]: + return self.repo.get(user_id) + + def get_by_email(self, email: str) -> Optional[User]: + return self.repo.get_by_email(email) + + def list_users(self, *, offset: int = 0, limit: int = 50): + return self.repo.list(offset=offset, limit=limit) + + def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None, + is_active: bool | None = None, password: str | None = None) -> User: + fields = {} + if full_name is not None: fields["full_name"] = full_name + if role is not None: fields["role"] = role + if is_active is not None: fields["is_active"] = is_active + if password: fields["password_hash"] = hash_password(password) + return self.repo.update(user, **fields) + + def delete_user(self, user: User) -> None: + self.repo.delete(user) + + # Auth + def authenticate(self, *, email: str, password: str) -> Optional[User]: + user = self.repo.get_by_email(email) + if not user or not user.is_active: + return None + if not verify_password(password, user.password_hash): + return None + return user +PY + +# api routes +write_file services/auth/src/app/api/routes/auth.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead +from app.services.user_service import UserService +from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims + +router = APIRouter(prefix="/v1", tags=["auth"]) + +@router.post("/register", response_model=UserRead, status_code=201) +def register(payload: UserCreate, db: Session = Depends(get_db)): + svc = UserService(db) + try: + user = svc.create_user(email=payload.email, password=payload.password, + full_name=payload.full_name, role=payload.role) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return user + +@router.post("/token", response_model=TokenPair) +def token(payload: LoginRequest, db: Session = Depends(get_db)): + svc = UserService(db) + user = svc.authenticate(email=payload.email, password=payload.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + access = create_access_token(sub=str(user.id), email=user.email, role=user.role) + refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role) + return TokenPair(access_token=access, refresh_token=refresh) + +class RefreshRequest(LoginRequest.__class__): + refresh_token: str # type: ignore + +@router.post("/refresh", response_model=TokenPair) +def refresh_token(req: dict): + # expects: {"refresh_token": ""} + from app.core.security import decode_token + token = req.get("refresh_token") + if not token: + raise HTTPException(status_code=400, detail="Missing refresh_token") + claims = decode_token(token) + if claims.type != "refresh": + raise HTTPException(status_code=400, detail="Not a refresh token") + access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role) + refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role) + return TokenPair(access_token=access, refresh_token=refresh) + +@router.get("/me", response_model=UserRead) +def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)): + svc = UserService(db) + u = svc.get_user(claims.sub) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return u +PY + +write_file services/auth/src/app/api/routes/users.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import require_roles +from app.schemas.user import UserRead, UserUpdate, UserCreate +from app.services.user_service import UserService + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +@router.get("", response_model=list[UserRead]) +def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + return UserService(db).list_users(offset=offset, limit=limit) + +@router.post("", response_model=UserRead, status_code=201) +def create_user(payload: UserCreate, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + try: + return UserService(db).create_user(email=payload.email, password=payload.password, + full_name=payload.full_name, role=payload.role) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/{user_id}", response_model=UserRead) +def get_user(user_id: str, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + u = UserService(db).get_user(user_id) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return u + +@router.patch("/{user_id}", response_model=UserRead) +def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + svc = UserService(db) + u = svc.get_user(user_id) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return svc.update_user(u, full_name=payload.full_name, role=payload.role, + is_active=payload.is_active, password=payload.password) + +@router.delete("/{user_id}", status_code=204) +def delete_user(user_id: str, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + svc = UserService(db) + u = svc.get_user(user_id) + if not u: + return + svc.delete_user(u) +PY + +# main.py update for auth +write_file services/auth/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.auth import router as auth_router +from .api.routes.users import router as users_router + +app = FastAPI(title="AUTH Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "auth"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(auth_router) +app.include_router(users_router) +PY + +# ------------------------------------------------------------------- +# 5) PROFILES service — Profile + Photo CRUD + поиск +# ------------------------------------------------------------------- +write_file services/profiles/src/app/models/profile.py <<'PY' +from __future__ import annotations +import uuid +from datetime import date, datetime + +from sqlalchemy import String, Date, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.session import Base + +class Profile(Base): + __tablename__ = "profiles" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other + birthdate: Mapped[date | None] = mapped_column(Date, default=None) + city: Mapped[str | None] = mapped_column(String(120), default=None) + bio: Mapped[str | None] = mapped_column(Text, default=None) + languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"] + interests: Mapped[dict | None] = mapped_column(JSONB, default=list) + preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) + verification_status: Mapped[str] = mapped_column(String(16), default="unverified") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") +PY + +write_file services/profiles/src/app/models/photo.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.session import Base + +class Photo(Base): + __tablename__ = "photos" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + url: Mapped[str] = mapped_column(String(500), nullable=False) + is_main: Mapped[bool] = mapped_column(Boolean, default=False) + status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True) +PY + +write_file services/profiles/src/app/models/__init__.py <<'PY' +from .profile import Profile # noqa +from .photo import Photo # noqa +PY + +write_file services/profiles/src/app/schemas/profile.py <<'PY' +from __future__ import annotations +from datetime import date +from typing import Optional, Any +from pydantic import BaseModel, ConfigDict + +class PhotoCreate(BaseModel): + url: str + is_main: bool = False + +class PhotoRead(BaseModel): + id: str + url: str + is_main: bool + status: str + model_config = ConfigDict(from_attributes=True) + +class ProfileCreate(BaseModel): + gender: str + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict[str, Any]] = None + +class ProfileUpdate(BaseModel): + gender: Optional[str] = None + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict[str, Any]] = None + verification_status: Optional[str] = None + +class ProfileRead(BaseModel): + id: str + user_id: str + gender: str + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict] = None + verification_status: str + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/profiles/src/app/repositories/profile_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from datetime import date, timedelta + +from sqlalchemy import select, and_ +from sqlalchemy.orm import Session + +from app.models.profile import Profile +from app.models.photo import Photo + +class ProfileRepository: + def __init__(self, db: Session): + self.db = db + + # Profile CRUD + def create_profile(self, *, user_id, **fields) -> Profile: + p = Profile(user_id=user_id, **fields) + self.db.add(p) + self.db.commit() + self.db.refresh(p) + return p + + def get_profile(self, profile_id) -> Optional[Profile]: + return self.db.get(Profile, profile_id) + + def get_by_user(self, user_id) -> Optional[Profile]: + stmt = select(Profile).where(Profile.user_id == user_id) + return self.db.execute(stmt).scalar_one_or_none() + + def update_profile(self, profile: Profile, **fields) -> Profile: + for k, v in fields.items(): + if v is not None: + setattr(profile, k, v) + self.db.add(profile) + self.db.commit() + self.db.refresh(profile) + return profile + + def delete_profile(self, profile: Profile) -> None: + self.db.delete(profile) + self.db.commit() + + def list_profiles(self, *, gender: str | None = None, city: str | None = None, + age_min: int | None = None, age_max: int | None = None, + offset: int = 0, limit: int = 50) -> Sequence[Profile]: + stmt = select(Profile) + conds = [] + if gender: + conds.append(Profile.gender == gender) + if city: + conds.append(Profile.city == city) + # Age filter -> birthdate between (today - age_max) and (today - age_min) + if age_min is not None or age_max is not None: + today = date.today() + if age_min is not None: + max_birthdate = date(today.year - age_min, today.month, today.day) + conds.append(Profile.birthdate <= max_birthdate) + if age_max is not None: + min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1) + conds.append(Profile.birthdate >= min_birthdate) + if conds: + stmt = stmt.where(and_(*conds)) + stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + # Photos + def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo: + photo = Photo(profile_id=profile_id, url=url, is_main=is_main) + self.db.add(photo) + if is_main: + # unset other main photos + self.db.execute(select(Photo).where(Photo.profile_id == profile_id)) + self.db.commit() + self.db.refresh(photo) + return photo + + def list_photos(self, *, profile_id) -> Sequence[Photo]: + stmt = select(Photo).where(Photo.profile_id == profile_id) + return self.db.execute(stmt).scalars().all() + + def get_photo(self, photo_id) -> Optional[Photo]: + return self.db.get(Photo, photo_id) + + def delete_photo(self, photo: Photo) -> None: + self.db.delete(photo) + self.db.commit() +PY + +write_file services/profiles/src/app/services/profile_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional + +from app.repositories.profile_repository import ProfileRepository +from app.models.profile import Profile +from app.models.photo import Photo + +class ProfileService: + def __init__(self, db: Session): + self.repo = ProfileRepository(db) + + def create_profile(self, *, user_id, **fields) -> Profile: + return self.repo.create_profile(user_id=user_id, **fields) + + def get_profile(self, profile_id) -> Optional[Profile]: + return self.repo.get_profile(profile_id) + + def get_by_user(self, user_id) -> Optional[Profile]: + return self.repo.get_by_user(user_id) + + def update_profile(self, profile: Profile, **fields) -> Profile: + return self.repo.update_profile(profile, **fields) + + def delete_profile(self, profile: Profile) -> None: + return self.repo.delete_profile(profile) + + def list_profiles(self, **filters): + return self.repo.list_profiles(**filters) + + # photos + def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo: + return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main) + + def list_photos(self, profile_id): + return self.repo.list_photos(profile_id=profile_id) + + def get_photo(self, photo_id) -> Photo | None: + return self.repo.get_photo(photo_id) + + def delete_photo(self, photo: Photo) -> None: + self.repo.delete_photo(photo) +PY + +write_file services/profiles/src/app/api/routes/profiles.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead +from app.services.profile_service import ProfileService + +router = APIRouter(prefix="/v1", tags=["profiles"]) + +@router.post("/profiles", response_model=ProfileRead, status_code=201) +def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + if svc.get_by_user(user.sub): + raise HTTPException(status_code=400, detail="Profile already exists") + p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True)) + return p + +@router.get("/profiles/me", response_model=ProfileRead) +def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_by_user(user.sub) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + return p + +@router.get("/profiles", response_model=list[ProfileRead]) +def list_profiles(gender: str | None = None, city: str | None = None, + age_min: int | None = Query(None, ge=18, le=120), + age_max: int | None = Query(None, ge=18, le=120), + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit) + +@router.get("/profiles/{profile_id}", response_model=ProfileRead) +def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + p = ProfileService(db).get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + return p + +@router.patch("/profiles/{profile_id}", response_model=ProfileRead) +def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.update_profile(p, **payload.model_dump(exclude_none=True)) + +@router.delete("/profiles/{profile_id}", status_code=204) +def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + return + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + svc.delete_profile(p) + +# Photos +@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201) +def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main) + return photo + +@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead]) +def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + return svc.list_photos(profile_id) + +@router.delete("/photos/{photo_id}", status_code=204) +def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + photo = svc.get_photo(photo_id) + if not photo: + return + # Lookup profile to check ownership + p = svc.get_profile(photo.profile_id) + if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")): + raise HTTPException(status_code=403, detail="Not allowed") + svc.delete_photo(photo) +PY + +# main.py for profiles +write_file services/profiles/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.profiles import router as profiles_router + +app = FastAPI(title="PROFILES Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "profiles"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(profiles_router) +PY + +# ------------------------------------------------------------------- +# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked) +# ------------------------------------------------------------------- +write_file services/match/src/app/models/pair.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, Float, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class MatchPair(Base): + __tablename__ = "match_pairs" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + # User IDs to validate permissions; profile IDs можно добавить позже + user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked + score: Mapped[float | None] = mapped_column(Float, default=None) + notes: Mapped[str | None] = mapped_column(String(1000), default=None) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/match/src/app/models/__init__.py <<'PY' +from .pair import MatchPair # noqa +PY + +write_file services/match/src/app/schemas/pair.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class PairCreate(BaseModel): + user_id_a: str + user_id_b: str + score: Optional[float] = None + notes: Optional[str] = None + +class PairUpdate(BaseModel): + score: Optional[float] = None + notes: Optional[str] = None + +class PairRead(BaseModel): + id: str + user_id_a: str + user_id_b: str + status: str + score: Optional[float] = None + notes: Optional[str] = None + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/match/src/app/repositories/pair_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy import select, or_ +from sqlalchemy.orm import Session + +from app.models.pair import MatchPair + +class PairRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, **fields) -> MatchPair: + obj = MatchPair(**fields) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get(self, pair_id) -> Optional[MatchPair]: + return self.db.get(MatchPair, pair_id) + + def list(self, *, for_user_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = 50) -> Sequence[MatchPair]: + stmt = select(MatchPair) + if for_user_id: + stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id)) + if status: + stmt = stmt.where(MatchPair.status == status) + stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def update(self, obj: MatchPair, **fields) -> MatchPair: + for k, v in fields.items(): + if v is not None: + setattr(obj, k, v) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, obj: MatchPair) -> None: + self.db.delete(obj) + self.db.commit() +PY + +write_file services/match/src/app/services/pair_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional +from app.repositories.pair_repository import PairRepository +from app.models.pair import MatchPair + +class PairService: + def __init__(self, db: Session): + self.repo = PairRepository(db) + + def create(self, **fields) -> MatchPair: + return self.repo.create(**fields) + + def get(self, pair_id) -> Optional[MatchPair]: + return self.repo.get(pair_id) + + def list(self, **filters): + return self.repo.list(**filters) + + def update(self, obj: MatchPair, **fields) -> MatchPair: + return self.repo.update(obj, **fields) + + def delete(self, obj: MatchPair) -> None: + return self.repo.delete(obj) + + def set_status(self, obj: MatchPair, status: str) -> MatchPair: + return self.repo.update(obj, status=status) +PY + +write_file services/match/src/app/api/routes/pairs.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.pair import PairCreate, PairUpdate, PairRead +from app.services.pair_service import PairService + +router = APIRouter(prefix="/v1/pairs", tags=["pairs"]) + +@router.post("", response_model=PairRead, status_code=201) +def create_pair(payload: PairCreate, db: Session = Depends(get_db), + user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b, + score=payload.score, notes=payload.notes, created_by=user.sub) + +@router.get("", response_model=list[PairRead]) +def list_pairs(for_user_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + _: UserClaims = Depends(get_current_user)): + return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit) + +@router.get("/{pair_id}", response_model=PairRead) +def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + obj = PairService(db).get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return obj + +@router.patch("/{pair_id}", response_model=PairRead) +def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return svc.update(obj, **payload.model_dump(exclude_none=True)) + +@router.post("/{pair_id}/accept", response_model=PairRead) +def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + # Validate that current user participates + if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.set_status(obj, "accepted") + +@router.post("/{pair_id}/reject", response_model=PairRead) +def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.set_status(obj, "rejected") + +@router.delete("/{pair_id}", status_code=204) +def delete_pair(pair_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + return + svc.delete(obj) +PY + +write_file services/match/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.pairs import router as pairs_router + +app = FastAPI(title="MATCH Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "match"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(pairs_router) +PY + +# ------------------------------------------------------------------- +# 7) CHAT service — комнаты и сообщения (REST, без WS) +# ------------------------------------------------------------------- +write_file services/chat/src/app/models/chat.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class ChatRoom(Base): + __tablename__ = "chat_rooms" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title: Mapped[str | None] = mapped_column(String(255), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + +class ChatParticipant(Base): + __tablename__ = "chat_participants" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + +class Message(Base): + __tablename__ = "chat_messages" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) +PY + +write_file services/chat/src/app/models/__init__.py <<'PY' +from .chat import ChatRoom, ChatParticipant, Message # noqa +PY + +write_file services/chat/src/app/schemas/chat.py <<'PY' +from __future__ import annotations +from pydantic import BaseModel, ConfigDict +from typing import Optional + +class RoomCreate(BaseModel): + title: Optional[str] = None + participants: list[str] # user IDs + +class RoomRead(BaseModel): + id: str + title: Optional[str] = None + model_config = ConfigDict(from_attributes=True) + +class MessageCreate(BaseModel): + content: str + +class MessageRead(BaseModel): + id: str + room_id: str + sender_id: str + content: str + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/chat/src/app/repositories/chat_repository.py <<'PY' +from __future__ import annotations +from typing import Sequence, Optional +from sqlalchemy.orm import Session +from sqlalchemy import select, or_ + +from app.models.chat import ChatRoom, ChatParticipant, Message + +class ChatRepository: + def __init__(self, db: Session): + self.db = db + + # Rooms + def create_room(self, title: str | None) -> ChatRoom: + r = ChatRoom(title=title) + self.db.add(r) + self.db.commit() + self.db.refresh(r) + return r + + def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant: + p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin) + self.db.add(p) + self.db.commit() + self.db.refresh(p) + return p + + def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]: + stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\ + .where(ChatParticipant.user_id == user_id) + return self.db.execute(stmt).scalars().all() + + def get_room(self, room_id) -> Optional[ChatRoom]: + return self.db.get(ChatRoom, room_id) + + # Messages + def create_message(self, room_id, sender_id, content: str) -> Message: + m = Message(room_id=room_id, sender_id=sender_id, content=content) + self.db.add(m) + self.db.commit() + self.db.refresh(m) + return m + + def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]: + stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc()) + return self.db.execute(stmt).scalars().all() +PY + +write_file services/chat/src/app/services/chat_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional, Sequence + +from app.repositories.chat_repository import ChatRepository +from app.models.chat import ChatRoom, ChatParticipant, Message + +class ChatService: + def __init__(self, db: Session): + self.repo = ChatRepository(db) + + def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom: + room = self.repo.create_room(title) + # creator -> admin + self.repo.add_participant(room.id, creator_id, is_admin=True) + for uid in participant_ids: + if uid != creator_id: + self.repo.add_participant(room.id, uid, is_admin=False) + return room + + def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]: + return self.repo.list_rooms_for_user(user_id) + + def get_room(self, room_id: str) -> ChatRoom | None: + return self.repo.get_room(room_id) + + def create_message(self, room_id: str, sender_id: str, content: str) -> Message: + return self.repo.create_message(room_id, sender_id, content) + + def list_messages(self, room_id: str, offset: int = 0, limit: int = 100): + return self.repo.list_messages(room_id, offset=offset, limit=limit) +PY + +write_file services/chat/src/app/api/routes/chat.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, UserClaims +from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead +from app.services.chat_service import ChatService + +router = APIRouter(prefix="/v1", tags=["chat"]) + +@router.post("/rooms", response_model=RoomRead, status_code=201) +def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub) + return room + +@router.get("/rooms", response_model=list[RoomRead]) +def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + return ChatService(db).list_rooms_for_user(user.sub) + +@router.get("/rooms/{room_id}", response_model=RoomRead) +def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + room = ChatService(db).get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Not found") + # NOTE: для простоты опускаем проверку участия (добавьте в проде) + return room + +@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201) +def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + msg = svc.create_message(room_id, user.sub, payload.content) + return msg + +@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead]) +def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500), + db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + return svc.list_messages(room_id, offset=offset, limit=limit) +PY + +write_file services/chat/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.chat import router as chat_router + +app = FastAPI(title="CHAT Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "chat"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(chat_router) +PY + +# ------------------------------------------------------------------- +# 8) PAYMENTS service — инвойсы (простая версия) +# ------------------------------------------------------------------- +write_file services/payments/src/app/models/payment.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Numeric +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class Invoice(Base): + __tablename__ = "invoices" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="USD") + status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled + description: Mapped[str | None] = mapped_column(String(500), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/payments/src/app/models/__init__.py <<'PY' +from .payment import Invoice # noqa +PY + +write_file services/payments/src/app/schemas/payment.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class InvoiceCreate(BaseModel): + client_id: str + amount: float + currency: str = "USD" + description: Optional[str] = None + +class InvoiceUpdate(BaseModel): + amount: Optional[float] = None + currency: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + +class InvoiceRead(BaseModel): + id: str + client_id: str + amount: float + currency: str + status: str + description: Optional[str] = None + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/payments/src/app/repositories/payment_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy.orm import Session +from sqlalchemy import select + +from app.models.payment import Invoice + +class PaymentRepository: + def __init__(self, db: Session): + self.db = db + + def create_invoice(self, **fields) -> Invoice: + obj = Invoice(**fields) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_invoice(self, inv_id) -> Optional[Invoice]: + return self.db.get(Invoice, inv_id) + + def list_invoices(self, *, client_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = 50) -> Sequence[Invoice]: + stmt = select(Invoice) + if client_id: + stmt = stmt.where(Invoice.client_id == client_id) + if status: + stmt = stmt.where(Invoice.status == status) + stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def update_invoice(self, obj: Invoice, **fields) -> Invoice: + for k, v in fields.items(): + if v is not None: + setattr(obj, k, v) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete_invoice(self, obj: Invoice) -> None: + self.db.delete(obj) + self.db.commit() +PY + +write_file services/payments/src/app/services/payment_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional +from app.repositories.payment_repository import PaymentRepository +from app.models.payment import Invoice + +class PaymentService: + def __init__(self, db: Session): + self.repo = PaymentRepository(db) + + def create_invoice(self, **fields) -> Invoice: + return self.repo.create_invoice(**fields) + + def get_invoice(self, inv_id) -> Invoice | None: + return self.repo.get_invoice(inv_id) + + def list_invoices(self, **filters): + return self.repo.list_invoices(**filters) + + def update_invoice(self, obj: Invoice, **fields) -> Invoice: + return self.repo.update_invoice(obj, **fields) + + def delete_invoice(self, obj: Invoice) -> None: + return self.repo.delete_invoice(obj) + + def mark_paid(self, obj: Invoice) -> Invoice: + return self.repo.update_invoice(obj, status="paid") +PY + +write_file services/payments/src/app/api/routes/payments.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead +from app.services.payment_service import PaymentService + +router = APIRouter(prefix="/v1/invoices", tags=["payments"]) + +@router.post("", response_model=InvoiceRead, status_code=201) +def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True)) + +@router.get("", response_model=list[InvoiceRead]) +def list_invoices(client_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + user: UserClaims = Depends(get_current_user)): + # Клиент видит только свои инвойсы, админ/матчмейкер — любые + if user.role in ("ADMIN","MATCHMAKER"): + return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit) + else: + return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit) + +@router.get("/{inv_id}", response_model=InvoiceRead) +def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + inv = PaymentService(db).get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub: + raise HTTPException(status_code=403, detail="Not allowed") + return inv + +@router.patch("/{inv_id}", response_model=InvoiceRead) +def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + return svc.update_invoice(inv, **payload.model_dump(exclude_none=True)) + +@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead) +def mark_paid(inv_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + return svc.mark_paid(inv) + +@router.delete("/{inv_id}", status_code=204) +def delete_invoice(inv_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + return + svc.delete_invoice(inv) +PY + +write_file services/payments/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.payments import router as payments_router + +app = FastAPI(title="PAYMENTS Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "payments"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(payments_router) +PY + +# ------------------------------------------------------------------- +# 9) Обновить __init__.py пакетов (если scaffold создал пустые) +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + touch "services/$s/src/app/__init__.py" + touch "services/$s/src/app/api/__init__.py" + touch "services/$s/src/app/api/routes/__init__.py" + touch "services/$s/src/app/core/__init__.py" + touch "services/$s/src/app/db/__init__.py" + touch "services/$s/src/app/repositories/__init__.py" + touch "services/$s/src/app/schemas/__init__.py" + touch "services/$s/src/app/services/__init__.py" +done + +for s in auth profiles match chat payments; do + docker compose run --rm $s alembic revision --autogenerate -m "init" +done + +echo "✅ Models + CRUD + API + Auth applied." + +cat <<'NEXT' +Next steps: + +1) Сгенерируйте первичные миграции по моделям: + for s in auth profiles match chat payments; do + docker compose run --rm $s alembic revision --autogenerate -m "init" + done + +2) Поднимите окружение (alembic upgrade выполнится в entrypoint): + docker compose up --build + +3) Получите токен: + POST http://localhost:8080/auth/v1/register + POST http://localhost:8080/auth/v1/token + -> Authorization: Bearer + +4) Проверьте CRUD: + - Profiles: GET http://localhost:8080/profiles/v1/profiles/me + - Match: POST http://localhost:8080/match/v1/pairs + - Chat: POST http://localhost:8080/chat/v1/rooms + - Payments: POST http://localhost:8080/payments/v1/invoices + +Замечания по безопасности/продакшену: +- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD. +- Сроки жизни токенов подберите под бизнес-политику. +- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте + хранилище jti/ревокацию. +- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав. +- В PROFILES поля languages/interests/preferences — JSONB; при желании замените + на нормализованные таблицы или ARRAY. +NEXT diff --git a/.history/patch_20250808204338.sh b/.history/patch_20250808204338.sh new file mode 100644 index 0000000..e69de29 diff --git a/.history/patch_20250808204342.sh b/.history/patch_20250808204342.sh new file mode 100644 index 0000000..4c1b211 --- /dev/null +++ b/.history/patch_20250808204342.sh @@ -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 diff --git a/.history/patch_alembic_template_20250808201930.sh b/.history/patch_alembic_template_20250808201930.sh new file mode 100644 index 0000000..e69de29 diff --git a/.history/patch_alembic_template_20250808201932.sh b/.history/patch_alembic_template_20250808201932.sh new file mode 100644 index 0000000..32b1e3a --- /dev/null +++ b/.history/patch_alembic_template_20250808201932.sh @@ -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 diff --git a/.history/patch_alembic_template_20250808201952.sh b/.history/patch_alembic_template_20250808201952.sh new file mode 100644 index 0000000..fe35e0b --- /dev/null +++ b/.history/patch_alembic_template_20250808201952.sh @@ -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//alembic/versions/) +for s in auth profiles match chat payments; do + echo "[gen] $s" + docker compose run --rm -v "$PWD/services/$s":/app "$s" \ + sh -lc 'alembic revision --autogenerate -m "init"' +done \ No newline at end of file diff --git a/.history/patch_alembic_template_20250808202000.sh b/.history/patch_alembic_template_20250808202000.sh new file mode 100644 index 0000000..1e5d5ec --- /dev/null +++ b/.history/patch_alembic_template_20250808202000.sh @@ -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//alembic/versions/) +for s in auth profiles match chat payments; do + echo "[gen] $s" + docker compose run --rm -v "$PWD/services/$s":/app "$s" \ + sh -lc 'alembic revision --autogenerate -m "init"' +done + +for s in auth profiles match chat payments; do + echo "---- $s" + ls -1 services/$s/alembic/versions/ +done \ No newline at end of file diff --git a/.history/scripts/api_e2e_20250808212121.py b/.history/scripts/api_e2e_20250808212121.py new file mode 100644 index 0000000..e69de29 diff --git a/.history/scripts/api_e2e_20250808212124.py b/.history/scripts/api_e2e_20250808212124.py new file mode 100644 index 0000000..9b376e1 --- /dev/null +++ b/.history/scripts/api_e2e_20250808212124.py @@ -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) diff --git a/.history/scripts/api_e2e_20250808213334.py b/.history/scripts/api_e2e_20250808213334.py new file mode 100644 index 0000000..f7179fe --- /dev/null +++ b/.history/scripts/api_e2e_20250808213334.py @@ -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) diff --git a/.history/scripts/api_e2e_20250808215311.py b/.history/scripts/api_e2e_20250808215311.py new file mode 100644 index 0000000..c48b626 --- /dev/null +++ b/.history/scripts/api_e2e_20250808215311.py @@ -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) diff --git a/.history/scripts/api_e2e_20250808215326.py b/.history/scripts/api_e2e_20250808215326.py new file mode 100644 index 0000000..360ab82 --- /dev/null +++ b/.history/scripts/api_e2e_20250808215326.py @@ -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) diff --git a/.history/scripts/api_e2e_20250808215359.py b/.history/scripts/api_e2e_20250808215359.py new file mode 100644 index 0000000..f5e0329 --- /dev/null +++ b/.history/scripts/api_e2e_20250808215359.py @@ -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) diff --git a/.history/scripts/api_e2e_20250808215427.py b/.history/scripts/api_e2e_20250808215427.py new file mode 100644 index 0000000..42a572e --- /dev/null +++ b/.history/scripts/api_e2e_20250808215427.py @@ -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) diff --git a/.history/scripts/api_e2e_20250808215516.py b/.history/scripts/api_e2e_20250808215516.py new file mode 100644 index 0000000..54b9aef --- /dev/null +++ b/.history/scripts/api_e2e_20250808215516.py @@ -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) diff --git a/.history/scripts/api_e2e_20250808215528.py b/.history/scripts/api_e2e_20250808215528.py new file mode 100644 index 0000000..2eba317 --- /dev/null +++ b/.history/scripts/api_e2e_20250808215528.py @@ -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) diff --git a/.history/scripts/api_e2e_20250808215617.py b/.history/scripts/api_e2e_20250808215617.py new file mode 100644 index 0000000..7e9f8e5 --- /dev/null +++ b/.history/scripts/api_e2e_20250808215617.py @@ -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) diff --git a/.history/scripts/e2e_20250808205322.sh b/.history/scripts/e2e_20250808205322.sh new file mode 100644 index 0000000..e69de29 diff --git a/.history/scripts/e2e_20250808205324.sh b/.history/scripts/e2e_20250808205324.sh new file mode 100644 index 0000000..0f54893 --- /dev/null +++ b/.history/scripts/e2e_20250808205324.sh @@ -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 (dot notation; arrays allowed by numeric index) + python3 - "$1" "$2" <<'PY' +import sys, json +f, path = sys.argv[1], sys.argv[2] +with open(f, 'r') as fh: + try: + data = json.load(fh) + except Exception: + print(""); sys.exit(0) +cur = data +for key in path.split('.'): + if isinstance(cur, list): + try: + key = int(key) + except: + print(""); sys.exit(0) + cur = cur[key] if 0 <= key < len(cur) else None + elif isinstance(cur, dict): + cur = cur.get(key) + else: + cur = None + if cur is None: + break +print("" if cur is None else cur) +PY +} + +http_req() { + # http_req [] [] -> prints HTTP code; body to $RESP + local METHOD="$1"; shift + local URL="$1"; shift + local TOKEN="${1:-}"; shift || true + local BODY="${1:-}"; shift || true + local RESP="${TMP_DIR}/resp_$(date +%s%N).json" + + local args=(-sS -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") + if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi + if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi + + local CODE + CODE="$(curl "${args[@]}")" + echo "$CODE|$RESP" +} + +expect_code() { + # expect_code "" "||..." + local ACT="$1"; local ALLOWED="$2" + if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then + return 0 + fi + return 1 +} + +wait_health() { + local NAME="$1"; local URL="$2"; local tries=60 + log "Waiting ${NAME} health: ${URL}" + for ((i=1; i<=tries; i++)); do + local CODE + CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" + if [[ "$CODE" == "200" ]]; then ok "${NAME} is healthy"; return 0; fi + sleep 1 + done + fail "${NAME} not healthy in time: ${URL}" +} + +register_or_login() { + # register_or_login -> echoes "|" + local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" + + local BODY REG RESPCODE RESP REG_ID + BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") + REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" + RESPCODE="${REG%%|*}"; RESP="${REG##*|}" + + if expect_code "$RESPCODE" "201|200"; then + ok "Registered user ${EMAIL}" + else + # maybe already exists + local MSG + MSG="$(json_get "$RESP" "detail")" + if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then + warn "User ${EMAIL} already exists, will login" + else + warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" + fi + fi + + # token + local TOK TOKCODE TOKRESP + BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") + TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")" + TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" + expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" + + local ACCESS REFRESH + ACCESS="$(json_get "$TOKRESP" "access_token")" + REFRESH="$(json_get "$TOKRESP" "refresh_token")" + [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}" + + # resolve user id via /me + local ME MECODE MERESP UID + ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")" + MECODE="${ME%%|*}"; MERESP="${ME##*|}" + expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")" + UID="$(json_get "$MERESP" "id")" + [[ -n "$UID" ]] || fail "Failed to parse user id for ${EMAIL}" + + echo "${UID}|${ACCESS}" +} + +ensure_profile() { + # ensure_profile + local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5" + + # GET /profiles/me: 200 or 404 + local ME MECODE MERESP + ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" + MECODE="${ME%%|*}"; MERESP="${ME##*|}" + if [[ "$MECODE" == "200" ]]; then + ok "Profile already exists" + echo "$MERESP" > "${TMP_DIR}/last_profile.json" + return 0 + elif [[ "$MECODE" != "404" ]]; then + warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")" + fi + + # Create profile + IFS=',' read -r -a langs <<< "$LANGS_CSV" + IFS=',' read -r -a intrs <<< "$INTERESTS_CSV" + local langs_json intrs_json + langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" + intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" + local BODY + BODY=$(cat < "${TMP_DIR}/last_profile.json" +} + +main() { + echo -e "${B}=== E2E smoke test start ===${NC}" + echo "BASE_URL: $BASE_URL" + echo + + # 0) Wait for services + wait_health "gateway" "$BASE_URL/" + wait_health "auth" "$AUTH/health" + wait_health "profiles" "$PROFILES/health" + wait_health "match" "$MATCH/health" + wait_health "chat" "$CHAT/health" + wait_health "payments" "$PAYMENTS/health" + + # 1) Register/login users + TS="$(date +%s)" + ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" + ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" + BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" + PASS="${PASS:-secret123}" + + log "Register/login admin: ${ADMIN_EMAIL}" + IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN") + ok "Admin id: $ADMIN_ID" + + log "Register/login Alice: ${ALICE_EMAIL}" + IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT") + ok "Alice id: $ALICE_ID" + + log "Register/login Bob: ${BOB_EMAIL}" + IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT") + ok "Bob id: $BOB_ID" + + # 2) Ensure profiles for all three + log "Ensure profile for Admin" + ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" + + log "Ensure profile for Alice" + ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" + + log "Ensure profile for Bob" + ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" + + # 3) Create match pair (admin) + log "Create match pair (Alice ↔ Bob)" + BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) + PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")" + PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" + expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" + PAIR_ID="$(json_get "$PRESP" "id")" + ok "Pair created: $PAIR_ID" + + # 4) Create chat room and send a message (admin) + log "Create chat room (Admin + Alice + Bob)" + BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") + ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")" + RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" + expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" + ROOM_ID="$(json_get "$RRESP" "id")" + ok "Room created: $ROOM_ID" + + log "Send message to room" + BODY='{"content":"Hello from admin (e2e)"}' + MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")" + MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" + expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" + MSG_ID="$(json_get "$MRESP" "id")" + ok "Message sent: $MSG_ID" + + # 5) Create invoice for Alice and mark paid (admin) + log "Create invoice for Alice" + BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") + INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")" + INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" + expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" + INV_ID="$(json_get "$INVRESP" "id")" + ok "Invoice created: $INV_ID" + + log "Mark invoice paid" + PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")" + PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" + expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" + STATUS="$(json_get "$PDRESP" "status")" + [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')" + ok "Invoice marked paid" + + echo + echo -e "${B}=== E2E summary ===${NC}" + echo -e "Admin: ${G}${ADMIN_EMAIL}${NC} (id: ${ADMIN_ID})" + echo -e "Alice: ${G}${ALICE_EMAIL}${NC} (id: ${ALICE_ID})" + echo -e "Bob: ${G}${BOB_EMAIL}${NC} (id: ${BOB_ID})" + echo -e "Pair: ${C}${PAIR_ID}${NC}" + echo -e "Room: ${C}${ROOM_ID}${NC} Message: ${C}${MSG_ID}${NC}" + echo -e "Invoice:${C}${INV_ID}${NC} Status: ${G}${STATUS}${NC}" + echo + ok "E2E smoke test finished successfully." +} + +main "$@" diff --git a/.history/scripts/e2e_20250808205905.sh b/.history/scripts/e2e_20250808205905.sh new file mode 100644 index 0000000..a6a3626 --- /dev/null +++ b/.history/scripts/e2e_20250808205905.sh @@ -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 + python3 - "$1" "$2" <<'PY' +import sys, json +f, path = sys.argv[1], sys.argv[2] +with open(f, 'r') as fh: + try: + data = json.load(fh) + except Exception: + print(""); sys.exit(0) +cur = data +for key in path.split('.'): + if isinstance(cur, list): + try: + key = int(key) + except: + print(""); sys.exit(0) + cur = cur[key] if 0 <= key < len(cur) else None + elif isinstance(cur, dict): + cur = cur.get(key) + else: + cur = None + if cur is None: + break +print("" if cur is None else cur) +PY +} + +http_req() { + # http_req [] [] -> prints HTTP code; body to $RESP + local METHOD="$1"; shift + local URL="$1"; shift + local TOKEN="${1:-}"; shift || true + local BODY="${1:-}"; shift || true + local RESP="${TMP_DIR}/resp_$(date +%s%N).json" + + local args=(-sS --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") + if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi + if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi + + local CODE + CODE="$(curl "${args[@]}" || true)" + echo "$CODE|$RESP" +} + +expect_code() { + # expect_code "" "||..." + local ACT="$1"; local ALLOWED="$2" + if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then + return 0 + fi + return 1 +} + +wait_http() { + # wait_http [|default 200] [|60] + local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}" + log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})" + for ((i=1; i<=TRIES; i++)); do + local CODE + CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" + if expect_code "$CODE" "$ALLOWED"; then + ok "${NAME} is ready (${CODE})" + return 0 + fi + sleep 1 + done + fail "${NAME} not ready in time: ${URL}" +} + +wait_health() { + # wait_health [|60] (expects 200) + wait_http "$1" "$2" "200" "${3:-60}" +} + +register_or_login() { + # register_or_login -> echoes "|" + local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" + + local BODY REG RESPCODE RESP + BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") + REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" + RESPCODE="${REG%%|*}"; RESP="${REG##*|}" + + if expect_code "$RESPCODE" "201|200"; then + ok "Registered user ${EMAIL}" + else + local MSG + MSG="$(json_get "$RESP" "detail")" + if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then + warn "User ${EMAIL} already exists, will login" + else + warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" + fi + fi + + local TOK TOKCODE TOKRESP + BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") + TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")" + TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" + expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" + + local ACCESS + ACCESS="$(json_get "$TOKRESP" "access_token")" + [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}" + + local ME MECODE MERESP UID + ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")" + MECODE="${ME%%|*}"; MERESP="${ME##*|}" + expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")" + UID="$(json_get "$MERESP" "id")" + [[ -n "$UID" ]] || fail "Failed to parse user id for ${EMAIL}" + + echo "${UID}|${ACCESS}" +} + +ensure_profile() { + # ensure_profile + local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5" + + local ME MECODE MERESP + ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" + MECODE="${ME%%|*}"; MERESP="${ME##*|}" + if [[ "$MECODE" == "200" ]]; then + ok "Profile already exists" + echo "$MERESP" > "${TMP_DIR}/last_profile.json" + return 0 + elif [[ "$MECODE" != "404" ]]; then + warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")" + fi + + IFS=',' read -r -a langs <<< "$LANGS_CSV" + IFS=',' read -r -a intrs <<< "$INTERESTS_CSV" + local langs_json intrs_json + langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" + intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" + + local BODY + BODY=$(cat < "${TMP_DIR}/last_profile.json" +} + +main() { + echo -e "${B}=== E2E smoke test start ===${NC}" + echo "BASE_URL: $BASE_URL" + echo + + # 0) Wait for gateway by checking proxied /auth/health (root / может отдавать 404/403) + wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}" + + # 1) Wait for services (через gateway) + wait_health "auth" "$AUTH/health" + wait_health "profiles" "$PROFILES/health" + wait_health "match" "$MATCH/health" + wait_health "chat" "$CHAT/health" + wait_health "payments" "$PAYMENTS/health" + + # 2) Register/login users + TS="$(date +%s)" + ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" + ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" + BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" + PASS="${PASS:-secret123}" + + log "Register/login admin: ${ADMIN_EMAIL}" + IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN") + ok "Admin id: $ADMIN_ID" + + log "Register/login Alice: ${ALICE_EMAIL}" + IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT") + ok "Alice id: $ALICE_ID" + + log "Register/login Bob: ${BOB_EMAIL}" + IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT") + ok "Bob id: $BOB_ID" + + # 3) Ensure profiles + log "Ensure profile for Admin" + ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" + + log "Ensure profile for Alice" + ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" + + log "Ensure profile for Bob" + ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" + + # 4) Create match pair + log "Create match pair (Alice ↔ Bob)" + BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) + PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")" + PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" + expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" + PAIR_ID="$(json_get "$PRESP" "id")" + ok "Pair created: $PAIR_ID" + + # 5) Create chat room and send a message + log "Create chat room (Admin + Alice + Bob)" + BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") + ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")" + RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" + expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" + ROOM_ID="$(json_get "$RRESP" "id")" + ok "Room created: $ROOM_ID" + + log "Send message to room" + BODY='{"content":"Hello from admin (e2e)"}' + MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")" + MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" + expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" + MSG_ID="$(json_get "$MRESP" "id")" + ok "Message sent: $MSG_ID" + + # 6) Create invoice for Alice and mark paid + log "Create invoice for Alice" + BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") + INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")" + INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" + expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" + INV_ID="$(json_get "$INVRESP" "id")" + ok "Invoice created: $INV_ID" + + log "Mark invoice paid" + PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")" + PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" + expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" + STATUS="$(json_get "$PDRESP" "status")" + [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')" + ok "Invoice marked paid" + + echo + echo -e "${B}=== E2E summary ===${NC}" + echo -e "Admin: ${G}${ADMIN_EMAIL}${NC} (id: ${ADMIN_ID})" + echo -e "Alice: ${G}${ALICE_EMAIL}${NC} (id: ${ALICE_ID})" + echo -e "Bob: ${G}${BOB_EMAIL}${NC} (id: ${BOB_ID})" + echo -e "Pair: ${C}${PAIR_ID}${NC}" + echo -e "Room: ${C}${ROOM_ID}${NC} Message: ${C}${MSG_ID}${NC}" + echo -e "Invoice:${C}${INV_ID}${NC} Status: ${G}${STATUS}${NC}" + echo + ok "E2E smoke test finished successfully." +} + +main "$@" diff --git a/.history/scripts/e2e_20250808210443.sh b/.history/scripts/e2e_20250808210443.sh new file mode 100644 index 0000000..8bb0f6e --- /dev/null +++ b/.history/scripts/e2e_20250808210443.sh @@ -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 + python3 - "$1" "$2" <<'PY' +import sys, json, os +f, path = sys.argv[1], sys.argv[2] +if not os.path.exists(f): + print(""); sys.exit(0) +with open(f, 'r') as fh: + try: + data = json.load(fh) + except Exception: + print(""); sys.exit(0) +cur = data +for key in path.split('.'): + if isinstance(cur, list): + try: + key = int(key) + except: + print(""); sys.exit(0) + cur = cur[key] if 0 <= key < len(cur) else None + elif isinstance(cur, dict): + cur = cur.get(key) + else: + cur = None + if cur is None: + break +print("" if cur is None else cur) +PY +} + +http_req() { + # http_req [] [] -> prints "HTTP_CODE|/path/to/body.json" + local METHOD="$1"; shift + local URL="$1"; shift + local TOKEN="${1:-}"; shift || true + local BODY="${1:-}"; shift || true + local RESP="${TMP_DIR}/resp_$(date +%s%N).json" + + local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") + if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi + if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi + + local CODE + CODE="$(curl "${args[@]}" || true)" + [[ -e "$RESP" ]] || : > "$RESP" # гарантируем наличие файла + echo "$CODE|$RESP" +} + +expect_code() { + # expect_code "" "||..." + local ACT="$1"; local ALLOWED="$2" + if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then + return 0 + fi + return 1 +} + +wait_http() { + # wait_http [|default 200] [|60] + local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}" + log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})" + for ((i=1; i<=TRIES; i++)); do + local CODE + CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" + if expect_code "$CODE" "$ALLOWED"; then + ok "${NAME} is ready (${CODE})" + return 0 + fi + sleep 1 + done + fail "${NAME} not ready in time: ${URL}" +} + +wait_health() { wait_http "$1" "$2" "200" "${3:-60}"; } + +register_or_login() { + # register_or_login -> echoes "|" + local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" + + # try register (не фатально, даже если 500/409 — дальше попытаемся получить токен) + local BODY REG RESPCODE RESP + BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") + REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" + RESPCODE="${REG%%|*}"; RESP="${REG##*|}" + if expect_code "$RESPCODE" "201|200"; then + ok "Registered user ${EMAIL}" + else + local MSG; MSG="$(json_get "$RESP" "detail")" + if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then + warn "User ${EMAIL} already exists, will login" + else + warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" + fi + fi + + # get token (обязательно) + local TOK TOKCODE TOKRESP + BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") + TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")" + TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" + expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" + + local ACCESS + ACCESS="$(json_get "$TOKRESP" "access_token")" + [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}" + + # resolve user id via /me + local ME MECODE MERESP USER_ID + ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")" + MECODE="${ME%%|*}"; MERESP="${ME##*|}" + expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")" + USER_ID="$(json_get "$MERESP" "id")" + [[ -n "$USER_ID" ]] || fail "Failed to parse user id for ${EMAIL}" + + # ВНИМАНИЕ: в stdout только данные! + echo "${USER_ID}|${ACCESS}" +} + +ensure_profile() { + # ensure_profile + local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5" + [[ -n "$TOKEN" ]] || fail "Empty token passed to ensure_profile" + + local ME MECODE MERESP + ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" + MECODE="${ME%%|*}"; MERESP="${ME##*|}" + if [[ "$MECODE" == "200" ]]; then + ok "Profile already exists" + echo "$MERESP" > "${TMP_DIR}/last_profile.json" + return 0 + elif [[ "$MECODE" != "404" ]]; then + warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")" + fi + + IFS=',' read -r -a langs <<< "$LANGS_CSV" + IFS=',' read -r -a intrs <<< "$INTERESTS_CSV" + local langs_json intrs_json + langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" + intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)" + + local BODY + BODY=$(cat < "${TMP_DIR}/last_profile.json" +} + +main() { + echo -e "${B}=== E2E smoke test start ===${NC}" >&2 + echo "BASE_URL: $BASE_URL" >&2 + echo >&2 + + # 0) Gateway health via proxied /auth/health + wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}" + + # 1) Service health (via gateway) + wait_health "auth" "$AUTH/health" + wait_health "profiles" "$PROFILES/health" + wait_health "match" "$MATCH/health" + wait_health "chat" "$CHAT/health" + wait_health "payments" "$PAYMENTS/health" + + # 2) Register/login users + TS="$(date +%s)" + ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" + ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" + BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" + PASS="${PASS:-secret123}" + + log "Register/login admin: ${ADMIN_EMAIL}" + IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN") + ok "Admin id: $ADMIN_ID" + + log "Register/login Alice: ${ALICE_EMAIL}" + IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT") + ok "Alice id: $ALICE_ID" + + log "Register/login Bob: ${BOB_EMAIL}" + IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT") + ok "Bob id: $BOB_ID" + + # 3) Ensure profiles + log "Ensure profile for Admin" + ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" + + log "Ensure profile for Alice" + ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" + + log "Ensure profile for Bob" + ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" + + # 4) Create match pair + log "Create match pair (Alice ↔ Bob)" + BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) + PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")" + PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" + expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" + PAIR_ID="$(json_get "$PRESP" "id")" + ok "Pair created: $PAIR_ID" + + # 5) Create chat room and send a message + log "Create chat room (Admin + Alice + Bob)" + BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") + ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")" + RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" + expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" + ROOM_ID="$(json_get "$RRESP" "id")" + ok "Room created: $ROOM_ID" + + log "Send message to room" + BODY='{"content":"Hello from admin (e2e)"}' + MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")" + MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" + expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" + MSG_ID="$(json_get "$MRESP" "id")" + ok "Message sent: $MSG_ID" + + # 6) Create invoice for Alice and mark paid + log "Create invoice for Alice" + BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") + INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")" + INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" + expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" + INV_ID="$(json_get "$INVRESP" "id")" + ok "Invoice created: $INV_ID" + + log "Mark invoice paid" + PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")" + PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" + expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" + STATUS="$(json_get "$PDRESP" "status")" + [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')" + ok "Invoice marked paid" + + { + echo "=== E2E summary ===" + echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})" + echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})" + echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})" + echo "Pair: ${PAIR_ID}" + echo "Room: ${ROOM_ID} Message: ${MSG_ID}" + echo "Invoice:${INV_ID} Status: ${STATUS}" + } >&2 + + ok "E2E smoke test finished successfully." +} + +main "$@" diff --git a/.history/scripts/e2e_20250808211132.sh b/.history/scripts/e2e_20250808211132.sh new file mode 100644 index 0000000..85999b4 --- /dev/null +++ b/.history/scripts/e2e_20250808211132.sh @@ -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 +python3 - "$1" "$2" <<'PY' +import sys, json, base64 +t, claim = sys.argv[1], sys.argv[2] +try: + b = t.split('.')[1] + b += '=' * (-len(b) % 4) + payload = json.loads(base64.urlsafe_b64decode(b).decode()) + print(payload.get(claim,"")) +except Exception: + print("") +PY +} + +http_req(){ + local METHOD="$1"; shift; local URL="$1"; shift + local TOKEN="${1:-}"; shift || true + local BODY="${1:-}"; shift || true + local RESP="${TMP_DIR}/resp_$(date +%s%N).json" + local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") + [[ -n "$TOKEN" ]] && args+=(-H "Authorization: Bearer $TOKEN") + [[ -n "$BODY" ]] && args+=(-H "Content-Type: application/json" -d "$BODY") + local CODE; CODE="$(curl "${args[@]}" || true)" + [[ -e "$RESP" ]] || : > "$RESP" + echo "$CODE|$RESP" +} + +expect_code(){ [[ "$2" == *"|${1}|"* || "$2" == "${1}|"* || "$2" == *"|${1}" || "$2" == "${1}" ]]; } + +wait_http(){ + local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}" + log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})" + for((i=1;i<=TRIES;i++)); do + local CODE; CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" + if expect_code "$CODE" "$ALLOWED"; then ok "${NAME} is ready (${CODE})"; return 0; fi + sleep 1 + done; fail "${NAME} not ready in time: ${URL}" +} +wait_health(){ wait_http "$1" "$2" "200" "${3:-60}"; } + +login_or_register(){ # echo "|" + local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" + local BODY TOK TOKCODE TOKRESP ACCESS USER_ID + + # 1) пытаемся логиниться + BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") + TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" + if expect_code "$TOKCODE" "200"; then + ACCESS="$(json_get "$TOKRESP" "access_token")" + USER_ID="$(jwt_get "$ACCESS" sub)" + [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed for $EMAIL" + ok "Login ok for $EMAIL" + echo "${USER_ID}|${ACCESS}"; return 0 + fi + warn "Login failed for $EMAIL ($TOKCODE) → will register" + + # 2) регистрируем + BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") + local REG RESPCODE RESP; REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" + RESPCODE="${REG%%|*}"; RESP="${REG##*|}" + if expect_code "$RESPCODE" "201|200"; then + ok "Registered $EMAIL" + else + local MSG; MSG="$(json_get "$RESP" "detail")" + if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then + warn "Already exists: $EMAIL" + else + warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" + fi + fi + + # 3) снова логин + TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" + expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" + ACCESS="$(json_get "$TOKRESP" "access_token")" + USER_ID="$(jwt_get "$ACCESS" sub)" + [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed after register for $EMAIL" + echo "${USER_ID}|${ACCESS}" +} + +ensure_profile(){ # + local TOKEN="$1" G="$2" CITY="$3" LANGS="$4" INTRS="$5" + [[ -n "$TOKEN" ]] || fail "Empty token in ensure_profile" + + local ME MECODE MERESP; ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" + MECODE="${ME%%|*}"; MERESP="${ME##*|}" + if [[ "$MECODE" == "200" ]]; then ok "Profile exists"; return 0 + elif [[ "$MECODE" != "404" ]]; then warn "Unexpected /profiles/me $MECODE: $(cat "$MERESP")"; fi + + local lj ij; IFS=',' read -r -a _l <<< "$LANGS"; lj="$(printf '%s\n' "${_l[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)" + IFS=',' read -r -a _i <<< "$INTRS"; ij="$(printf '%s\n' "${_i[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)" + local BODY; BODY=$(cat <&2 + echo "BASE_URL: $BASE_URL" >&2; echo >&2 + + wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}" + wait_health "auth" "$AUTH/health"; wait_health "profiles" "$PROFILES/health" + wait_health "match" "$MATCH/health"; wait_health "chat" "$CHAT/health"; wait_health "payments" "$PAYMENTS/health" + + TS="$(date +%s)" + ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" + ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" + BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" + PASS="${PASS:-secret123}" + + log "Admin: ${ADMIN_EMAIL}" + IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(login_or_register "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN"); ok "Admin id: $ADMIN_ID" + + log "Alice: ${ALICE_EMAIL}" + IFS='|' read -r ALICE_ID ALICE_ACCESS < <(login_or_register "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT"); ok "Alice id: $ALICE_ID" + + log "Bob: ${BOB_EMAIL}" + IFS='|' read -r BOB_ID BOB_ACCESS < <(login_or_register "$BOB_EMAIL" "$PASS" "Bob" "CLIENT"); ok "Bob id: $BOB_ID" + + log "Profiles" + ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" + ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" + ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" + + log "Match Alice ↔ Bob" + BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) + PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"; PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" + expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" + PAIR_ID="$(json_get "$PRESP" "id")"; ok "Pair: $PAIR_ID" + + log "Chat" + BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") + ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"; RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" + expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" + ROOM_ID="$(json_get "$RRESP" "id")"; ok "Room: $ROOM_ID" + + BODY='{"content":"Hello from admin (e2e)"}' + MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"; MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" + expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" + MSG_ID="$(json_get "$MRESP" "id")"; ok "Message: $MSG_ID" + + log "Payments" + BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") + INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"; INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" + expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" + INV_ID="$(json_get "$INVRESP" "id")"; ok "Invoice: $INV_ID" + + PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"; PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" + expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" + STATUS="$(json_get "$PDRESP" "status")"; [[ "$STATUS" == "paid" ]] || fail "Invoice not paid" + ok "Invoice status: $STATUS" + + { + echo "=== E2E summary ===" + echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})" + echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})" + echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})" + echo "Pair: ${PAIR_ID}" + echo "Room: ${ROOM_ID} Message: ${MSG_ID}" + echo "Invoice:${INV_ID} Status: ${STATUS}" + } >&2 + + ok "E2E smoke test finished successfully." +} +main "$@" diff --git a/.history/scripts/fix_email_validation_20250808211220.sh b/.history/scripts/fix_email_validation_20250808211220.sh new file mode 100644 index 0000000..e69de29 diff --git a/.history/scripts/fix_email_validation_20250808211222.sh b/.history/scripts/fix_email_validation_20250808211222.sh new file mode 100644 index 0000000..2cb5f68 --- /dev/null +++ b/.history/scripts/fix_email_validation_20250808211222.sh @@ -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 \ No newline at end of file diff --git a/.history/scripts/migrate_20250808200714.sh b/.history/scripts/migrate_20250808200714.sh new file mode 100644 index 0000000..e99b378 --- /dev/null +++ b/.history/scripts/migrate_20250808200714.sh @@ -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 diff --git a/.history/scripts/migrate_20250808214443.sh b/.history/scripts/migrate_20250808214443.sh new file mode 100644 index 0000000..feb04f8 --- /dev/null +++ b/.history/scripts/migrate_20250808214443.sh @@ -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 diff --git a/.history/scripts/patch_20250808204341.sh b/.history/scripts/patch_20250808204341.sh new file mode 100644 index 0000000..4c1b211 --- /dev/null +++ b/.history/scripts/patch_20250808204341.sh @@ -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 diff --git a/.history/scripts/patch_20250808211820.sh b/.history/scripts/patch_20250808211820.sh new file mode 100644 index 0000000..63c15ec --- /dev/null +++ b/.history/scripts/patch_20250808211820.sh @@ -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 \ No newline at end of file diff --git a/.history/scripts/patch_20250808212435.sh b/.history/scripts/patch_20250808212435.sh new file mode 100644 index 0000000..b1b5740 --- /dev/null +++ b/.history/scripts/patch_20250808212435.sh @@ -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 diff --git a/.history/scripts/patch_20250808213107.sh b/.history/scripts/patch_20250808213107.sh new file mode 100644 index 0000000..e7295c6 --- /dev/null +++ b/.history/scripts/patch_20250808213107.sh @@ -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 diff --git a/.history/scripts/patch_20250808213457.sh b/.history/scripts/patch_20250808213457.sh new file mode 100644 index 0000000..fe650f7 --- /dev/null +++ b/.history/scripts/patch_20250808213457.sh @@ -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 diff --git a/.history/scripts/patch_20250808213938.sh b/.history/scripts/patch_20250808213938.sh new file mode 100644 index 0000000..fe650f7 --- /dev/null +++ b/.history/scripts/patch_20250808213938.sh @@ -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 diff --git a/.history/scripts/patch_20250808213956.sh b/.history/scripts/patch_20250808213956.sh new file mode 100644 index 0000000..6f49ac1 --- /dev/null +++ b/.history/scripts/patch_20250808213956.sh @@ -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 diff --git a/.history/scripts/patch_20250808214013.sh b/.history/scripts/patch_20250808214013.sh new file mode 100644 index 0000000..fc60753 --- /dev/null +++ b/.history/scripts/patch_20250808214013.sh @@ -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 diff --git a/.history/scripts/patch_20250808214025.sh b/.history/scripts/patch_20250808214025.sh new file mode 100644 index 0000000..e5404b1 --- /dev/null +++ b/.history/scripts/patch_20250808214025.sh @@ -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 diff --git a/.history/scripts/test_20250808204608.sh b/.history/scripts/test_20250808204608.sh new file mode 100644 index 0000000..92f9447 --- /dev/null +++ b/.history/scripts/test_20250808204608.sh @@ -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" diff --git a/.history/scripts/test_20250808214044.sh b/.history/scripts/test_20250808214044.sh new file mode 100644 index 0000000..e707b02 --- /dev/null +++ b/.history/scripts/test_20250808214044.sh @@ -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 . diff --git a/.history/services/auth/requirements_20250808195758.txt b/.history/services/auth/requirements_20250808195758.txt new file mode 100644 index 0000000..add41b9 --- /dev/null +++ b/.history/services/auth/requirements_20250808195758.txt @@ -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 diff --git a/.history/services/auth/requirements_20250808200038.txt b/.history/services/auth/requirements_20250808200038.txt new file mode 100644 index 0000000..04996b5 --- /dev/null +++ b/.history/services/auth/requirements_20250808200038.txt @@ -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 diff --git a/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py new file mode 100644 index 0000000..eada411 --- /dev/null +++ b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py @@ -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 ### diff --git a/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py new file mode 100644 index 0000000..6f6ba0c --- /dev/null +++ b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py @@ -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 ### diff --git a/.history/services/profiles/docker-entrypoint_20250808194542.sh b/.history/services/profiles/docker-entrypoint_20250808194542.sh new file mode 100644 index 0000000..2828898 --- /dev/null +++ b/.history/services/profiles/docker-entrypoint_20250808194542.sh @@ -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 diff --git a/.history/services/profiles/docker-entrypoint_20250808203201.sh b/.history/services/profiles/docker-entrypoint_20250808203201.sh new file mode 100644 index 0000000..ae2ee5e --- /dev/null +++ b/.history/services/profiles/docker-entrypoint_20250808203201.sh @@ -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 diff --git a/.history/services/profiles/src/app/models/photo_20250808195936.py b/.history/services/profiles/src/app/models/photo_20250808195936.py new file mode 100644 index 0000000..d7b2a81 --- /dev/null +++ b/.history/services/profiles/src/app/models/photo_20250808195936.py @@ -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) diff --git a/.history/services/profiles/src/app/models/photo_20250808204310.py b/.history/services/profiles/src/app/models/photo_20250808204310.py new file mode 100644 index 0000000..49d3db9 --- /dev/null +++ b/.history/services/profiles/src/app/models/photo_20250808204310.py @@ -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") \ No newline at end of file diff --git a/.history/services/profiles/src/app/models/profile_20250808195936.py b/.history/services/profiles/src/app/models/profile_20250808195936.py new file mode 100644 index 0000000..23df3d2 --- /dev/null +++ b/.history/services/profiles/src/app/models/profile_20250808195936.py @@ -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") diff --git a/.history/services/profiles/src/app/models/profile_20250808204008.py b/.history/services/profiles/src/app/models/profile_20250808204008.py new file mode 100644 index 0000000..652b24c --- /dev/null +++ b/.history/services/profiles/src/app/models/profile_20250808204008.py @@ -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") diff --git a/.history/services/profiles/src/app/models/profile_20250808204024.py b/.history/services/profiles/src/app/models/profile_20250808204024.py new file mode 100644 index 0000000..ef84110 --- /dev/null +++ b/.history/services/profiles/src/app/models/profile_20250808204024.py @@ -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") diff --git a/.history/services/profiles/src/app/models/profile_20250808204059.py b/.history/services/profiles/src/app/models/profile_20250808204059.py new file mode 100644 index 0000000..ee678b1 --- /dev/null +++ b/.history/services/profiles/src/app/models/profile_20250808204059.py @@ -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) \ No newline at end of file diff --git a/.history/services/profiles/src/app/models/profile_20250808204229.py b/.history/services/profiles/src/app/models/profile_20250808204229.py new file mode 100644 index 0000000..0c561fd --- /dev/null +++ b/.history/services/profiles/src/app/models/profile_20250808204229.py @@ -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") \ No newline at end of file diff --git a/.history/test_20250808204537.sh b/.history/test_20250808204537.sh new file mode 100644 index 0000000..e69de29 diff --git a/.history/test_20250808204550.sh b/.history/test_20250808204550.sh new file mode 100644 index 0000000..f09d470 --- /dev/null +++ b/.history/test_20250808204550.sh @@ -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" diff --git a/.history/test_20250808204607.sh b/.history/test_20250808204607.sh new file mode 100644 index 0000000..8ce0151 --- /dev/null +++ b/.history/test_20250808204607.sh @@ -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" diff --git a/.history/test_20250808204608.sh b/.history/test_20250808204608.sh new file mode 100644 index 0000000..8ce0151 --- /dev/null +++ b/.history/test_20250808204608.sh @@ -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" diff --git a/.history/test_20250808204610.sh b/.history/test_20250808204610.sh new file mode 100644 index 0000000..92f9447 --- /dev/null +++ b/.history/test_20250808204610.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 63aa1a6..eb86f45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.9" + services: postgres: image: postgres:16 diff --git a/infra/db/init/02_create_tables.sql b/infra/db/init/02_create_tables.sql new file mode 100644 index 0000000..e69de29 diff --git a/infra/gateway/nginx.conf b/infra/gateway/nginx.conf index 784757b..bcac648 100644 --- a/infra/gateway/nginx.conf +++ b/infra/gateway/nginx.conf @@ -9,6 +9,10 @@ server { } location /auth/ { 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_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/logs/api.log b/logs/api.log new file mode 100644 index 0000000..1e121d7 --- /dev/null +++ b/logs/api.log @@ -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"} diff --git a/scripts/api_e2e.py b/scripts/api_e2e.py new file mode 100644 index 0000000..7e9f8e5 --- /dev/null +++ b/scripts/api_e2e.py @@ -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) diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..85999b4 --- /dev/null +++ b/scripts/e2e.sh @@ -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 +python3 - "$1" "$2" <<'PY' +import sys, json, base64 +t, claim = sys.argv[1], sys.argv[2] +try: + b = t.split('.')[1] + b += '=' * (-len(b) % 4) + payload = json.loads(base64.urlsafe_b64decode(b).decode()) + print(payload.get(claim,"")) +except Exception: + print("") +PY +} + +http_req(){ + local METHOD="$1"; shift; local URL="$1"; shift + local TOKEN="${1:-}"; shift || true + local BODY="${1:-}"; shift || true + local RESP="${TMP_DIR}/resp_$(date +%s%N).json" + local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}") + [[ -n "$TOKEN" ]] && args+=(-H "Authorization: Bearer $TOKEN") + [[ -n "$BODY" ]] && args+=(-H "Content-Type: application/json" -d "$BODY") + local CODE; CODE="$(curl "${args[@]}" || true)" + [[ -e "$RESP" ]] || : > "$RESP" + echo "$CODE|$RESP" +} + +expect_code(){ [[ "$2" == *"|${1}|"* || "$2" == "${1}|"* || "$2" == *"|${1}" || "$2" == "${1}" ]]; } + +wait_http(){ + local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}" + log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})" + for((i=1;i<=TRIES;i++)); do + local CODE; CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)" + if expect_code "$CODE" "$ALLOWED"; then ok "${NAME} is ready (${CODE})"; return 0; fi + sleep 1 + done; fail "${NAME} not ready in time: ${URL}" +} +wait_health(){ wait_http "$1" "$2" "200" "${3:-60}"; } + +login_or_register(){ # echo "|" + local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4" + local BODY TOK TOKCODE TOKRESP ACCESS USER_ID + + # 1) пытаемся логиниться + BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS") + TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" + if expect_code "$TOKCODE" "200"; then + ACCESS="$(json_get "$TOKRESP" "access_token")" + USER_ID="$(jwt_get "$ACCESS" sub)" + [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed for $EMAIL" + ok "Login ok for $EMAIL" + echo "${USER_ID}|${ACCESS}"; return 0 + fi + warn "Login failed for $EMAIL ($TOKCODE) → will register" + + # 2) регистрируем + BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE") + local REG RESPCODE RESP; REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")" + RESPCODE="${REG%%|*}"; RESP="${REG##*|}" + if expect_code "$RESPCODE" "201|200"; then + ok "Registered $EMAIL" + else + local MSG; MSG="$(json_get "$RESP" "detail")" + if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then + warn "Already exists: $EMAIL" + else + warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)" + fi + fi + + # 3) снова логин + TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}" + expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")" + ACCESS="$(json_get "$TOKRESP" "access_token")" + USER_ID="$(jwt_get "$ACCESS" sub)" + [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed after register for $EMAIL" + echo "${USER_ID}|${ACCESS}" +} + +ensure_profile(){ # + local TOKEN="$1" G="$2" CITY="$3" LANGS="$4" INTRS="$5" + [[ -n "$TOKEN" ]] || fail "Empty token in ensure_profile" + + local ME MECODE MERESP; ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")" + MECODE="${ME%%|*}"; MERESP="${ME##*|}" + if [[ "$MECODE" == "200" ]]; then ok "Profile exists"; return 0 + elif [[ "$MECODE" != "404" ]]; then warn "Unexpected /profiles/me $MECODE: $(cat "$MERESP")"; fi + + local lj ij; IFS=',' read -r -a _l <<< "$LANGS"; lj="$(printf '%s\n' "${_l[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)" + IFS=',' read -r -a _i <<< "$INTRS"; ij="$(printf '%s\n' "${_i[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)" + local BODY; BODY=$(cat <&2 + echo "BASE_URL: $BASE_URL" >&2; echo >&2 + + wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}" + wait_health "auth" "$AUTH/health"; wait_health "profiles" "$PROFILES/health" + wait_health "match" "$MATCH/health"; wait_health "chat" "$CHAT/health"; wait_health "payments" "$PAYMENTS/health" + + TS="$(date +%s)" + ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}" + ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}" + BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}" + PASS="${PASS:-secret123}" + + log "Admin: ${ADMIN_EMAIL}" + IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(login_or_register "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN"); ok "Admin id: $ADMIN_ID" + + log "Alice: ${ALICE_EMAIL}" + IFS='|' read -r ALICE_ID ALICE_ACCESS < <(login_or_register "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT"); ok "Alice id: $ALICE_ID" + + log "Bob: ${BOB_EMAIL}" + IFS='|' read -r BOB_ID BOB_ACCESS < <(login_or_register "$BOB_EMAIL" "$PASS" "Bob" "CLIENT"); ok "Bob id: $BOB_ID" + + log "Profiles" + ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops" + ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel" + ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading" + + log "Match Alice ↔ Bob" + BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87) + PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"; PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}" + expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")" + PAIR_ID="$(json_get "$PRESP" "id")"; ok "Pair: $PAIR_ID" + + log "Chat" + BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID") + ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"; RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}" + expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")" + ROOM_ID="$(json_get "$RRESP" "id")"; ok "Room: $ROOM_ID" + + BODY='{"content":"Hello from admin (e2e)"}' + MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"; MCODE="${MSG%%|*}"; MRESP="${MSG##*|}" + expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")" + MSG_ID="$(json_get "$MRESP" "id")"; ok "Message: $MSG_ID" + + log "Payments" + BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID") + INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"; INVCODE="${INV%%|*}"; INVRESP="${INV##*|}" + expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")" + INV_ID="$(json_get "$INVRESP" "id")"; ok "Invoice: $INV_ID" + + PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"; PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}" + expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")" + STATUS="$(json_get "$PDRESP" "status")"; [[ "$STATUS" == "paid" ]] || fail "Invoice not paid" + ok "Invoice status: $STATUS" + + { + echo "=== E2E summary ===" + echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})" + echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})" + echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})" + echo "Pair: ${PAIR_ID}" + echo "Room: ${ROOM_ID} Message: ${MSG_ID}" + echo "Invoice:${INV_ID} Status: ${STATUS}" + } >&2 + + ok "E2E smoke test finished successfully." +} +main "$@" diff --git a/scripts/fix_alembic.sh b/scripts/fix_alembic.sh new file mode 100755 index 0000000..7abd070 --- /dev/null +++ b/scripts/fix_alembic.sh @@ -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." diff --git a/scripts/fix_email_validation.sh b/scripts/fix_email_validation.sh new file mode 100755 index 0000000..2cb5f68 --- /dev/null +++ b/scripts/fix_email_validation.sh @@ -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 \ No newline at end of file diff --git a/scripts/fix_profiles_deps.sh b/scripts/fix_profiles_deps.sh new file mode 100755 index 0000000..2785990 --- /dev/null +++ b/scripts/fix_profiles_deps.sh @@ -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 diff --git a/scripts/fix_profiles_fk.sh b/scripts/fix_profiles_fk.sh new file mode 100755 index 0000000..e0e84da --- /dev/null +++ b/scripts/fix_profiles_fk.sh @@ -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 diff --git a/scripts/fix_profiles_schema_uuid.sh b/scripts/fix_profiles_schema_uuid.sh new file mode 100755 index 0000000..e7316f5 --- /dev/null +++ b/scripts/fix_profiles_schema_uuid.sh @@ -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 diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..feb04f8 --- /dev/null +++ b/scripts/migrate.sh @@ -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 diff --git a/scripts/models.sh b/scripts/models.sh new file mode 100755 index 0000000..1469e6c --- /dev/null +++ b/scripts/models.sh @@ -0,0 +1,1564 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ------------------------------------------------------------------- +# Apply models + CRUD + API + JWT auth to the existing scaffold +# Requires: the scaffold created earlier (services/* exist) +# ------------------------------------------------------------------- + +ROOT_DIR="." +SERVICES=(auth profiles match chat payments) + +ensure_line() { + # ensure_line + local file="$1" ; shift + local line="$*" + grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file" +} + +write_file() { + # write_file <<'EOF' ... EOF + local path="$1" + mkdir -p "$(dirname "$path")" + # The content will be provided by heredoc by the caller + cat > "$path" +} + +append_file() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat >> "$path" +} + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "ERROR: Missing $path. Run scaffold.sh first." >&2 + exit 1 + fi +} + +# Basic checks +require_file docker-compose.yml + +# ------------------------------------------------------------------- +# 1) .env.example — добавить JWT настройки (общие для всех сервисов) +# ------------------------------------------------------------------- +ENV_FILE=".env.example" +require_file "$ENV_FILE" + +ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------" +ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me" +ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256" +ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15" +ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days" + +# ------------------------------------------------------------------- +# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt] +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + REQ="services/$s/requirements.txt" + require_file "$REQ" + ensure_line "$REQ" "PyJWT>=2.8" + if [[ "$s" == "auth" ]]; then + ensure_line "$REQ" "passlib[bcrypt]>=1.7" + fi +done + +# ------------------------------------------------------------------- +# 3) Общая безопасность (JWT) для всех сервисов +# В auth добавим + генерацию токенов, в остальных — верификация и RBAC +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + SEC="services/$s/src/app/core/security.py" + mkdir -p "$(dirname "$SEC")" + if [[ "$s" == "auth" ]]; then + write_file "$SEC" <<'PY' +from __future__ import annotations +import os +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Any, Callable, Optional + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15")) +REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200")) + +class TokenType(str, Enum): + access = "access" + refresh = "refresh" + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str: + now = datetime.now(timezone.utc) + exp = now + timedelta(minutes=expires_minutes) + payload: dict[str, Any] = { + "sub": sub, + "email": email, + "role": role, + "type": token_type.value, + "exp": int(exp.timestamp()), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + +def create_access_token(*, sub: str, email: str, role: str) -> str: + return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN) + +def create_refresh_token(*, sub: str, email: str, role: str) -> str: + return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN) + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]: + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep +PY + else + write_file "$SEC" <<'PY' +from __future__ import annotations +import os +from enum import Enum +from typing import Any, Callable + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str): + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep +PY + fi +done + +# ------------------------------------------------------------------- +# 4) AUTH service — модели, CRUD, токены, эндпоинты +# ------------------------------------------------------------------- +# models +write_file services/auth/src/app/models/user.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, Boolean, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class Role(str, Enum): + ADMIN = "ADMIN" + MATCHMAKER = "MATCHMAKER" + CLIENT = "CLIENT" + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str | None] = mapped_column(String(255), default=None) + role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/auth/src/app/models/__init__.py <<'PY' +from .user import User, Role # noqa: F401 +PY + +# schemas +write_file services/auth/src/app/schemas/user.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, EmailStr, ConfigDict + +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + role: str = "CLIENT" + is_active: bool = True + +class UserCreate(BaseModel): + email: EmailStr + password: str + full_name: Optional[str] = None + role: str = "CLIENT" + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + password: Optional[str] = None + +class UserRead(BaseModel): + id: str + email: EmailStr + full_name: Optional[str] = None + role: str + is_active: bool + model_config = ConfigDict(from_attributes=True) + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" +PY + +# passwords +write_file services/auth/src/app/core/passwords.py <<'PY' +from passlib.context import CryptContext + +_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(p: str) -> str: + return _pwd.hash(p) + +def verify_password(p: str, hashed: str) -> bool: + return _pwd.verify(p, hashed) +PY + +# repositories +write_file services/auth/src/app/repositories/user_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy.orm import Session +from sqlalchemy import select, update, delete + +from app.models.user import User + +class UserRepository: + def __init__(self, db: Session): + self.db = db + + def get(self, user_id) -> Optional[User]: + return self.db.get(User, user_id) + + def get_by_email(self, email: str) -> Optional[User]: + stmt = select(User).where(User.email == email) + return self.db.execute(stmt).scalar_one_or_none() + + def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]: + stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User: + user = User(email=email, password_hash=password_hash, full_name=full_name, role=role) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def update(self, user: User, **fields) -> User: + for k, v in fields.items(): + if v is not None: + setattr(user, k, v) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def delete(self, user: User) -> None: + self.db.delete(user) + self.db.commit() +PY + +# services +write_file services/auth/src/app/services/user_service.py <<'PY' +from __future__ import annotations +from typing import Optional +from sqlalchemy.orm import Session + +from app.repositories.user_repository import UserRepository +from app.core.passwords import hash_password, verify_password +from app.models.user import User + +class UserService: + def __init__(self, db: Session): + self.repo = UserRepository(db) + + # CRUD + def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User: + if self.repo.get_by_email(email): + raise ValueError("Email already in use") + pwd_hash = hash_password(password) + return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role) + + def get_user(self, user_id) -> Optional[User]: + return self.repo.get(user_id) + + def get_by_email(self, email: str) -> Optional[User]: + return self.repo.get_by_email(email) + + def list_users(self, *, offset: int = 0, limit: int = 50): + return self.repo.list(offset=offset, limit=limit) + + def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None, + is_active: bool | None = None, password: str | None = None) -> User: + fields = {} + if full_name is not None: fields["full_name"] = full_name + if role is not None: fields["role"] = role + if is_active is not None: fields["is_active"] = is_active + if password: fields["password_hash"] = hash_password(password) + return self.repo.update(user, **fields) + + def delete_user(self, user: User) -> None: + self.repo.delete(user) + + # Auth + def authenticate(self, *, email: str, password: str) -> Optional[User]: + user = self.repo.get_by_email(email) + if not user or not user.is_active: + return None + if not verify_password(password, user.password_hash): + return None + return user +PY + +# api routes +write_file services/auth/src/app/api/routes/auth.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead +from app.services.user_service import UserService +from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims + +router = APIRouter(prefix="/v1", tags=["auth"]) + +@router.post("/register", response_model=UserRead, status_code=201) +def register(payload: UserCreate, db: Session = Depends(get_db)): + svc = UserService(db) + try: + user = svc.create_user(email=payload.email, password=payload.password, + full_name=payload.full_name, role=payload.role) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return user + +@router.post("/token", response_model=TokenPair) +def token(payload: LoginRequest, db: Session = Depends(get_db)): + svc = UserService(db) + user = svc.authenticate(email=payload.email, password=payload.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + access = create_access_token(sub=str(user.id), email=user.email, role=user.role) + refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role) + return TokenPair(access_token=access, refresh_token=refresh) + +class RefreshRequest(LoginRequest.__class__): + refresh_token: str # type: ignore + +@router.post("/refresh", response_model=TokenPair) +def refresh_token(req: dict): + # expects: {"refresh_token": ""} + from app.core.security import decode_token + token = req.get("refresh_token") + if not token: + raise HTTPException(status_code=400, detail="Missing refresh_token") + claims = decode_token(token) + if claims.type != "refresh": + raise HTTPException(status_code=400, detail="Not a refresh token") + access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role) + refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role) + return TokenPair(access_token=access, refresh_token=refresh) + +@router.get("/me", response_model=UserRead) +def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)): + svc = UserService(db) + u = svc.get_user(claims.sub) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return u +PY + +write_file services/auth/src/app/api/routes/users.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import require_roles +from app.schemas.user import UserRead, UserUpdate, UserCreate +from app.services.user_service import UserService + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +@router.get("", response_model=list[UserRead]) +def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + return UserService(db).list_users(offset=offset, limit=limit) + +@router.post("", response_model=UserRead, status_code=201) +def create_user(payload: UserCreate, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + try: + return UserService(db).create_user(email=payload.email, password=payload.password, + full_name=payload.full_name, role=payload.role) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/{user_id}", response_model=UserRead) +def get_user(user_id: str, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + u = UserService(db).get_user(user_id) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return u + +@router.patch("/{user_id}", response_model=UserRead) +def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + svc = UserService(db) + u = svc.get_user(user_id) + if not u: + raise HTTPException(status_code=404, detail="User not found") + return svc.update_user(u, full_name=payload.full_name, role=payload.role, + is_active=payload.is_active, password=payload.password) + +@router.delete("/{user_id}", status_code=204) +def delete_user(user_id: str, db: Session = Depends(get_db), + _: dict = Depends(require_roles("ADMIN"))): + svc = UserService(db) + u = svc.get_user(user_id) + if not u: + return + svc.delete_user(u) +PY + +# main.py update for auth +write_file services/auth/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.auth import router as auth_router +from .api.routes.users import router as users_router + +app = FastAPI(title="AUTH Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "auth"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(auth_router) +app.include_router(users_router) +PY + +# ------------------------------------------------------------------- +# 5) PROFILES service — Profile + Photo CRUD + поиск +# ------------------------------------------------------------------- +write_file services/profiles/src/app/models/profile.py <<'PY' +from __future__ import annotations +import uuid +from datetime import date, datetime + +from sqlalchemy import String, Date, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.session import Base + +class Profile(Base): + __tablename__ = "profiles" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other + birthdate: Mapped[date | None] = mapped_column(Date, default=None) + city: Mapped[str | None] = mapped_column(String(120), default=None) + bio: Mapped[str | None] = mapped_column(Text, default=None) + languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"] + interests: Mapped[dict | None] = mapped_column(JSONB, default=list) + preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict) + verification_status: Mapped[str] = mapped_column(String(16), default="unverified") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan") +PY + +write_file services/profiles/src/app/models/photo.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.session import Base + +class Photo(Base): + __tablename__ = "photos" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + url: Mapped[str] = mapped_column(String(500), nullable=False) + is_main: Mapped[bool] = mapped_column(Boolean, default=False) + status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True) +PY + +write_file services/profiles/src/app/models/__init__.py <<'PY' +from .profile import Profile # noqa +from .photo import Photo # noqa +PY + +write_file services/profiles/src/app/schemas/profile.py <<'PY' +from __future__ import annotations +from datetime import date +from typing import Optional, Any +from pydantic import BaseModel, ConfigDict + +class PhotoCreate(BaseModel): + url: str + is_main: bool = False + +class PhotoRead(BaseModel): + id: str + url: str + is_main: bool + status: str + model_config = ConfigDict(from_attributes=True) + +class ProfileCreate(BaseModel): + gender: str + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict[str, Any]] = None + +class ProfileUpdate(BaseModel): + gender: Optional[str] = None + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict[str, Any]] = None + verification_status: Optional[str] = None + +class ProfileRead(BaseModel): + id: str + user_id: str + gender: str + birthdate: Optional[date] = None + city: Optional[str] = None + bio: Optional[str] = None + languages: Optional[list[str]] = None + interests: Optional[list[str]] = None + preferences: Optional[dict] = None + verification_status: str + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/profiles/src/app/repositories/profile_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from datetime import date, timedelta + +from sqlalchemy import select, and_ +from sqlalchemy.orm import Session + +from app.models.profile import Profile +from app.models.photo import Photo + +class ProfileRepository: + def __init__(self, db: Session): + self.db = db + + # Profile CRUD + def create_profile(self, *, user_id, **fields) -> Profile: + p = Profile(user_id=user_id, **fields) + self.db.add(p) + self.db.commit() + self.db.refresh(p) + return p + + def get_profile(self, profile_id) -> Optional[Profile]: + return self.db.get(Profile, profile_id) + + def get_by_user(self, user_id) -> Optional[Profile]: + stmt = select(Profile).where(Profile.user_id == user_id) + return self.db.execute(stmt).scalar_one_or_none() + + def update_profile(self, profile: Profile, **fields) -> Profile: + for k, v in fields.items(): + if v is not None: + setattr(profile, k, v) + self.db.add(profile) + self.db.commit() + self.db.refresh(profile) + return profile + + def delete_profile(self, profile: Profile) -> None: + self.db.delete(profile) + self.db.commit() + + def list_profiles(self, *, gender: str | None = None, city: str | None = None, + age_min: int | None = None, age_max: int | None = None, + offset: int = 0, limit: int = 50) -> Sequence[Profile]: + stmt = select(Profile) + conds = [] + if gender: + conds.append(Profile.gender == gender) + if city: + conds.append(Profile.city == city) + # Age filter -> birthdate between (today - age_max) and (today - age_min) + if age_min is not None or age_max is not None: + today = date.today() + if age_min is not None: + max_birthdate = date(today.year - age_min, today.month, today.day) + conds.append(Profile.birthdate <= max_birthdate) + if age_max is not None: + min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1) + conds.append(Profile.birthdate >= min_birthdate) + if conds: + stmt = stmt.where(and_(*conds)) + stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + # Photos + def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo: + photo = Photo(profile_id=profile_id, url=url, is_main=is_main) + self.db.add(photo) + if is_main: + # unset other main photos + self.db.execute(select(Photo).where(Photo.profile_id == profile_id)) + self.db.commit() + self.db.refresh(photo) + return photo + + def list_photos(self, *, profile_id) -> Sequence[Photo]: + stmt = select(Photo).where(Photo.profile_id == profile_id) + return self.db.execute(stmt).scalars().all() + + def get_photo(self, photo_id) -> Optional[Photo]: + return self.db.get(Photo, photo_id) + + def delete_photo(self, photo: Photo) -> None: + self.db.delete(photo) + self.db.commit() +PY + +write_file services/profiles/src/app/services/profile_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional + +from app.repositories.profile_repository import ProfileRepository +from app.models.profile import Profile +from app.models.photo import Photo + +class ProfileService: + def __init__(self, db: Session): + self.repo = ProfileRepository(db) + + def create_profile(self, *, user_id, **fields) -> Profile: + return self.repo.create_profile(user_id=user_id, **fields) + + def get_profile(self, profile_id) -> Optional[Profile]: + return self.repo.get_profile(profile_id) + + def get_by_user(self, user_id) -> Optional[Profile]: + return self.repo.get_by_user(user_id) + + def update_profile(self, profile: Profile, **fields) -> Profile: + return self.repo.update_profile(profile, **fields) + + def delete_profile(self, profile: Profile) -> None: + return self.repo.delete_profile(profile) + + def list_profiles(self, **filters): + return self.repo.list_profiles(**filters) + + # photos + def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo: + return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main) + + def list_photos(self, profile_id): + return self.repo.list_photos(profile_id=profile_id) + + def get_photo(self, photo_id) -> Photo | None: + return self.repo.get_photo(photo_id) + + def delete_photo(self, photo: Photo) -> None: + self.repo.delete_photo(photo) +PY + +write_file services/profiles/src/app/api/routes/profiles.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead +from app.services.profile_service import ProfileService + +router = APIRouter(prefix="/v1", tags=["profiles"]) + +@router.post("/profiles", response_model=ProfileRead, status_code=201) +def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + if svc.get_by_user(user.sub): + raise HTTPException(status_code=400, detail="Profile already exists") + p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True)) + return p + +@router.get("/profiles/me", response_model=ProfileRead) +def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_by_user(user.sub) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + return p + +@router.get("/profiles", response_model=list[ProfileRead]) +def list_profiles(gender: str | None = None, city: str | None = None, + age_min: int | None = Query(None, ge=18, le=120), + age_max: int | None = Query(None, ge=18, le=120), + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit) + +@router.get("/profiles/{profile_id}", response_model=ProfileRead) +def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + p = ProfileService(db).get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + return p + +@router.patch("/profiles/{profile_id}", response_model=ProfileRead) +def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.update_profile(p, **payload.model_dump(exclude_none=True)) + +@router.delete("/profiles/{profile_id}", status_code=204) +def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + return + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + svc.delete_profile(p) + +# Photos +@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201) +def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + p = svc.get_profile(profile_id) + if not p: + raise HTTPException(status_code=404, detail="Profile not found") + if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"): + raise HTTPException(status_code=403, detail="Not allowed") + photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main) + return photo + +@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead]) +def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + return svc.list_photos(profile_id) + +@router.delete("/photos/{photo_id}", status_code=204) +def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ProfileService(db) + photo = svc.get_photo(photo_id) + if not photo: + return + # Lookup profile to check ownership + p = svc.get_profile(photo.profile_id) + if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")): + raise HTTPException(status_code=403, detail="Not allowed") + svc.delete_photo(photo) +PY + +# main.py for profiles +write_file services/profiles/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.profiles import router as profiles_router + +app = FastAPI(title="PROFILES Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "profiles"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(profiles_router) +PY + +# ------------------------------------------------------------------- +# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked) +# ------------------------------------------------------------------- +write_file services/match/src/app/models/pair.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, Float, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class MatchPair(Base): + __tablename__ = "match_pairs" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + # User IDs to validate permissions; profile IDs можно добавить позже + user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked + score: Mapped[float | None] = mapped_column(Float, default=None) + notes: Mapped[str | None] = mapped_column(String(1000), default=None) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/match/src/app/models/__init__.py <<'PY' +from .pair import MatchPair # noqa +PY + +write_file services/match/src/app/schemas/pair.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class PairCreate(BaseModel): + user_id_a: str + user_id_b: str + score: Optional[float] = None + notes: Optional[str] = None + +class PairUpdate(BaseModel): + score: Optional[float] = None + notes: Optional[str] = None + +class PairRead(BaseModel): + id: str + user_id_a: str + user_id_b: str + status: str + score: Optional[float] = None + notes: Optional[str] = None + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/match/src/app/repositories/pair_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy import select, or_ +from sqlalchemy.orm import Session + +from app.models.pair import MatchPair + +class PairRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, **fields) -> MatchPair: + obj = MatchPair(**fields) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get(self, pair_id) -> Optional[MatchPair]: + return self.db.get(MatchPair, pair_id) + + def list(self, *, for_user_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = 50) -> Sequence[MatchPair]: + stmt = select(MatchPair) + if for_user_id: + stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id)) + if status: + stmt = stmt.where(MatchPair.status == status) + stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def update(self, obj: MatchPair, **fields) -> MatchPair: + for k, v in fields.items(): + if v is not None: + setattr(obj, k, v) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, obj: MatchPair) -> None: + self.db.delete(obj) + self.db.commit() +PY + +write_file services/match/src/app/services/pair_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional +from app.repositories.pair_repository import PairRepository +from app.models.pair import MatchPair + +class PairService: + def __init__(self, db: Session): + self.repo = PairRepository(db) + + def create(self, **fields) -> MatchPair: + return self.repo.create(**fields) + + def get(self, pair_id) -> Optional[MatchPair]: + return self.repo.get(pair_id) + + def list(self, **filters): + return self.repo.list(**filters) + + def update(self, obj: MatchPair, **fields) -> MatchPair: + return self.repo.update(obj, **fields) + + def delete(self, obj: MatchPair) -> None: + return self.repo.delete(obj) + + def set_status(self, obj: MatchPair, status: str) -> MatchPair: + return self.repo.update(obj, status=status) +PY + +write_file services/match/src/app/api/routes/pairs.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.pair import PairCreate, PairUpdate, PairRead +from app.services.pair_service import PairService + +router = APIRouter(prefix="/v1/pairs", tags=["pairs"]) + +@router.post("", response_model=PairRead, status_code=201) +def create_pair(payload: PairCreate, db: Session = Depends(get_db), + user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b, + score=payload.score, notes=payload.notes, created_by=user.sub) + +@router.get("", response_model=list[PairRead]) +def list_pairs(for_user_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + _: UserClaims = Depends(get_current_user)): + return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit) + +@router.get("/{pair_id}", response_model=PairRead) +def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + obj = PairService(db).get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return obj + +@router.patch("/{pair_id}", response_model=PairRead) +def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return svc.update(obj, **payload.model_dump(exclude_none=True)) + +@router.post("/{pair_id}/accept", response_model=PairRead) +def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + # Validate that current user participates + if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.set_status(obj, "accepted") + +@router.post("/{pair_id}/reject", response_model=PairRead) +def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.set_status(obj, "rejected") + +@router.delete("/{pair_id}", status_code=204) +def delete_pair(pair_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + return + svc.delete(obj) +PY + +write_file services/match/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.pairs import router as pairs_router + +app = FastAPI(title="MATCH Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "match"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(pairs_router) +PY + +# ------------------------------------------------------------------- +# 7) CHAT service — комнаты и сообщения (REST, без WS) +# ------------------------------------------------------------------- +write_file services/chat/src/app/models/chat.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class ChatRoom(Base): + __tablename__ = "chat_rooms" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title: Mapped[str | None] = mapped_column(String(255), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + +class ChatParticipant(Base): + __tablename__ = "chat_participants" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + +class Message(Base): + __tablename__ = "chat_messages" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) +PY + +write_file services/chat/src/app/models/__init__.py <<'PY' +from .chat import ChatRoom, ChatParticipant, Message # noqa +PY + +write_file services/chat/src/app/schemas/chat.py <<'PY' +from __future__ import annotations +from pydantic import BaseModel, ConfigDict +from typing import Optional + +class RoomCreate(BaseModel): + title: Optional[str] = None + participants: list[str] # user IDs + +class RoomRead(BaseModel): + id: str + title: Optional[str] = None + model_config = ConfigDict(from_attributes=True) + +class MessageCreate(BaseModel): + content: str + +class MessageRead(BaseModel): + id: str + room_id: str + sender_id: str + content: str + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/chat/src/app/repositories/chat_repository.py <<'PY' +from __future__ import annotations +from typing import Sequence, Optional +from sqlalchemy.orm import Session +from sqlalchemy import select, or_ + +from app.models.chat import ChatRoom, ChatParticipant, Message + +class ChatRepository: + def __init__(self, db: Session): + self.db = db + + # Rooms + def create_room(self, title: str | None) -> ChatRoom: + r = ChatRoom(title=title) + self.db.add(r) + self.db.commit() + self.db.refresh(r) + return r + + def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant: + p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin) + self.db.add(p) + self.db.commit() + self.db.refresh(p) + return p + + def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]: + stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\ + .where(ChatParticipant.user_id == user_id) + return self.db.execute(stmt).scalars().all() + + def get_room(self, room_id) -> Optional[ChatRoom]: + return self.db.get(ChatRoom, room_id) + + # Messages + def create_message(self, room_id, sender_id, content: str) -> Message: + m = Message(room_id=room_id, sender_id=sender_id, content=content) + self.db.add(m) + self.db.commit() + self.db.refresh(m) + return m + + def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]: + stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc()) + return self.db.execute(stmt).scalars().all() +PY + +write_file services/chat/src/app/services/chat_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional, Sequence + +from app.repositories.chat_repository import ChatRepository +from app.models.chat import ChatRoom, ChatParticipant, Message + +class ChatService: + def __init__(self, db: Session): + self.repo = ChatRepository(db) + + def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom: + room = self.repo.create_room(title) + # creator -> admin + self.repo.add_participant(room.id, creator_id, is_admin=True) + for uid in participant_ids: + if uid != creator_id: + self.repo.add_participant(room.id, uid, is_admin=False) + return room + + def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]: + return self.repo.list_rooms_for_user(user_id) + + def get_room(self, room_id: str) -> ChatRoom | None: + return self.repo.get_room(room_id) + + def create_message(self, room_id: str, sender_id: str, content: str) -> Message: + return self.repo.create_message(room_id, sender_id, content) + + def list_messages(self, room_id: str, offset: int = 0, limit: int = 100): + return self.repo.list_messages(room_id, offset=offset, limit=limit) +PY + +write_file services/chat/src/app/api/routes/chat.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, UserClaims +from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead +from app.services.chat_service import ChatService + +router = APIRouter(prefix="/v1", tags=["chat"]) + +@router.post("/rooms", response_model=RoomRead, status_code=201) +def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub) + return room + +@router.get("/rooms", response_model=list[RoomRead]) +def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + return ChatService(db).list_rooms_for_user(user.sub) + +@router.get("/rooms/{room_id}", response_model=RoomRead) +def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + room = ChatService(db).get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Not found") + # NOTE: для простоты опускаем проверку участия (добавьте в проде) + return room + +@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201) +def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + msg = svc.create_message(room_id, user.sub, payload.content) + return msg + +@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead]) +def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500), + db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + return svc.list_messages(room_id, offset=offset, limit=limit) +PY + +write_file services/chat/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.chat import router as chat_router + +app = FastAPI(title="CHAT Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "chat"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(chat_router) +PY + +# ------------------------------------------------------------------- +# 8) PAYMENTS service — инвойсы (простая версия) +# ------------------------------------------------------------------- +write_file services/payments/src/app/models/payment.py <<'PY' +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Numeric +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class Invoice(Base): + __tablename__ = "invoices" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="USD") + status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled + description: Mapped[str | None] = mapped_column(String(500), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) +PY + +write_file services/payments/src/app/models/__init__.py <<'PY' +from .payment import Invoice # noqa +PY + +write_file services/payments/src/app/schemas/payment.py <<'PY' +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class InvoiceCreate(BaseModel): + client_id: str + amount: float + currency: str = "USD" + description: Optional[str] = None + +class InvoiceUpdate(BaseModel): + amount: Optional[float] = None + currency: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + +class InvoiceRead(BaseModel): + id: str + client_id: str + amount: float + currency: str + status: str + description: Optional[str] = None + model_config = ConfigDict(from_attributes=True) +PY + +write_file services/payments/src/app/repositories/payment_repository.py <<'PY' +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy.orm import Session +from sqlalchemy import select + +from app.models.payment import Invoice + +class PaymentRepository: + def __init__(self, db: Session): + self.db = db + + def create_invoice(self, **fields) -> Invoice: + obj = Invoice(**fields) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_invoice(self, inv_id) -> Optional[Invoice]: + return self.db.get(Invoice, inv_id) + + def list_invoices(self, *, client_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = 50) -> Sequence[Invoice]: + stmt = select(Invoice) + if client_id: + stmt = stmt.where(Invoice.client_id == client_id) + if status: + stmt = stmt.where(Invoice.status == status) + stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def update_invoice(self, obj: Invoice, **fields) -> Invoice: + for k, v in fields.items(): + if v is not None: + setattr(obj, k, v) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete_invoice(self, obj: Invoice) -> None: + self.db.delete(obj) + self.db.commit() +PY + +write_file services/payments/src/app/services/payment_service.py <<'PY' +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional +from app.repositories.payment_repository import PaymentRepository +from app.models.payment import Invoice + +class PaymentService: + def __init__(self, db: Session): + self.repo = PaymentRepository(db) + + def create_invoice(self, **fields) -> Invoice: + return self.repo.create_invoice(**fields) + + def get_invoice(self, inv_id) -> Invoice | None: + return self.repo.get_invoice(inv_id) + + def list_invoices(self, **filters): + return self.repo.list_invoices(**filters) + + def update_invoice(self, obj: Invoice, **fields) -> Invoice: + return self.repo.update_invoice(obj, **fields) + + def delete_invoice(self, obj: Invoice) -> None: + return self.repo.delete_invoice(obj) + + def mark_paid(self, obj: Invoice) -> Invoice: + return self.repo.update_invoice(obj, status="paid") +PY + +write_file services/payments/src/app/api/routes/payments.py <<'PY' +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead +from app.services.payment_service import PaymentService + +router = APIRouter(prefix="/v1/invoices", tags=["payments"]) + +@router.post("", response_model=InvoiceRead, status_code=201) +def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True)) + +@router.get("", response_model=list[InvoiceRead]) +def list_invoices(client_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + user: UserClaims = Depends(get_current_user)): + # Клиент видит только свои инвойсы, админ/матчмейкер — любые + if user.role in ("ADMIN","MATCHMAKER"): + return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit) + else: + return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit) + +@router.get("/{inv_id}", response_model=InvoiceRead) +def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + inv = PaymentService(db).get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub: + raise HTTPException(status_code=403, detail="Not allowed") + return inv + +@router.patch("/{inv_id}", response_model=InvoiceRead) +def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + return svc.update_invoice(inv, **payload.model_dump(exclude_none=True)) + +@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead) +def mark_paid(inv_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + return svc.mark_paid(inv) + +@router.delete("/{inv_id}", status_code=204) +def delete_invoice(inv_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + return + svc.delete_invoice(inv) +PY + +write_file services/payments/src/app/main.py <<'PY' +from fastapi import FastAPI +from .api.routes.ping import router as ping_router +from .api.routes.payments import router as payments_router + +app = FastAPI(title="PAYMENTS Service") + +@app.get("/health") +def health(): + return {"status": "ok", "service": "payments"} + +app.include_router(ping_router, prefix="/v1") +app.include_router(payments_router) +PY + +# ------------------------------------------------------------------- +# 9) Обновить __init__.py пакетов (если scaffold создал пустые) +# ------------------------------------------------------------------- +for s in "${SERVICES[@]}"; do + touch "services/$s/src/app/__init__.py" + touch "services/$s/src/app/api/__init__.py" + touch "services/$s/src/app/api/routes/__init__.py" + touch "services/$s/src/app/core/__init__.py" + touch "services/$s/src/app/db/__init__.py" + touch "services/$s/src/app/repositories/__init__.py" + touch "services/$s/src/app/schemas/__init__.py" + touch "services/$s/src/app/services/__init__.py" +done + +for s in auth profiles match chat payments; do + docker compose run --rm $s alembic revision --autogenerate -m "init" +done + +echo "✅ Models + CRUD + API + Auth applied." + +cat <<'NEXT' +Next steps: + +1) Сгенерируйте первичные миграции по моделям: + for s in auth profiles match chat payments; do + docker compose run --rm $s alembic revision --autogenerate -m "init" + done + +2) Поднимите окружение (alembic upgrade выполнится в entrypoint): + docker compose up --build + +3) Получите токен: + POST http://localhost:8080/auth/v1/register + POST http://localhost:8080/auth/v1/token + -> Authorization: Bearer + +4) Проверьте CRUD: + - Profiles: GET http://localhost:8080/profiles/v1/profiles/me + - Match: POST http://localhost:8080/match/v1/pairs + - Chat: POST http://localhost:8080/chat/v1/rooms + - Payments: POST http://localhost:8080/payments/v1/invoices + +Замечания по безопасности/продакшену: +- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD. +- Сроки жизни токенов подберите под бизнес-политику. +- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте + хранилище jti/ревокацию. +- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав. +- В PROFILES поля languages/interests/preferences — JSONB; при желании замените + на нормализованные таблицы или ARRAY. +NEXT diff --git a/scripts/patch.sh b/scripts/patch.sh new file mode 100755 index 0000000..e5404b1 --- /dev/null +++ b/scripts/patch.sh @@ -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 diff --git a/scripts/patch_alembic_template.sh b/scripts/patch_alembic_template.sh new file mode 100755 index 0000000..1e5d5ec --- /dev/null +++ b/scripts/patch_alembic_template.sh @@ -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//alembic/versions/) +for s in auth profiles match chat payments; do + echo "[gen] $s" + docker compose run --rm -v "$PWD/services/$s":/app "$s" \ + sh -lc 'alembic revision --autogenerate -m "init"' +done + +for s in auth profiles match chat payments; do + echo "---- $s" + ls -1 services/$s/alembic/versions/ +done \ No newline at end of file diff --git a/scripts/patch_gateway_auth_header.sh b/scripts/patch_gateway_auth_header.sh new file mode 100755 index 0000000..6cb4fe8 --- /dev/null +++ b/scripts/patch_gateway_auth_header.sh @@ -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 diff --git a/scripts/patch_profiles_repo_service.sh b/scripts/patch_profiles_repo_service.sh new file mode 100755 index 0000000..9ab0103 --- /dev/null +++ b/scripts/patch_profiles_repo_service.sh @@ -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 diff --git a/scripts/patch_profiles_router.sh b/scripts/patch_profiles_router.sh new file mode 100755 index 0000000..157e256 --- /dev/null +++ b/scripts/patch_profiles_router.sh @@ -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 diff --git a/scripts/patch_profiles_security.sh b/scripts/patch_profiles_security.sh new file mode 100755 index 0000000..9fb0301 --- /dev/null +++ b/scripts/patch_profiles_security.sh @@ -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 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..e707b02 --- /dev/null +++ b/scripts/test.sh @@ -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 . diff --git a/services/auth/alembic/env.py b/services/auth/alembic/env.py index df746af..034d7df 100644 --- a/services/auth/alembic/env.py +++ b/services/auth/alembic/env.py @@ -12,6 +12,7 @@ if SRC_DIR not in sys.path: sys.path.append(SRC_DIR) from app.db.session import Base # noqa +from app import models # noqa: F401 config = context.config diff --git a/services/auth/alembic/script.py.mako b/services/auth/alembic/script.py.mako new file mode 100644 index 0000000..e378537 --- /dev/null +++ b/services/auth/alembic/script.py.mako @@ -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"} diff --git a/services/auth/alembic/versions/df0effc5d87a_init.py b/services/auth/alembic/versions/df0effc5d87a_init.py new file mode 100644 index 0000000..3053dfd --- /dev/null +++ b/services/auth/alembic/versions/df0effc5d87a_init.py @@ -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 ### diff --git a/services/auth/requirements.txt b/services/auth/requirements.txt index 87d1574..04996b5 100644 --- a/services/auth/requirements.txt +++ b/services/auth/requirements.txt @@ -5,6 +5,9 @@ psycopg2-binary alembic pydantic>=2 pydantic-settings +pydantic[email] python-dotenv httpx>=0.27 pytest +PyJWT>=2.8 +passlib[bcrypt]>=1.7 diff --git a/services/auth/src/app/api/routes/auth.py b/services/auth/src/app/api/routes/auth.py new file mode 100644 index 0000000..f74db56 --- /dev/null +++ b/services/auth/src/app/api/routes/auth.py @@ -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": ""} + 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 diff --git a/services/auth/src/app/api/routes/users.py b/services/auth/src/app/api/routes/users.py new file mode 100644 index 0000000..7abbbf9 --- /dev/null +++ b/services/auth/src/app/api/routes/users.py @@ -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) diff --git a/services/auth/src/app/core/passwords.py b/services/auth/src/app/core/passwords.py new file mode 100644 index 0000000..3c560ff --- /dev/null +++ b/services/auth/src/app/core/passwords.py @@ -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) diff --git a/services/auth/src/app/core/security.py b/services/auth/src/app/core/security.py new file mode 100644 index 0000000..1b12ec2 --- /dev/null +++ b/services/auth/src/app/core/security.py @@ -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 diff --git a/services/auth/src/app/main.py b/services/auth/src/app/main.py index 8d35f4d..0c112ef 100644 --- a/services/auth/src/app/main.py +++ b/services/auth/src/app/main.py @@ -1,5 +1,7 @@ from fastapi import FastAPI from .api.routes.ping import router as ping_router +from .api.routes.auth import router as auth_router +from .api.routes.users import router as users_router app = FastAPI(title="AUTH Service") @@ -7,5 +9,6 @@ app = FastAPI(title="AUTH Service") def health(): return {"status": "ok", "service": "auth"} -# v1 API app.include_router(ping_router, prefix="/v1") +app.include_router(auth_router) +app.include_router(users_router) diff --git a/services/auth/src/app/models/__init__.py b/services/auth/src/app/models/__init__.py index e69de29..f6b37fb 100644 --- a/services/auth/src/app/models/__init__.py +++ b/services/auth/src/app/models/__init__.py @@ -0,0 +1 @@ +from .user import User, Role # noqa: F401 diff --git a/services/auth/src/app/models/user.py b/services/auth/src/app/models/user.py new file mode 100644 index 0000000..3b70f32 --- /dev/null +++ b/services/auth/src/app/models/user.py @@ -0,0 +1,28 @@ +from __future__ import annotations +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, Boolean, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class Role(str, Enum): + ADMIN = "ADMIN" + MATCHMAKER = "MATCHMAKER" + CLIENT = "CLIENT" + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str | None] = mapped_column(String(255), default=None) + role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/services/auth/src/app/repositories/user_repository.py b/services/auth/src/app/repositories/user_repository.py new file mode 100644 index 0000000..13c3dbc --- /dev/null +++ b/services/auth/src/app/repositories/user_repository.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy.orm import Session +from sqlalchemy import select, update, delete + +from app.models.user import User + +class UserRepository: + def __init__(self, db: Session): + self.db = db + + def get(self, user_id) -> Optional[User]: + return self.db.get(User, user_id) + + def get_by_email(self, email: str) -> Optional[User]: + stmt = select(User).where(User.email == email) + return self.db.execute(stmt).scalar_one_or_none() + + def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]: + stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User: + user = User(email=email, password_hash=password_hash, full_name=full_name, role=role) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def update(self, user: User, **fields) -> User: + for k, v in fields.items(): + if v is not None: + setattr(user, k, v) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def delete(self, user: User) -> None: + self.db.delete(user) + self.db.commit() diff --git a/services/auth/src/app/schemas/user.py b/services/auth/src/app/schemas/user.py new file mode 100644 index 0000000..5e69bed --- /dev/null +++ b/services/auth/src/app/schemas/user.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, EmailStr, ConfigDict + +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + role: str = "CLIENT" + is_active: bool = True + +class UserCreate(BaseModel): + email: EmailStr + password: str + full_name: Optional[str] = None + role: str = "CLIENT" + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + password: Optional[str] = None + +class UserRead(BaseModel): + id: str + email: EmailStr + full_name: Optional[str] = None + role: str + is_active: bool + model_config = ConfigDict(from_attributes=True) + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" diff --git a/services/auth/src/app/services/user_service.py b/services/auth/src/app/services/user_service.py new file mode 100644 index 0000000..95bbf65 --- /dev/null +++ b/services/auth/src/app/services/user_service.py @@ -0,0 +1,48 @@ +from __future__ import annotations +from typing import Optional +from sqlalchemy.orm import Session + +from app.repositories.user_repository import UserRepository +from app.core.passwords import hash_password, verify_password +from app.models.user import User + +class UserService: + def __init__(self, db: Session): + self.repo = UserRepository(db) + + # CRUD + def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User: + if self.repo.get_by_email(email): + raise ValueError("Email already in use") + pwd_hash = hash_password(password) + return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role) + + def get_user(self, user_id) -> Optional[User]: + return self.repo.get(user_id) + + def get_by_email(self, email: str) -> Optional[User]: + return self.repo.get_by_email(email) + + def list_users(self, *, offset: int = 0, limit: int = 50): + return self.repo.list(offset=offset, limit=limit) + + def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None, + is_active: bool | None = None, password: str | None = None) -> User: + fields = {} + if full_name is not None: fields["full_name"] = full_name + if role is not None: fields["role"] = role + if is_active is not None: fields["is_active"] = is_active + if password: fields["password_hash"] = hash_password(password) + return self.repo.update(user, **fields) + + def delete_user(self, user: User) -> None: + self.repo.delete(user) + + # Auth + def authenticate(self, *, email: str, password: str) -> Optional[User]: + user = self.repo.get_by_email(email) + if not user or not user.is_active: + return None + if not verify_password(password, user.password_hash): + return None + return user diff --git a/services/chat/alembic/env.py b/services/chat/alembic/env.py index df746af..034d7df 100644 --- a/services/chat/alembic/env.py +++ b/services/chat/alembic/env.py @@ -12,6 +12,7 @@ if SRC_DIR not in sys.path: sys.path.append(SRC_DIR) from app.db.session import Base # noqa +from app import models # noqa: F401 config = context.config diff --git a/services/chat/alembic/script.py.mako b/services/chat/alembic/script.py.mako new file mode 100644 index 0000000..e378537 --- /dev/null +++ b/services/chat/alembic/script.py.mako @@ -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"} diff --git a/services/chat/alembic/versions/8cc8115aaf0e_init.py b/services/chat/alembic/versions/8cc8115aaf0e_init.py new file mode 100644 index 0000000..79749dd --- /dev/null +++ b/services/chat/alembic/versions/8cc8115aaf0e_init.py @@ -0,0 +1,56 @@ +"""init + +Revision ID: 8cc8115aaf0e +Revises: +Create Date: 2025-08-08 11:20:07.718286+00:00 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '8cc8115aaf0e' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('chat_messages', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('room_id', sa.UUID(), nullable=False), + sa.Column('sender_id', sa.UUID(), nullable=False), + sa.Column('content', sa.Text(), 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_chat_messages_room_id'), 'chat_messages', ['room_id'], unique=False) + op.create_index(op.f('ix_chat_messages_sender_id'), 'chat_messages', ['sender_id'], unique=False) + op.create_table('chat_participants', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('room_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('is_admin', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chat_participants_room_id'), 'chat_participants', ['room_id'], unique=False) + op.create_index(op.f('ix_chat_participants_user_id'), 'chat_participants', ['user_id'], unique=False) + op.create_table('chat_rooms', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('chat_rooms') + op.drop_index(op.f('ix_chat_participants_user_id'), table_name='chat_participants') + op.drop_index(op.f('ix_chat_participants_room_id'), table_name='chat_participants') + op.drop_table('chat_participants') + op.drop_index(op.f('ix_chat_messages_sender_id'), table_name='chat_messages') + op.drop_index(op.f('ix_chat_messages_room_id'), table_name='chat_messages') + op.drop_table('chat_messages') + # ### end Alembic commands ### diff --git a/services/chat/requirements.txt b/services/chat/requirements.txt index 87d1574..1b92356 100644 --- a/services/chat/requirements.txt +++ b/services/chat/requirements.txt @@ -8,3 +8,4 @@ pydantic-settings python-dotenv httpx>=0.27 pytest +PyJWT>=2.8 diff --git a/services/chat/src/app/api/routes/chat.py b/services/chat/src/app/api/routes/chat.py new file mode 100644 index 0000000..ea4a481 --- /dev/null +++ b/services/chat/src/app/api/routes/chat.py @@ -0,0 +1,46 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, UserClaims +from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead +from app.services.chat_service import ChatService + +router = APIRouter(prefix="/v1", tags=["chat"]) + +@router.post("/rooms", response_model=RoomRead, status_code=201) +def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub) + return room + +@router.get("/rooms", response_model=list[RoomRead]) +def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + return ChatService(db).list_rooms_for_user(user.sub) + +@router.get("/rooms/{room_id}", response_model=RoomRead) +def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + room = ChatService(db).get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Not found") + # NOTE: для простоты опускаем проверку участия (добавьте в проде) + return room + +@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201) +def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + msg = svc.create_message(room_id, user.sub, payload.content) + return msg + +@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead]) +def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500), + db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = ChatService(db) + room = svc.get_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + return svc.list_messages(room_id, offset=offset, limit=limit) diff --git a/services/chat/src/app/core/security.py b/services/chat/src/app/core/security.py new file mode 100644 index 0000000..6842ef3 --- /dev/null +++ b/services/chat/src/app/core/security.py @@ -0,0 +1,40 @@ +from __future__ import annotations +import os +from enum import Enum +from typing import Any, Callable + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str): + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep diff --git a/services/chat/src/app/main.py b/services/chat/src/app/main.py index e570874..a4ebf68 100644 --- a/services/chat/src/app/main.py +++ b/services/chat/src/app/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI from .api.routes.ping import router as ping_router +from .api.routes.chat import router as chat_router app = FastAPI(title="CHAT Service") @@ -7,5 +8,5 @@ app = FastAPI(title="CHAT Service") def health(): return {"status": "ok", "service": "chat"} -# v1 API app.include_router(ping_router, prefix="/v1") +app.include_router(chat_router) diff --git a/services/chat/src/app/models/__init__.py b/services/chat/src/app/models/__init__.py index e69de29..b4e6f58 100644 --- a/services/chat/src/app/models/__init__.py +++ b/services/chat/src/app/models/__init__.py @@ -0,0 +1 @@ +from .chat import ChatRoom, ChatParticipant, Message # noqa diff --git a/services/chat/src/app/models/chat.py b/services/chat/src/app/models/chat.py new file mode 100644 index 0000000..9bbaac6 --- /dev/null +++ b/services/chat/src/app/models/chat.py @@ -0,0 +1,30 @@ +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class ChatRoom(Base): + __tablename__ = "chat_rooms" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title: Mapped[str | None] = mapped_column(String(255), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + +class ChatParticipant(Base): + __tablename__ = "chat_participants" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + +class Message(Base): + __tablename__ = "chat_messages" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/services/chat/src/app/repositories/chat_repository.py b/services/chat/src/app/repositories/chat_repository.py new file mode 100644 index 0000000..e1299e7 --- /dev/null +++ b/services/chat/src/app/repositories/chat_repository.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from typing import Sequence, Optional +from sqlalchemy.orm import Session +from sqlalchemy import select, or_ + +from app.models.chat import ChatRoom, ChatParticipant, Message + +class ChatRepository: + def __init__(self, db: Session): + self.db = db + + # Rooms + def create_room(self, title: str | None) -> ChatRoom: + r = ChatRoom(title=title) + self.db.add(r) + self.db.commit() + self.db.refresh(r) + return r + + def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant: + p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin) + self.db.add(p) + self.db.commit() + self.db.refresh(p) + return p + + def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]: + stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\ + .where(ChatParticipant.user_id == user_id) + return self.db.execute(stmt).scalars().all() + + def get_room(self, room_id) -> Optional[ChatRoom]: + return self.db.get(ChatRoom, room_id) + + # Messages + def create_message(self, room_id, sender_id, content: str) -> Message: + m = Message(room_id=room_id, sender_id=sender_id, content=content) + self.db.add(m) + self.db.commit() + self.db.refresh(m) + return m + + def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]: + stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc()) + return self.db.execute(stmt).scalars().all() diff --git a/services/chat/src/app/schemas/chat.py b/services/chat/src/app/schemas/chat.py new file mode 100644 index 0000000..48b35a2 --- /dev/null +++ b/services/chat/src/app/schemas/chat.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from pydantic import BaseModel, ConfigDict +from typing import Optional + +class RoomCreate(BaseModel): + title: Optional[str] = None + participants: list[str] # user IDs + +class RoomRead(BaseModel): + id: str + title: Optional[str] = None + model_config = ConfigDict(from_attributes=True) + +class MessageCreate(BaseModel): + content: str + +class MessageRead(BaseModel): + id: str + room_id: str + sender_id: str + content: str + model_config = ConfigDict(from_attributes=True) diff --git a/services/chat/src/app/services/chat_service.py b/services/chat/src/app/services/chat_service.py new file mode 100644 index 0000000..344dfa4 --- /dev/null +++ b/services/chat/src/app/services/chat_service.py @@ -0,0 +1,31 @@ +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional, Sequence + +from app.repositories.chat_repository import ChatRepository +from app.models.chat import ChatRoom, ChatParticipant, Message + +class ChatService: + def __init__(self, db: Session): + self.repo = ChatRepository(db) + + def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom: + room = self.repo.create_room(title) + # creator -> admin + self.repo.add_participant(room.id, creator_id, is_admin=True) + for uid in participant_ids: + if uid != creator_id: + self.repo.add_participant(room.id, uid, is_admin=False) + return room + + def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]: + return self.repo.list_rooms_for_user(user_id) + + def get_room(self, room_id: str) -> ChatRoom | None: + return self.repo.get_room(room_id) + + def create_message(self, room_id: str, sender_id: str, content: str) -> Message: + return self.repo.create_message(room_id, sender_id, content) + + def list_messages(self, room_id: str, offset: int = 0, limit: int = 100): + return self.repo.list_messages(room_id, offset=offset, limit=limit) diff --git a/services/match/alembic/env.py b/services/match/alembic/env.py index df746af..034d7df 100644 --- a/services/match/alembic/env.py +++ b/services/match/alembic/env.py @@ -12,6 +12,7 @@ if SRC_DIR not in sys.path: sys.path.append(SRC_DIR) from app.db.session import Base # noqa +from app import models # noqa: F401 config = context.config diff --git a/services/match/alembic/script.py.mako b/services/match/alembic/script.py.mako new file mode 100644 index 0000000..e378537 --- /dev/null +++ b/services/match/alembic/script.py.mako @@ -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"} diff --git a/services/match/alembic/versions/00ce87deada6_init.py b/services/match/alembic/versions/00ce87deada6_init.py new file mode 100644 index 0000000..62e7950 --- /dev/null +++ b/services/match/alembic/versions/00ce87deada6_init.py @@ -0,0 +1,41 @@ +"""init + +Revision ID: 00ce87deada6 +Revises: +Create Date: 2025-08-08 11:20:06.424809+00:00 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '00ce87deada6' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('match_pairs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id_a', sa.UUID(), nullable=False), + sa.Column('user_id_b', sa.UUID(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('score', sa.Float(), nullable=True), + sa.Column('notes', sa.String(length=1000), nullable=True), + sa.Column('created_by', sa.UUID(), nullable=True), + 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_match_pairs_user_id_a'), 'match_pairs', ['user_id_a'], unique=False) + op.create_index(op.f('ix_match_pairs_user_id_b'), 'match_pairs', ['user_id_b'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_match_pairs_user_id_b'), table_name='match_pairs') + op.drop_index(op.f('ix_match_pairs_user_id_a'), table_name='match_pairs') + op.drop_table('match_pairs') + # ### end Alembic commands ### diff --git a/services/match/requirements.txt b/services/match/requirements.txt index 87d1574..1b92356 100644 --- a/services/match/requirements.txt +++ b/services/match/requirements.txt @@ -8,3 +8,4 @@ pydantic-settings python-dotenv httpx>=0.27 pytest +PyJWT>=2.8 diff --git a/services/match/src/app/api/routes/pairs.py b/services/match/src/app/api/routes/pairs.py new file mode 100644 index 0000000..a2e8053 --- /dev/null +++ b/services/match/src/app/api/routes/pairs.py @@ -0,0 +1,70 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.pair import PairCreate, PairUpdate, PairRead +from app.services.pair_service import PairService + +router = APIRouter(prefix="/v1/pairs", tags=["pairs"]) + +@router.post("", response_model=PairRead, status_code=201) +def create_pair(payload: PairCreate, db: Session = Depends(get_db), + user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b, + score=payload.score, notes=payload.notes, created_by=user.sub) + +@router.get("", response_model=list[PairRead]) +def list_pairs(for_user_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + _: UserClaims = Depends(get_current_user)): + return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit) + +@router.get("/{pair_id}", response_model=PairRead) +def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)): + obj = PairService(db).get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return obj + +@router.patch("/{pair_id}", response_model=PairRead) +def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + return svc.update(obj, **payload.model_dump(exclude_none=True)) + +@router.post("/{pair_id}/accept", response_model=PairRead) +def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + # Validate that current user participates + if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.set_status(obj, "accepted") + +@router.post("/{pair_id}/reject", response_model=PairRead) +def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + raise HTTPException(status_code=404, detail="Not found") + if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)): + raise HTTPException(status_code=403, detail="Not allowed") + return svc.set_status(obj, "rejected") + +@router.delete("/{pair_id}", status_code=204) +def delete_pair(pair_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PairService(db) + obj = svc.get(pair_id) + if not obj: + return + svc.delete(obj) diff --git a/services/match/src/app/core/security.py b/services/match/src/app/core/security.py new file mode 100644 index 0000000..6842ef3 --- /dev/null +++ b/services/match/src/app/core/security.py @@ -0,0 +1,40 @@ +from __future__ import annotations +import os +from enum import Enum +from typing import Any, Callable + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str): + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep diff --git a/services/match/src/app/main.py b/services/match/src/app/main.py index b185770..e0c339d 100644 --- a/services/match/src/app/main.py +++ b/services/match/src/app/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI from .api.routes.ping import router as ping_router +from .api.routes.pairs import router as pairs_router app = FastAPI(title="MATCH Service") @@ -7,5 +8,5 @@ app = FastAPI(title="MATCH Service") def health(): return {"status": "ok", "service": "match"} -# v1 API app.include_router(ping_router, prefix="/v1") +app.include_router(pairs_router) diff --git a/services/match/src/app/models/__init__.py b/services/match/src/app/models/__init__.py index e69de29..fbf60b8 100644 --- a/services/match/src/app/models/__init__.py +++ b/services/match/src/app/models/__init__.py @@ -0,0 +1 @@ +from .pair import MatchPair # noqa diff --git a/services/match/src/app/models/pair.py b/services/match/src/app/models/pair.py new file mode 100644 index 0000000..8847535 --- /dev/null +++ b/services/match/src/app/models/pair.py @@ -0,0 +1,22 @@ +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, Float, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class MatchPair(Base): + __tablename__ = "match_pairs" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + # User IDs to validate permissions; profile IDs можно добавить позже + user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked + score: Mapped[float | None] = mapped_column(Float, default=None) + notes: Mapped[str | None] = mapped_column(String(1000), default=None) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/services/match/src/app/repositories/pair_repository.py b/services/match/src/app/repositories/pair_repository.py new file mode 100644 index 0000000..57984b3 --- /dev/null +++ b/services/match/src/app/repositories/pair_repository.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy import select, or_ +from sqlalchemy.orm import Session + +from app.models.pair import MatchPair + +class PairRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, **fields) -> MatchPair: + obj = MatchPair(**fields) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get(self, pair_id) -> Optional[MatchPair]: + return self.db.get(MatchPair, pair_id) + + def list(self, *, for_user_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = 50) -> Sequence[MatchPair]: + stmt = select(MatchPair) + if for_user_id: + stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id)) + if status: + stmt = stmt.where(MatchPair.status == status) + stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def update(self, obj: MatchPair, **fields) -> MatchPair: + for k, v in fields.items(): + if v is not None: + setattr(obj, k, v) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, obj: MatchPair) -> None: + self.db.delete(obj) + self.db.commit() diff --git a/services/match/src/app/schemas/pair.py b/services/match/src/app/schemas/pair.py new file mode 100644 index 0000000..310798a --- /dev/null +++ b/services/match/src/app/schemas/pair.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class PairCreate(BaseModel): + user_id_a: str + user_id_b: str + score: Optional[float] = None + notes: Optional[str] = None + +class PairUpdate(BaseModel): + score: Optional[float] = None + notes: Optional[str] = None + +class PairRead(BaseModel): + id: str + user_id_a: str + user_id_b: str + status: str + score: Optional[float] = None + notes: Optional[str] = None + model_config = ConfigDict(from_attributes=True) diff --git a/services/match/src/app/services/pair_service.py b/services/match/src/app/services/pair_service.py new file mode 100644 index 0000000..d88961b --- /dev/null +++ b/services/match/src/app/services/pair_service.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional +from app.repositories.pair_repository import PairRepository +from app.models.pair import MatchPair + +class PairService: + def __init__(self, db: Session): + self.repo = PairRepository(db) + + def create(self, **fields) -> MatchPair: + return self.repo.create(**fields) + + def get(self, pair_id) -> Optional[MatchPair]: + return self.repo.get(pair_id) + + def list(self, **filters): + return self.repo.list(**filters) + + def update(self, obj: MatchPair, **fields) -> MatchPair: + return self.repo.update(obj, **fields) + + def delete(self, obj: MatchPair) -> None: + return self.repo.delete(obj) + + def set_status(self, obj: MatchPair, status: str) -> MatchPair: + return self.repo.update(obj, status=status) diff --git a/services/payments/alembic/env.py b/services/payments/alembic/env.py index df746af..034d7df 100644 --- a/services/payments/alembic/env.py +++ b/services/payments/alembic/env.py @@ -12,6 +12,7 @@ if SRC_DIR not in sys.path: sys.path.append(SRC_DIR) from app.db.session import Base # noqa +from app import models # noqa: F401 config = context.config diff --git a/services/payments/alembic/script.py.mako b/services/payments/alembic/script.py.mako new file mode 100644 index 0000000..e378537 --- /dev/null +++ b/services/payments/alembic/script.py.mako @@ -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"} diff --git a/services/payments/alembic/versions/6641523a6967_init.py b/services/payments/alembic/versions/6641523a6967_init.py new file mode 100644 index 0000000..226a638 --- /dev/null +++ b/services/payments/alembic/versions/6641523a6967_init.py @@ -0,0 +1,38 @@ +"""init + +Revision ID: 6641523a6967 +Revises: +Create Date: 2025-08-08 11:20:09.064584+00:00 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '6641523a6967' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('invoices', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('client_id', sa.UUID(), nullable=False), + sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('description', sa.String(length=500), nullable=True), + 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_invoices_client_id'), 'invoices', ['client_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invoices_client_id'), table_name='invoices') + op.drop_table('invoices') + # ### end Alembic commands ### diff --git a/services/payments/requirements.txt b/services/payments/requirements.txt index 87d1574..1b92356 100644 --- a/services/payments/requirements.txt +++ b/services/payments/requirements.txt @@ -8,3 +8,4 @@ pydantic-settings python-dotenv httpx>=0.27 pytest +PyJWT>=2.8 diff --git a/services/payments/src/app/api/routes/payments.py b/services/payments/src/app/api/routes/payments.py new file mode 100644 index 0000000..89b09ab --- /dev/null +++ b/services/payments/src/app/api/routes/payments.py @@ -0,0 +1,62 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.security import get_current_user, require_roles, UserClaims +from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead +from app.services.payment_service import PaymentService + +router = APIRouter(prefix="/v1/invoices", tags=["payments"]) + +@router.post("", response_model=InvoiceRead, status_code=201) +def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True)) + +@router.get("", response_model=list[InvoiceRead]) +def list_invoices(client_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = Query(50, le=200), + db: Session = Depends(get_db), + user: UserClaims = Depends(get_current_user)): + # Клиент видит только свои инвойсы, админ/матчмейкер — любые + if user.role in ("ADMIN","MATCHMAKER"): + return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit) + else: + return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit) + +@router.get("/{inv_id}", response_model=InvoiceRead) +def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)): + inv = PaymentService(db).get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub: + raise HTTPException(status_code=403, detail="Not allowed") + return inv + +@router.patch("/{inv_id}", response_model=InvoiceRead) +def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + return svc.update_invoice(inv, **payload.model_dump(exclude_none=True)) + +@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead) +def mark_paid(inv_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + raise HTTPException(status_code=404, detail="Not found") + return svc.mark_paid(inv) + +@router.delete("/{inv_id}", status_code=204) +def delete_invoice(inv_id: str, db: Session = Depends(get_db), + _: UserClaims = Depends(require_roles("ADMIN"))): + svc = PaymentService(db) + inv = svc.get_invoice(inv_id) + if not inv: + return + svc.delete_invoice(inv) diff --git a/services/payments/src/app/core/security.py b/services/payments/src/app/core/security.py new file mode 100644 index 0000000..6842ef3 --- /dev/null +++ b/services/payments/src/app/core/security.py @@ -0,0 +1,40 @@ +from __future__ import annotations +import os +from enum import Enum +from typing import Any, Callable + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") + +class UserClaims(BaseModel): + sub: str + email: str + role: str + type: str + exp: int + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token") + +def decode_token(token: str) -> UserClaims: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return UserClaims(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims: + return decode_token(token) + +def require_roles(*roles: str): + def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims: + if roles and user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + return dep diff --git a/services/payments/src/app/main.py b/services/payments/src/app/main.py index 58c9f4f..46148c2 100644 --- a/services/payments/src/app/main.py +++ b/services/payments/src/app/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI from .api.routes.ping import router as ping_router +from .api.routes.payments import router as payments_router app = FastAPI(title="PAYMENTS Service") @@ -7,5 +8,5 @@ app = FastAPI(title="PAYMENTS Service") def health(): return {"status": "ok", "service": "payments"} -# v1 API app.include_router(ping_router, prefix="/v1") +app.include_router(payments_router) diff --git a/services/payments/src/app/models/__init__.py b/services/payments/src/app/models/__init__.py index e69de29..ffbe1c2 100644 --- a/services/payments/src/app/models/__init__.py +++ b/services/payments/src/app/models/__init__.py @@ -0,0 +1 @@ +from .payment import Invoice # noqa diff --git a/services/payments/src/app/models/payment.py b/services/payments/src/app/models/payment.py new file mode 100644 index 0000000..5ad8684 --- /dev/null +++ b/services/payments/src/app/models/payment.py @@ -0,0 +1,20 @@ +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Numeric +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.session import Base + +class Invoice(Base): + __tablename__ = "invoices" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) + amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="USD") + status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled + description: Mapped[str | None] = mapped_column(String(500), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/services/payments/src/app/repositories/payment_repository.py b/services/payments/src/app/repositories/payment_repository.py new file mode 100644 index 0000000..0fa95cd --- /dev/null +++ b/services/payments/src/app/repositories/payment_repository.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import Optional, Sequence +from sqlalchemy.orm import Session +from sqlalchemy import select + +from app.models.payment import Invoice + +class PaymentRepository: + def __init__(self, db: Session): + self.db = db + + def create_invoice(self, **fields) -> Invoice: + obj = Invoice(**fields) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_invoice(self, inv_id) -> Optional[Invoice]: + return self.db.get(Invoice, inv_id) + + def list_invoices(self, *, client_id: str | None = None, status: str | None = None, + offset: int = 0, limit: int = 50) -> Sequence[Invoice]: + stmt = select(Invoice) + if client_id: + stmt = stmt.where(Invoice.client_id == client_id) + if status: + stmt = stmt.where(Invoice.status == status) + stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc()) + return self.db.execute(stmt).scalars().all() + + def update_invoice(self, obj: Invoice, **fields) -> Invoice: + for k, v in fields.items(): + if v is not None: + setattr(obj, k, v) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete_invoice(self, obj: Invoice) -> None: + self.db.delete(obj) + self.db.commit() diff --git a/services/payments/src/app/schemas/payment.py b/services/payments/src/app/schemas/payment.py new file mode 100644 index 0000000..7947d40 --- /dev/null +++ b/services/payments/src/app/schemas/payment.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, ConfigDict + +class InvoiceCreate(BaseModel): + client_id: str + amount: float + currency: str = "USD" + description: Optional[str] = None + +class InvoiceUpdate(BaseModel): + amount: Optional[float] = None + currency: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + +class InvoiceRead(BaseModel): + id: str + client_id: str + amount: float + currency: str + status: str + description: Optional[str] = None + model_config = ConfigDict(from_attributes=True) diff --git a/services/payments/src/app/services/payment_service.py b/services/payments/src/app/services/payment_service.py new file mode 100644 index 0000000..f279f41 --- /dev/null +++ b/services/payments/src/app/services/payment_service.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from sqlalchemy.orm import Session +from typing import Optional +from app.repositories.payment_repository import PaymentRepository +from app.models.payment import Invoice + +class PaymentService: + def __init__(self, db: Session): + self.repo = PaymentRepository(db) + + def create_invoice(self, **fields) -> Invoice: + return self.repo.create_invoice(**fields) + + def get_invoice(self, inv_id) -> Invoice | None: + return self.repo.get_invoice(inv_id) + + def list_invoices(self, **filters): + return self.repo.list_invoices(**filters) + + def update_invoice(self, obj: Invoice, **fields) -> Invoice: + return self.repo.update_invoice(obj, **fields) + + def delete_invoice(self, obj: Invoice) -> None: + return self.repo.delete_invoice(obj) + + def mark_paid(self, obj: Invoice) -> Invoice: + return self.repo.update_invoice(obj, status="paid") diff --git a/services/profiles/alembic/env.py b/services/profiles/alembic/env.py index df746af..034d7df 100644 --- a/services/profiles/alembic/env.py +++ b/services/profiles/alembic/env.py @@ -12,6 +12,7 @@ if SRC_DIR not in sys.path: sys.path.append(SRC_DIR) from app.db.session import Base # noqa +from app import models # noqa: F401 config = context.config diff --git a/services/profiles/alembic/script.py.mako b/services/profiles/alembic/script.py.mako new file mode 100644 index 0000000..e378537 --- /dev/null +++ b/services/profiles/alembic/script.py.mako @@ -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"} diff --git a/services/profiles/alembic/versions/5c69d1403313_add_fk_photos_profile_id_profiles_id.py b/services/profiles/alembic/versions/5c69d1403313_add_fk_photos_profile_id_profiles_id.py new file mode 100644 index 0000000..c5685a1 --- /dev/null +++ b/services/profiles/alembic/versions/5c69d1403313_add_fk_photos_profile_id_profiles_id.py @@ -0,0 +1,26 @@ +"""add FK photos.profile_id -> profiles.id + +Revision ID: 5c69d1403313 +Revises: 769f535c9249 +Create Date: 2025-08-08 11:43:53.014776+00:00 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '5c69d1403313' +down_revision = '769f535c9249' +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key(None, 'photos', 'profiles', ['profile_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'photos', type_='foreignkey') + # ### end Alembic commands ### diff --git a/services/profiles/alembic/versions/769f535c9249_init.py b/services/profiles/alembic/versions/769f535c9249_init.py new file mode 100644 index 0000000..6f6ba0c --- /dev/null +++ b/services/profiles/alembic/versions/769f535c9249_init.py @@ -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 ### diff --git a/services/profiles/docker-entrypoint.sh b/services/profiles/docker-entrypoint.sh index 2828898..ae2ee5e 100755 --- a/services/profiles/docker-entrypoint.sh +++ b/services/profiles/docker-entrypoint.sh @@ -3,4 +3,4 @@ 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 +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level debug diff --git a/services/profiles/requirements.txt b/services/profiles/requirements.txt index 87d1574..1b92356 100644 --- a/services/profiles/requirements.txt +++ b/services/profiles/requirements.txt @@ -8,3 +8,4 @@ pydantic-settings python-dotenv httpx>=0.27 pytest +PyJWT>=2.8 diff --git a/services/profiles/src/app/api/routes/profiles.py b/services/profiles/src/app/api/routes/profiles.py new file mode 100644 index 0000000..437b933 --- /dev/null +++ b/services/profiles/src/app/api/routes/profiles.py @@ -0,0 +1,31 @@ +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) diff --git a/services/profiles/src/app/core/security.py b/services/profiles/src/app/core/security.py new file mode 100644 index 0000000..e179799 --- /dev/null +++ b/services/profiles/src/app/core/security.py @@ -0,0 +1,59 @@ +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) diff --git a/services/profiles/src/app/db/deps.py b/services/profiles/src/app/db/deps.py new file mode 100644 index 0000000..95e6616 --- /dev/null +++ b/services/profiles/src/app/db/deps.py @@ -0,0 +1,10 @@ +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() diff --git a/services/profiles/src/app/main.py b/services/profiles/src/app/main.py index c4b5a7f..a903e4f 100644 --- a/services/profiles/src/app/main.py +++ b/services/profiles/src/app/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI from .api.routes.ping import router as ping_router +from .api.routes.profiles import router as profiles_router app = FastAPI(title="PROFILES Service") @@ -7,5 +8,5 @@ app = FastAPI(title="PROFILES Service") def health(): return {"status": "ok", "service": "profiles"} -# v1 API app.include_router(ping_router, prefix="/v1") +app.include_router(profiles_router) diff --git a/services/profiles/src/app/models/__init__.py b/services/profiles/src/app/models/__init__.py index e69de29..8e3d0d6 100644 --- a/services/profiles/src/app/models/__init__.py +++ b/services/profiles/src/app/models/__init__.py @@ -0,0 +1,2 @@ +from .profile import Profile # noqa +from .photo import Photo # noqa diff --git a/services/profiles/src/app/models/photo.py b/services/profiles/src/app/models/photo.py new file mode 100644 index 0000000..4b790cb --- /dev/null +++ b/services/profiles/src/app/models/photo.py @@ -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") diff --git a/services/profiles/src/app/models/profile.py b/services/profiles/src/app/models/profile.py new file mode 100644 index 0000000..ef84110 --- /dev/null +++ b/services/profiles/src/app/models/profile.py @@ -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") diff --git a/services/profiles/src/app/repositories/profile_repository.py b/services/profiles/src/app/repositories/profile_repository.py new file mode 100644 index 0000000..3ad39eb --- /dev/null +++ b/services/profiles/src/app/repositories/profile_repository.py @@ -0,0 +1,26 @@ +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 diff --git a/services/profiles/src/app/schemas/profile.py b/services/profiles/src/app/schemas/profile.py new file mode 100644 index 0000000..55d6123 --- /dev/null +++ b/services/profiles/src/app/schemas/profile.py @@ -0,0 +1,32 @@ +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 diff --git a/services/profiles/src/app/services/profile_service.py b/services/profiles/src/app/services/profile_service.py new file mode 100644 index 0000000..2970a7b --- /dev/null +++ b/services/profiles/src/app/services/profile_service.py @@ -0,0 +1,13 @@ +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)