From 325fd7d87028450361f804caedf3ec78dc1ce6c8 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 18:22:07 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add unit tests for DependentString, NodeTemplate, RegisteredNode --- state-manager/tests/_notes/FRAMEWORK.md | 5 + .../unit/models/test_dependent_string.py | 130 +++++++++++++ .../unit/models/test_node_template_model.py | 146 ++++++++++++++ .../tests/unit/models/test_registered_node.py | 184 ++++++++++++++++++ 4 files changed, 465 insertions(+) create mode 100644 state-manager/tests/_notes/FRAMEWORK.md create mode 100644 state-manager/tests/unit/models/test_dependent_string.py create mode 100644 state-manager/tests/unit/models/test_node_template_model.py create mode 100644 state-manager/tests/unit/models/test_registered_node.py diff --git a/state-manager/tests/_notes/FRAMEWORK.md b/state-manager/tests/_notes/FRAMEWORK.md new file mode 100644 index 00000000..b9efc1c8 --- /dev/null +++ b/state-manager/tests/_notes/FRAMEWORK.md @@ -0,0 +1,5 @@ +# Notes + +- Tests authored with pytest, matching repository conventions discovered via search. +- Models use Pydantic v2 (field_validator), so assertions expect pydantic.ValidationError wrapping ValueError messages from validators. +- No new dependencies introduced. \ No newline at end of file diff --git a/state-manager/tests/unit/models/test_dependent_string.py b/state-manager/tests/unit/models/test_dependent_string.py new file mode 100644 index 00000000..e05ecf01 --- /dev/null +++ b/state-manager/tests/unit/models/test_dependent_string.py @@ -0,0 +1,130 @@ +import pytest + +# Attempt multiple import paths to accommodate common layouts +try: + # e.g., src/state_manager/models/dependent_string.py + from state_manager.models.dependent_string import DependentString, Dependent +except Exception: + try: + # e.g., state_manager/dependent_string.py + from state_manager.dependent_string import DependentString, Dependent + except Exception: + try: + # e.g., state-manager/state_manager/models/dependent_string.py accessible as package + from models.dependent_string import DependentString, Dependent + except Exception: + # Fallback: relative import if tests live alongside source + from dependent_string import DependentString, Dependent # type: ignore + + +class TestCreateDependentString: + def test_no_placeholders_returns_head_only_and_empty_dependents(self): + s = "plain string without placeholders" + ds = DependentString.create_dependent_string(s) + assert isinstance(ds, DependentString) + assert ds.head == s + assert ds.dependents == {} + # get_identifier_field should be empty for no dependents + assert ds.get_identifier_field() == [] + + def test_single_placeholder_happy_path(self): + template = "Hello ${{ step.outputs.foo }} world" + ds = DependentString.create_dependent_string(template) + # Head is the prefix before the first placeholder + assert ds.head == "Hello " + # One dependent keyed by order 0 + assert list(ds.dependents.keys()) == [0] + dep = ds.dependents[0] + assert isinstance(dep, Dependent) + assert dep.identifier == "step" + assert dep.field == "foo" + assert dep.tail == " world" + # Not set yet -> generate_string should raise + with pytest.raises(ValueError) as exc: + ds.generate_string() + assert "Dependent value is not set" in str(exc.value) + # After setting, generation should succeed + ds.set_value("step", "foo", "BAR") + assert ds.generate_string() == "Hello BAR world" + + def test_placeholder_at_end_results_in_empty_tail(self): + template = "Hi ${{ a.outputs.x }}" + ds = DependentString.create_dependent_string(template) + assert ds.dependents[0].tail == "" + ds.set_value("a", "x", "V") + assert ds.generate_string() == "Hi V" + + def test_multiple_placeholders_in_order(self): + template = "Start ${{ a.outputs.x }} mid ${{ b.outputs.y }} end" + ds = DependentString.create_dependent_string(template) + # Keys should reflect insertion order (0, 1) when sorted + assert sorted(ds.dependents.keys()) == [0, 1] + assert ds.dependents[0].identifier == "a" + assert ds.dependents[0].field == "x" + assert ds.dependents[0].tail == " mid " + assert ds.dependents[1].identifier == "b" + assert ds.dependents[1].field == "y" + assert ds.dependents[1].tail == " end" + ds.set_value("a", "x", "AX") + ds.set_value("b", "y", "BY") + assert ds.generate_string() == "Start AX mid BY end" + + def test_unclosed_placeholder_raises_value_error(self): + template = "Start ${{ a.outputs.x end" + with pytest.raises(ValueError) as exc: + DependentString.create_dependent_string(template) + msg = str(exc.value) + assert "Invalid syntax string placeholder" in msg + assert "'${{'" in msg or "not closed" in msg + + def test_invalid_placeholder_wrong_parts_count_raises(self): + # Missing the third part after outputs + template = "Start ${{ a.outputs }} end" + with pytest.raises(ValueError) as exc: + DependentString.create_dependent_string(template) + assert "Invalid syntax string placeholder" in str(exc.value) + + def test_invalid_placeholder_wrong_keyword_raises(self): + template = "Start ${{ a.outputz.x }} end" + with pytest.raises(ValueError) as exc: + DependentString.create_dependent_string(template) + assert "Invalid syntax string placeholder" in str(exc.value) + + def test_placeholder_with_extra_whitespace_is_parsed(self): + template = "P ${{ step . outputs . foo }} T" + ds = DependentString.create_dependent_string(template) + assert ds.dependents[0].identifier == "step" + assert ds.dependents[0].field == "foo" + ds.set_value("step", "foo", "VAL") + assert ds.generate_string() == "P VAL T" + + +class TestMappingAndSetValue: + def test_get_identifier_field_returns_unique_keys(self): + template = "A ${{ a.outputs.x }} B ${{ a.outputs.x }} C ${{ b.outputs.y }}" + ds = DependentString.create_dependent_string(template) + # Should return two unique keys: ('a','x') and ('b','y'), order not guaranteed + keys = ds.get_identifier_field() + assert set(keys) == {("a", "x"), ("b", "y")} + + def test_set_value_updates_all_matching_dependents(self): + template = "A ${{ a.outputs.x }} B ${{ a.outputs.x }}" + ds = DependentString.create_dependent_string(template) + ds.set_value("a", "x", "V") + # Both dependents should get the same value and render twice + assert ds.generate_string() == "A V B V" + + def test_set_value_with_unknown_mapping_raises_keyerror(self): + template = "A ${{ a.outputs.x }}" + ds = DependentString.create_dependent_string(template) + with pytest.raises(KeyError): + ds.set_value("unknown", "field", "V") + + def test_build_mapping_is_idempotent_and_cached(self): + template = "A ${{ a.outputs.x }} B ${{ b.outputs.y }}" + ds = DependentString.create_dependent_string(template) + # First call builds mapping + keys1 = set(ds.get_identifier_field()) + # Second call should not change + keys2 = set(ds.get_identifier_field()) + assert keys1 == keys2 == {("a", "x"), ("b", "y")} diff --git a/state-manager/tests/unit/models/test_node_template_model.py b/state-manager/tests/unit/models/test_node_template_model.py new file mode 100644 index 00000000..a117e384 --- /dev/null +++ b/state-manager/tests/unit/models/test_node_template_model.py @@ -0,0 +1,146 @@ +""" +Test suite for NodeTemplate and Unites models. + +Testing library/framework: pytest (with Pydantic v2 ValidationError assertions). + +Covers: +- Successful model creation with valid data +- Validators: node_name, identifier (non-empty), next_nodes (non-empty, unique) +- Optional field `next_nodes` can be None +- Optional nested model `unites` with identifier non-empty when provided +- get_dependent_strings: returns one entry per input; raises on non-string values; handles empty inputs +""" + +import builtins +import types +import sys +import inspect +import pytest + +try: + # Try common import paths — adapt if project layout differs. + # Prefer the actual model module housing NodeTemplate and Unites. + from state_manager.models.node_template_model import NodeTemplate, Unites # type: ignore + import state_manager.models.node_template_model as node_template_module # type: ignore +except Exception: + try: + from state_manager.models.node_template import NodeTemplate, Unites # type: ignore + import state_manager.models.node_template as node_template_module # type: ignore + except Exception: + # Fallback: dynamically locate the module that defines NodeTemplate in sys.modules + # This keeps tests resilient if the path changes while still validating behavior. + candidates = [] + for name, mod in list(sys.modules.items()): + if not isinstance(mod, types.ModuleType): + continue + try: + if hasattr(mod, "NodeTemplate") and hasattr(mod, "Unites"): + candidates.append(mod) + except Exception: + continue + if not candidates: + # Attempt a lazy import scan by probing typical packages under repo + # This is a last-resort guard — if it fails, tests will clearly indicate missing module. + raise + node_template_module = candidates[0] + NodeTemplate = getattr(node_template_module, "NodeTemplate") + Unites = getattr(node_template_module, "Unites") + + +from pydantic import ValidationError + + +def _valid_payload(**overrides): + data = { + "node_name": "node_A", + "namespace": "ns.main", + "identifier": "node-A-id", + "inputs": {"foo": "bar", "alpha": "beta"}, + "next_nodes": ["node_B", "node_C"], + "unites": Unites(identifier="u-1"), + } + data.update(overrides) + return data + + +class TestNodeTemplateValidation: + def test_valid_creation_happy_path(self): + model = NodeTemplate(**_valid_payload()) + assert model.node_name == "node_A" + assert model.namespace == "ns.main" + assert model.identifier == "node-A-id" + assert model.next_nodes == ["node_B", "node_C"] + assert model.unites is not None and model.unites.identifier == "u-1" + + @pytest.mark.parametrize( + "field, bad_value, expected_msg", + [ + ("node_name", "", "Node name cannot be empty"), + ("identifier", "", "Node identifier cannot be empty"), + ], + ) + def test_required_string_fields_must_not_be_empty(self, field, bad_value, expected_msg): + payload = _valid_payload(**{field: bad_value}) + with pytest.raises(ValidationError) as ei: + NodeTemplate(**payload) + # ValueError raised in field_validator should surface inside ValidationError text + assert expected_msg in str(ei.value) + + def test_next_nodes_none_is_allowed(self): + payload = _valid_payload(next_nodes=None) + model = NodeTemplate(**payload) + assert model.next_nodes is None + + def test_next_nodes_rejects_empty_and_duplicates_with_aggregated_errors(self): + # includes duplicate "dup" and an empty string + payload = _valid_payload(next_nodes=["ok1", "dup", "dup", ""]) + with pytest.raises(ValidationError) as ei: + NodeTemplate(**payload) + msg = str(ei.value) + assert "Next node identifier dup is not unique" in msg + assert "Next node identifier cannot be empty" in msg + + def test_unites_identifier_must_not_be_empty_when_provided(self): + payload = _valid_payload(unites=Unites(identifier="")) + with pytest.raises(ValidationError) as ei: + NodeTemplate(**payload) + assert "Unites identifier cannot be empty" in str(ei.value) + + +class TestGetDependentStrings: + def test_returns_one_dependent_string_per_input_value(self): + payload = _valid_payload(inputs={"a": "X", "b": "Y", "c": "Z"}) + model = NodeTemplate(**payload) + + # Prefer not to mock: assert count and type if available + result = model.get_dependent_strings() + assert isinstance(result, list) + assert len(result) == 3 + + # If DependentString is importable from the module, verify type + DepStr = getattr(node_template_module, "DependentString", None) + if DepStr is not None and inspect.isclass(DepStr): + assert all(isinstance(d, DepStr) for d in result) + + def test_raises_value_error_when_any_input_value_is_not_string(self): + payload = _valid_payload(inputs={"a": "X", "b": 123, "c": "Z"}) + model = NodeTemplate(**payload) + with pytest.raises(ValueError) as ei: + model.get_dependent_strings() + assert "Input 123 is not a string" in str(ei.value) + + def test_handles_empty_inputs_dict(self): + payload = _valid_payload(inputs={}) + model = NodeTemplate(**payload) + out = model.get_dependent_strings() + assert out == [] + + +# Extra edge cases: whitespace-only strings considered non-empty by type, +# but validators explicitly only check for equality with empty string. +# Decide expected behavior: whitespace is allowed per current implementation. +def test_whitespace_strings_are_allowed_by_validators(): + payload = _valid_payload(node_name=" name ", identifier=" id ") + model = NodeTemplate(**payload) + assert model.node_name.strip() == "name" + assert model.identifier.strip() == "id" \ No newline at end of file diff --git a/state-manager/tests/unit/models/test_registered_node.py b/state-manager/tests/unit/models/test_registered_node.py new file mode 100644 index 00000000..00581d42 --- /dev/null +++ b/state-manager/tests/unit/models/test_registered_node.py @@ -0,0 +1,184 @@ +# Framework/Library note: +# - Testing framework: pytest +# - Async support: pytest-asyncio (function-level @pytest.mark.asyncio) +# These tests focus on RegisteredNode's validation, indexes, and async query methods. + +import pytest +from pydantic import ValidationError +from unittest.mock import AsyncMock + +# Prefer canonical import; fallback to dynamic import if package layout differs. +try: + from state_manager.models.registered_node import RegisteredNode +except Exception: + # Dynamic fallback: locate a registered_node.py exposing RegisteredNode + import importlib.util + import sys + from pathlib import Path + + def _import_registered_node() -> type: + # Search up to repo root for a file named registered_node.py that defines RegisteredNode + start = Path(__file__).resolve() + roots = list(start.parents)[:6] # limit search depth + for root in roots: + for path in root.rglob("registered_node.py"): + try: + text = path.read_text(encoding="utf-8", errors="ignore") + if "class RegisteredNode" not in text: + continue + mod_name = f"_rn_{abs(hash(str(path)))}" + spec = importlib.util.spec_from_file_location(mod_name, path) + if not spec or not spec.loader: + continue + module = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = module + spec.loader.exec_module(module) # type: ignore[attr-defined] + if hasattr(module, "RegisteredNode"): + return getattr(module, "RegisteredNode") + except Exception: + continue + raise ImportError("Could not import RegisteredNode via dynamic search.") + RegisteredNode = _import_registered_node() + +# Local lightweight stand-in for NodeTemplate to avoid coupling to its full schema. +class _DummyTemplate: + def __init__(self, node_name: str, namespace: str) -> None: + self.node_name = node_name + self.namespace = namespace + +def _valid_node_data(): + return { + "name": "example-node", + "namespace": "example-ns", + "runtime_name": "rt", + "runtime_namespace": "rt-ns", + "inputs_schema": {"type": "object", "properties": {}}, + "outputs_schema": {"type": "object", "properties": {}}, + # 'secrets' intentionally omitted to verify default behavior + } + +@pytest.mark.parametrize( + "missing_field", + ["name", "namespace", "runtime_name", "runtime_namespace", "inputs_schema", "outputs_schema"], +) +def test_registered_node_required_fields_validation(missing_field): + data = _valid_node_data() + data.pop(missing_field) + with pytest.raises(ValidationError): + RegisteredNode(**data) + +def test_registered_node_secrets_default_and_not_shared(): + n1 = RegisteredNode(**_valid_node_data()) + n2 = RegisteredNode(**_valid_node_data() | {"name": "another", "namespace": "ns2"}) + assert isinstance(n1.secrets, list) + assert isinstance(n2.secrets, list) + assert n1.secrets == [] + assert n2.secrets == [] + assert n1.secrets is not n2.secrets + n1.secrets.append("S1") + assert n1.secrets == ["S1"] + assert n2.secrets == [] # ensure no shared mutable default + +def test_registered_node_invalid_schema_types_raise(): + bad_inputs = _valid_node_data() | {"inputs_schema": "not-a-dict"} + with pytest.raises(ValidationError): + RegisteredNode(**bad_inputs) + bad_outputs = _valid_node_data() | {"outputs_schema": ["also", "not", "a", "dict"]} + with pytest.raises(ValidationError): + RegisteredNode(**bad_outputs) + +def test_settings_contains_unique_index_over_name_and_namespace(): + # Validate presence and structure of the compound unique index (name, namespace). + settings = getattr(RegisteredNode, "Settings", None) + assert settings is not None, "Settings class is missing on RegisteredNode" + indexes = getattr(settings, "indexes", None) + assert indexes, "No indexes defined on RegisteredNode.Settings" + + unique_doc = None + for idx in indexes: + # PyMongo's IndexModel exposes a .document property with details. + doc = getattr(idx, "document", None) + if not doc: + continue + if doc.get("name") == "unique_name_namespace": + unique_doc = doc + break + + assert unique_doc is not None, "Expected index 'unique_name_namespace' not found." + assert unique_doc.get("unique") is True + key = unique_doc.get("key") + # key may be a SON/OrderedDict or list of pairs depending on PyMongo internals + if hasattr(key, "items"): + key_items = list(key.items()) + else: + key_items = list(key) + assert key_items == [("name", 1), ("namespace", 1)], f"Unexpected index key ordering/fields: {key_items}" + +@pytest.mark.asyncio +async def test_get_by_name_and_namespace_happy_path(monkeypatch): + expected = object() + captured = {} + async def fake_find_one(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return expected + + monkeypatch.setattr(RegisteredNode, "find_one", fake_find_one, raising=True) + result = await RegisteredNode.get_by_name_and_namespace("node-1", "ns-1") + assert result is expected + assert tuple(captured.get("args", ())) and not captured.get("kwargs") + # Ensure filters contain the provided values (string representation fallback for ODM expressions) + args_str = " ".join(map(lambda a: f"{a!r}", captured["args"])) + assert "node-1" in args_str + assert "ns-1" in args_str + +@pytest.mark.asyncio +async def test_get_by_name_and_namespace_not_found(monkeypatch): + async def fake_find_one(*_args, **_kwargs): + return None + monkeypatch.setattr(RegisteredNode, "find_one", fake_find_one, raising=True) + result = await RegisteredNode.get_by_name_and_namespace("missing", "ns") + assert result is None + +@pytest.mark.asyncio +async def test_list_nodes_by_templates_empty_short_circuits(monkeypatch): + called = {"find": False} + def fake_find(_query): + called["find"] = True + # Should not be called when templates list is empty + class _C: + async def to_list(self): + return [] + return _C() + monkeypatch.setattr(RegisteredNode, "find", fake_find, raising=True) + result = await RegisteredNode.list_nodes_by_templates([]) + assert result == [] + assert called["find"] is False + +@pytest.mark.asyncio +async def test_list_nodes_by_templates_builds_correct_query_and_returns_results(monkeypatch): + t1 = _DummyTemplate("alpha", "ns-a") + t2 = _DummyTemplate("beta", "ns-b") + + expected = [object(), object()] + captured = {} + + class _Cursor: + def __init__(self, items): self._items = items + async def to_list(self): return self._items + + def fake_find(query): + captured["query"] = query + return _Cursor(expected) + + monkeypatch.setattr(RegisteredNode, "find", fake_find, raising=True) + + result = await RegisteredNode.list_nodes_by_templates([t1, t2]) + assert result == expected + + q = captured.get("query") + assert isinstance(q, dict) and "$or" in q + or_clause = q["$or"] + assert isinstance(or_clause, list) and len(or_clause) == 2 + assert {"name": "alpha", "namespace": "ns-a"} in or_clause + assert {"name": "beta", "namespace": "ns-b"} in or_clause \ No newline at end of file