test: 19 юнит-тестов (converter + state_machine) — все PASS
This commit is contained in:
+10
-1
@@ -165,13 +165,22 @@ def _convert_operations(operations):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def convert_service(stands_yaml_path):
|
||||
"""Читает STANDS YAML, возвращает polygon-конфиг как dict."""
|
||||
"""Читает STANDS YAML из файла, возвращает polygon-конфиг как dict."""
|
||||
with open(stands_yaml_path, "r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
return convert_service_from_dict(raw)
|
||||
|
||||
|
||||
def convert_service_from_dict(raw):
|
||||
"""Конвертирует STANDS YAML (уже распарсенный dict) → polygon-конфиг.
|
||||
|
||||
Отдельная функция для тестов — не требует файла.
|
||||
"""
|
||||
|
||||
out = {}
|
||||
|
||||
# 1. Поля верхнего уровня (1:1)
|
||||
|
||||
@@ -145,9 +145,10 @@ def _merge_params(inst, op_params, svc_operation_id, services):
|
||||
def _extract_subresource_name(op_params, subresource_name):
|
||||
"""Извлечь имя subresource-объекта из параметров операции.
|
||||
|
||||
Например для create_user: ищем параметр с кодом "username" или "user".
|
||||
Если не нашли — используем "default".
|
||||
Ищет первое непустое строковое значение в op_params.
|
||||
Если не нашли — fallback на subresource_name.
|
||||
"""
|
||||
# Ищем по коду параметра (нужен доступ к cfsParams, но здесь у нас только op_params по id)
|
||||
# Простой fallback: имя subresource
|
||||
for val in op_params.values():
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val
|
||||
return subresource_name
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Tests for from_stands.py — конвертер STANDS YAML → polygon config.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# Добавляем site/ в path для импорта from_stands
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "site"))
|
||||
import from_stands
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_yaml():
|
||||
"""Минимальный STANDS YAML для тестов."""
|
||||
return {
|
||||
"name": "dummy",
|
||||
"service_id": 1,
|
||||
"service_display_name": "Болванка",
|
||||
"service_short_name": "dummy",
|
||||
"lifecycle": {
|
||||
"suspend_on_destroy_default": True,
|
||||
"adopt_existing_on_create_default": False,
|
||||
},
|
||||
"operations": [
|
||||
{
|
||||
"name": "create",
|
||||
"id": 18,
|
||||
"kind": "instance",
|
||||
"action": "create",
|
||||
"params": [
|
||||
{
|
||||
"id": 242,
|
||||
"code": "resourceRealm",
|
||||
"data_type": "string",
|
||||
"required": True,
|
||||
"default": "dummy",
|
||||
"value_list": ["dummy"],
|
||||
},
|
||||
{
|
||||
"id": 198,
|
||||
"code": "durationMs",
|
||||
"data_type": "integer >= 0",
|
||||
"required": True,
|
||||
"default": "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"name": "delete", "id": 71, "kind": "instance", "action": "delete"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_yaml():
|
||||
"""STANDS YAML с subresources для тестов."""
|
||||
return {
|
||||
"name": "postgres",
|
||||
"service_id": 90,
|
||||
"service_display_name": "PostgreSQL",
|
||||
"service_short_name": "postgres",
|
||||
"lifecycle": {
|
||||
"suspend_on_destroy_default": True,
|
||||
"adopt_existing_on_create_default": False,
|
||||
},
|
||||
"operations": [
|
||||
{
|
||||
"name": "create",
|
||||
"id": 19,
|
||||
"kind": "instance",
|
||||
"action": "create",
|
||||
"params": [
|
||||
{
|
||||
"id": 788,
|
||||
"code": "clusterConfiguration",
|
||||
"data_type": "map-fixed",
|
||||
"required": True,
|
||||
"sub_params": [
|
||||
{"id": 106, "code": "replicas", "data_type": "integer > 0", "required": True, "default": "1"},
|
||||
{"id": 107, "code": "disk", "data_type": "integer > 0", "required": True, "default": "10"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{"name": "create_user", "id": 44, "kind": "subresource", "action": "create"},
|
||||
{"name": "delete_user", "id": 45, "kind": "subresource", "action": "delete"},
|
||||
{"name": "create_database", "id": 46, "kind": "subresource", "action": "create"},
|
||||
{"name": "delete_database", "id": 47, "kind": "subresource", "action": "delete"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestConvertService:
|
||||
"""Тесты конвертации одного сервиса."""
|
||||
|
||||
def test_basic_fields(self, dummy_yaml):
|
||||
"""Поля верхнего уровня переносятся 1:1."""
|
||||
cfg = from_stands.convert_service_from_dict(dummy_yaml)
|
||||
assert cfg["name"] == "dummy"
|
||||
assert cfg["service_id"] == 1
|
||||
assert cfg["service_display_name"] == "Болванка"
|
||||
|
||||
def test_lifecycle(self, dummy_yaml):
|
||||
"""lifecycle конвертируется в camelCase."""
|
||||
cfg = from_stands.convert_service_from_dict(dummy_yaml)
|
||||
assert cfg["lifecycle"]["suspendOnDestroyDefault"] is True
|
||||
assert cfg["lifecycle"]["adoptExistingOnCreateDefault"] is False
|
||||
|
||||
def test_operations_count(self, dummy_yaml):
|
||||
"""Количество операций совпадает."""
|
||||
cfg = from_stands.convert_service_from_dict(dummy_yaml)
|
||||
assert len(cfg["operations"]) == 2
|
||||
|
||||
def test_html_unescape(self, dummy_yaml):
|
||||
"""HTML entities раскодируются: > → >."""
|
||||
cfg = from_stands.convert_service_from_dict(dummy_yaml)
|
||||
cfs = {p["svcOperationCfsParamId"]: p for p in cfg["cfsParams"]}
|
||||
assert cfs[198]["dataType"] == "integer >= 0"
|
||||
assert ">" not in cfs[198]["dataType"]
|
||||
|
||||
def test_value_list(self, dummy_yaml):
|
||||
"""valueList конвертируется."""
|
||||
cfg = from_stands.convert_service_from_dict(dummy_yaml)
|
||||
cfs = {p["svcOperationCfsParamId"]: p for p in cfg["cfsParams"]}
|
||||
assert cfs[242]["valueList"] == ["dummy"]
|
||||
|
||||
def test_state_params_from_create(self, dummy_yaml):
|
||||
"""stateParams генерируются из create-операции."""
|
||||
cfg = from_stands.convert_service_from_dict(dummy_yaml)
|
||||
assert cfg["stateParams"]["resourceRealm"] == "dummy"
|
||||
assert cfg["stateParams"]["durationMs"] == "0"
|
||||
|
||||
def test_data_descriptor_from_sub_params(self, pg_yaml):
|
||||
"""map-fixed/sub_params → dataDescriptor."""
|
||||
cfg = from_stands.convert_service_from_dict(pg_yaml)
|
||||
cfs = {p["svcOperationCfsParamId"]: p for p in cfg["cfsParams"]}
|
||||
dd = cfs[788]["dataDescriptor"]
|
||||
assert "replicas" in dd
|
||||
assert "disk" in dd
|
||||
assert dd["replicas"]["dataType"] == "integer > 0"
|
||||
assert dd["replicas"]["defaultValue"] == "1"
|
||||
|
||||
def test_state_out_subresources(self, pg_yaml):
|
||||
"""stateOut генерируется из subresource-операций."""
|
||||
cfg = from_stands.convert_service_from_dict(pg_yaml)
|
||||
assert "users" in cfg["stateOut"]
|
||||
assert "databases" in cfg["stateOut"]
|
||||
|
||||
def test_cfs_params_by_op(self, dummy_yaml):
|
||||
"""cfsParamsByOp связывает opId → список paramId."""
|
||||
cfg = from_stands.convert_service_from_dict(dummy_yaml)
|
||||
assert 18 in cfg["cfsParamsByOp"]
|
||||
assert 242 in cfg["cfsParamsByOp"][18]
|
||||
assert 198 in cfg["cfsParamsByOp"][18]
|
||||
|
||||
|
||||
class TestFileRoundtrip:
|
||||
"""Тесты записи/чтения YAML."""
|
||||
|
||||
def test_roundtrip(self, dummy_yaml):
|
||||
"""Сгенерированный YAML можно записать и прочитать обратно."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
fname = f.name
|
||||
|
||||
try:
|
||||
# Записать
|
||||
with open(fname, "w") as f:
|
||||
cfg = from_stands.convert_service_from_dict(dummy_yaml)
|
||||
yaml.dump(cfg, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
# Прочитать
|
||||
with open(fname) as f:
|
||||
loaded = yaml.safe_load(f)
|
||||
|
||||
assert loaded["name"] == "dummy"
|
||||
assert len(loaded["cfsParams"]) == 2
|
||||
finally:
|
||||
os.unlink(fname)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Tests for state_machine.py — apply_effect для всех типов операций.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "site"))
|
||||
import mock_state
|
||||
import state_machine
|
||||
|
||||
# Минимальный services-словарь для тестов
|
||||
SERVICES = {
|
||||
1: {
|
||||
"name": "dummy",
|
||||
"service_id": 1,
|
||||
"service_display_name": "Болванка",
|
||||
"stateParams": {"resourceRealm": "dummy", "durationMs": "0"},
|
||||
"stateOut": {},
|
||||
"cfsParams": [
|
||||
{"svcOperationCfsParamId": 242, "svcOperationCfsParam": "resourceRealm", "dataType": "string"},
|
||||
{"svcOperationCfsParamId": 198, "svcOperationCfsParam": "durationMs", "dataType": "integer"},
|
||||
],
|
||||
"cfsParamsByOp": {18: [242, 198], 92: [242, 198]},
|
||||
"operations": [
|
||||
{"svcOperationId": 18, "operation": "create", "kind": "instance", "action": "create"},
|
||||
{"svcOperationId": 92, "operation": "modify", "kind": "instance", "action": "modify"},
|
||||
{"svcOperationId": 71, "operation": "delete", "kind": "instance", "action": "delete"},
|
||||
{"svcOperationId": 93, "operation": "suspend", "kind": "instance", "action": "suspend"},
|
||||
{"svcOperationId": 94, "operation": "resume", "kind": "instance", "action": "resume"},
|
||||
],
|
||||
},
|
||||
90: {
|
||||
"name": "postgres",
|
||||
"service_id": 90,
|
||||
"service_display_name": "PostgreSQL",
|
||||
"stateParams": {"version": "17"},
|
||||
"stateOut": {"users": {}, "databases": {}},
|
||||
"cfsParams": [],
|
||||
"cfsParamsByOp": {},
|
||||
"operations": [
|
||||
{"svcOperationId": 19, "operation": "create", "kind": "instance", "action": "create"},
|
||||
{"svcOperationId": 44, "operation": "create_user", "kind": "subresource", "action": "create"},
|
||||
{"svcOperationId": 45, "operation": "delete_user", "kind": "subresource", "action": "delete"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
"""Перед каждым тестом — чистый state."""
|
||||
mock_state.state.reset()
|
||||
yield
|
||||
|
||||
|
||||
def _create_and_run(service_id, display_name, svc_op_id, operation, kind="instance", action="create"):
|
||||
"""Хелпер: создать инстанс + операцию + run. Возвращает (instanceUid, opUid)."""
|
||||
st = mock_state.state
|
||||
uid = st.create_instance(service_id, display_name, SERVICES[service_id])
|
||||
op_uid = st.create_operation(uid, svc_op_id, operation, kind, action)
|
||||
state_machine.apply_effect(op_uid, st, SERVICES)
|
||||
return uid, op_uid
|
||||
|
||||
|
||||
def _run_op(instance_uid, svc_op_id, operation, kind="instance", action="create"):
|
||||
"""Хелпер: создать операцию на СУЩЕСТВУЮЩЕМ инстансе + run. Возвращает opUid."""
|
||||
st = mock_state.state
|
||||
op_uid = st.create_operation(instance_uid, svc_op_id, operation, kind, action)
|
||||
state_machine.apply_effect(op_uid, st, SERVICES)
|
||||
return op_uid
|
||||
|
||||
|
||||
class TestInstanceOperations:
|
||||
"""Тесты instance-операций."""
|
||||
|
||||
def test_create_sets_running_and_params(self):
|
||||
"""create → статус running, params из шаблона."""
|
||||
uid, _ = _create_and_run(1, "test", 18, "create")
|
||||
inst = mock_state.state.get_instance(uid)
|
||||
assert inst["status"] == "running"
|
||||
assert inst["state"]["params"]["resourceRealm"] == "dummy"
|
||||
assert inst["state"]["params"]["durationMs"] == "0"
|
||||
|
||||
def test_create_sets_state_out(self):
|
||||
"""create → state.out из шаблона (пустой для dummy, заполнен для pg)."""
|
||||
uid, _ = _create_and_run(90, "pg", 19, "create")
|
||||
inst = mock_state.state.get_instance(uid)
|
||||
assert "users" in inst["state"]["out"]
|
||||
assert "databases" in inst["state"]["out"]
|
||||
|
||||
def test_delete_removes_instance(self):
|
||||
"""delete → инстанс удалён."""
|
||||
uid, _ = _create_and_run(1, "test", 18, "create")
|
||||
_run_op(uid, 71, "delete", action="delete")
|
||||
assert mock_state.state.get_instance(uid) is None
|
||||
|
||||
def test_suspend_sets_suspended(self):
|
||||
"""suspend → статус suspended."""
|
||||
uid, _ = _create_and_run(1, "test", 18, "create")
|
||||
_run_op(uid, 93, "suspend", action="suspend")
|
||||
inst = mock_state.state.get_instance(uid)
|
||||
assert inst["status"] == "suspended"
|
||||
|
||||
def test_resume_sets_running(self):
|
||||
"""resume → статус running."""
|
||||
uid, _ = _create_and_run(1, "test", 18, "create")
|
||||
_run_op(uid, 93, "suspend", action="suspend")
|
||||
_run_op(uid, 94, "resume", action="resume")
|
||||
inst = mock_state.state.get_instance(uid)
|
||||
assert inst["status"] == "running"
|
||||
|
||||
def test_modify_merges_params(self):
|
||||
"""modify → params мержатся."""
|
||||
uid, _ = _create_and_run(1, "test", 18, "create")
|
||||
# Установить новый paramValue через op_params
|
||||
op2 = _run_op(uid, 92, "modify", action="modify")
|
||||
mock_state.state.op_params[op2][198] = "999"
|
||||
state_machine.apply_effect(op2, mock_state.state, SERVICES)
|
||||
inst = mock_state.state.get_instance(uid)
|
||||
assert inst["state"]["params"]["durationMs"] == "999"
|
||||
# resourceRealm остался неизменным
|
||||
assert inst["state"]["params"]["resourceRealm"] == "dummy"
|
||||
|
||||
|
||||
class TestSubresourceOperations:
|
||||
"""Тесты subresource-операций."""
|
||||
|
||||
def test_create_user_adds_to_state_out(self):
|
||||
"""create_user → state.out.users[name] = {}."""
|
||||
uid, _ = _create_and_run(90, "pg", 19, "create")
|
||||
op2 = _run_op(uid, 44, "create_user", kind="subresource", action="create")
|
||||
mock_state.state.op_params[op2][999] = "alice"
|
||||
state_machine.apply_effect(op2, mock_state.state, SERVICES)
|
||||
inst = mock_state.state.get_instance(uid)
|
||||
assert "alice" in inst["state"]["out"].get("users", {})
|
||||
|
||||
def test_delete_user_removes_from_state_out(self):
|
||||
"""delete_user → state.out.users[name] удалён."""
|
||||
uid, _ = _create_and_run(90, "pg", 19, "create")
|
||||
# Добавить пользователя вручную
|
||||
inst = mock_state.state.get_instance(uid)
|
||||
inst["state"]["out"]["users"]["alice"] = {}
|
||||
|
||||
# Удалить
|
||||
op2 = _run_op(uid, 45, "delete_user", kind="subresource", action="delete")
|
||||
mock_state.state.op_params[op2][999] = "alice"
|
||||
state_machine.apply_effect(op2, mock_state.state, SERVICES)
|
||||
assert "alice" not in inst["state"]["out"]["users"]
|
||||
|
||||
|
||||
class TestReset:
|
||||
"""Тест сброса состояния."""
|
||||
|
||||
def test_reset_clears_all(self):
|
||||
"""reset → всё пусто."""
|
||||
_create_and_run(1, "t1", 18, "create")
|
||||
_create_and_run(1, "t2", 18, "create")
|
||||
mock_state.state.reset()
|
||||
assert len(mock_state.state.instances) == 0
|
||||
assert len(mock_state.state.operations) == 0
|
||||
Reference in New Issue
Block a user