Files
naeel 58d54f2a95 v0.5.0: 3 стенда — изоляция состояния, WSGI middleware, OpenAPI server selector, POLYGON_STANDS env
- loader.py: STANDS dict, get_services/get_ops_index per stand, POLYGON_STANDS env
- mock_state.py: get_state(stand_id) — ленивая инициализация
- app.py: StandMiddleware — перезапись PATH_INFO до Flask-роутинга
- Все роуты: LocalProxy(state), LocalProxy(SERVICES/OPS_INDEX) через g.stand_id
- openapi.py: _build_servers() — по одному серверу на стенд
- services/stand{1,2,3}/ — 37 YAML на стенд из STANDS/dev и STANDS/test
- tests/test_api.py: +4 multi-stand теста (изоляция, fail-next, OpenAPI servers)
- Обратная совместимость: /api/v1/svc/... → стенд по умолчанию
2026-08-01 20:10:19 +04:00

420 lines
16 KiB
Python

#!/usr/bin/env python3
"""
test_api.py — smoke + edge-case тесты Polygon API.
Запуск: python3 tests/test_api.py
Требует: pip install requests
"""
import os
import sys
import json
try:
import requests
except ImportError:
print("❌ Нужен requests: pip install requests")
sys.exit(1)
BASE = os.getenv("POLYGON_ENDPOINT", "https://polygon.pythonk8s.dev.nubes.ru")
AUTH = os.getenv("MOCK_AUTH_TOKEN", "test-token-123")
H = {"Content-Type": "application/json"}
MH = {**H, "X-Mock-Auth": AUTH}
passed = 0
failed = 0
errors = []
def t(name, fn):
global passed, failed
try:
fn()
passed += 1
print(f" ✅ {name}")
except AssertionError as e:
failed += 1
msg = f" ❌ {name}: {e}"
print(msg)
errors.append(msg)
except Exception as e:
failed += 1
msg = f" 💥 {name}: {type(e).__name__}: {e}"
print(msg)
errors.append(msg)
def st(r, expected, note=""):
extra = f" ({note})" if note else ""
assert r.status_code == expected, f"expected {expected}, got {r.status_code}: {r.text[:200]}{extra}"
def js(r):
try:
return r.json()
except Exception:
raise AssertionError(f"not JSON: {r.text[:200]}")
# ═══════════════════════════════════════
# Setup
# ═══════════════════════════════════════
print("=" * 60)
print(f"Polygon API Tests — {BASE}")
print("=" * 60)
r = requests.post(f"{BASE}/api/v1/svc/_mock/reset", headers=MH)
st(r, 200)
print()
# Состояние между тестами
svc_list = {}
inst_uid = None
op_uid = None
svc_op_id = None
fail_inst_uid = None
fail_op_uid = None
# ═══════════════════════════════════════
# 1. HEALTH
# ═══════════════════════════════════════
print("── 1. Health ──")
def test_health():
st(requests.get(f"{BASE}/health"), 200)
t("GET /health → 200 OK", test_health)
# ═══════════════════════════════════════
# 2. SERVICES
# ═══════════════════════════════════════
print("\n── 2. Services ──")
def test_svc_list():
global svc_list
d = js(requests.get(f"{BASE}/api/v1/svc/services"))
svc_list = d
assert "results" in d, "no results key"
assert len(d["results"]) >= 37, f"only {len(d['results'])} services"
t("GET /services → 200, >=37 сервисов", test_svc_list)
def test_svc_fields():
for s in svc_list["results"]:
for k in ("svcId", "svc", "svcShort"):
assert k in s, f"missing {k} in {s}"
t("GET /services → каждый имеет svcId, svc, svcShort", test_svc_fields)
def test_svc_detail():
d = js(requests.get(f"{BASE}/api/v1/svc/services/1"))
assert "svc" in d
assert "operations" in d["svc"]
assert len(d["svc"]["operations"]) > 0
t("GET /services/1 → 200 + operations", test_svc_detail)
def test_svc_404():
st(requests.get(f"{BASE}/api/v1/svc/services/99999"), 404)
t("GET /services/99999 → 404", test_svc_404)
# ═══════════════════════════════════════
# 3. INSTANCES
# ═══════════════════════════════════════
print("\n── 3. Instances ──")
def test_inst_empty():
d = js(requests.get(f"{BASE}/api/v1/svc/instances"))
assert d["total"] == 0
t("GET /instances (после reset) → total=0", test_inst_empty)
def test_inst_no_service_id():
st(requests.post(f"{BASE}/api/v1/svc/instances", json={}, headers=H), 400)
t("POST /instances (без serviceId) → 400", test_inst_no_service_id)
def test_inst_bad_service():
st(requests.post(f"{BASE}/api/v1/svc/instances", json={"serviceId": 99999}, headers=H), 404)
t("POST /instances (serviceId=99999) → 404", test_inst_bad_service)
def test_inst_create():
global inst_uid
r = requests.post(f"{BASE}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": "test-bolvanka"}, headers=H)
st(r, 201)
assert "Location" in r.headers
d = js(r)
assert "instanceUid" in d
inst_uid = d["instanceUid"]
t("POST /instances → 201 + Location + instanceUid", test_inst_create)
def test_inst_list_one():
d = js(requests.get(f"{BASE}/api/v1/svc/instances"))
assert d["total"] == 1
t("GET /instances → total=1", test_inst_list_one)
def test_inst_pagesize_clamp():
d = js(requests.get(f"{BASE}/api/v1/svc/instances?pageSize=500"))
assert d["pageSize"] == 200
t("GET /instances?pageSize=500 → pageSize=200 (clamped)", test_inst_pagesize_clamp)
def test_inst_negative_page():
d = js(requests.get(f"{BASE}/api/v1/svc/instances?page=-1"))
assert d["page"] >= 1
t("GET /instances?page=-1 → не падает", test_inst_negative_page)
def test_inst_detail():
d = js(requests.get(f"{BASE}/api/v1/svc/instances/{inst_uid}"))
inst = d["instance"]
assert inst["status"] == "creating"
assert inst["displayName"] == "test-bolvanka"
t("GET /instances/{uid} → status=creating, имя верное", test_inst_detail)
def test_inst_fields():
d = js(requests.get(f"{BASE}/api/v1/svc/instances/{inst_uid}?fields=dtCreate,status"))
assert "instance" in d
t("GET /instances/{uid}?fields=... → 200", test_inst_fields)
def test_inst_404():
st(requests.get(f"{BASE}/api/v1/svc/instances/00000000-0000-0000-0000-000000000000"), 404)
t("GET /instances/nonexistent → 404", test_inst_404)
# ═══════════════════════════════════════
# 4. OPERATIONS
# ═══════════════════════════════════════
print("\n── 4. Operations ──")
def test_op_default():
global svc_op_id
d = js(requests.get(f"{BASE}/api/v1/svc/instanceOperations/default/18"))
assert "svcOperation" in d
assert "cfsParams" in d["svcOperation"]
assert len(d["svcOperation"]["cfsParams"]) > 0
svc_op_id = 18
t("GET /instanceOperations/default/18 → 200 + cfsParams", test_op_default)
def test_op_create():
global op_uid
r = requests.post(f"{BASE}/api/v1/svc/instanceOperations",
json={"instanceUid": inst_uid, "operation": "create",
"svcOperationId": svc_op_id}, headers=H)
st(r, 201)
assert "Location" in r.headers
d = js(r)
assert "instanceOperationUid" in d
op_uid = d["instanceOperationUid"]
t("POST /instanceOperations → 201 + Location", test_op_create)
def test_op_null_fields():
d = js(requests.get(f"{BASE}/api/v1/svc/instanceOperations/{op_uid}"))
op = d["instanceOperation"]
assert op["dtStart"] is None, f"dtStart={op['dtStart']}"
assert op["dtFinish"] is None, f"dtFinish={op['dtFinish']}"
assert op["isSuccessful"] is None, f"isSuccessful={op['isSuccessful']}"
t("GET /op/{uid} → dtStart=null, dtFinish=null, isSuccessful=null", test_op_null_fields)
def test_op_set_param():
st(requests.post(f"{BASE}/api/v1/svc/instanceOperationCfsParams",
json={"instanceOperationUid": op_uid,
"svcOperationCfsParamId": 242,
"paramValue": "test-realm"}, headers=H), 200)
t("POST /instanceOperationCfsParams → 200", test_op_set_param)
def test_op_validate():
st(requests.get(f"{BASE}/api/v1/svc/instanceOperations/{op_uid}/validate-cfs"), 200)
t("GET /validate-cfs → 200", test_op_validate)
def test_op_run():
d = js(requests.post(f"{BASE}/api/v1/svc/instanceOperations/{op_uid}/run", headers=H))
assert d["ok"] is True
t("POST /run → ok=true", test_op_run)
def test_op_double_run():
st(requests.post(f"{BASE}/api/v1/svc/instanceOperations/{op_uid}/run", headers=H), 409)
t("POST /run (повторно) → 409", test_op_double_run)
def test_op_after_run():
d = js(requests.get(f"{BASE}/api/v1/svc/instanceOperations/{op_uid}"))
op = d["instanceOperation"]
assert op["dtFinish"] is not None
assert op["isSuccessful"] is True
t("GET /op/{uid} (после run) → dtFinish!=null, isSuccessful=true", test_op_after_run)
# ═══════════════════════════════════════
# 5. FAIL-NEXT
# ═══════════════════════════════════════
print("\n── 5. Fail-next ──")
def test_fail_set():
d = js(requests.post(f"{BASE}/api/v1/svc/_mock/fail-next", headers=MH))
assert d["fail_next"] is True
t("POST /_mock/fail-next → 200, fail_next=true", test_fail_set)
def test_fail_inst():
global fail_inst_uid
r = requests.post(f"{BASE}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": "fail-test"}, headers=H)
st(r, 201)
fail_inst_uid = js(r)["instanceUid"]
t("POST /instances (fail-next) → 201", test_fail_inst)
def test_fail_op():
global fail_op_uid
r = requests.post(f"{BASE}/api/v1/svc/instanceOperations",
json={"instanceUid": fail_inst_uid, "operation": "create",
"svcOperationId": 18}, headers=H)
st(r, 201)
fail_op_uid = js(r)["instanceOperationUid"]
t("POST /instanceOperations (fail-next) → 201", test_fail_op)
def test_fail_run():
d = js(requests.post(f"{BASE}/api/v1/svc/instanceOperations/{fail_op_uid}/run", headers=H))
assert d["ok"] is False
assert "error" in d
assert "mock failure" in d["error"]
t("POST /run (fail-next) → ok=false, error='mock failure'", test_fail_run)
# ═══════════════════════════════════════
# 6. AUTH
# ═══════════════════════════════════════
print("\n── 6. Auth (_mock/*) ──")
# Детектируем, включена ли auth на сервере
_auth_enabled = requests.post(f"{BASE}/api/v1/svc/_mock/reset", headers=H).status_code == 403
if _auth_enabled:
def test_auth_no_token():
st(requests.post(f"{BASE}/api/v1/svc/_mock/reset", headers=H), 403)
t("POST /_mock/reset (без токена) → 403", test_auth_no_token)
def test_auth_bad_token():
st(requests.post(f"{BASE}/api/v1/svc/_mock/reset",
headers={**H, "X-Mock-Auth": "wrong-token"}), 403)
t("POST /_mock/reset (неверный токен) → 403", test_auth_bad_token)
else:
print(" ⚠️ Auth отключена (MOCK_AUTH_TOKEN не задан) — тесты 403 пропущены")
def test_auth_good_token():
st(requests.post(f"{BASE}/api/v1/svc/_mock/reset", headers=MH), 200)
t("POST /_mock/reset (верный токен) → 200", test_auth_good_token)
def test_no_auth_services():
st(requests.get(f"{BASE}/api/v1/svc/services"), 200)
t("GET /services (без токена) → 200", test_no_auth_services)
def test_no_auth_create():
r = requests.post(f"{BASE}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": "no-auth"}, headers=H)
st(r, 201)
t("POST /instances (без токена) → 201", test_no_auth_create)
# ═══════════════════════════════════════
# 7. MOCK STATE
# ═══════════════════════════════════════
print("\n── 7. Mock (state/services/delay) ──")
def test_state():
d = js(requests.get(f"{BASE}/api/v1/svc/_mock/state", headers=MH))
assert "instances" in d
assert "operations" in d
t("GET /_mock/state → instances + operations", test_state)
def test_mock_services():
d = js(requests.get(f"{BASE}/api/v1/svc/_mock/services", headers=MH))
assert d["count"] >= 37
t("GET /_mock/services → count >= 37", test_mock_services)
def test_delay_ok():
d = js(requests.post(f"{BASE}/api/v1/svc/_mock/delay/0.1", headers=MH))
assert d["delay"] == 0.1
t("POST /_mock/delay/0.1 → delay=0.1", test_delay_ok)
def test_delay_too_high():
st(requests.post(f"{BASE}/api/v1/svc/_mock/delay/100", headers=MH), 400)
t("POST /_mock/delay/100 → 400", test_delay_too_high)
def test_delay_negative():
st(requests.post(f"{BASE}/api/v1/svc/_mock/delay/-1", headers=MH), 400)
t("POST /_mock/delay/-1 → 400", test_delay_negative)
# ═══════════════════════════════════════
# 8. MULTI-STAND (v0.5.0+)
# ═══════════════════════════════════════
print("\n── 8. Multi-Stand (stand1/stand2/stand3) ──")
STANDS = ["stand1", "stand2", "stand3"]
stand_instances = {}
def test_stands_services():
for sid in STANDS:
r = requests.get(f"{BASE}/{sid}/api/v1/svc/services")
st(r, 200, f"{sid} services")
t("GET /{stand}/api/v1/svc/services → 200 для всех стендов", test_stands_services)
def test_stands_isolated():
"""Каждый стенд — свои инстансы."""
for sid in STANDS:
requests.post(f"{BASE}/{sid}/api/v1/svc/_mock/reset", headers=MH)
# Создаём по 1 инстансу на стенд
for i, sid in enumerate(STANDS):
r = requests.post(f"{BASE}/{sid}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": f"inst-on-{sid}"}, headers=H)
st(r, 201)
stand_instances[sid] = js(r)["instanceUid"]
# Проверяем что на каждом стенде ровно 1 инстанс
for sid in STANDS:
d = js(requests.get(f"{BASE}/{sid}/api/v1/svc/instances"))
assert d["total"] == 1, f"{sid} has {d['total']} instances, expected 1"
t("Изоляция: каждый стенд — свои инстансы", test_stands_isolated)
def test_stands_openapi():
r = requests.get(f"{BASE}/api/v1/svc/openapi.json")
spec = js(r)
servers = [s["url"] for s in spec["servers"]]
assert len(servers) == 3, f"expected 3 servers, got {len(servers)}"
for sid in STANDS:
assert any(f"/{sid}" in u for u in servers), f"{sid} not in servers"
t("GET /openapi.json → 3 servers (по одному на стенд)", test_stands_openapi)
def test_stands_fail_next_isolated():
"""fail-next на одном стенде не влияет на другой."""
requests.post(f"{BASE}/stand1/api/v1/svc/_mock/reset", headers=MH)
requests.post(f"{BASE}/stand2/api/v1/svc/_mock/reset", headers=MH)
# fail-next только на stand1
requests.post(f"{BASE}/stand1/api/v1/svc/_mock/fail-next", headers=MH)
# Создаём операции на обоих стендах
for sid, label in [("stand1", "fail"), ("stand2", "ok")]:
r = requests.post(f"{BASE}/{sid}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": label}, headers=H)
inst_uid = js(r)["instanceUid"]
r = requests.post(f"{BASE}/{sid}/api/v1/svc/instanceOperations",
json={"instanceUid": inst_uid, "operation": "create", "svcOperationId": 18}, headers=H)
op_uid = js(r)["instanceOperationUid"]
d = js(requests.post(f"{BASE}/{sid}/api/v1/svc/instanceOperations/{op_uid}/run", headers=H))
if sid == "stand1":
assert d["ok"] is False, f"stand1 should fail, got ok={d['ok']}"
else:
assert d["ok"] is True, f"stand2 should succeed, got ok={d['ok']}"
t("Изоляция fail-next: stand1 падает, stand2 работает", test_stands_fail_next_isolated)
# ═══════════════════════════════════════
# ИТОГ
# ═══════════════════════════════════════
print()
print("=" * 60)
total = passed + failed
print(f" PASS: {passed}/{total}")
if failed:
print(f" FAIL: {failed}/{total}")
for e in errors:
print(f" {e}")
else:
print(" 🎉 Все тесты пройдены!")
print("=" * 60)
sys.exit(0 if failed == 0 else 1)