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

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

417
scripts/api_e2e.py Normal file
View File

@@ -0,0 +1,417 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import base64
import json
import logging
import os
import random
import string
import sys
import time
from dataclasses import dataclass
from logging.handlers import RotatingFileHandler
from typing import Any, Dict, Iterable, List, Optional, Tuple
from urllib.parse import urljoin
import requests
from faker import Faker
# -------------------------
# Конфигурация по умолчанию
# -------------------------
DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
# -------------------------
# Логирование
# -------------------------
def setup_logger(path: str) -> logging.Logger:
os.makedirs(os.path.dirname(path), exist_ok=True)
logger = logging.getLogger("api_e2e")
logger.setLevel(logging.DEBUG)
# Ротация логов: до 5 файлов по 5 МБ
file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
))
logger.addHandler(file_handler)
# Консоль — INFO и короче
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
logger.addHandler(console)
return logger
# -------------------------
# Утилиты
# -------------------------
def b64url_json(token_part: str) -> Dict[str, Any]:
"""Декодирует часть JWT (payload) без валидации сигнатуры."""
s = token_part + "=" * (-len(token_part) % 4)
return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
def decode_jwt_sub(token: str) -> str:
try:
payload = b64url_json(token.split(".")[1])
return str(payload.get("sub", "")) # UUID пользователя
except Exception:
return ""
def mask_token(token: Optional[str]) -> str:
if not token:
return ""
return token[:12] + "..." if len(token) > 12 else token
def now_ms() -> int:
return int(time.time() * 1000)
@dataclass
class UserCreds:
id: str
email: str
access_token: str
role: str
# -------------------------
# Класс-клиент
# -------------------------
class APIE2E:
def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
self.base_url = base_url.rstrip("/") + "/"
self.logger = logger
self.timeout = timeout
self.sess = requests.Session()
self.urls = {
"auth": urljoin(self.base_url, "auth/"),
"profiles": urljoin(self.base_url, "profiles/"),
"match": urljoin(self.base_url, "match/"),
"chat": urljoin(self.base_url, "chat/"),
"payments": urljoin(self.base_url, "payments/"),
}
# --------- низкоуровневый запрос с логированием ----------
def req(self, method, url, token=None, body=None, expected=(200,), name=None):
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
log_body = {}
if body:
log_body = dict(body)
for key in list(log_body.keys()):
if key.lower() in ("password", "token", "access_token", "refresh_token"):
log_body[key] = "***hidden***"
started = now_ms()
self.logger.debug(
f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}"
)
try:
resp = self.sess.request(method=method, url=url, json=body, timeout=self.timeout)
except Exception as e:
duration = now_ms() - started
self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)")
raise
text = resp.text or ""
try:
data = resp.json() if text else {}
except ValueError:
data = {}
duration = now_ms() - started
self.logger.debug(f"{resp.status_code} in {duration} ms | body={text[:2000]}")
if expected and resp.status_code not in expected:
msg = f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}"
self.logger.error(msg)
raise RuntimeError(msg)
return resp.status_code, data, text
# --------- health ----------
def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
self.logger.info(f"Waiting {name} health: {url}")
deadline = time.time() + timeout_sec
while time.time() < deadline:
try:
code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
if code == 200:
self.logger.info(f"{name} is healthy")
return
except Exception:
pass
time.sleep(1)
raise TimeoutError(f"{name} not healthy in time: {url}")
# --------- auth ----------
def login(self, email: str, password: str) -> Tuple[str, str]:
url = urljoin(self.urls["auth"], "v1/token")
_, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
token = data.get("access_token", "")
if not token:
raise RuntimeError("access_token is empty")
user_id = decode_jwt_sub(token)
if not user_id:
raise RuntimeError("cannot decode user id (sub) from token")
return user_id, token
def register(self, email: str, password: str, full_name: str, role: str) -> None:
url = urljoin(self.urls["auth"], "v1/register")
# /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
# поэтому не падаем на 500 сразу, а логинимся ниже.
try:
self.req(
"POST",
url,
body={"email": email, "password": password, "full_name": full_name, "role": role},
expected=(200, 201),
name="register",
)
except RuntimeError as e:
self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
# 1) пробуем логин
try:
uid, token = self.login(email, password)
self.logger.info(f"Login OK: {email} -> {uid}")
return UserCreds(id=uid, email=email, access_token=token, role=role)
except Exception as e:
self.logger.info(f"Login failed for {email}: {e}; will try register")
# 2) регистрируем (не фатально, если вернулся 500)
self.register(email, password, full_name, role)
# 3) снова логин
uid, token = self.login(email, password)
self.logger.info(f"Registered+Login OK: {email} -> {uid}")
return UserCreds(id=uid, email=email, access_token=token, role=role)
# --------- profiles ----------
def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
url = urljoin(self.urls["profiles"], "v1/profiles/me")
code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
return code, data
def create_profile(
self,
token: str,
gender: str,
city: str,
languages: List[str],
interests: List[str],
) -> Dict[str, Any]:
url = urljoin(self.urls["profiles"], "v1/profiles")
_, data, _ = self.req(
"POST",
url,
token=token,
body={"gender": gender, "city": city, "languages": languages, "interests": interests},
expected=(200, 201),
name="profiles/create",
)
return data
def ensure_profile(
self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
) -> Dict[str, Any]:
code, p = self.get_my_profile(token)
if code == 200:
self.logger.info(f"Profile exists: id={p.get('id')}")
return p
self.logger.info("Profile not found -> creating")
p = self.create_profile(token, gender, city, languages, interests)
self.logger.info(f"Profile created: id={p.get('id')}")
return p
# --------- match ----------
def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
url = urljoin(self.urls["match"], "v1/pairs")
_, data, _ = self.req(
"POST",
url,
token=admin_token,
body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
expected=(200, 201),
name="match/create_pair",
)
return data
# --------- chat ----------
def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
url = urljoin(self.urls["chat"], "v1/rooms")
_, data, _ = self.req(
"POST",
url,
token=admin_token,
body={"title": title, "participants": participants},
expected=(200, 201),
name="chat/create_room",
)
return data
def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
_, data, _ = self.req(
"POST",
url,
token=admin_token,
body={"content": content},
expected=(200, 201),
name="chat/send_message",
)
return data
# --------- payments ----------
def create_invoice(
self, admin_token: str, client_id: str, amount: float, currency: str, description: str
) -> Dict[str, Any]:
url = urljoin(self.urls["payments"], "v1/invoices")
_, data, _ = self.req(
"POST",
url,
token=admin_token,
body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
expected=(200, 201),
name="payments/create_invoice",
)
return data
def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
_, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
return data
# -------------------------
# Генерация данных
# -------------------------
GENDERS = ["female", "male", "other"]
CITIES = [
"Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
"Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
]
LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
def pick_languages(n: int = 2) -> List[str]:
n = max(1, min(n, len(LANG_POOL)))
return sorted(random.sample(LANG_POOL, n))
def pick_interests(n: int = 3) -> List[str]:
n = max(1, min(n, len(INTR_POOL)))
return sorted(random.sample(INTR_POOL, n))
def random_email(prefix: str, domain: str) -> str:
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
# -------------------------
# Основной сценарий
# -------------------------
def main():
import argparse
parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
args = parser.parse_args()
random.seed(args.seed)
fake = Faker()
logger = setup_logger(args.log_file)
logger.info("=== API E2E START ===")
logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
if args.clients < 2:
logger.error("Нужно минимум 2 клиента (для пары).")
sys.exit(2)
api = APIE2E(args.base_url, logger)
# Health checks через gateway
api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
api.wait_health("match", urljoin(api.urls["match"], "health"))
api.wait_health("chat", urljoin(api.urls["chat"], "health"))
api.wait_health("payments", urljoin(api.urls["payments"], "health"))
# Админ
admin_email = random_email("admin", args.email_domain)
admin_full = fake.name()
admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
# Клиенты
clients: List[UserCreds] = []
for i in range(args.clients):
email = random_email(f"user{i+1}", args.email_domain)
full = fake.name()
u = api.login_or_register(email, args.password, full, role="CLIENT")
clients.append(u)
# Профили для всех
for i, u in enumerate([admin] + clients, start=1):
gender = random.choice(GENDERS)
city = random.choice(CITIES)
languages = pick_languages(random.choice([1, 2, 3]))
interests = pick_interests(random.choice([2, 3, 4]))
logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
api.ensure_profile(u.access_token, gender, city, languages, interests)
# Matchпара между двумя случайными клиентами
a, b = random.sample(clients, 2)
score = round(random.uniform(0.6, 0.98), 2)
pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
pair_id = str(pair.get("id", ""))
logger.info(f"Match pair created: id={pair_id}, {a.email}{b.email}, score={score}")
# Чат‑комната и сообщение
room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
room_id = str(room.get("id", ""))
msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
msg_id = str(msg.get("id", ""))
logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
# Счёт для первого клиента
amount = random.choice([99.0, 199.0, 299.0])
inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
description="Consultation (e2e)")
inv_id = str(inv.get("id", ""))
invp = api.mark_invoice_paid(admin.access_token, inv_id)
logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
# Итог
summary = {
"admin": {"email": admin.email, "id": admin.id},
"clients": [{"email": c.email, "id": c.id} for c in clients],
"pair_id": pair_id,
"room_id": room_id,
"message_id": msg_id,
"invoice_id": inv_id,
"invoice_status": invp.get("status"),
}
logger.info("=== SUMMARY ===")
logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)

