1+ import pytest
2+ import asyncio
3+ import warnings
4+ from unittest .mock import AsyncMock , patch , MagicMock
5+ from pydantic import BaseModel
6+ from exospherehost .runtime import Runtime , _setup_default_logging
7+ from exospherehost .node .BaseNode import BaseNode
8+
9+
10+ class MockTestNode (BaseNode ):
11+ class Inputs (BaseModel ):
12+ name : str
13+
14+ class Outputs (BaseModel ):
15+ message : str
16+
17+ class Secrets (BaseModel ):
18+ api_key : str
19+
20+ async def execute (self ):
21+ return self .Outputs (message = f"Hello { self .inputs .name } " ) # type: ignore
22+
23+
24+ class MockTestNodeWithNonStringFields (BaseNode ):
25+ class Inputs (BaseModel ):
26+ name : str
27+ count : int # This should cause validation error
28+
29+ class Outputs (BaseModel ):
30+ message : str
31+
32+ class Secrets (BaseModel ):
33+ api_key : str
34+
35+ async def execute (self ):
36+ return self .Outputs (message = f"Hello { self .inputs .name } " ) # type: ignore
37+
38+
39+ class MockTestNodeWithoutSecrets (BaseNode ):
40+ class Inputs (BaseModel ):
41+ name : str
42+
43+ class Outputs (BaseModel ):
44+ message : str
45+
46+ class Secrets (BaseModel ):
47+ pass # Empty secrets
48+
49+ async def execute (self ):
50+ return self .Outputs (message = f"Hello { self .inputs .name } " ) # type: ignore
51+
52+
53+ class MockTestNodeWithError (BaseNode ):
54+ class Inputs (BaseModel ):
55+ should_fail : str
56+
57+ class Outputs (BaseModel ):
58+ result : str
59+
60+ class Secrets (BaseModel ):
61+ api_key : str
62+
63+ async def execute (self ):
64+ if self .inputs .should_fail == "true" : # type: ignore
65+ raise ValueError ("Test error" )
66+ return self .Outputs (result = "success" )
67+
68+
69+ class TestRuntimeEdgeCases :
70+ """Test edge cases and error handling scenarios in the Runtime class."""
71+
72+ def test_setup_default_logging_disabled (self , monkeypatch ):
73+ """Test that _setup_default_logging returns early when disabled."""
74+ monkeypatch .setenv ('EXOSPHERE_DISABLE_DEFAULT_LOGGING' , 'true' )
75+
76+ # This should not raise any exceptions and should return early
77+ _setup_default_logging ()
78+
79+ def test_setup_default_logging_invalid_level (self , monkeypatch ):
80+ """Test _setup_default_logging with invalid log level."""
81+ monkeypatch .setenv ('EXOSPHERE_LOG_LEVEL' , 'INVALID_LEVEL' )
82+
83+ # Should fall back to INFO level
84+ _setup_default_logging ()
85+
86+ def test_runtime_validation_non_string_fields (self ):
87+ """Test that Runtime validates node fields are strings."""
88+ with pytest .raises (ValueError , match = "must be of type str" ):
89+ Runtime (
90+ namespace = "test" ,
91+ name = "test" ,
92+ nodes = [MockTestNodeWithNonStringFields ],
93+ state_manager_uri = "http://localhost:8080" ,
94+ key = "test_key"
95+ )
96+
97+ def test_runtime_validation_duplicate_node_names (self ):
98+ """Test that Runtime validates no duplicate node names."""
99+ # Create two classes with the same name
100+ class TestNode1 (MockTestNode ):
101+ pass
102+
103+ class TestNode2 (MockTestNode ):
104+ pass
105+
106+ # Rename the second class to have the same name as the first
107+ TestNode2 .__name__ = "TestNode1"
108+
109+ # Suppress the RuntimeWarning about unawaited coroutines
110+ with warnings .catch_warnings ():
111+ warnings .filterwarnings ("ignore" , message = ".*coroutine.*was never awaited.*" , category = RuntimeWarning )
112+ with pytest .raises (ValueError , match = "Duplicate node class names found" ):
113+ Runtime (
114+ namespace = "test" ,
115+ name = "test" ,
116+ nodes = [TestNode1 , TestNode2 ],
117+ state_manager_uri = "http://localhost:8080" ,
118+ key = "test_key"
119+ )
120+
121+ def test_need_secrets_empty_secrets (self ):
122+ """Test _need_secrets with empty secrets class."""
123+ runtime = Runtime (
124+ namespace = "test" ,
125+ name = "test" ,
126+ nodes = [MockTestNodeWithoutSecrets ],
127+ state_manager_uri = "http://localhost:8080" ,
128+ key = "test_key"
129+ )
130+
131+ # Should return False for empty secrets
132+ assert not runtime ._need_secrets (MockTestNodeWithoutSecrets )
133+
134+ def test_need_secrets_with_secrets (self ):
135+ """Test _need_secrets with secrets class that has fields."""
136+ runtime = Runtime (
137+ namespace = "test" ,
138+ name = "test" ,
139+ nodes = [MockTestNode ],
140+ state_manager_uri = "http://localhost:8080" ,
141+ key = "test_key"
142+ )
143+
144+ # Should return True for secrets with fields
145+ assert runtime ._need_secrets (MockTestNode )
146+
147+ @pytest .mark .asyncio
148+ async def test_enqueue_error_handling (self ):
149+ """Test error handling in _enqueue method."""
150+ runtime = Runtime (
151+ namespace = "test" ,
152+ name = "test" ,
153+ nodes = [MockTestNode ],
154+ state_manager_uri = "http://localhost:8080" ,
155+ key = "test_key"
156+ )
157+
158+ # Mock _enqueue_call to raise an exception
159+ with patch .object (runtime , '_enqueue_call' , side_effect = Exception ("Test error" )):
160+ # This should not raise an exception but log an error
161+ # We'll test this by checking that the method doesn't crash
162+ task = asyncio .create_task (runtime ._enqueue ())
163+ await asyncio .sleep (0.1 ) # Let it run briefly
164+ task .cancel ()
165+ try :
166+ await task
167+ except asyncio .CancelledError :
168+ pass
169+
170+ def test_start_without_running_loop (self ):
171+ """Test start method when no event loop is running."""
172+ runtime = Runtime (
173+ namespace = "test" ,
174+ name = "test" ,
175+ nodes = [MockTestNode ],
176+ state_manager_uri = "http://localhost:8080" ,
177+ key = "test_key"
178+ )
179+
180+ # Mock _start to avoid actual execution
181+ with patch .object (runtime , '_start' , new_callable = AsyncMock ):
182+ # This should not raise an exception
183+ result = runtime .start ()
184+ assert result is None
185+
186+ def test_start_with_running_loop (self ):
187+ """Test start method when an event loop is already running."""
188+ runtime = Runtime (
189+ namespace = "test" ,
190+ name = "test" ,
191+ nodes = [MockTestNode ],
192+ state_manager_uri = "http://localhost:8080" ,
193+ key = "test_key"
194+ )
195+
196+ # Mock _start to avoid actual execution
197+ with patch .object (runtime , '_start' , new_callable = AsyncMock ):
198+ # Create a mock loop
199+ mock_loop = MagicMock ()
200+ mock_task = MagicMock ()
201+ mock_loop .create_task .return_value = mock_task
202+
203+ with patch ('asyncio.get_running_loop' , return_value = mock_loop ):
204+ result = runtime .start ()
205+ assert result == mock_task
0 commit comments