From 529d577b84d2d99f052ebfb68ab994a69ee5bfc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 31 Jul 2026 23:02:34 +0400 Subject: [PATCH] =?UTF-8?q?test:=2015=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D1=85=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20polygon=20+=20bump=20v1.2.25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/app.py | 2 +- tests/conftest.py | 52 +++++++ tests/test_polygon_integration.py | 227 ++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 tests/test_polygon_integration.py diff --git a/site/app.py b/site/app.py index 1ef513f..bb91ac8 100644 --- a/site/app.py +++ b/site/app.py @@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Нужна для фильтрации истории (пользователь видит только записи своей версии). -VERSION = "1.2.24" +VERSION = "1.2.25" # Flask-приложение с Jinja2-шаблонами из папки templates/ app = Flask(__name__, template_folder="templates", static_folder="static") diff --git a/tests/conftest.py b/tests/conftest.py index 440f3ab..cb535e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,10 @@ from pathlib import Path +import subprocess import sys +import time import pytest +import requests # Make `site/` importable as top-level modules: app, routes, db, operations, ... @@ -9,11 +12,60 @@ SITE_DIR = Path(__file__).resolve().parents[1] / "site" if str(SITE_DIR) not in sys.path: sys.path.insert(0, str(SITE_DIR)) +# Путь к polygon: соседняя репа в корне autotest +POLYGON_DIR = Path(__file__).resolve().parents[2] / "polygon" / "site" +POLYGON_URL = "http://localhost:5000" + @pytest.fixture def app_client(): + """Flask test client для app-autotest.""" from app import app app.config.update(TESTING=True) with app.test_client() as client: yield client + + +@pytest.fixture(scope="session") +def polygon_server(): + """Запустить polygon как subprocess на весь pytest-session. + + Используется для интеграционных тестов app-autotest ↔ polygon. + После всех тестов процесс убивается. + """ + if not POLYGON_DIR.is_dir(): + pytest.skip("polygon repo not found") + + proc = subprocess.Popen( + ["python3", "app.py"], + cwd=str(POLYGON_DIR), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + # Ждём готовности (poll /health, timeout 10s) + deadline = time.time() + 10 + while time.time() < deadline: + try: + r = requests.get(f"{POLYGON_URL}/health", timeout=2) + if r.status_code == 200: + break + except requests.ConnectionError: + time.sleep(0.5) + else: + proc.terminate() + proc.wait() + pytest.fail("polygon did not start within 10s") + + yield POLYGON_URL + + proc.terminate() + proc.wait() + + +@pytest.fixture(autouse=True) +def reset_polygon(polygon_server): + """Перед каждым интеграционным тестом — сброс состояния polygon.""" + requests.post(f"{polygon_server}/api/v1/svc/_mock/reset", timeout=5) + diff --git a/tests/test_polygon_integration.py b/tests/test_polygon_integration.py new file mode 100644 index 0000000..9b957e7 --- /dev/null +++ b/tests/test_polygon_integration.py @@ -0,0 +1,227 @@ +""" +Интеграционные тесты app-autotest ↔ polygon. + +Каждый тест требует запущенного polygon (фикстура polygon_server). +Перед каждым тестом состояние polygon сбрасывается (autouse reset_polygon). + +Все запросы — через requests (HTTP), имитируя работу HttpClient из app-autotest. +""" + +import pytest +import requests + +API = "/api/v1/svc" + + +def _create_instance(url, service_id=1, display_name="test"): + """POST /instances → извлечь instanceUid из Location.""" + r = requests.post( + f"{url}{API}/instances", + json={"serviceId": service_id, "displayName": display_name}, + allow_redirects=False, + timeout=10, + ) + assert r.status_code == 201 + location = r.headers.get("Location", "") + uid = location.rsplit("/", 1)[-1] + assert len(uid) >= 32 # UUID без дефисов + return uid + + +def _create_operation(url, instance_uid, operation, svc_op_id=None): + """POST /instanceOperations → извлечь opUid из Location.""" + body = {"instanceUid": instance_uid, "operation": operation} + if svc_op_id is not None: + body["svcOperationId"] = svc_op_id + r = requests.post( + f"{url}{API}/instanceOperations", + json=body, + allow_redirects=False, + timeout=10, + ) + assert r.status_code == 201 + location = r.headers.get("Location", "") + uid = location.rsplit("/", 1)[-1] + assert len(uid) >= 32 + return uid + + +def _run(url, op_uid): + """POST /run → дождаться dtFinish.""" + r = requests.post(f"{url}{API}/instanceOperations/{op_uid}/run", timeout=10) + assert r.status_code == 200 + # Проверить что операция завершена + r2 = requests.get(f"{url}{API}/instanceOperations/{op_uid}", timeout=10) + op = r2.json()["instanceOperation"] + assert op["dtFinish"] is not None + assert op["isSuccessful"] is True + + +class TestServices: + """Тесты эндпоинтов /services.""" + + def test_list_services(self, polygon_server): + """GET /services возвращает все сервисы.""" + r = requests.get(f"{polygon_server}{API}/services", timeout=10) + assert r.status_code == 200 + data = r.json() + assert len(data["results"]) >= 30 # минимум 30 сервисов + + def test_service_detail(self, polygon_server): + """GET /services/1 возвращает операции Болванки.""" + r = requests.get(f"{polygon_server}{API}/services/1", timeout=10) + assert r.status_code == 200 + ops = r.json()["svc"]["operations"] + assert any(op["operation"] == "create" for op in ops) + + def test_service_404(self, polygon_server): + """GET /services/99999 → 404.""" + r = requests.get(f"{polygon_server}{API}/services/99999", timeout=10) + assert r.status_code == 404 + + +class TestInstanceCreate: + """Тесты создания инстанса.""" + + def test_create_dummy(self, polygon_server): + """POST /instances → 201 + Location → GET → status=creating.""" + uid = _create_instance(polygon_server) + r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10) + assert r.status_code == 200 + inst = r.json()["instance"] + assert inst["status"] == "creating" + assert inst["serviceId"] == 1 + + def test_create_unknown_service(self, polygon_server): + """POST /instances с несуществующим serviceId → 404.""" + r = requests.post( + f"{polygon_server}{API}/instances", + json={"serviceId": 99999, "displayName": "bad"}, + timeout=10, + ) + assert r.status_code == 404 + + +class TestFullCreateFlow: + """Полный цикл create → run → проверить params.""" + + def test_create_and_run(self, polygon_server): + """create → run → статус running + params из шаблона.""" + uid = _create_instance(polygon_server) + op_uid = _create_operation(polygon_server, uid, "create") + _run(polygon_server, op_uid) + + r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10) + inst = r.json()["instance"] + assert inst["status"] == "running" + assert "resourceRealm" in inst["state"]["params"] + assert inst["state"]["params"]["resourceRealm"] == "dummy" + + def test_create_postgres_state_out(self, polygon_server): + """create PG → state.out содержит users, databases.""" + uid = _create_instance(polygon_server, service_id=90, display_name="pg") + op_uid = _create_operation(polygon_server, uid, "create") + _run(polygon_server, op_uid) + + r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10) + out = r.json()["instance"]["state"]["out"] + assert "users" in out + assert "databases" in out + + def test_run_twice_409(self, polygon_server): + """Повторный POST /run → 409.""" + uid = _create_instance(polygon_server) + op_uid = _create_operation(polygon_server, uid, "create") + _run(polygon_server, op_uid) + + r = requests.post( + f"{polygon_server}{API}/instanceOperations/{op_uid}/run", timeout=10 + ) + assert r.status_code == 409 + + +class TestOperations: + """Тесты операций: modify, suspend, resume, delete.""" + + def test_modify_merges_params(self, polygon_server): + """modify → новый paramValue в state.params.""" + uid = _create_instance(polygon_server) + _run(polygon_server, _create_operation(polygon_server, uid, "create")) + + # Установить новое значение durationMs (modify-версия: paramId=287, create-версия: 198) + op_uid = _create_operation(polygon_server, uid, "modify") + requests.post( + f"{polygon_server}{API}/instanceOperationCfsParams", + json={"instanceOperationUid": op_uid, "svcOperationCfsParamId": 287, "paramValue": "999"}, + timeout=10, + ) + _run(polygon_server, op_uid) + + r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10) + assert r.json()["instance"]["state"]["params"]["durationMs"] == "999" + + def test_suspend_resume(self, polygon_server): + """suspend → status=suspended, resume → status=running.""" + uid = _create_instance(polygon_server) + _run(polygon_server, _create_operation(polygon_server, uid, "create")) + + _run(polygon_server, _create_operation(polygon_server, uid, "suspend")) + r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10) + assert r.json()["instance"]["status"] == "suspended" + + _run(polygon_server, _create_operation(polygon_server, uid, "resume")) + r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10) + assert r.json()["instance"]["status"] == "running" + + def test_delete_removes_instance(self, polygon_server): + """delete → инстанс удалён из списка.""" + uid = _create_instance(polygon_server) + _run(polygon_server, _create_operation(polygon_server, uid, "create")) + _run(polygon_server, _create_operation(polygon_server, uid, "delete")) + + r = requests.get(f"{polygon_server}{API}/instances", timeout=10) + uids = [i["instanceUid"] for i in r.json()["results"]] + assert uid not in uids + + +class TestPagination: + """Тесты пагинации.""" + + def test_pagination(self, polygon_server): + """3 инстанса, pageSize=2 → 2 страницы.""" + for i in range(3): + _create_instance(polygon_server, display_name=f"p{i}") + + r1 = requests.get( + f"{polygon_server}{API}/instances", params={"pageSize": 2, "page": 1}, timeout=10 + ) + assert len(r1.json()["results"]) == 2 + + r2 = requests.get( + f"{polygon_server}{API}/instances", params={"pageSize": 2, "page": 2}, timeout=10 + ) + assert len(r2.json()["results"]) == 1 # последний + + +class TestMockEndpoints: + """Тесты /_mock/* эндпоинтов.""" + + def test_mock_state(self, polygon_server): + """GET /_mock/state — отладочный дамп.""" + _create_instance(polygon_server) + r = requests.get(f"{polygon_server}{API}/_mock/state", timeout=10) + assert r.status_code == 200 + assert len(r.json()["instances"]) == 1 + + def test_mock_services(self, polygon_server): + """GET /_mock/services — список конфигов.""" + r = requests.get(f"{polygon_server}{API}/_mock/services", timeout=10) + assert r.status_code == 200 + assert r.json()["count"] >= 30 + + def test_mock_delay(self, polygon_server): + """POST /_mock/delay — изменение задержки.""" + r = requests.post(f"{polygon_server}{API}/_mock/delay/2", timeout=10) + assert r.json()["delay"] == 2.0 + # Сбросить + requests.post(f"{polygon_server}{API}/_mock/delay/0.1", timeout=10)