208
scripts/e2e.sh Executable file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -Eeuo pipefail
BASE_URL="${BASE_URL:-http://localhost:8080}"
AUTH="$BASE_URL/auth"; PROFILES="$BASE_URL/profiles"; MATCH="$BASE_URL/match"; CHAT="$BASE_URL/chat"; PAYMENTS="$BASE_URL/payments"
GW_HEALTH_PATH="${GW_HEALTH_PATH:-/auth/health}"
NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m'
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
require(){ command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required" >&2; exit 1; }; }
require curl; require python3
log(){ echo -e "${C}[$(date +%H:%M:%S)]${NC} $*" >&2; }
ok(){ echo -e "${G}${NC} $*" >&2; }
warn(){ echo -e "${Y}${NC} $*" >&2; }
fail(){ echo -e "${R}${NC} $*" >&2; exit 1; }
json_get(){ python3 - "$1" "$2" <<'PY'
import sys, json, os
f,p=sys.argv[1],sys.argv[2]
if not os.path.exists(f): print(""); sys.exit(0)
try: data=json.load(open(f))
except: print(""); sys.exit(0)
cur=data
for k in p.split('.'):
if isinstance(cur,list):
try:k=int(k)
except: print(""); sys.exit(0)
cur=cur[k] if 0<=k<len(cur) else None
elif isinstance(cur,dict):
cur=cur.get(k)
else: cur=None
if cur is None: break
print("" if cur is None else cur)
PY
}
jwt_get(){ # jwt_get <token> <claim>
python3 - "$1" "$2" <<'PY'
import sys, json, base64
t, claim = sys.argv[1], sys.argv[2]
try:
b = t.split('.')[1]
b += '=' * (-len(b) % 4)
payload = json.loads(base64.urlsafe_b64decode(b).decode())
print(payload.get(claim,""))
except Exception:
print("")
PY
}
http_req(){
local METHOD="$1"; shift; local URL="$1"; shift
local TOKEN="${1:-}"; shift || true
local BODY="${1:-}"; shift || true
local RESP="${TMP_DIR}/resp_$(date +%s%N).json"
local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}")
[[ -n "$TOKEN" ]] && args+=(-H "Authorization: Bearer $TOKEN")
[[ -n "$BODY" ]] && args+=(-H "Content-Type: application/json" -d "$BODY")
local CODE; CODE="$(curl "${args[@]}" || true)"
[[ -e "$RESP" ]] || : > "$RESP"
echo "$CODE|$RESP"
}
expect_code(){ [[ "$2" == *"|${1}|"* || "$2" == "${1}|"* || "$2" == *"|${1}" || "$2" == "${1}" ]]; }
wait_http(){
local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}"
log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})"
for((i=1;i<=TRIES;i++)); do
local CODE; CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)"
if expect_code "$CODE" "$ALLOWED"; then ok "${NAME} is ready (${CODE})"; return 0; fi
sleep 1
done; fail "${NAME} not ready in time: ${URL}"
}
wait_health(){ wait_http "$1" "$2" "200" "${3:-60}"; }
login_or_register(){ # echo "<user_id>|<access_token>"
local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4"
local BODY TOK TOKCODE TOKRESP ACCESS USER_ID
# 1) пытаемся логиниться
BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS")
TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
if expect_code "$TOKCODE" "200"; then
ACCESS="$(json_get "$TOKRESP" "access_token")"
USER_ID="$(jwt_get "$ACCESS" sub)"
[[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed for $EMAIL"
ok "Login ok for $EMAIL"
echo "${USER_ID}|${ACCESS}"; return 0
fi
warn "Login failed for $EMAIL ($TOKCODE) → will register"
# 2) регистрируем
BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE")
local REG RESPCODE RESP; REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")"
RESPCODE="${REG%%|*}"; RESP="${REG##*|}"
if expect_code "$RESPCODE" "201|200"; then
ok "Registered $EMAIL"
else
local MSG; MSG="$(json_get "$RESP" "detail")"
if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then
warn "Already exists: $EMAIL"
else
warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)"
fi
fi
# 3) снова логин
TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")"
ACCESS="$(json_get "$TOKRESP" "access_token")"
USER_ID="$(jwt_get "$ACCESS" sub)"
[[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed after register for $EMAIL"
echo "${USER_ID}|${ACCESS}"
}
ensure_profile(){ # <token> <gender> <city> <langs_csv> <interests_csv>
local TOKEN="$1" G="$2" CITY="$3" LANGS="$4" INTRS="$5"
[[ -n "$TOKEN" ]] || fail "Empty token in ensure_profile"
local ME MECODE MERESP; ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")"
MECODE="${ME%%|*}"; MERESP="${ME##*|}"
if [[ "$MECODE" == "200" ]]; then ok "Profile exists"; return 0
elif [[ "$MECODE" != "404" ]]; then warn "Unexpected /profiles/me $MECODE: $(cat "$MERESP")"; fi
local lj ij; IFS=',' read -r -a _l <<< "$LANGS"; lj="$(printf '%s\n' "${_l[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)"
IFS=',' read -r -a _i <<< "$INTRS"; ij="$(printf '%s\n' "${_i[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)"
local BODY; BODY=$(cat <<JSON
{"gender":"$G","city":"$CITY","languages":[${lj}],"interests":[${ij}]}
JSON
)
local CR CRCODE CRRESP; CR="$(http_req POST "$PROFILES/v1/profiles" "$TOKEN" "$BODY")"
CRCODE="${CR%%|*}"; CRRESP="${CR##*|}"
expect_code "$CRCODE" "201|200" || fail "Create profile failed (${CRCODE}): $(cat "$CRRESP")"
ok "Profile created"
}
main(){
echo -e "${B}=== E2E smoke test start ===${NC}" >&2
echo "BASE_URL: $BASE_URL" >&2; echo >&2
wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}"
wait_health "auth" "$AUTH/health"; wait_health "profiles" "$PROFILES/health"
wait_health "match" "$MATCH/health"; wait_health "chat" "$CHAT/health"; wait_health "payments" "$PAYMENTS/health"
TS="$(date +%s)"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}"
ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}"
BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}"
PASS="${PASS:-secret123}"
log "Admin: ${ADMIN_EMAIL}"
IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(login_or_register "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN"); ok "Admin id: $ADMIN_ID"
log "Alice: ${ALICE_EMAIL}"
IFS='|' read -r ALICE_ID ALICE_ACCESS < <(login_or_register "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT"); ok "Alice id: $ALICE_ID"
log "Bob: ${BOB_EMAIL}"
IFS='|' read -r BOB_ID BOB_ACCESS < <(login_or_register "$BOB_EMAIL" "$PASS" "Bob" "CLIENT"); ok "Bob id: $BOB_ID"
log "Profiles"
ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops"
ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel"
ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading"
log "Match Alice ↔ Bob"
BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87)
PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"; PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}"
expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")"
PAIR_ID="$(json_get "$PRESP" "id")"; ok "Pair: $PAIR_ID"
log "Chat"
BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID")
ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"; RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}"
expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")"
ROOM_ID="$(json_get "$RRESP" "id")"; ok "Room: $ROOM_ID"
BODY='{"content":"Hello from admin (e2e)"}'
MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"; MCODE="${MSG%%|*}"; MRESP="${MSG##*|}"
expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")"
MSG_ID="$(json_get "$MRESP" "id")"; ok "Message: $MSG_ID"
log "Payments"
BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID")
INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"; INVCODE="${INV%%|*}"; INVRESP="${INV##*|}"
expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")"
INV_ID="$(json_get "$INVRESP" "id")"; ok "Invoice: $INV_ID"
PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"; PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}"
expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")"
STATUS="$(json_get "$PDRESP" "status")"; [[ "$STATUS" == "paid" ]] || fail "Invoice not paid"
ok "Invoice status: $STATUS"
{
echo "=== E2E summary ==="
echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})"
echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})"
echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})"
echo "Pair: ${PAIR_ID}"
echo "Room: ${ROOM_ID} Message: ${MSG_ID}"
echo "Invoice:${INV_ID} Status: ${STATUS}"
} >&2
ok "E2E smoke test finished successfully."
}
main "$@"

