Skip to content

Commit 1458925

Browse files
committed
Add comprehensive tests for BaseNode and StateManager functionality
- Introduced a suite of tests for BaseNode, covering initialization, execution, and edge cases, ensuring robust validation of node behavior. - Added integration tests for Runtime and StateManager, validating state management, error handling, and interaction with external services. - Implemented tests for package imports and structure, ensuring all expected components are accessible and correctly configured. - Enhanced test coverage for concurrency scenarios, verifying the correct handling of multiple simultaneous executions and state triggers. - Improved overall test organization and clarity, contributing to a more maintainable and reliable codebase.
1 parent e6de04e commit 1458925

6 files changed

Lines changed: 2121 additions & 0 deletions
Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
import pytest
2+
import asyncio
3+
from unittest.mock import patch, MagicMock
4+
from pydantic import BaseModel
5+
from exospherehost.node.BaseNode import BaseNode
6+
7+
8+
class ValidNode(BaseNode):
9+
class Inputs(BaseModel):
10+
name: str
11+
count: str
12+
13+
class Outputs(BaseModel):
14+
message: str
15+
result: str
16+
17+
class Secrets(BaseModel):
18+
api_key: str
19+
token: str
20+
21+
async def execute(self):
22+
return self.Outputs(
23+
message=f"Hello {self.inputs.name}",
24+
result=f"Count: {self.inputs.count}"
25+
)
26+
27+
28+
class NodeWithListOutput(BaseNode):
29+
class Inputs(BaseModel):
30+
items: str
31+
32+
class Outputs(BaseModel):
33+
processed: str
34+
35+
class Secrets(BaseModel):
36+
api_key: str
37+
38+
async def execute(self):
39+
count = int(self.inputs.items)
40+
return [self.Outputs(processed=str(i)) for i in range(count)]
41+
42+
43+
class NodeWithNoneOutput(BaseNode):
44+
class Inputs(BaseModel):
45+
name: str
46+
47+
class Outputs(BaseModel):
48+
message: str
49+
50+
class Secrets(BaseModel):
51+
api_key: str
52+
53+
async def execute(self):
54+
return None
55+
56+
57+
class NodeWithError(BaseNode):
58+
class Inputs(BaseModel):
59+
should_fail: str
60+
61+
class Outputs(BaseModel):
62+
result: str
63+
64+
class Secrets(BaseModel):
65+
api_key: str
66+
67+
async def execute(self):
68+
if self.inputs.should_fail == "true":
69+
raise ValueError("Test error")
70+
return self.Outputs(result="success")
71+
72+
73+
class NodeWithComplexSecrets(BaseNode):
74+
class Inputs(BaseModel):
75+
operation: str
76+
77+
class Outputs(BaseModel):
78+
status: str
79+
80+
class Secrets(BaseModel):
81+
database_url: str
82+
api_key: str
83+
encryption_key: str
84+
85+
async def execute(self):
86+
return self.Outputs(status=f"Operation {self.inputs.operation} completed")
87+
88+
89+
class TestBaseNodeInitialization:
90+
def test_base_node_initialization(self):
91+
# BaseNode is abstract, so we can't instantiate it directly
92+
# Instead, test that it has the expected attributes
93+
assert hasattr(BaseNode, 'Inputs')
94+
assert hasattr(BaseNode, 'Outputs')
95+
assert hasattr(BaseNode, 'Secrets')
96+
assert hasattr(BaseNode, 'execute')
97+
98+
def test_valid_node_initialization(self):
99+
node = ValidNode()
100+
assert node.inputs is None
101+
assert hasattr(node, 'Inputs')
102+
assert hasattr(node, 'Outputs')
103+
assert hasattr(node, 'Secrets')
104+
105+
def test_node_schema_validation(self):
106+
# Test that Inputs, Outputs, and Secrets are proper Pydantic models
107+
assert issubclass(ValidNode.Inputs, BaseModel)
108+
assert issubclass(ValidNode.Outputs, BaseModel)
109+
assert issubclass(ValidNode.Secrets, BaseModel)
110+
111+
def test_node_schema_fields(self):
112+
# Test that all fields are strings as required
113+
for field_name, field_info in ValidNode.Inputs.model_fields.items():
114+
assert field_info.annotation is str, f"Input field {field_name} must be str"
115+
116+
for field_name, field_info in ValidNode.Outputs.model_fields.items():
117+
assert field_info.annotation is str, f"Output field {field_name} must be str"
118+
119+
for field_name, field_info in ValidNode.Secrets.model_fields.items():
120+
assert field_info.annotation is str, f"Secret field {field_name} must be str"
121+
122+
123+
class TestBaseNodeExecute:
124+
@pytest.mark.asyncio
125+
async def test_valid_node_execute(self):
126+
node = ValidNode()
127+
inputs = ValidNode.Inputs(name="test_user", count="5")
128+
secrets = ValidNode.Secrets(api_key="test_key", token="test_token")
129+
130+
result = await node._execute(inputs, secrets)
131+
132+
assert isinstance(result, ValidNode.Outputs)
133+
assert result.message == "Hello test_user"
134+
assert result.result == "Count: 5"
135+
assert node.inputs == inputs
136+
assert node.secrets == secrets
137+
138+
@pytest.mark.asyncio
139+
async def test_node_with_list_output(self):
140+
node = NodeWithListOutput()
141+
inputs = NodeWithListOutput.Inputs(items="3")
142+
secrets = NodeWithListOutput.Secrets(api_key="test_key")
143+
144+
result = await node._execute(inputs, secrets)
145+
146+
assert isinstance(result, list)
147+
assert len(result) == 3
148+
assert all(isinstance(output, NodeWithListOutput.Outputs) for output in result)
149+
assert result[0].processed == "0"
150+
assert result[1].processed == "1"
151+
assert result[2].processed == "2"
152+
153+
@pytest.mark.asyncio
154+
async def test_node_with_none_output(self):
155+
node = NodeWithNoneOutput()
156+
inputs = NodeWithNoneOutput.Inputs(name="test")
157+
secrets = NodeWithNoneOutput.Secrets(api_key="test_key")
158+
159+
result = await node._execute(inputs, secrets)
160+
161+
assert result is None
162+
assert node.inputs == inputs
163+
assert node.secrets == secrets
164+
165+
@pytest.mark.asyncio
166+
async def test_node_with_error(self):
167+
node = NodeWithError()
168+
inputs = NodeWithError.Inputs(should_fail="true")
169+
secrets = NodeWithError.Secrets(api_key="test_key")
170+
171+
with pytest.raises(ValueError, match="Test error"):
172+
await node._execute(inputs, secrets)
173+
174+
@pytest.mark.asyncio
175+
async def test_node_with_complex_secrets(self):
176+
node = NodeWithComplexSecrets()
177+
inputs = NodeWithComplexSecrets.Inputs(operation="backup")
178+
secrets = NodeWithComplexSecrets.Secrets(
179+
database_url="postgresql://localhost/db",
180+
api_key="secret_key",
181+
encryption_key="encryption_key"
182+
)
183+
184+
result = await node._execute(inputs, secrets)
185+
186+
assert isinstance(result, NodeWithComplexSecrets.Outputs)
187+
assert result.status == "Operation backup completed"
188+
assert node.secrets == secrets
189+
190+
191+
class TestBaseNodeEdgeCases:
192+
@pytest.mark.asyncio
193+
async def test_node_with_empty_strings(self):
194+
node = ValidNode()
195+
inputs = ValidNode.Inputs(name="", count="0")
196+
secrets = ValidNode.Secrets(api_key="", token="")
197+
198+
result = await node._execute(inputs, secrets)
199+
200+
assert result.message == "Hello "
201+
assert result.result == "Count: 0"
202+
203+
@pytest.mark.asyncio
204+
async def test_node_with_special_characters(self):
205+
node = ValidNode()
206+
inputs = ValidNode.Inputs(name="test@user.com", count="42")
207+
secrets = ValidNode.Secrets(api_key="key!@#$%", token="token&*()")
208+
209+
result = await node._execute(inputs, secrets)
210+
211+
assert result.message == "Hello test@user.com"
212+
assert result.result == "Count: 42"
213+
214+
@pytest.mark.asyncio
215+
async def test_node_with_unicode_characters(self):
216+
node = ValidNode()
217+
inputs = ValidNode.Inputs(name="José", count="100")
218+
secrets = ValidNode.Secrets(api_key="🔑", token="🎫")
219+
220+
result = await node._execute(inputs, secrets)
221+
222+
assert result.message == "Hello José"
223+
assert result.result == "Count: 100"
224+
225+
226+
class TestBaseNodeErrorHandling:
227+
@pytest.mark.asyncio
228+
async def test_node_raises_custom_exception(self):
229+
class NodeWithCustomError(BaseNode):
230+
class Inputs(BaseModel):
231+
trigger: str
232+
233+
class Outputs(BaseModel):
234+
result: str
235+
236+
class Secrets(BaseModel):
237+
api_key: str
238+
239+
async def execute(self):
240+
if self.inputs.trigger == "custom":
241+
raise RuntimeError("Custom runtime error")
242+
return self.Outputs(result="ok")
243+
244+
node = NodeWithCustomError()
245+
inputs = NodeWithCustomError.Inputs(trigger="custom")
246+
secrets = NodeWithCustomError.Secrets(api_key="test")
247+
248+
with pytest.raises(RuntimeError, match="Custom runtime error"):
249+
await node._execute(inputs, secrets)
250+
251+
@pytest.mark.asyncio
252+
async def test_node_raises_attribute_error(self):
253+
class NodeWithAttributeError(BaseNode):
254+
class Inputs(BaseModel):
255+
name: str
256+
257+
class Outputs(BaseModel):
258+
result: str
259+
260+
class Secrets(BaseModel):
261+
api_key: str
262+
263+
async def execute(self):
264+
# This will raise AttributeError
265+
return self.Outputs(result=self.inputs.nonexistent_field)
266+
267+
node = NodeWithAttributeError()
268+
inputs = NodeWithAttributeError.Inputs(name="test")
269+
secrets = NodeWithAttributeError.Secrets(api_key="test")
270+
271+
with pytest.raises(AttributeError):
272+
await node._execute(inputs, secrets)
273+
274+
275+
class TestBaseNodeAbstractMethods:
276+
def test_base_node_execute_is_abstract(self):
277+
# BaseNode should not be instantiable directly
278+
with pytest.raises(TypeError, match="Can't instantiate abstract class"):
279+
BaseNode()
280+
281+
def test_concrete_node_implements_execute(self):
282+
# ValidNode should implement execute
283+
node = ValidNode()
284+
# This should not raise NotImplementedError
285+
assert hasattr(node, 'execute')
286+
assert callable(node.execute)
287+
288+
289+
class TestBaseNodeModelValidation:
290+
def test_inputs_validation(self):
291+
# Test that invalid inputs raise validation error
292+
with pytest.raises(Exception): # Pydantic validation error
293+
ValidNode.Inputs(name=123, count="5") # name should be str
294+
295+
def test_outputs_validation(self):
296+
# Test that invalid outputs raise validation error
297+
with pytest.raises(Exception): # Pydantic validation error
298+
ValidNode.Outputs(message=123, result="test") # message should be str
299+
300+
def test_secrets_validation(self):
301+
# Test that invalid secrets raise validation error
302+
with pytest.raises(Exception): # Pydantic validation error
303+
ValidNode.Secrets(api_key=123, token="test") # api_key should be str
304+
305+
306+
class TestBaseNodeConcurrency:
307+
@pytest.mark.asyncio
308+
async def test_multiple_concurrent_executions(self):
309+
node = ValidNode()
310+
inputs = ValidNode.Inputs(name="test", count="1")
311+
secrets = ValidNode.Secrets(api_key="key", token="token")
312+
313+
# Run multiple concurrent executions
314+
tasks = [
315+
node._execute(inputs, secrets) for _ in range(5)
316+
]
317+
318+
results = await asyncio.gather(*tasks)
319+
320+
assert len(results) == 5
321+
for result in results:
322+
assert isinstance(result, ValidNode.Outputs)
323+
assert result.message == "Hello test"
324+
325+
@pytest.mark.asyncio
326+
async def test_node_with_async_operation(self):
327+
class AsyncNode(BaseNode):
328+
class Inputs(BaseModel):
329+
delay: str
330+
331+
class Outputs(BaseModel):
332+
result: str
333+
334+
class Secrets(BaseModel):
335+
api_key: str
336+
337+
async def execute(self):
338+
delay = float(self.inputs.delay)
339+
await asyncio.sleep(delay)
340+
return self.Outputs(result=f"Completed after {delay}s")
341+
342+
node = AsyncNode()
343+
inputs = AsyncNode.Inputs(delay="0.1")
344+
secrets = AsyncNode.Secrets(api_key="test")
345+
346+
result = await node._execute(inputs, secrets)
347+
348+
assert result.result == "Completed after 0.1s"
349+
350+
351+
class TestBaseNodeIntegration:
352+
@pytest.mark.asyncio
353+
async def test_node_chain_execution(self):
354+
# Test that multiple nodes can be executed in sequence
355+
node1 = ValidNode()
356+
node2 = NodeWithComplexSecrets()
357+
358+
inputs1 = ValidNode.Inputs(name="user1", count="10")
359+
secrets1 = ValidNode.Secrets(api_key="key1", token="token1")
360+
361+
inputs2 = NodeWithComplexSecrets.Inputs(operation="process")
362+
secrets2 = NodeWithComplexSecrets.Secrets(
363+
database_url="db://test",
364+
api_key="key2",
365+
encryption_key="enc2"
366+
)
367+
368+
result1 = await node1._execute(inputs1, secrets1)
369+
result2 = await node2._execute(inputs2, secrets2)
370+
371+
assert result1.message == "Hello user1"
372+
assert result2.status == "Operation process completed"
373+
374+
@pytest.mark.asyncio
375+
async def test_node_with_different_output_types(self):
376+
# Test nodes that return different types of outputs
377+
node1 = ValidNode() # Returns single output
378+
node2 = NodeWithListOutput() # Returns list of outputs
379+
node3 = NodeWithNoneOutput() # Returns None
380+
381+
inputs1 = ValidNode.Inputs(name="test", count="1")
382+
secrets1 = ValidNode.Secrets(api_key="key", token="token")
383+
384+
inputs2 = NodeWithListOutput.Inputs(items="2")
385+
secrets2 = NodeWithListOutput.Secrets(api_key="key")
386+
387+
inputs3 = NodeWithNoneOutput.Inputs(name="test")
388+
secrets3 = NodeWithNoneOutput.Secrets(api_key="key")
389+
390+
result1 = await node1._execute(inputs1, secrets1)
391+
result2 = await node2._execute(inputs2, secrets2)
392+
result3 = await node3._execute(inputs3, secrets3)
393+
394+
assert isinstance(result1, ValidNode.Outputs)
395+
assert isinstance(result2, list)
396+
assert result3 is None

0 commit comments

Comments
 (0)