diff --git a/site/from_stands.py b/site/from_stands.py index eef08b3..3b7120e 100644 --- a/site/from_stands.py +++ b/site/from_stands.py @@ -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) diff --git a/site/state_machine.py b/site/state_machine.py index b048018..d152a1e 100644 --- a/site/state_machine.py +++ b/site/state_machine.py @@ -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 diff --git a/tests/test_converter.py b/tests/test_converter.py new file mode 100644 index 0000000..cec5bdd --- /dev/null +++ b/tests/test_converter.py @@ -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) diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py new file mode 100644 index 0000000..ac8973b --- /dev/null +++ b/tests/test_state_machine.py @@ -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