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:
2026-08-04 08:48:36 +04:00
parent 764592ba60
commit b89c0138e4
3 changed files with 435 additions and 0 deletions
+125
View File
@@ -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
+114
View File
@@ -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")
+196
View File
@@ -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")