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