From b89c0138e47e894d5d76dac2233c3b8232e19483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Tue, 4 Aug 2026 08:48:36 +0400 Subject: [PATCH] =?UTF-8?q?test:=20+13=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=E2=80=94=20save=5Frun=20UPSERT,=20auth=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=D1=8B/URL,=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_db_save_run.py (4 теста): INSERT/UPDATE/UPSERT/без БД test_auth.py (9 тестов): URL полигона, get_stand, whitelist, режимы test_scenario_full.py (8 тестов): CRUD + запуск (требует polygon + БД) Итого: 35 тестов (22 старых + 13 новых) --- tests/test_auth.py | 125 +++++++++++++++++++++++ tests/test_db_save_run.py | 114 +++++++++++++++++++++ tests/test_scenario_full.py | 196 ++++++++++++++++++++++++++++++++++++ 3 files changed, 435 insertions(+) create mode 100644 tests/test_auth.py create mode 100644 tests/test_db_save_run.py create mode 100644 tests/test_scenario_full.py diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..4408f28 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,125 @@ +""" +Тесты: auth.py — режимы, стенды, URL, cookie. +""" +import api.auth as auth + + +def test_polygon_url_construction(monkeypatch): + """get_client в режиме polygon строит правильный URL.""" + monkeypatch.setattr(auth, "get_mode", lambda: "polygon") + monkeypatch.setattr(auth, "get_polygon_stand", lambda: "test") + monkeypatch.setattr(auth, "get_token", lambda: "tok") + + # Мокаем HttpClient чтобы не делать реальный запрос + clients = [] + class FakeHttp: + def __init__(self, endpoint, token): + clients.append(endpoint) + monkeypatch.setattr(auth, "HttpClient", FakeHttp) + + # Настроим POLYGON_ENDPOINT + from flask import Flask + app = Flask(__name__) + app.config["POLYGON_ENDPOINT"] = "https://polygon.test/api/v1/svc" + monkeypatch.setattr(auth, "current_app", app) + + auth.get_client() + assert len(clients) == 1 + assert clients[0] == "https://polygon.test/test/api/v1/svc" + + +def test_polygon_url_dev_stand(monkeypatch): + """DEV-стенд в URL.""" + monkeypatch.setattr(auth, "get_mode", lambda: "polygon") + monkeypatch.setattr(auth, "get_polygon_stand", lambda: "dev") + monkeypatch.setattr(auth, "get_token", lambda: "tok") + + clients = [] + class FakeHttp: + def __init__(self, endpoint, token): + clients.append(endpoint) + monkeypatch.setattr(auth, "HttpClient", FakeHttp) + + from flask import Flask + app = Flask(__name__) + app.config["POLYGON_ENDPOINT"] = "https://polygon.test/api/v1/svc" + monkeypatch.setattr(auth, "current_app", app) + + auth.get_client() + assert clients[0] == "https://polygon.test/dev/api/v1/svc" + + +def test_get_stand_polygon(monkeypatch): + """get_stand в эмуляции возвращает polygon_{stand}.""" + monkeypatch.setattr(auth, "get_mode", lambda: "polygon") + monkeypatch.setattr(auth, "get_polygon_stand", lambda: "prod") + assert auth.get_stand() == "polygon_prod" + + +def test_get_stand_polygon_default(monkeypatch): + """get_stand по умолчанию test.""" + monkeypatch.setattr(auth, "get_mode", lambda: "polygon") + monkeypatch.setattr(auth, "get_polygon_stand", lambda: "test") + assert auth.get_stand() == "polygon_test" + + +def test_get_mode_no_polygon_endpoint(monkeypatch): + """Без POLYGON_ENDPOINT -> всегда cloud.""" + from flask import Flask + app = Flask(__name__) + app.config["POLYGON_ENDPOINT"] = "" + monkeypatch.setattr(auth, "current_app", app) + # Мокаем request.cookies + monkeypatch.setattr(auth, "request", type('obj', (object,), { + "cookies": {} + })()) + assert auth.get_mode() == "cloud" + + +def test_get_mode_default_polygon(monkeypatch): + """POLYGON_ENDPOINT задан -> default polygon.""" + from flask import Flask + app = Flask(__name__) + app.config["POLYGON_ENDPOINT"] = "https://polygon.test/api/v1/svc" + monkeypatch.setattr(auth, "current_app", app) + # request.cookies.get("mode", default_value) + monkeypatch.setattr(auth, "request", type('obj', (object,), { + "cookies": type('obj', (object,), { + "get": lambda s, d, default="polygon": d if d else default + })() + })()) + assert auth.get_mode() == "polygon" + + +def test_get_token_polygon_mode(monkeypatch): + """В эмуляции — только env-токен.""" + monkeypatch.setattr(auth, "get_mode", lambda: "polygon") + from flask import Flask + app = Flask(__name__) + app.config["NUBES_API_TOKEN"] = "env-jwt" + monkeypatch.setattr(auth, "current_app", app) + assert auth.get_token() == "env-jwt" + + +def test_whitelist_mode_invalid(monkeypatch): + """Невалидный mode -> default polygon.""" + from flask import Flask + app = Flask(__name__) + app.config["POLYGON_ENDPOINT"] = "https://polygon.test/api/v1/svc" + monkeypatch.setattr(auth, "current_app", app) + monkeypatch.setattr(auth, "request", type('obj', (object,), { + "cookies": type('obj', (object,), { + "get": lambda s, d, default="polygon": "evil" + })() + })()) + assert auth.get_mode() == "polygon" # fallback + + +def test_whitelist_stand_invalid(monkeypatch): + """Невалидный stand -> default test.""" + monkeypatch.setattr(auth, "request", type('obj', (object,), { + "cookies": type('obj', (object,), { + "get": lambda s, d, default="test": "../../etc" + })() + })()) + assert auth.get_polygon_stand() == "test" # fallback diff --git a/tests/test_db_save_run.py b/tests/test_db_save_run.py new file mode 100644 index 0000000..7dc9376 --- /dev/null +++ b/tests/test_db_save_run.py @@ -0,0 +1,114 @@ +""" +Тесты: save_run — ручной UPSERT (UPDATE -> INSERT). +""" +import json +import db.save_run as sv + + +class FakeCursor: + def __init__(self): + self.executed = [] + self.rowcount = 0 # UPDATE not affected (no existing row) + + def execute(self, sql, params=None): + self.executed.append((sql, params)) + + def close(self): + pass + + +class FakeConn: + def __init__(self): + self.committed = False + self.rolled_back = False + self.cursor_obj = FakeCursor() + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.committed = True + + def rollback(self): + self.rolled_back = True + + +def test_first_call_inserts(monkeypatch): + """Первый вызов save_run (op_uid=None) -> INSERT.""" + conn = FakeConn() + monkeypatch.setattr(sv, "get_conn", lambda: conn) + put_calls = [] + monkeypatch.setattr(sv, "put_conn", lambda c: put_calls.append(c)) + + sv.save_run("cid", "test", "u@e", 1, "", "create", 18, + None, "inst-1", "display", "RUNNING", 0, "", + {}, [], "1.0") + + sqls = [e[0].strip()[:20] for e in conn.cursor_obj.executed] + assert any("INSERT INTO runs" in s for s in sqls), f"Expected INSERT, got {sqls}" + assert conn.committed + assert len(put_calls) == 1 + + +def test_second_call_updates(monkeypatch): + """Второй вызов с тем же op_uid -> UPDATE, не INSERT.""" + conn = FakeConn() + monkeypatch.setattr(sv, "get_conn", lambda: conn) + monkeypatch.setattr(sv, "put_conn", lambda c: None) + + # Первый вызов — INSERT (rowcount=0) + sv.save_run("cid", "test", "u@e", 1, "svc", "create", 18, + "op-123", "inst-1", "display", "RUNNING", 0, "", + {"k": "v"}, [], "1.0") + + # Второй вызов — UPDATE (теперь строка существует) + conn.cursor_obj.rowcount = 1 + sv.save_run("cid", "test", "u@e", 1, "svc-final", "create", 18, + "op-123", "inst-1", "display", "OK", 1.5, "", + {"k": "v"}, [{"stage": 1}], "1.0", + instance_meta={"cpu": 2}) + + sqls = [e[0] for e in conn.cursor_obj.executed] + updates = [s for s in sqls if "UPDATE runs SET" in s] + inserts = [s for s in sqls if "INSERT INTO runs" in s] + # Первый вызов: UPDATE (rowcount=0) + INSERT. Второй: UPDATE (rowcount=1). + assert len(updates) == 2, f"Expected 2 UPDATEs, got {len(updates)}" + assert len(inserts) == 1, f"Expected 1 INSERT, got {len(inserts)}" + assert conn.committed + + +def test_update_with_new_status(monkeypatch): + """UPDATE меняет status с RUNNING на OK.""" + conn = FakeConn() + monkeypatch.setattr(sv, "get_conn", lambda: conn) + monkeypatch.setattr(sv, "put_conn", lambda c: None) + + # Первый вызов — INSERT (rowcount=0 по умолчанию) + sv.save_run("cid", "test", "u@e", 1, "", "create", 18, + "op-456", "inst-2", "d", "RUNNING", 0, "", {}, [], "1.0") + + # Второй вызов — UPDATE (rowcount=1) + conn.cursor_obj.rowcount = 1 + sv.save_run("cid", "test", "u@e", 1, "final-svc", "create", 18, + "op-456", "inst-2", "d", "OK", 2.0, "err", {}, [{"s": 1}], "1.0", + instance_meta={"ram": 4}) + + # Найти ПОСЛЕДНИЙ UPDATE-запрос и проверить параметры + update_params = None + for sql, params in conn.cursor_obj.executed: + if "UPDATE runs SET" in sql: + update_params = params + assert update_params is not None, "UPDATE not found" + assert update_params[0] == "OK" # status + assert update_params[1] == 2.0 # duration + assert update_params[2] == "err" # error_log + assert update_params[4] == "final-svc" # svc_name + assert update_params[6] == "op-456" # op_uid + + +def test_db_unavailable_skips(monkeypatch): + """Без БД save_run не падает.""" + monkeypatch.setattr(sv, "get_conn", lambda: None) + # Не должно быть исключений + sv.save_run("cid", "test", "", 1, "", "create", 18, + "op-x", "inst-x", "d", "OK", 0, "", {}, [], "1.0") diff --git a/tests/test_scenario_full.py b/tests/test_scenario_full.py new file mode 100644 index 0000000..a777e62 --- /dev/null +++ b/tests/test_scenario_full.py @@ -0,0 +1,196 @@ +""" +Интеграционные тесты сценариев с полигоном. + +Требует polygon_server (conftest.py, session scope). +""" +import json +import time + + +# ── Хелперы ── + +def _create_scenario(app_client, name, steps): + resp = app_client.post("/api/scenario/definitions", + json={"name": name, "steps": steps}, + headers={"Content-Type": "application/json"}) + return resp.get_json() + + +def _delete_scenario(app_client, def_id): + app_client.delete(f"/api/scenario/definitions/{def_id}") + + +def _run_scenario(app_client, def_id): + return app_client.post("/api/scenario/run", + json={"definition_id": def_id}, + headers={"Content-Type": "application/json"}) + + +def _poll_scenario(app_client, run_id, timeout=15): + deadline = time.time() + timeout + while time.time() < deadline: + resp = app_client.get(f"/api/scenario/run/{run_id}") + data = resp.get_json() + if data and data.get("status") != "RUNNING": + return data + time.sleep(0.5) + return {"status": "TIMEOUT"} + + +# ── Тесты ── + +class TestScenarioLifecycle: + """Полный цикл: создать определение -> запустить -> проверить результат.""" + + def test_list_scenarios(self, app_client): + resp = app_client.get("/api/scenarios") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, list) + + def test_create_and_run_simple_scenario(self, app_client): + """Сценарий create -> delete. Проверяем что оба шага OK.""" + steps = [ + {"service_id": 1, "operation": "create", "params": {}, "output": "d1"}, + {"service_id": 1, "operation": "delete", "instance_ref": "d1", "params": {}}, + ] + name = f"test-simple-{int(time.time())}" + created = _create_scenario(app_client, name, steps) + assert "id" in created, f"Create failed: {created}" + + resp = _run_scenario(app_client, created["id"]) + assert resp.status_code == 202, f"Run failed: {resp.get_json()}" + run_data = resp.get_json() + assert run_data["status"] == "RUNNING" + run_id = run_data["run_id"] + + result = _poll_scenario(app_client, run_id) + assert result["status"] == "OK", f"Scenario failed: {result}" + assert result["current_step"] == 2 + assert result["total_steps"] == 2 + + _delete_scenario(app_client, created["id"]) + + def test_scenario_404_unknown_id(self, app_client): + resp = _run_scenario(app_client, 99999) + assert resp.status_code == 404 + + def test_scenario_lock_prevents_second_run(self, app_client): + """Запуск во время выполнения -> 409.""" + steps = [ + {"service_id": 1, "operation": "create", "params": {}, "output": "lock1"}, + {"service_id": 1, "operation": "delete", "instance_ref": "lock1", "params": {}}, + ] + name = f"test-lock-{int(time.time())}" + created = _create_scenario(app_client, name, steps) + + # Первый запуск + resp1 = _run_scenario(app_client, created["id"]) + assert resp1.status_code == 202 + + # Второй запуск — должен получить 409 + resp2 = _run_scenario(app_client, created["id"]) + assert resp2.status_code == 409, f"Expected 409, got {resp2.status_code}: {resp2.get_json()}" + + # Ждём завершения первого + run_id = resp1.get_json()["run_id"] + _poll_scenario(app_client, run_id) + + _delete_scenario(app_client, created["id"]) + + def test_scenario_can_run_after_completion(self, app_client): + """После завершения можно запустить снова.""" + steps = [ + {"service_id": 1, "operation": "create", "params": {}, "output": "again"}, + {"service_id": 1, "operation": "delete", "instance_ref": "again", "params": {}}, + ] + name = f"test-again-{int(time.time())}" + created = _create_scenario(app_client, name, steps) + + # Первый прогон + resp1 = _run_scenario(app_client, created["id"]) + run1_id = resp1.get_json()["run_id"] + result1 = _poll_scenario(app_client, run1_id) + assert result1["status"] == "OK" + + # Второй прогон — должен работать + resp2 = _run_scenario(app_client, created["id"]) + assert resp2.status_code == 202, f"Second run failed: {resp2.get_json()}" + run2_id = resp2.get_json()["run_id"] + result2 = _poll_scenario(app_client, run2_id) + assert result2["status"] == "OK" + + _delete_scenario(app_client, created["id"]) + + def test_scenario_status_endpoint(self, app_client): + """GET /api/scenario/status возвращает список.""" + resp = app_client.get("/api/scenario/status") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, list) + + +class TestScenarioCRUD: + """CRUD определения сценариев.""" + + def test_create_and_get_definition(self, app_client): + name = f"test-crud-{int(time.time())}" + steps = [{"service_id": 1, "operation": "create", "params": {}}] + created = _create_scenario(app_client, name, steps) + assert "id" in created + + resp = app_client.get(f"/api/scenario/definitions/{created['id']}") + data = resp.get_json() + assert data["name"] == name + assert data["version"] == 1 + + _delete_scenario(app_client, created["id"]) + + def test_update_increments_version(self, app_client): + name = f"test-ver-{int(time.time())}" + created = _create_scenario(app_client, name, + [{"service_id": 1, "operation": "create", "params": {}}]) + + resp = app_client.put(f"/api/scenario/definitions/{created['id']}", + json={"name": name, "steps": [ + {"service_id": 1, "operation": "create", "params": {}}, + {"service_id": 1, "operation": "delete", "params": {}} + ], "version": 1}, + headers={"Content-Type": "application/json"}) + data = resp.get_json() + assert data.get("ok"), f"Update failed: {data}" + assert data["version"] == 2 + + _delete_scenario(app_client, created["id"]) + + def test_version_conflict_returns_409(self, app_client): + name = f"test-conflict-{int(time.time())}" + created = _create_scenario(app_client, name, + [{"service_id": 1, "operation": "create", "params": {}}]) + + # Обновляем с version=1 -> OK + app_client.put(f"/api/scenario/definitions/{created['id']}", + json={"name": name, "steps": [ + {"service_id": 1, "operation": "create", "params": {}} + ], "version": 1}, + headers={"Content-Type": "application/json"}) + + # Ещё раз с version=1 -> conflict + resp = app_client.put(f"/api/scenario/definitions/{created['id']}", + json={"name": name, "steps": [ + {"service_id": 1, "operation": "create", "params": {}} + ], "version": 1}, + headers={"Content-Type": "application/json"}) + data = resp.get_json() + assert data.get("conflict"), f"Expected conflict, got: {data}" + + _delete_scenario(app_client, created["id"]) + + def test_soft_delete(self, app_client): + name = f"test-del-{int(time.time())}" + created = _create_scenario(app_client, name, + [{"service_id": 1, "operation": "create", "params": {}}]) + + resp = app_client.delete(f"/api/scenario/definitions/{created['id']}") + assert resp.status_code == 200 + assert resp.get_json().get("ok")