54
scripts/fix_alembic.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
SERVICES=(auth profiles match chat payments)
# Добавим импорт моделей в env.py, если его нет
for s in "${SERVICES[@]}"; do
ENV="services/$s/alembic/env.py"
if ! grep -q "from app import models" "$ENV"; then
# вставим строку сразу после импорта Base
awk '
/from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next}
{print}
' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
echo "[fix] added 'from app import models' to $ENV"
fi
done
# Создадим шаблон mako для Alembic в каждом сервисе (если отсутствует)
for s in "${SERVICES[@]}"; do
TPL="services/$s/alembic/script.py.mako"
if [[ ! -f "$TPL" ]]; then
mkdir -p "$(dirname "$TPL")"
cat > "$TPL" <<'MAKO'
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '${up_revision}'
down_revision: Union[str, None] = ${down_revision | repr}
branch_labels: Union[str, Sequence[str], None] = ${branch_labels | repr}
depends_on: Union[str, Sequence[str], None] = ${depends_on | repr}
def upgrade() -> None:
pass
def downgrade() -> None:
pass
MAKO
echo "[fix] created $TPL"
fi
done
echo "✅ Alembic templates fixed."
echo "Совет: предупреждение docker-compose про 'version' можно игнорировать или удалить строку 'version: \"3.9\"' из docker-compose.yml."

