test: +13 тестов — save_run UPSERT, auth режимы/URL, сценарии
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 новых)
This commit is contained in:
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user