From f36dd3bbc1a535fb5ec425c8122efe7452ff39a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Fri, 1 May 2026 22:30:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=81=D0=B2=D1=8F=D0=B7=D1=8C=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B2=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=20=D0=BD=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=B0=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?https://git.sabilin.com/EventHub/EventHubBack/issues/9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 67 +++++++++- docker/ApiTests.Dockerfile | 29 ++++ docker/build-images.sh | 14 ++ docker/docker-compose.swarm.yml | 191 ++++++++++++++++++++++++++ docker/docker-compose.yml | 25 +++- docker/traefik/dynamic_conf.yml | 118 ++++++++-------- include/records.hrl | 2 +- rebar.config | 4 +- src/config/vm.args | 7 +- src/eventhub_app.erl | 64 +++++---- src/infra/admin_utils.erl | 5 + src/infra/cluster_discovery.erl | 36 +++++ test/api/api_admin_tests.erl | 200 ++++++++++++++++------------ test/api/api_auth_tests.erl | 2 +- test/api/api_booking_tests.erl | 2 +- test/api/api_calendar_tests.erl | 2 +- test/api/api_event_tests.erl | 2 +- test/api/api_moderation_tests.erl | 4 +- test/api/api_reviews_tests.erl | 2 +- test/api/api_search_tests.erl | 2 +- test/api/api_subscription_tests.erl | 2 +- test/api/api_test_runner.erl | 161 +++++++++++++++------- test/api/api_tickets_tests.erl | 4 +- test/api/api_websocket_tests.erl | 61 +++++++-- test/api_SUITE.erl | 196 +++++++++++++++------------ 25 files changed, 870 insertions(+), 332 deletions(-) create mode 100644 docker/ApiTests.Dockerfile create mode 100644 docker/build-images.sh create mode 100644 docker/docker-compose.swarm.yml create mode 100644 src/infra/cluster_discovery.erl diff --git a/Makefile b/Makefile index 0edddaa..e6ad211 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,9 @@ eunit-verbose: ## Запустить EUnit тесты с подробным вы test-api: test-ct test-ct: ## Запустить Common Test для API + @echo "Cleaning old data..." + @rm -rf Mnesia.* + @rm -rf logs/test/ct/ct_run.* @$(REBAR3) ct --sname $(SNAME)_api_test test-ct-verbose: ## Запустить Common Test с подробным выводом @@ -109,6 +112,28 @@ test-ct-verbose: ## Запустить Common Test с подробным выв -logdir build \ -verbosity 50 +test-remote: + @CT_MODE=remote API_HOST=http://localhost:8080 ADMIN_API_HOST=http://localhost:8445 rebar3 ct + +test-remote-cluster: + @rm -rf logs/test/ct/ct_run.* + @CT_MODE=remote \ + API_HOST=https://api.eventhub.local/api \ + WS_HOST=wss://ws.eventhub.local \ + ADMIN_API_HOST=https://admin-api.eventhub.local/api \ + ADMIN_WS_HOST=wss://admin-ws.eventhub.local \ + rebar3 ct + +test-api-docker: + @docker build -t eventhub-tests -f docker/ApiTests.Dockerfile . + @docker run --rm \ + -e CT_MODE=remote \ + -e "API_HOST=http://eventhub:8080" \ + -e "ADMIN_API_HOST=http://eventhub:8445" \ + -e "WS_HOST=ws://eventhub:8081" \ + -e "ADMIN_WS_HOST=ws://eventhub:8446" \ + eventhub-tests + test-scripts: ## Запустить тесты с фильтром (make test-runner PATTERN=booking) @chmod +x test/scripts/*.sh @cd test/scripts && ./run_tests.sh $(PATTERN) @@ -128,24 +153,24 @@ tsung-test: ## Запустить нагрузочный тест Tsung @echo "Отчёт: logs/tsung/*/report.html" wrk-register: ## Нагрузочный тест регистрации (wrk2) - @wrk -t4 -c100 -d30s -t100 -s test/wrk/scripts/wrk_register.lua http://localhost/v1/register + @wrk -t4 -c100 -d30s -t100 -s test/wrk/scripts/wrk_register.lua https://api.eventhub.local/api/v1/register wrk-search: ## Нагрузочный тест поиска (wrk2) - @TOKEN=$$(curl -s -X POST http://localhost:8080/v1/register \ + @TOKEN=$$(curl -s -X POST https://api.eventhub.local/api/v1/register \ -H "Content-Type: application/json" \ -d '{"email":"wrktest@test.com","password":"pass"}' | \ grep -o '"token":"[^"]*"' | cut -d'"' -f4); \ wrk -t4 -c100 -d30s -t200 \ -H "Authorization: Bearer $$TOKEN" \ - http://localhost:8080/v1/search?type=event\&q=test + https://api.eventhub.local/api/v1/search?type=event\&q=test curl-health: - for i in {1..120}; do curl -k -s -o /dev/null -w "%{http_code}\n" -H "Host: api.eventhub.local" https://localhost/health; done + for i in {1..2}; do curl -k -s -o /dev/null -w "%{http_code}\n" -H "Host: api.eventhub.local" https://localhost/api/health; done wrk-health: ## Нагрузочный тест health (wrk2) wrk -t4 -c100 -d30s -t100 \ -H "Host: api.eventhub.local" \ - https://api.eventhub.local/health + https://api.eventhub.local/api/health # ============================================================================ # CODE QUALITY @@ -251,6 +276,38 @@ docker-clean: docker-stop ## Очистить Docker образы и volumes @docker volume rm eventhub-data 2>/dev/null || true @echo "✅ Docker очищен" +docker-swarm-deploy: ## Запустить кластер + RELEASE_COOKIE=$$(grep RELEASE_COOKIE docker/.env | cut -d '=' -f2) \ + JWT_SECRET=$$(grep JWT_SECRET docker/.env | cut -d '=' -f2) \ + docker stack deploy -c docker/docker-compose.swarm.yml eventhub + @echo "✅ Кластер запущен" + +docker-swarm-stop: ## Запустить кластер + @docker stack rm eventhub + @docker volume prune -f + @echo "✅ Кластер удален" + +docker-swarm-scale: ## Изменить количество реплик (например, make scale REPLICAS=5) + @echo "Масштабирование до ${REPLICAS} реплик..." + docker service scale eventhub_eventhub=${REPLICAS} + @echo "✅ Сервис масштабирован" + +docker-swarm-status: ## Показать состояние кластера + @echo "Количество узлов в кластере:" + @docker exec $$(docker ps -qf "name=eventhub_eventhub" | head -n 1) /app/bin/eventhub eval 'length(nodes()).' + @echo "Список узлов:" + @docker exec $$(docker ps -qf "name=eventhub_eventhub" | head -n 1) /app/bin/eventhub eval 'nodes().' + +docker-swarm-reg-admin: + @docker exec $$(docker ps -qf "name=eventhub_eventhub" | head -n 1) /app/bin/eventhub eval 'core_admin:create(<<"admin">>,<<"123456">>,superadmin).' + +docker-swarm-check-admin: + @docker exec $$(docker ps -qf "name=eventhub_eventhub" | head -n 1) /app/bin/eventhub eval 'eventhub_auth:authenticate_admin_request(<<"">>,<<"admin@eventhub.local">>,<<"123456">>).' + +docker-swarm-shell: + @docker exec $$(docker ps -qf "name=eventhub_eventhub" | head -n 1) /app/bin/eventhub remote_console + + # ============================================================================ # UTILITIES # ============================================================================ diff --git a/docker/ApiTests.Dockerfile b/docker/ApiTests.Dockerfile new file mode 100644 index 0000000..7b11427 --- /dev/null +++ b/docker/ApiTests.Dockerfile @@ -0,0 +1,29 @@ +# ============================================================ +# Одноэтапный Dockerfile (сборка и рантайм в одном образе) +# ============================================================ +FROM erlang:28-alpine + +# Устанавливаем инструменты для сборки и runtime-зависимости +RUN apk add \ + # для сборки + #git curl gcc + make musl-dev \ + rust cargo openssl-dev libsodium-dev + #\ + # для рантайма + #openssl libstdc++ libgcc ncurses-libs + +# Рабочая директория +#RUN mkdir -p log/test/ct + +# Копируем конфигурацию и исходники +COPY rebar.config ./ +COPY include/ include/ +COPY src/ src/ +COPY test/api_SUITE.erl test/ +COPY test/api/ test/api/ + +# Компилируем и запускаем тесты +RUN rebar3 compile + +CMD rebar3 ct --sname ci_api_test -v \ No newline at end of file diff --git a/docker/build-images.sh b/docker/build-images.sh new file mode 100644 index 0000000..b5cd48b --- /dev/null +++ b/docker/build-images.sh @@ -0,0 +1,14 @@ +# Основной образ EventHub (единственный для нод) +docker build -t eventhub:latest -f docker/Dockerfile . + +# Сервис заглушка +docker build -t fallback:latest -f docker/fallback/Dockerfile docker/fallback + +# Observer Web +docker build -t observer_web:latest -f docker/ObserverWeb.Dockerfile . + +# Logrotate +docker build -t logrotate:latest -f docker/logrotate/Dockerfile docker/logrotate + +# Admin UI – из соседней папки EventHubFrontAdmin +docker build -t admin-ui:latest -f ../EventHubFrontAdmin/Dockerfile ../EventHubFrontAdmin \ No newline at end of file diff --git a/docker/docker-compose.swarm.yml b/docker/docker-compose.swarm.yml new file mode 100644 index 0000000..639b766 --- /dev/null +++ b/docker/docker-compose.swarm.yml @@ -0,0 +1,191 @@ +# docker/docker-compose.swarm.yml +version: "3.8" + +services: + # ================== Балансировщик ================== + traefik: + image: traefik:latest + command: +# - "--log.level=DEBUG" + - "--api.insecure=true" + - "--providers.docker=true" +# - "--providers.swarm.endpoint=unix:///var/run/docker.sock" # провайдер Swarm + - "--providers.docker.exposedbydefault=false" + - "--providers.file.filename=/etc/traefik/dynamic_conf.yml" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--metrics.prometheus=true" + - "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0" + - "--metrics.prometheus.addEntryPointsLabels=true" + - "--metrics.prometheus.addServicesLabels=true" + - "--accesslog=true" + - "--accesslog.filepath=/var/log/traefik/access.log" + - "--accesslog.format=json" + - "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" + - "--experimental.plugins.coraza.version=v0.3.0" + ports: + - "80:80" + - "443:443" + - "8080:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./traefik/certs:/etc/traefik/certs:ro" + - "./traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro" + - "traefik-logs:/var/log/traefik" + - "traefik-plugins:/plugins-storage" + networks: + - eventhub-net + deploy: + replicas: 1 + restart_policy: + condition: any + + # ================== Сервис заглушка ================== + fallback: + image: fallback:latest + networks: + - eventhub-net + deploy: + replicas: 0 + restart_policy: + condition: any + + # ================== Кластер EventHub ================== + eventhub: + image: eventhub:latest + hostname: "eventhub-node{{.Task.Slot}}" + environment: + - NODE_NAME=eventhub-node{{.Task.Slot}} + - RELEASE_COOKIE=${RELEASE_COOKIE:-eventhub_cookie} + - JWT_SECRET=${JWT_SECRET:-eventhub_top_secret} + - ADMIN_SUPER_EMAIL=${ADMIN_SUPER_EMAIL:-superadmin@eventhub.local} + - ADMIN_SUPER_PASSWORD=${ADMIN_SUPER_PASSWORD:-SuperAdmin123!} + - ADMIN_MODER_EMAIL=${ADMIN_MODER_EMAIL:-moderator@eventhub.local} + - ADMIN_MODER_PASSWORD=${ADMIN_MODER_PASSWORD:-Moderator123!} + - ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL:-support@eventhub.local} + - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD:-Support123!} + networks: + eventhub-net: + aliases: + - eventhub-node + volumes: + - eventhub-data:/app/data + deploy: + replicas: 1 + endpoint_mode: dnsrr + restart_policy: + condition: any + labels: + - "traefik.enable=true" + + # ================== Admin UI ================== + admin-ui: + image: admin-ui:latest + networks: + - eventhub-net + deploy: + replicas: 0 + restart_policy: + condition: any + labels: + - "traefik.enable=true" + + # ================== Мониторинг ================== + prometheus: + image: prom/prometheus:latest + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + - '--storage.tsdb.retention.time=30d' + - '--storage.tsdb.retention.size=15GB' + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + networks: + - eventhub-net + ports: + - "9090:9090" + deploy: + replicas: 0 + restart_policy: + condition: any + + grafana: + image: grafana/grafana:latest + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_SECURITY_DISABLE_INITIAL_ADMIN_PASSWORD_CHANGE=false + - GF_USERS_ALLOW_SIGN_UP=false + - GF_AUTH_ANONYMOUS_ENABLED=false + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/etc/grafana/dashboards + - grafana-data:/var/lib/grafana + networks: + - eventhub-net + ports: + - "3000:3000" + deploy: + replicas: 0 + restart_policy: + condition: any + + # ================== Аналитика логов ================== + loglynx: + image: k0lin/loglynx:latest + user: root + ports: + - "6123:6123" + volumes: + - traefik-logs:/app/traefik/logs:ro + - loglynx-data:/app/data + environment: + - TRAEFIK_LOG_PATH=${TRAEFIK_LOG_PATH} + - SERVER_PORT=6123 + - DATABASE_PATH=/app/data/loglynx.db + networks: + - eventhub-net + deploy: + replicas: 0 + restart_policy: + condition: any + + # ================== Инструмент отладки ================== + observer_web: + image: observer_web:latest + environment: + - RELEASE_COOKIE=${RELEASE_COOKIE} + networks: + - eventhub-net + ports: + - "4000:4000" + deploy: + replicas: 0 + restart_policy: + condition: any + + # ================== Ротация логов ================== + logrotate: + image: logrotate:latest + volumes: + - traefik-logs:/var/log/traefik:rw + networks: + - eventhub-net + deploy: + replicas: 0 + restart_policy: + condition: any + +networks: + eventhub-net: + driver: overlay + +volumes: + eventhub-data: + prometheus-data: + grafana-data: + traefik-logs: + loglynx-data: + traefik-plugins: \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c45e8e2..3438283 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -45,6 +45,7 @@ services: networks: - eventhub-net restart: unless-stopped + profiles: ['ci'] # ================== Кластер EventHub (3 ноды) ================== eventhub-node1: @@ -56,9 +57,10 @@ services: - NODE_NAME=eventhub-node1@eventhub-node1.local - RELEASE_COOKIE=${RELEASE_COOKIE} - JWT_SECRET=${JWT_SECRET} - - JOIN_NODES=eventhub-node1@eventhub-node1.local,eventhub-node2@eventhub-node2.local,eventhub-node3@eventhub-node3.local networks: - - eventhub-net + eventhub-net: + aliases: + - eventhub-node # ← общий алиас для DNS-лукапа volumes: - eventhub-node1-data:/app/data labels: @@ -74,9 +76,10 @@ services: - NODE_NAME=eventhub-node2@eventhub-node2.local - RELEASE_COOKIE=${RELEASE_COOKIE} - JWT_SECRET=${JWT_SECRET} - - JOIN_NODES=eventhub-node1@eventhub-node1.local,eventhub-node2@eventhub-node2.local,eventhub-node3@eventhub-node3.local networks: - - eventhub-net + eventhub-net: + aliases: + - eventhub-node # ← общий алиас для DNS-лукапа volumes: - eventhub-node2-data:/app/data labels: @@ -92,9 +95,10 @@ services: - NODE_NAME=eventhub-node3@eventhub-node3.local - RELEASE_COOKIE=${RELEASE_COOKIE} - JWT_SECRET=${JWT_SECRET} - - JOIN_NODES=eventhub-node1@eventhub-node1.local,eventhub-node2@eventhub-node2.local,eventhub-node3@eventhub-node3.local networks: - - eventhub-net + eventhub-net: + aliases: + - eventhub-node # ← общий алиас для DNS-лукапа volumes: - eventhub-node3-data:/app/data labels: @@ -108,9 +112,11 @@ services: dockerfile: Dockerfile networks: - eventhub-net - restart: unless-stopped +# restart: unless-stopped + restart: no labels: - "traefik.enable=true" + profiles: ['ui'] # ================== Мониторинг ================== prometheus: image: prom/prometheus:latest @@ -129,6 +135,7 @@ services: ports: - "9090:9090" restart: unless-stopped + profiles: ['ci'] grafana: image: grafana/grafana:latest @@ -146,6 +153,7 @@ services: ports: - "3000:3000" restart: unless-stopped + profiles: ['ci'] # ================== Аналитика логов ================== loglynx: @@ -163,6 +171,7 @@ services: - DATABASE_PATH=/app/data/loglynx.db networks: - eventhub-net + profiles: ['ci'] # ================== Инструмент отладки ================== observer_web: @@ -176,6 +185,7 @@ services: ports: - "4000:4000" restart: unless-stopped + profiles: ['ci'] # ================== Ротация логов Traefik ================== logrotate: @@ -187,6 +197,7 @@ services: networks: - eventhub-net restart: unless-stopped + profiles: ['ci'] networks: eventhub-net: diff --git a/docker/traefik/dynamic_conf.yml b/docker/traefik/dynamic_conf.yml index ea411e0..c053798 100644 --- a/docker/traefik/dynamic_conf.yml +++ b/docker/traefik/dynamic_conf.yml @@ -9,50 +9,58 @@ tls: keyFile: /etc/traefik/certs/traefik.key http: + serversTransports: + http1-ws-transport: + disableHTTP2: true + insecureSkipVerify: true + middlewares: redirect-to-https: redirectScheme: scheme: https permanent: true + strip-api-prefix: + stripPrefix: + prefixes: + - "/api" + waf: plugin: coraza: directives: - # - "SecRuleEngine DetectionOnly" # можно раскомментировать для тестирования - "SecRuleEngine On" - "SecDebugLog /dev/stdout" - "SecDebugLogLevel 2" - # - "SecRule REQUEST_URI \"@rx /admin\" \"id:101,phase:1,log,deny,status:403\"" - "SecRule ARGS \"@rx (union|select|insert|drop|alter)\" \"id:102,phase:2,log,deny,status:403\"" api-ratelimit: rateLimit: - average: 100 + average: 5000 period: 1m - burst: 50 + burst: 500 admin-ratelimit: rateLimit: - average: 20 + average: 5000 period: 1m - burst: 5 + burst: 500 routers: - # --- REST API пользователей --- + # Пользовательский REST API api: rule: "Host(`api.eventhub.local`)" entryPoints: ["web"] - middlewares: ["redirect-to-https", "api-ratelimit", "waf"] + middlewares: ["redirect-to-https", "strip-api-prefix", "api-ratelimit", "waf"] service: "api" api-secure: rule: "Host(`api.eventhub.local`)" entryPoints: ["websecure"] tls: true - middlewares: ["api-ratelimit", "waf"] + middlewares: ["strip-api-prefix", "api-ratelimit", "waf"] service: "api" - # --- WebSocket пользователей (без WAF) --- + # Пользовательский WebSocket ws: rule: "Host(`ws.eventhub.local`)" entryPoints: ["web"] @@ -64,45 +72,56 @@ http: tls: true service: "ws" - # --- Админ-панель (SPA) --- + # Админский REST API + admin-api: + rule: "Host(`admin-api.eventhub.local`)" + entryPoints: ["web"] + middlewares: ["redirect-to-https", "strip-api-prefix", "admin-ratelimit", "waf"] + service: "admin-api" + admin-api-secure: + rule: "Host(`admin-api.eventhub.local`)" + entryPoints: ["websecure"] + tls: true + middlewares: ["strip-api-prefix", "admin-ratelimit", "waf"] + service: "admin-api" + + # Админский WebSocket + admin-ws: + rule: "Host(`admin-ws.eventhub.local`)" + entryPoints: ["web"] + middlewares: ["redirect-to-https"] + service: "admin-ws" + admin-ws-secure: + rule: "Host(`admin-ws.eventhub.local`)" + entryPoints: ["websecure"] + tls: true + service: "admin-ws" + + # Админский UI admin-ui: - rule: "Host(`admin.eventhub.local`) && !PathPrefix(`/api/`) && !PathPrefix(`/ws/`)" + rule: "Host(`admin-ui.eventhub.local`)" entryPoints: ["web"] middlewares: ["redirect-to-https"] service: "admin-ui-service" admin-ui-secure: - rule: "Host(`admin.eventhub.local`) && !PathPrefix(`/api/`) && !PathPrefix(`/ws/`)" + rule: "Host(`admin-ui.eventhub.local`)" entryPoints: ["websecure"] tls: true service: "admin-ui-service" - # --- Проксирование /api/ на админский REST --- - admin-api-proxy: - rule: "Host(`admin.eventhub.local`) && PathPrefix(`/api/`)" - entryPoints: ["web"] - middlewares: ["redirect-to-https", "admin-ratelimit", "waf"] - service: "admin-api" - admin-api-proxy-secure: - rule: "Host(`admin.eventhub.local`) && PathPrefix(`/api/`)" - entryPoints: ["websecure"] - tls: true - middlewares: ["admin-ratelimit", "waf"] - service: "admin-api" - - # --- Проксирование /ws/ на админский WebSocket --- - admin-ws-proxy: - rule: "Host(`admin.eventhub.local`) && PathPrefix(`/ws/`)" + # Клиентский UI + client-ui: + rule: "Host(`ui.eventhub.local`)" entryPoints: ["web"] middlewares: ["redirect-to-https"] - service: "admin-ws" - admin-ws-proxy-secure: - rule: "Host(`admin.eventhub.local`) && PathPrefix(`/ws/`)" + service: "client-ui-service" + client-ui-secure: + rule: "Host(`ui.eventhub.local`)" entryPoints: ["websecure"] tls: true - service: "admin-ws" + service: "client-ui-service" services: - # --- Пользовательский REST API (failover) --- api: failover: service: api-live @@ -110,9 +129,7 @@ http: api-live: loadbalancer: servers: - - url: "http://eventhub-node1:8080" - - url: "http://eventhub-node2:8080" - - url: "http://eventhub-node3:8080" + - url: "http://eventhub:8080" healthCheck: path: "/health" interval: "10s" @@ -122,15 +139,12 @@ http: servers: - url: "http://fallback:80" - # --- Пользовательский WebSocket --- ws: loadbalancer: servers: - - url: "http://eventhub-node1:8081" - - url: "http://eventhub-node2:8081" - - url: "http://eventhub-node3:8081" + - url: "http://eventhub:8081" + serversTransport: http1-ws-transport - # --- Админский REST (failover) --- admin-api: failover: service: admin-api-live @@ -138,11 +152,9 @@ http: admin-api-live: loadbalancer: servers: - - url: "http://eventhub-node1:8445" - - url: "http://eventhub-node2:8445" - - url: "http://eventhub-node3:8445" + - url: "http://eventhub:8445" healthCheck: - path: "/health" + path: "/v1/admin/health" interval: "10s" timeout: "3s" admin-api-fallback: @@ -150,16 +162,18 @@ http: servers: - url: "http://fallback:80" - # --- Админский WebSocket --- admin-ws: loadbalancer: servers: - - url: "http://eventhub-node1:8446" - - url: "http://eventhub-node2:8446" - - url: "http://eventhub-node3:8446" + - url: "http://eventhub:8446" + serversTransport: http1-ws-transport - # --- SPA (админ‑панель) --- admin-ui-service: loadbalancer: servers: - - url: "http://admin-ui:80" \ No newline at end of file + - url: "http://admin-ui:80" + + client-ui-service: + loadbalancer: + servers: + - url: "http://client-ui:80" \ No newline at end of file diff --git a/include/records.hrl b/include/records.hrl index 93feab4..8d4c493 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -26,7 +26,7 @@ id :: binary(), email :: binary(), password_hash :: binary(), - role :: superadmin | moderator | support, + role :: superadmin | admin | moderator | support, status :: active | blocked, created_at :: calendar:datetime(), updated_at :: calendar:datetime() diff --git a/rebar.config b/rebar.config index bb963d0..3a8a0b9 100644 --- a/rebar.config +++ b/rebar.config @@ -8,7 +8,7 @@ {jose, "1.11.10"}, {argon2, "1.2.0"}, {meck, "0.9.2"}, - {gun, "2.0.0"}, + {gun, "2.2.0"}, {prometheus_cowboy, "0.2.0"} ]}. @@ -44,7 +44,7 @@ {ct_opts, [ {src_dirs, ["src", "test/api"]}, {sys_config, ["config/sys.config"]}, % Load app config - {logdir, "_build/test/ct"}, % Where to put HTML reports + {logdir, "logs/test/ct"}, % Where to put HTML reports {verbose, true} % Print more info to console ]}. diff --git a/src/config/vm.args b/src/config/vm.args index afea6c5..cc7b26b 100644 --- a/src/config/vm.args +++ b/src/config/vm.args @@ -1,3 +1,6 @@ --name ${NODE_NAME} +-sname ${NODE_NAME} -setcookie ${RELEASE_COOKIE} --kernel inet_dist_use_interface {0,0,0,0} \ No newline at end of file ++K true ++A 8 +-kernel inet_dist_listen_min 2370 +-kernel inet_dist_listen_max 2370 \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 82d1ab7..e1395e4 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -10,12 +10,19 @@ start(_StartType, _StartArgs) -> {ok, Pid} -> ok = infra_mnesia:init_tables(), ok = infra_mnesia:wait_for_tables(), - connect_nodes(), - init_default_superadmin(), + % Включаем авто‑обнаружение только в режиме remote/swarm + ClusterMode = os:getenv("CLUSTER_MODE", "local"), + if ClusterMode =:= "swarm" orelse ClusterMode =:= "remote" -> + spawn(fun cluster_discovery:discover/0); + true -> + % Локальный запуск – подключаемся по старинке или вообще не подключаемся + ok + end, start_http(), % Пользовательский API (8080) start_admin_http(), % Административный API (8445) application:ensure_all_started(prometheus), application:ensure_all_started(prometheus_cowboy), + init_default_admins(), {ok, Pid}; Error -> Error @@ -114,34 +121,33 @@ start_admin_http() -> io:format("WebSocket started on ports 8081 (user) and 8446 (admin)~n"). -%% =================================================================== -%% Ручное подключение к нодам кластера (запасной вариант) -%% =================================================================== -connect_nodes() -> - case os:getenv("JOIN_NODES") of - false -> ok; - NodesStr -> - Nodes = [list_to_atom(string:trim(N)) || N <- string:tokens(NodesStr, ",")], - lists:foreach(fun(Node) -> - case net_kernel:connect_node(Node) of - true -> io:format("Connected to ~s~n", [Node]); - false -> io:format("ERROR: Failed to connect to ~s~n", [Node]); - ignored -> ok - end - end, Nodes) - end. - -init_default_superadmin() -> +%% ---------- Инициализация администраторов ---------- +init_default_admins() -> case core_admin:list_all() of [] -> - AdminEmail = os:getenv("ADMIN_EMAIL", "admin@eventhub.local"), - AdminPassword = os:getenv("ADMIN_PASSWORD", "123456"), - {ok, _Admin} = core_admin:create( - list_to_binary(AdminEmail), - list_to_binary(AdminPassword), - superadmin - ), - io:format("Default superadmin created: ~s~n", [AdminEmail]); + % Суперадмин + SuperEmail = list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin2@eventhub.local")), + SuperPass = list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")), + {ok, _} = core_admin:create(SuperEmail, SuperPass, superadmin), + io:format("Default superadmin created: ~s~n", [SuperEmail]), + + % Админ + AdminEmail = list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")), + AdminPass = list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")), + {ok, _} = core_admin:create(AdminEmail, AdminPass, admin), + io:format("Default admin created: ~s~n", [AdminEmail]), + + % Модератор + ModerEmail = list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")), + ModerPass = list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")), + {ok, _} = core_admin:create(ModerEmail, ModerPass, moderator), + io:format("Default moderator created: ~s~n", [ModerEmail]), + + % Поддержка + SupportEmail = list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local")), + SupportPass = list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456")), + {ok, _} = core_admin:create(SupportEmail, SupportPass, support), + io:format("Default support created: ~s~n", [SupportEmail]); _ -> - io:format("Superadmin already exists. Skipping creation.~n") + io:format("Admins already exist. Skipping creation.~n") end. \ No newline at end of file diff --git a/src/infra/admin_utils.erl b/src/infra/admin_utils.erl index 467361f..033ecb2 100644 --- a/src/infra/admin_utils.erl +++ b/src/infra/admin_utils.erl @@ -30,6 +30,11 @@ get_permissions(superadmin) -> <<"manage_calendars">>, <<"manage_reviews">>, <<"manage_reports">>, <<"manage_tickets">>, <<"manage_banned_words">>, <<"view_stats">>, <<"view_audit">>]; +get_permissions(admin) -> + [<<"manage_users">>, <<"manage_events">>, + <<"manage_calendars">>, <<"manage_reviews">>, <<"manage_reports">>, + <<"manage_tickets">>, <<"manage_banned_words">>, <<"view_stats">>, + <<"view_audit">>]; get_permissions(moderator) -> [<<"manage_events">>, <<"manage_calendars">>, <<"manage_reviews">>, <<"manage_reports">>, <<"manage_tickets">>, <<"manage_banned_words">>, diff --git a/src/infra/cluster_discovery.erl b/src/infra/cluster_discovery.erl new file mode 100644 index 0000000..ef11faa --- /dev/null +++ b/src/infra/cluster_discovery.erl @@ -0,0 +1,36 @@ +-module(cluster_discovery). +-export([discover/0]). + +-define(DNS_ALIAS, "eventhub-node"). +-define(RETRY_INTERVAL, 5000). + +discover() -> + io:format("Starting cluster DNS discovery via epmd (~s)...~n", [?DNS_ALIAS]), + discover_loop(). + +discover_loop() -> + case inet:getaddrs(?DNS_ALIAS, inet) of + {ok, IPs} when is_list(IPs) -> + lists:foreach(fun(IP) -> + IPStr = inet:ntoa(IP), +%% io:format("Checking epmd on ~s...~n", [IPStr]), + case erl_epmd:names(IP) of + {ok, List} -> + lists:foreach(fun({Name, _Port}) -> + Node = list_to_atom(Name ++ "@" ++ Name), +%% io:format(" Trying net_kernel:connect_node(~s)...~n", [Node]), + case net_kernel:connect_node(Node) of + true -> ok; %io:format(" *** Connected to ~s ***~n", [Node]); + false -> io:format(" *** Failed to connect to ~s ***~n", [Node]); + ignored -> ok + end + end, List); + {error, Reason} -> + io:format(" epmd error on ~s: ~p~n", [IPStr, Reason]) + end + end, IPs); + {error, Reason} -> + io:format("DNS lookup failed (~p), retrying...~n", [Reason]) + end, + timer:sleep(?RETRY_INTERVAL), + discover_loop(). \ No newline at end of file diff --git a/test/api/api_admin_tests.erl b/test/api/api_admin_tests.erl index 9c95db7..e3b28e5 100644 --- a/test/api/api_admin_tests.erl +++ b/test/api/api_admin_tests.erl @@ -1,173 +1,203 @@ -module(api_admin_tests). - --include_lib("eunit/include/eunit.hrl"). -export([test/0]). -test() -> - io:format("Testing admin panel API...~n"), - AdminURL = "http://localhost:8445", +%% Учётные данные по умолчанию (используются, если словарь процесса пуст) +-define(FALLBACK_ADMIN_SUPER_EMAIL, <<"superadmin@eventhub.local">>). +-define(FALLBACK_ADMIN_SUPER_PASSWORD, <<"123456">>). +-define(FALLBACK_ADMIN_MODER_EMAIL, <<"moderator@eventhub.local">>). +-define(FALLBACK_ADMIN_MODER_PASSWORD, <<"123456">>). +-define(FALLBACK_ADMIN_SUPPORT_EMAIL, <<"support@eventhub.local">>). +-define(FALLBACK_ADMIN_SUPPORT_PASSWORD,<<"123456">>). - % Получаем admin-токен через test runner (уже проверенный) +test() -> + ct:pal("Testing admin panel API...~n"), + AdminURL = api_test_runner:get_admin_url(), + UserURL = api_test_runner:get_base_url(), + + % Получаем токен суперадмина (уже проинициализирован в api_test_runner) AdminToken = api_test_runner:get_admin_token(), %% TEST 1: Admin healthcheck (public) - io:format(" TEST 1: Admin healthcheck... "), + ct:pal(" TEST 1: Admin healthcheck... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/health", []}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), %% TEST 2: Admin login (дополнительная проверка) - io:format(" TEST 2: Admin login (attempt)... "), - LoginBody = jsx:encode(#{<<"email">> => <<"admin@eventhub.local">>, <<"password">> => <<"123456">>}), + ct:pal(" TEST 2: Admin login (attempt)... "), + % Теперь используем суперадмина, который гарантированно создан + LoginBody = jsx:encode(#{<<"email">> => ?FALLBACK_ADMIN_SUPER_EMAIL, <<"password">> => ?FALLBACK_ADMIN_SUPER_PASSWORD}), case httpc:request(post, {AdminURL ++ "/v1/admin/login", [], "application/json", LoginBody}, [], []) of {ok, {{_, 200, _}, _, _}} -> - io:format("OK (logged in)~n"); + ct:pal("OK (logged in)~n"); _ -> - io:format("SKIPPED (credentials not found, using runner token)~n") + ct:pal("SKIPPED (credentials not found, using runner token)~n") end, %% TEST 3: Admin stats (superadmin) - io:format(" TEST 3: Admin stats (superadmin)... "), - LoginBody = jsx:encode(#{<<"email">> => <<"admin@eventhub.local">>, <<"password">> => <<"123456">>}), - {ok, {{_, 200, _}, _, LoginResp}} = httpc:request(post, - {AdminURL ++ "/v1/admin/login", [], "application/json", LoginBody}, [], []), - #{<<"token">> := SuperToken} = jsx:decode(list_to_binary(LoginResp), [return_maps]), - - % Без дат + ct:pal(" TEST 3: Admin stats (superadmin)... "), {ok, {{_, 200, _}, _, StatsResp1}} = httpc:request(get, - {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(SuperToken)}]}, [], []), + {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), Stats1 = jsx:decode(list_to_binary(StatsResp1), [return_maps]), - io:format(" OK (keys: ~p)~n", [maps:keys(Stats1)]), - - % С датами - {ok, {{_, 200, _}, _, StatsResp2}} = httpc:request(get, - {AdminURL ++ "/v1/admin/stats?from=2026-01-01T00:00:00&to=2026-12-31T23:59:59", - [{"Authorization", "Bearer " ++ binary_to_list(SuperToken)}]}, [], []), - Stats2 = jsx:decode(list_to_binary(StatsResp2), [return_maps]), - io:format(" (with dates, keys: ~p)~n", [maps:keys(Stats2)]), + ct:pal(" OK (keys: ~p)~n", [maps:keys(Stats1)]), %% TEST 4: List users - io:format(" TEST 4: List users... "), + ct:pal(" TEST 4: List users... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), %% TEST 5: Get user by ID - io:format(" TEST 5: Get user by ID... "), + ct:pal(" TEST 5: Get user by ID... "), UserId = api_test_runner:get_user_id(), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/users/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), %% TEST 6: List reports - io:format(" TEST 6: List reports... "), + ct:pal(" TEST 6: List reports... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 7: List banned words - io:format(" TEST 7: List banned words... "), + %% ── TEST 7: Full moderation flow (create event, report, resolve) ── + ct:pal(" TEST 7: Moderation flow... "), + + % Создаём календарь и событие от имени пользователя + UserToken = api_test_runner:get_user_token(), + CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ModerationTest">>}), + EventId = api_test_runner:create_event(UserToken, CalId, #{ + title => <<"Event to report">>, + start_time => api_SUITE:future_date(), + duration => 60 + }), + + % Подаём жалобу на это событие + CreateBody = jsx:encode(#{ + <<"target_type">> => <<"event">>, + <<"target_id">> => EventId, + <<"reason">> => <<"Inappropriate content">> + }), + {ok, {{_, 201, _}, _, CreateResp}} = httpc:request(post, + {UserURL ++ "/v1/reports", + [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], + "application/json", CreateBody}, [], []), + #{<<"id">> := ReportId} = jsx:decode(list_to_binary(CreateResp), [return_maps]), + + % Администратор изменяет статус жалобы + EditBody = jsx:encode(#{ + <<"status">> => <<"reviewed">>, + <<"reason">> => <<"Issue resolved">> + }), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, + {AdminURL ++ "/v1/admin/reports/" ++ binary_to_list(ReportId), + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], + "application/json", EditBody}, [], []), + ct:pal("OK~n"), + + %% TEST 8: List banned words + ct:pal(" TEST 8: List banned words... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 8: Add banned word - io:format(" TEST 8: Add banned word... "), + %% TEST 9: Add banned word + ct:pal(" TEST 9: Add banned word... "), BannedWordBody = jsx:encode(#{<<"word">> => <<"test_banned_word">>}), {ok, {{_, 201, _}, _, _}} = httpc:request(post, {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", BannedWordBody}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 9: Delete banned word - io:format(" TEST 9: Delete banned word... "), + %% TEST 10: Delete banned word + ct:pal(" TEST 10: Delete banned word... "), {ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/banned-words/test_banned_word", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 10: List tickets - io:format(" TEST 10: List tickets... "), + %% TEST 11: List tickets + ct:pal(" TEST 11: List tickets... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 11: Create ticket - io:format(" TEST 11: Create ticket... "), + %% TEST 12: Create ticket + ct:pal(" TEST 12: Create ticket... "), TicketBody = jsx:encode(#{<<"error_message">> => <<"Test error">>, <<"stacktrace">> => <<"trace">>}), {ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", TicketBody}, [], []), #{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 12: Get ticket by ID - io:format(" TEST 12: Get ticket by ID... "), + %% TEST 13: Get ticket by ID + ct:pal(" TEST 13: Get ticket by ID... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 13: Update ticket - io:format(" TEST 13: Update ticket... "), + %% TEST 14: Update ticket + ct:pal(" TEST 14: Update ticket... "), UpdateTicketBody = jsx:encode(#{<<"status">> => <<"closed">>}), {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateTicketBody}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 14: Delete ticket - io:format(" TEST 14: Delete ticket... "), + %% TEST 15: Delete ticket + ct:pal(" TEST 15: Delete ticket... "), {ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 15: Ticket stats - io:format(" TEST 15: Ticket stats... "), + %% TEST 16: Ticket stats + ct:pal(" TEST 16: Ticket stats... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 16: List subscriptions - io:format(" TEST 16: List subscriptions... "), + %% TEST 17: List subscriptions + ct:pal(" TEST 17: List subscriptions... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 17: Create subscription - io:format(" TEST 17: Create subscription... "), + %% TEST 18: Create subscription + ct:pal(" TEST 18: Create subscription... "), SubBody = jsx:encode(#{<<"user_id">> => UserId, <<"plan">> => <<"monthly">>}), {ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", SubBody}, [], []), #{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 18: Get subscription by ID - io:format(" TEST 18: Get subscription by ID... "), + %% TEST 19: Get subscription by ID + ct:pal(" TEST 19: Get subscription by ID... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 19: Update subscription - io:format(" TEST 19: Update subscription... "), + %% TEST 20: Update subscription + ct:pal(" TEST 20: Update subscription... "), UpdateSubBody = jsx:encode(#{<<"status">> => <<"cancelled">>}), {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateSubBody}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 20: Delete subscription - io:format(" TEST 20: Delete subscription... "), + %% TEST 21: Delete subscription + ct:pal(" TEST 21: Delete subscription... "), {ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 21: Moderation - block user - io:format(" TEST 21: Moderation - block user... "), - ModBody = jsx:encode(#{<<"action">> => <<"block">>}), + %% TEST 22: Moderation - block user + ct:pal(" TEST 22: Moderation - block user... "), + ModBody = jsx:encode(#{<<"action">> => <<"block">>, <<"reason">> => <<"test">>}), {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", ModBody}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - %% TEST 22: Moderation - unblock user - io:format(" TEST 22: Moderation - unblock user... "), - UnblockBody = jsx:encode(#{<<"action">> => <<"unblock">>}), + %% TEST 23: Moderation - unblock user + ct:pal(" TEST 23: Moderation - unblock user... "), + UnblockBody = jsx:encode(#{<<"action">> => <<"unblock">>, <<"reason">> => <<"restore">>}), {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UnblockBody}, [], []), - io:format("OK~n"), + ct:pal("OK~n"), - io:format("~n✅ Admin API tests passed!~n"), + ct:pal("~n✅ Admin API tests passed!~n"), {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_auth_tests.erl b/test/api/api_auth_tests.erl index 46f0ad0..c6ceadd 100644 --- a/test/api/api_auth_tests.erl +++ b/test/api/api_auth_tests.erl @@ -1,7 +1,7 @@ -module(api_auth_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). +-define(BASE_URL, api_test_runner:get_base_url()). test() -> io:format("Testing authentication API...~n"), diff --git a/test/api/api_booking_tests.erl b/test/api/api_booking_tests.erl index 860e2f8..c9b2234 100644 --- a/test/api/api_booking_tests.erl +++ b/test/api/api_booking_tests.erl @@ -1,7 +1,7 @@ -module(api_booking_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). +-define(BASE_URL, api_test_runner:get_base_url()). test() -> io:format("Testing booking API...~n"), diff --git a/test/api/api_calendar_tests.erl b/test/api/api_calendar_tests.erl index 2fd86d8..1747e7f 100644 --- a/test/api/api_calendar_tests.erl +++ b/test/api/api_calendar_tests.erl @@ -1,7 +1,7 @@ -module(api_calendar_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). +-define(BASE_URL, api_test_runner:get_base_url()). test() -> io:format("Testing calendar API...~n"), diff --git a/test/api/api_event_tests.erl b/test/api/api_event_tests.erl index 2caf373..c5f36b8 100644 --- a/test/api/api_event_tests.erl +++ b/test/api/api_event_tests.erl @@ -1,7 +1,7 @@ -module(api_event_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). +-define(BASE_URL, api_test_runner:get_base_url()). test() -> io:format("Testing event API...~n"), diff --git a/test/api/api_moderation_tests.erl b/test/api/api_moderation_tests.erl index 5dbd89e..94dfcf3 100644 --- a/test/api/api_moderation_tests.erl +++ b/test/api/api_moderation_tests.erl @@ -1,8 +1,8 @@ -module(api_moderation_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). --define(ADMIN_BASE_URL, "http://localhost:8445"). +-define(BASE_URL, api_test_runner:get_base_url()). +-define(ADMIN_BASE_URL, api_test_runner:get_admin_url()). test() -> io:format("Testing moderation API...~n"), diff --git a/test/api/api_reviews_tests.erl b/test/api/api_reviews_tests.erl index ea13ffd..f54c0e9 100644 --- a/test/api/api_reviews_tests.erl +++ b/test/api/api_reviews_tests.erl @@ -1,7 +1,7 @@ -module(api_reviews_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). +-define(BASE_URL, api_test_runner:get_base_url()). test() -> io:format("Testing reviews API...~n"), diff --git a/test/api/api_search_tests.erl b/test/api/api_search_tests.erl index 6f551fc..bde21c9 100644 --- a/test/api/api_search_tests.erl +++ b/test/api/api_search_tests.erl @@ -1,7 +1,7 @@ -module(api_search_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). +-define(BASE_URL, api_test_runner:get_base_url()). test() -> io:format("Testing search API...~n"), diff --git a/test/api/api_subscription_tests.erl b/test/api/api_subscription_tests.erl index dea69d1..7407dce 100644 --- a/test/api/api_subscription_tests.erl +++ b/test/api/api_subscription_tests.erl @@ -1,7 +1,7 @@ -module(api_subscription_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). +-define(BASE_URL, api_test_runner:get_base_url()). test() -> io:format("Testing subscription API...~n"), diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index 7e8ec8a..204f948 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -1,67 +1,132 @@ -module(api_test_runner). - --include("records.hrl"). - -export([run_all/0, run/1]). -export([http_post/2, http_post/3, http_get/1, http_get/2, http_put/3, http_delete/2]). -export([extract_json/2, extract_json/3, assert_status/2]). -export([unique_email/1, register_and_login/2, create_calendar/2, create_event/3]). --export([get_admin_token/0, get_admin_id/0, get_user_token/0, get_user_id/0]). +-export([get_admin_token/0, get_admin_id/0, get_user_token/0, get_user_id/0, get_admin_url/0, get_base_url/0, get_admin_ws_url/0, get_base_ws_url/0]). -export([wait_for_server/0]). --define(BASE_URL, "http://localhost:8080"). --define(ADMIN_URL, "http://localhost:8445"). +-define(BASE_URL, base_url()). +-define(ADMIN_URL, admin_base_url()). -%% ============ Глобальные переменные для тестов ============ --define(ADMIN_EMAIL, <<"admin@eventhub.local">>). --define(ADMIN_PASSWORD, <<"123456">>). --define(USER_EMAIL, <<"global_user@test.com">>). --define(USER_PASSWORD, <<"user123">>). +%% Учётные данные по умолчанию (используются в локальном режиме, если словарь пуст) +-define(FALLBACK_ADMIN_EMAIL, <<"admin@eventhub.local">>). +-define(FALLBACK_ADMIN_PASSWORD, <<"123456">>). +-define(USER_EMAIL, <<"global_user@test.com">>). +-define(USER_PASSWORD, <<"user123">>). + +%% ------------------------------------------------------------------ +%% Выбор базовых URL в зависимости от режима запуска +%% ------------------------------------------------------------------ +base_url() -> + case os:getenv("CT_MODE", "local") of + "remote" -> os:getenv("API_HOST", "http://localhost:8080"); + _ -> "http://localhost:8080" + end. + +base_ws_url() -> + case os:getenv("CT_MODE", "local") of + "remote" -> os:getenv("WS_HOST", "ws://localhost:8081"); + _ -> "ws://localhost:8081" + end. + +admin_base_url() -> + case os:getenv("CT_MODE", "local") of + "remote" -> os:getenv("ADMIN_API_HOST", "http://localhost:8445"); + _ -> "http://localhost:8445" + end. + +admin_ws_url() -> + case os:getenv("CT_MODE", "local") of + "remote" -> os:getenv("ADMIN_WS_HOST", "ws://localhost:8446"); + _ -> "ws://localhost:8446" + end. + +%% ------------------------------------------------------------------ +%% Инициализация глобальных тестовых пользователей +%% ------------------------------------------------------------------ +init_global_urls() -> + put(admin_url, admin_base_url()), + put(admin_ws_url, admin_ws_url()), + put(base_url, base_url()), + put(base_ws_url, base_ws_url()). -%% ============ Инициализация ============ init_global_users() -> case get(admin_token) of undefined -> - io:format("~n=== Initializing global test users ===~n"), + ct:pal("~n=== Initializing global test users ===~n"), - % ---------- АДМИНИСТРАТОР ---------- - % Проверяем, существует ли админ в таблице admin - case core_admin:get_by_email(?ADMIN_EMAIL) of - {ok, Admin} -> - io:format("Admin already exists: ~s~n", [Admin#admin.id]), - ok; - {error, not_found} -> - % Создаём суперадмина напрямую - {ok, Admin} = core_admin:create(?ADMIN_EMAIL, ?ADMIN_PASSWORD, superadmin), - io:format("Admin created: ~s~n", [Admin#admin.id]) - end, + %% 1. Администратор + AdminEmail = get(admin_super_email), + AdminPassword = get(admin_super_password), + AdminToken = + if + AdminEmail =/= undefined, AdminPassword =/= undefined -> + %% Учётные данные переданы из api_SUITE (remote‑режим) – просто логинимся + login_admin(AdminEmail, AdminPassword); + true -> + %% Локальный режим: админы уже есть, логинимся под суперадмином + login_admin(?FALLBACK_ADMIN_EMAIL, ?FALLBACK_ADMIN_PASSWORD) + end, - % Логинимся через админский API - LoginBody = jsx:encode(#{<<"email">> => ?ADMIN_EMAIL, <<"password">> => ?ADMIN_PASSWORD}), - {ok, {{_, 200, _}, _, LoginResp}} = httpc:request(post, - {?ADMIN_URL ++ "/v1/admin/login", [], "application/json", LoginBody}, [], []), - #{<<"token">> := AdminToken, <<"user">> := #{<<"id">> := AdminId}} = - jsx:decode(list_to_binary(LoginResp), [return_maps]), + %% Получаем ID администратора через /v1/admin/me + MeUrl = ?ADMIN_URL ++ "/v1/admin/me", + {ok, {{_, 200, _}, _, MeBody}} = httpc:request(get, + {MeUrl, [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, ssl_opts(), []), + #{<<"id">> := AdminId} = jsx:decode(list_to_binary(MeBody), [return_maps]), put(admin_token, AdminToken), put(admin_id, AdminId), - % ---------- ПОЛЬЗОВАТЕЛЬ ---------- + %% 2. Обычный пользователь UserToken = register_and_login(?USER_EMAIL, ?USER_PASSWORD), - {ok, {{_, 200, _}, _, UserMeResp}} = http_get("/v1/user/me", UserToken), - #{<<"id">> := UserId} = jsx:decode(list_to_binary(UserMeResp), [return_maps]), + {ok, {{_, 200, _}, _, UserMeBody}} = http_get("/v1/user/me", UserToken), + #{<<"id">> := UserId} = jsx:decode(list_to_binary(UserMeBody), [return_maps]), put(user_token, UserToken), put(user_id, UserId), - io:format("Admin ID: ~s, User ID: ~s~n", [AdminId, UserId]), - io:format("=== Global users initialized ===~n~n"), + ct:pal("Admin ID: ~s, User ID: ~s~n", [AdminId, UserId]), + ct:pal("=== Global users initialized ===~n~n"), ok; _ -> - io:format("Global users already initialized.~n"), + ct:pal("Global users already initialized.~n"), ok end. +%% ------------------------------------------------------------------ +%% Вход администратора (используется, когда учётки уже известны) +%% ------------------------------------------------------------------ +login_admin(Email, Password) -> + ct:pal("Admin url: ~s~n", [?ADMIN_URL]), + ct:pal("Admin: ~s, password: ~s~n", [Email, Password]), + LoginBody = jsx:encode(#{<<"email">> => Email, <<"password">> => Password}), + ct:pal("url: ~s, body: ~s~n", [?ADMIN_URL ++ "/v1/admin/login", LoginBody]), + {ok, {{_, _, _}, _, LoginResp}} = httpc:request(post, + {?ADMIN_URL ++ "/v1/admin/login", [], "application/json", LoginBody}, ssl_opts(), []), + ct:pal("LoginResp: ~s~n", [LoginResp]), + #{<<"token">> := Token} = jsx:decode(list_to_binary(LoginResp), [return_maps]), + Token. + +%% ------------------------------------------------------------------ +%% Остальные функции (без изменений, только используют ?BASE_URL / ?ADMIN_URL) +%% ------------------------------------------------------------------ +get_admin_url() -> + init_global_urls(), + get(admin_url). + +get_admin_ws_url() -> + init_global_urls(), + get(admin_ws_url). + +get_base_url() -> + init_global_urls(), + get(base_url). + +get_base_ws_url() -> + init_global_urls(), + get(base_ws_url). + get_admin_token() -> init_global_users(), get(admin_token). @@ -78,19 +143,18 @@ get_user_id() -> init_global_users(), get(user_id). -%% ============ Главные функции запуска ============ run_all() -> inets:start(), ssl:start(), case wait_for_server() of ok -> ok; - {error, _} -> io:format("❌ Server is not running!~n"), exit(server_not_running) + {error, _} -> ct:pal("❌ Server is not running!~n"), exit(server_not_running) end, init_global_users(), - io:format("Starting API tests...~n"), + ct:pal("Starting API tests...~n"), Modules = [ api_auth_tests, api_calendar_tests, @@ -111,14 +175,17 @@ run(Module) -> init_global_users(), Module:test(). -%% ============ HTTP запросы ============ +%% ── HTTP‑запросы ───────────────────────────────────────── +ssl_opts() -> + [{ssl, [{verify, verify_none}]}]. + http_post(Url, Body) -> http_post(Url, Body, undefined). http_post(Url, Body, Token) -> Headers = case Token of undefined -> [{"Content-Type", "application/json"}]; _ -> [{"Content-Type", "application/json"}, {"Authorization", "Bearer " ++ binary_to_list(Token)}] end, - httpc:request(post, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, [], []). + httpc:request(post, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, ssl_opts(), []). http_get(Url) -> http_get(Url, undefined). http_get(Url, Token) -> @@ -126,18 +193,17 @@ http_get(Url, Token) -> undefined -> []; _ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}] end, - httpc:request(get, {?BASE_URL ++ Url, Headers}, [], []). + httpc:request(get, {?BASE_URL ++ Url, Headers}, ssl_opts(), []). http_put(Url, Body, Token) -> Headers = [{"Content-Type", "application/json"}, {"Authorization", "Bearer " ++ binary_to_list(Token)}], - httpc:request(put, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, [], []). + httpc:request(put, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, ssl_opts(), []). http_delete(Url, Token) -> Headers = [{"Authorization", "Bearer " ++ binary_to_list(Token)}], - httpc:request(delete, {?BASE_URL ++ Url, Headers}, [], []). - -%% ============ Утилиты ============ + httpc:request(delete, {?BASE_URL ++ Url, Headers}, ssl_opts(), []). +%% ── Вспомогательные функции ────────────────────────────── extract_json({ok, {{_, 200, _}, _, Body}}, Field) -> Map = jsx:decode(list_to_binary(Body), [return_maps]), maps:get(Field, Map); @@ -170,7 +236,6 @@ register_and_login(Email, Password) -> Map = jsx:decode(list_to_binary(RegResp), [return_maps]), maps:get(<<"token">>, Map); {ok, {{_, 409, _}, _, _}} -> - % Уже существует - логинимся LoginBody = #{email => Email, password => Password}, {ok, {{_, 200, _}, _, LoginResp}} = http_post("/v1/login", LoginBody), Map = jsx:decode(list_to_binary(LoginResp), [return_maps]), @@ -189,7 +254,7 @@ create_event(Token, CalId, Params) -> wait_for_server() -> wait_for_server(30). wait_for_server(0) -> {error, timeout}; wait_for_server(Attempts) -> - case httpc:request(get, {?BASE_URL ++ "/health", []}, [], [{timeout, 1000}]) of + case httpc:request(get, {?BASE_URL ++ "/health", []}, ssl_opts(), [{timeout, 1000}]) of {ok, {{_, 200, _}, _, _}} -> ok; _ -> timer:sleep(1000), wait_for_server(Attempts - 1) end. \ No newline at end of file diff --git a/test/api/api_tickets_tests.erl b/test/api/api_tickets_tests.erl index fdfed4d..5de79d6 100644 --- a/test/api/api_tickets_tests.erl +++ b/test/api/api_tickets_tests.erl @@ -1,8 +1,8 @@ -module(api_tickets_tests). -export([test/0]). --define(ADMIN_BASE_URL, "http://localhost:8445"). --define(BASE_URL, "http://localhost:8080"). +-define(ADMIN_BASE_URL, api_test_runner:get_admin_url()). +-define(BASE_URL, api_test_runner:get_base_url()). test() -> io:format("Testing tickets API...~n"), diff --git a/test/api/api_websocket_tests.erl b/test/api/api_websocket_tests.erl index faec2b7..60837f4 100644 --- a/test/api/api_websocket_tests.erl +++ b/test/api/api_websocket_tests.erl @@ -1,9 +1,9 @@ -module(api_websocket_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). --define(WS_URL, "ws://localhost:8081/ws"). --define(ADMIN_WS_URL, "ws://localhost:8446/admin/ws"). +-define(BASE_URL, api_test_runner:get_base_url()). +-define(WS_URL, api_test_runner:get_base_ws_url() ++ "/ws"). +-define(ADMIN_WS_URL, api_test_runner:get_admin_ws_url() ++ "/admin/ws"). test() -> ct:pal("Testing WebSocket API..."), @@ -141,14 +141,24 @@ test_ws_connect_debug(Url, Token) -> "/ws?token=" ++ binary_to_list(Token) end, - Port = ws_port(Url), - Host = "localhost", + {ok, Port} = extract_port(Url), + {ok, Host} = extract_host(Url), + + Opts = case Port of + 443 -> + #{ + protocols => [http], + transport => tls, + tls_opts => [{verify, verify_none}] + }; + _ -> #{ protocols => [http] } + end, ct:pal(" Host: ~s", [Host]), ct:pal(" Port: ~p", [Port]), ct:pal(" Path: ~s", [Path]), - {ok, ConnPid} = gun:open(Host, Port, #{protocols => [http]}), + {ok, ConnPid} = gun:open(Host, Port, Opts), {ok, http} = gun:await_up(ConnPid, 5000), Headers = [ @@ -234,5 +244,40 @@ test_ws_recv(ConnPid, Timeout) -> test_ws_close(ConnPid) -> gun:close(ConnPid). -ws_port("ws://localhost:8081" ++ _) -> 8081; -ws_port("ws://localhost:8446" ++ _) -> 8446. \ No newline at end of file +%% ========== URL parsing helpers ========== + +normalize_scheme(S) when is_binary(S) -> S; +normalize_scheme(S) when is_list(S) -> list_to_binary(S); +normalize_scheme(S) when is_atom(S) -> atom_to_binary(S, utf8); +normalize_scheme(_) -> <<"unknown">>. + +extract_host(Url) -> + try + Parsed = uri_string:parse(Url), + #{scheme := SchemeRaw, host := Host} = Parsed, + Scheme = normalize_scheme(SchemeRaw), + if Scheme =:= <<"ws">>; Scheme =:= <<"wss">> -> ok; + true -> throw({invalid_scheme, SchemeRaw}) + end, + HostStr = if is_binary(Host) -> binary_to_list(Host); true -> Host end, + {ok, HostStr} + catch + Class:Reason:Stacktrace -> + {error, {parse_error, {Class, Reason}, Stacktrace}} + end. + +extract_port(Url) -> + try + Parsed = uri_string:parse(Url), + #{scheme := SchemeRaw} = Parsed, + Scheme = normalize_scheme(SchemeRaw), + DefaultPort = if Scheme =:= <<"ws">> -> 80; Scheme =:= <<"wss">> -> 443; true -> throw({invalid_scheme, SchemeRaw}) end, + case maps:find(port, Parsed) of + {ok, P} when is_integer(P) -> {ok, P}; + {ok, P} -> {ok, try list_to_integer(binary_to_list(normalize_scheme(P))) catch _:_ -> DefaultPort end}; + error -> {ok, DefaultPort} + end + catch + Class:Reason:Stacktrace -> + {error, {parse_error, {Class, Reason}, Stacktrace}} + end. \ No newline at end of file diff --git a/test/api_SUITE.erl b/test/api_SUITE.erl index 8ab70af..88877a3 100644 --- a/test/api_SUITE.erl +++ b/test/api_SUITE.erl @@ -2,97 +2,129 @@ -include_lib("common_test/include/ct.hrl"). -export([all/0, init_per_suite/1, end_per_suite/1]). --export([auth_test/1, calendar_test/1, event_test/1, booking_test/1]). --export([search_test/1, reviews_test/1, moderation_test/1]). --export([tickets_test/1, subscription_test/1, admin_test/1]). --export([websocket_test/1]). +-export([auth_test/1, calendar_test/1, event_test/1, booking_test/1, + search_test/1, reviews_test/1, moderation_test/1, tickets_test/1, + subscription_test/1, admin_test/1, websocket_test/1]). +-export([future_date/0]). -all() -> [ - auth_test, - calendar_test, - event_test, - booking_test, - search_test, - reviews_test, - moderation_test, - tickets_test, - subscription_test, - admin_test, - websocket_test -]. +all() -> + [ + auth_test, + calendar_test, + event_test, + booking_test, + search_test, + reviews_test, + moderation_test, + tickets_test, + subscription_test, + admin_test, + websocket_test + ]. init_per_suite(Config) -> - % Очищаем Mnesia перед тестами - io:format("~n=== Cleaning Mnesia for fresh test run ===~n"), - os:cmd("rm -rf Mnesia.* 2>/dev/null || true"), - timer:sleep(2000), - % Запускаем сервер - io:format("Starting server...~n"), - {ok, _Apps} = application:ensure_all_started(eventhub), + ct:pal("Start Api Testing ~n"), + Mode = os:getenv("CT_MODE", "local"), + ct:pal(" Mode: ~s", [Mode]), + AdminURL = os:getenv("ADMIN_API_HOST"), + ct:pal(" AdminURL: ~s", [AdminURL]), + AdminWsURL = os:getenv("ADMIN_WS_HOST"), + ct:pal(" AdminWsURL: ~s", [AdminWsURL]), + UserURL = os:getenv("API_HOST"), + ct:pal(" UserURL: ~s", [UserURL]), + UserWsURL = os:getenv("WS_HOST"), + ct:pal(" UserWsURL: ~s", [UserWsURL]), - % Компилируем модули из test/api/ - code:add_patha("_build/test/lib/eventhub/ebin"), - code:add_patha("test/api"), + case Mode of + "remote" -> + inets:start(), + ssl:start(), + % Отключаем авто-редирект и проверку сертификатов + httpc:set_options([ + {autoredirect, false}, + {ssl, [{verify, verify_none}]} + ]), + wait_for_server(), + timer:sleep(1000), + % Извлекаем учётные данные администраторов из переменных окружения + % и сохраняем их в словаре процесса для api_test_runner + put(admin_super_email, + list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local"))), + put(admin_super_password, + list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456"))), + put(admin_moder_email, + list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local"))), + put(admin_moder_password, + list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456"))), + put(admin_support_email, + list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local"))), + put(admin_support_password, + list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456"))), + Config; + _ -> + application:ensure_all_started(eventhub), + timer:sleep(3000), + check_admins(), + Config + end. - % Компилируем все файлы в test/api/ - compile_api_modules(), - - inets:start(), - ssl:start(), - - %% Perform healthcheck (simplified) - Url = "http://localhost:8080", - case httpc:request(get, {Url ++ "/health", []}, [], []) of - {ok, {{_Version, 200, _Reason}, _Headers, _Body}} -> - ok; %% Healthcheck passed - _Error -> - ct:log("Healthcheck failed for: ~p", [Url]), - error(healthcheck_failed) +end_per_suite(Config) -> + Mode = os:getenv("CT_MODE", "local"), + case Mode of + "remote" -> + ok; + _ -> + application:stop(eventhub) end, Config. -end_per_suite(_Config) -> - application:stop(eventhub), - ok. +%% ── Тестовые обёртки ────────────────────────────────── +auth_test(_) -> api_auth_tests:test(). +calendar_test(_) -> api_calendar_tests:test(). +event_test(_) -> api_event_tests:test(). +booking_test(_) -> api_booking_tests:test(). +search_test(_) -> api_search_tests:test(). +reviews_test(_) -> api_reviews_tests:test(). +moderation_test(_) -> api_moderation_tests:test(). +tickets_test(_) -> api_tickets_tests:test(). +subscription_test(_) -> api_subscription_tests:test(). +admin_test(_) -> api_admin_tests:test(). +websocket_test(_) -> api_websocket_tests:test(). -compile_api_modules() -> - Files = filelib:wildcard("test/api/*.erl"), - lists:foreach(fun(File) -> - compile:file(File, [report, {outdir, "test/api"}]) - end, Files), - code:add_patha("test/api"). +%% @doc Проверка наличия администраторов (только в remote‑режиме) +%% Если таблица admin пуста – роняем тест явно, чтобы не гадать. +check_admins() -> + case core_admin:list_all() of + [] -> + ct:fail("No admins found in remote cluster. Run init_default_admins first."); + Admins -> + ct:pal("Admins present: ~p", [length(Admins)]) + end. -%% ============ ТЕСТЫ-ПРОКСИ ============ +%% @doc Ожидание доступности healthcheck-эндпоинта (/health) +wait_for_server() -> + URL = case os:getenv("API_HOST") of + false -> "http://localhost:8080/health"; + Host -> Host ++ "/health" + end, + wait_for_server(URL, 30). -auth_test(_Config) -> - api_auth_tests:test(). +wait_for_server(URL, 0) -> + ct:fail("Healthcheck ~s not responding after 30 seconds", [URL]); +wait_for_server(URL, Attempts) -> + case httpc:request(get, {URL, []}, [{timeout, 2000}, {ssl, [{verify, verify_none}]}], []) of + {ok, {{_, 200, _}, _, _}} -> + ct:pal("Healthcheck OK", []); + _ -> + timer:sleep(1000), + wait_for_server(URL, Attempts - 1) + end. -calendar_test(_Config) -> - api_calendar_tests:test(). - -event_test(_Config) -> - api_event_tests:test(). - -booking_test(_Config) -> - api_booking_tests:test(). - -search_test(_Config) -> - api_search_tests:test(). - -reviews_test(_Config) -> - api_reviews_tests:test(). - -moderation_test(_Config) -> - api_moderation_tests:test(). - -tickets_test(_Config) -> - api_tickets_tests:test(). - -subscription_test(_Config) -> - api_subscription_tests:test(). - -admin_test(_Config) -> - api_admin_tests:test(). - -websocket_test(_Config) -> - api_websocket_tests:test(). \ No newline at end of file +future_date() -> + Now = calendar:universal_time(), + Tomorrow = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(Now) + 86400 + ), + {{Y, M, D}, {H, Min, S}} = Tomorrow, + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Y, M, D, H, Min, S])). \ No newline at end of file