18
scripts/fix_email_validation.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
FILE="services/auth/src/app/schemas/user.py"
[ -f "$FILE" ] || { echo "Not found: $FILE"; exit 1; }
tmp="$(mktemp)"
awk '
BEGIN{incls=""}
/^class (UserRead|UserPublic|UserOut|UserResponse)\b/ {incls=$1}
incls!="" && /email: *EmailStr/ { sub(/EmailStr/, "str") }
/^class [A-Za-z_0-9]+\b/ && $2!=incls { incls="" }
{ print }
' "$FILE" > "$tmp" && mv "$tmp" "$FILE"
echo "[auth] rebuilding..."
docker compose build auth
docker compose restart auth

27
scripts/fix_profiles_deps.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="services/profiles/src/app"
mkdir -p "$ROOT/db"
# __init__.py чтобы пакет точно импортировался
[[ -f "$ROOT/__init__.py" ]] || echo "# app package" > "$ROOT/__init__.py"
[[ -f "$ROOT/db/__init__.py" ]] || echo "# db package" > "$ROOT/db/__init__.py"
# deps.py с get_db()
cat > "$ROOT/db/deps.py" <<'PY'
from typing import Generator
from sqlalchemy.orm import Session
from app.db.session import SessionLocal # должен существовать в проекте
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
PY
echo "[profiles] rebuilding..."
docker compose build profiles
docker compose restart profiles

62
scripts/fix_profiles_fk.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
# 1) Обновим модель Photo: добавим ForeignKey + нормальную relationship
cat > services/profiles/src/app/models/photo.py <<'PY'
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.db.session import Base
class Photo(Base):
__tablename__ = "photos"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("profiles.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
url: Mapped[str] = mapped_column(String(500), nullable=False)
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
profile = relationship("Profile", back_populates="photos")
PY
# (необязательно, но полезно) поправим типы JSONB в Profile
awk '
{print}
/languages:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed languages type to list[str])"}
/interests:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed interests type to list[str])"}
' services/profiles/src/app/models/profile.py > services/profiles/src/app/models/profile.py.tmp \
&& mv services/profiles/src/app/models/profile.py.tmp services/profiles/src/app/models/profile.py || true
# 2) Сгенерируем ревизию Alembic (сравнить модели с БД)
docker compose up -d postgres
docker compose run --rm -v "$PWD/services/profiles":/app profiles \
sh -lc 'alembic revision --autogenerate -m "add FK photos.profile_id -> profiles.id"'
# 3) Если автогенерация не добавила FK — вживлём вручную в последнюю ревизию
LAST=$(ls -1t services/profiles/alembic/versions/*.py | head -n1)
if ! grep -q "create_foreign_key" "$LAST"; then
# вставим импорт postgresql (на будущее) и create_foreign_key в upgrade()
sed -i '/import sqlalchemy as sa/a from sqlalchemy.dialects import postgresql' "$LAST"
awk '
BEGIN{done=0}
/def upgrade/ && done==0 {print; print " op.create_foreign_key("; print " '\''fk_photos_profile_id_profiles'\'',"; print " '\''photos'\'', '\''profiles'\'',"; print " ['\''profile_id'\''], ['\''id'\''],"; print " ondelete='\''CASCADE'\''"; print " )"; done=1; next}
{print}
' "$LAST" > "$LAST.tmp" && mv "$LAST.tmp" "$LAST"
fi
# 4) Применим миграции и перезапустим сервис
docker compose run --rm profiles alembic upgrade head
docker compose restart profiles

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
SCHEMA="services/profiles/src/app/schemas/profile.py"
mkdir -p "$(dirname "$SCHEMA")"
cat > "$SCHEMA" <<'PY'
from __future__ import annotations
from typing import List
from uuid import UUID
try:
# Pydantic v2
from pydantic import BaseModel, Field, ConfigDict
_V2 = True
except Exception:
# Pydantic v1 fallback
from pydantic import BaseModel, Field
ConfigDict = None
_V2 = False
class ProfileBase(BaseModel):
gender: str
city: str
languages: List[str] = Field(default_factory=list)
interests: List[str] = Field(default_factory=list)
class ProfileCreate(ProfileBase):
pass
class ProfileOut(ProfileBase):
id: UUID
user_id: UUID
if _V2:
model_config = ConfigDict(from_attributes=True)
else:
class Config:
orm_mode = True
PY
echo "[profiles] rebuilding..."
docker compose build profiles
docker compose restart profiles

6
scripts/migrate.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
for s in auth profiles match chat payments; do
docker compose run --rm $s alembic revision --autogenerate -m "init"
docker compose run --rm $s alembic upgrade head
done

1564
scripts/models.sh Executable file

File diff suppressed because it is too large Load Diff

31
scripts/patch.sh Executable file
View File

@@ -0,0 +1,31 @@
# scripts/patch_gateway_auth_header.sh
cat > scripts/patch_gateway_auth_header.sh <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
CFG="infra/gateway/nginx.conf"
[ -f "$CFG" ] || { echo "Not found: $CFG"; exit 1; }
# Грубая, но надёжная вставка proxy_set_header Authorization во все блоки location к сервисам
awk '
/location[[:space:]]+\/(auth|profiles|match|chat|payments)\//,/\}/ {
print
if ($0 ~ /proxy_pass/ && !seen_auth) {
print " proxy_set_header Authorization $http_authorization;"
print " proxy_set_header X-Forwarded-Proto $scheme;"
print " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
print " proxy_set_header Host $host;"
seen_auth=1
}
next
}
{ print }
/\}/ { seen_auth=0 }
' "$CFG" > "$CFG.tmp" && mv "$CFG.tmp" "$CFG"
echo "[gateway] restart..."
docker compose restart gateway
BASH
chmod +x scripts/patch_gateway_auth_header.sh
./scripts/patch_gateway_auth_header.sh

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
SERVICES=(auth profiles match chat payments)
for s in "${SERVICES[@]}"; do
TPL="services/$s/alembic/script.py.mako"
mkdir -p "services/$s/alembic"
cat > "$TPL" <<'MAKO'
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
MAKO
echo "[ok] template updated: $TPL"
done
# Убедимся, что в env.py импортированы модели (для автогенерации)
for s in "${SERVICES[@]}"; do
ENV="services/$s/alembic/env.py"
if ! grep -q "from app import models" "$ENV"; then
awk '
/from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next}
{print}
' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
echo "[ok] added 'from app import models' to $ENV"
fi
done
# удалить ревизии, созданные с битым шаблоном
for s in auth profiles match chat payments; do
rm -f services/$s/alembic/versions/*.py
done
# поднять Postgres (если не запущен)
docker compose up -d postgres
# автогенерация первичных ревизий (каждая сохранится в services/<svc>/alembic/versions/)
for s in auth profiles match chat payments; do
echo "[gen] $s"
docker compose run --rm -v "$PWD/services/$s":/app "$s" \
sh -lc 'alembic revision --autogenerate -m "init"'
done
for s in auth profiles match chat payments; do
echo "---- $s"
ls -1 services/$s/alembic/versions/
done

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
CFG="infra/gateway/nginx.conf"
[ -f "$CFG" ] || { echo "Not found: $CFG"; exit 1; }
# Грубая, но надёжная вставка proxy_set_header Authorization во все блоки location к сервисам
awk '
/location[[:space:]]+\/(auth|profiles|match|chat|payments)\//,/\}/ {
print
if ($0 ~ /proxy_pass/ && !seen_auth) {
print " proxy_set_header Authorization $http_authorization;"
print " proxy_set_header X-Forwarded-Proto $scheme;"
print " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
print " proxy_set_header Host $host;"
seen_auth=1
}
next
}
{ print }
/\}/ { seen_auth=0 }
' "$CFG" > "$CFG.tmp" && mv "$CFG.tmp" "$CFG"
echo "[gateway] restart..."
docker compose restart gateway

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
REPO="services/profiles/src/app/repositories/profile_repository.py"
SRV="services/profiles/src/app/services/profile_service.py"
mkdir -p "$(dirname "$REPO")" "$(dirname "$SRV")"
cat > "$REPO" <<'PY'
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.models.profile import Profile
from app.schemas.profile import ProfileCreate
class ProfileRepository:
def __init__(self, db: Session):
self.db = db
def get_by_user(self, user_id: UUID) -> Optional[Profile]:
return self.db.execute(select(Profile).where(Profile.user_id == user_id)).scalar_one_or_none()
def create(self, user_id: UUID, data: ProfileCreate) -> Profile:
p = Profile(
user_id=user_id,
gender=data.gender,
city=data.city,
languages=list(data.languages or []),
interests=list(data.interests or []),
)
self.db.add(p)
self.db.commit()
self.db.refresh(p)
return p
PY
cat > "$SRV" <<'PY'
from uuid import UUID
from app.schemas.profile import ProfileCreate
from app.repositories.profile_repository import ProfileRepository
class ProfileService:
def __init__(self, repo: ProfileRepository):
self.repo = repo
def get_by_user(self, user_id: UUID):
return self.repo.get_by_user(user_id)
def create(self, user_id: UUID, data: ProfileCreate):
return self.repo.create(user_id, data)
PY
echo "[profiles] rebuilding..."
docker compose build profiles
docker compose restart profiles

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
ROUTER="services/profiles/src/app/api/routes/profiles.py"
mkdir -p "$(dirname "$ROUTER")"
cat > "$ROUTER" <<'PY'
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.deps import get_db
from app.core.security import get_current_user, JwtUser
from app.schemas.profile import ProfileCreate, ProfileOut
from app.repositories.profile_repository import ProfileRepository
from app.services.profile_service import ProfileService
# отключаем авто-редирект /path -> /path/
router = APIRouter(prefix="/v1/profiles", tags=["profiles"], redirect_slashes=False)
@router.get("/me", response_model=ProfileOut)
def get_my_profile(current: JwtUser = Depends(get_current_user),
db: Session = Depends(get_db)):
svc = ProfileService(ProfileRepository(db))
p = svc.get_by_user(current.sub)
if not p:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
return p
@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED)
def create_my_profile(payload: ProfileCreate,
current: JwtUser = Depends(get_current_user),
db: Session = Depends(get_db)):
svc = ProfileService(ProfileRepository(db))
existing = svc.get_by_user(current.sub)
if existing:
# если хотите строго — верните 409; оставлю 200/201 для удобства e2e
return existing
return svc.create(current.sub, payload)
PY
echo "[profiles] rebuilding..."
docker compose build profiles
docker compose restart profiles

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
REQ="services/profiles/requirements.txt"
PY="services/profiles/src/app/core/security.py"
# 1) гарантируем зависимость PyJWT
grep -qE '(^|[[:space:]])PyJWT' "$REQ" 2>/dev/null || {
echo "PyJWT>=2.8.0" >> "$REQ"
echo "[profiles] added PyJWT to requirements.txt"
}
# 2) модуль security.py
mkdir -p "$(dirname "$PY")"
cat > "$PY" <<'PY'
import os
from typing import Optional
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
reusable_bearer = HTTPBearer(auto_error=True)
JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
# Возможность включить строгую проверку audience/issuer в будущем
JWT_VERIFY_AUD = os.getenv("JWT_VERIFY_AUD", "0") == "1"
JWT_AUDIENCE: Optional[str] = os.getenv("JWT_AUDIENCE") or None
JWT_VERIFY_ISS = os.getenv("JWT_VERIFY_ISS", "0") == "1"
JWT_ISSUER: Optional[str] = os.getenv("JWT_ISSUER") or None
# Допустимая рассинхронизация часов (сек)
JWT_LEEWAY = int(os.getenv("JWT_LEEWAY", "30"))
class JwtUser(BaseModel):
sub: str
email: Optional[str] = None
role: Optional[str] = None
def decode_token(token: str) -> JwtUser:
options = {
"verify_signature": True,
"verify_exp": True,
"verify_aud": JWT_VERIFY_AUD,
"verify_iss": JWT_VERIFY_ISS,
}
kwargs = {"algorithms": [JWT_ALGORITHM], "options": options, "leeway": JWT_LEEWAY}
if JWT_VERIFY_AUD and JWT_AUDIENCE:
kwargs["audience"] = JWT_AUDIENCE
if JWT_VERIFY_ISS and JWT_ISSUER:
kwargs["issuer"] = JWT_ISSUER
try:
payload = jwt.decode(token, JWT_SECRET, **kwargs)
sub = str(payload.get("sub") or "")
if not sub:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub")
return JwtUser(sub=sub, email=payload.get("email"), role=payload.get("role"))
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.InvalidAudienceError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid audience")
except jwt.InvalidIssuerError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid issuer")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(reusable_bearer)) -> JwtUser:
if credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth scheme")
return decode_token(credentials.credentials)
PY
echo "[profiles] rebuilding..."
docker compose build profiles
docker compose restart profiles

29
scripts/test.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# 1) Здоровье сервисов
curl -sS http://localhost:8080/auth/health
curl -sS http://localhost:8080/profiles/health
# 2) Токен (любой юзер)
curl -sS -X POST http://localhost:8080/auth/v1/token \
-H "Content-Type: application/json" \
-d '{"email":"admin@agency.dev","password":"secret123"}' | tee /tmp/token.json
ACCESS=$(python3 - <<'PY' /tmp/token.json
import sys, json; print(json.load(open(sys.argv[1]))["access_token"])
PY
)
# 3) /me — ожидаемо 404 (если профиля нет), главное НЕ 401
curl -i -sS http://localhost:8080/profiles/v1/profiles/me \
-H "Authorization: Bearer $ACCESS"
# 4) Создать профиль — должно быть 201/200, без 500
curl -i -sS -X POST http://localhost:8080/profiles/v1/profiles \
-H "Authorization: Bearer $ACCESS" \
-H "Content-Type: application/json" \
-d '{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}'
# 5) Снова /me — теперь 200 с JSON (UUIDы как строки)
curl -sS http://localhost:8080/profiles/v1/profiles/me \
-H "Authorization: Bearer $ACCESS" | jq .