44from datetime import datetime , timezone
55from unittest .mock import MagicMock , patch
66
7+ import grpc
78import pytest
89
910from hiero_sdk_python .account .account_id import AccountId
1415from hiero_sdk_python .hapi .services .consensus_submit_message_pb2 import ConsensusMessageChunkInfo
1516from hiero_sdk_python .query .topic_message_query import TopicMessageQuery
1617from hiero_sdk_python .transaction .transaction_id import TransactionId
18+ from tests .unit .mock_server import RealRpcError
1719
1820
1921pytestmark = pytest .mark .unit
@@ -24,6 +26,7 @@ def mock_client():
2426 """Fixture to provide a mock Client instance."""
2527 client = MagicMock (spec = Client )
2628 client .operator_account_id = AccountId (0 , 0 , 12345 )
29+ client .mirror_stub = MagicMock ()
2730
2831 return client
2932
@@ -39,7 +42,7 @@ def mock_subscription_response():
3942 """Fixture to provide a mock response from a topic subscription."""
4043 return mirror_proto .ConsensusTopicResponse (
4144 consensusTimestamp = hapi_timestamp_pb2 .Timestamp (seconds = 12345 , nanos = 67890 ),
42- message = b"Hello, world !" ,
45+ message = b"Hello Hiero !" ,
4346 runningHash = b"\x00 " * 48 ,
4447 sequenceNumber = 1 ,
4548 )
@@ -56,7 +59,49 @@ def test_topic_message_query_initialization():
5659 assert query ._chunking_enabled is True
5760
5861
59- # This test uses fixtures (mock_client, mock_topic_id, mock_subscription_response) as parameters
62+ def test_topic_message_query_invalid_max_backoff ():
63+ """Test that invalid max_backoff raises errors."""
64+ query = TopicMessageQuery ()
65+
66+ with pytest .raises (ValueError , match = "max_backoff must be at least 500 ms" ):
67+ query .set_max_backoff (0.1 )
68+
69+
70+ def test_topic_message_query_invalid_max_attempts ():
71+ """Test that invalid max_attempts raises errors."""
72+ query = TopicMessageQuery ()
73+
74+ with pytest .raises (ValueError , match = "max_attempts must be greater than 0" ):
75+ query .set_max_attempts (0 )
76+
77+
78+ def test_topic_message_query_invalid_topic_id ():
79+ """Test that invalid topic_id raises errors."""
80+ query = TopicMessageQuery ()
81+
82+ # Invalid TopicId type
83+ with pytest .raises (TypeError , match = "Invalid topic_id format" ):
84+ query .set_topic_id (12345 )
85+
86+ # Invalid TopicId format
87+ with pytest .raises (ValueError , match = "Invalid topic ID string" ):
88+ query .set_topic_id ("12345" )
89+
90+
91+ def test_subscribe_missing_config (mock_client ):
92+ """Test that subscribe fails if Topic ID or Mirror Stub is missing."""
93+ # No TopicId
94+ query_no_id = TopicMessageQuery ()
95+ with pytest .raises (ValueError , match = "Topic ID must be set before subscribing" ):
96+ query_no_id .subscribe (mock_client , on_message = MagicMock ())
97+
98+ # No MirrorStub
99+ query_ok = TopicMessageQuery (topic_id = "0.0.123" )
100+ mock_client .mirror_stub = None
101+ with pytest .raises (ValueError , match = "Client has no mirror_stub" ):
102+ query_ok .subscribe (mock_client , on_message = MagicMock ())
103+
104+
60105def test_topic_message_query_subscription (mock_client , mock_topic_id , mock_subscription_response ):
61106 """Test subscribing to topic messages using TopicMessageQuery."""
62107 query = TopicMessageQuery ().set_topic_id (mock_topic_id ).set_start_time (datetime .now (tz = timezone .utc ))
@@ -77,35 +122,110 @@ def side_effect(client, on_message, on_error): # noqa: ARG001
77122 called_args = on_message .call_args [0 ][0 ]
78123 assert called_args .consensusTimestamp .seconds == 12345
79124 assert called_args .consensusTimestamp .nanos == 67890
80- assert called_args .message == b"Hello, world !"
125+ assert called_args .message == b"Hello Hiero !"
81126 assert called_args .sequenceNumber == 1
82127
83128 on_error .assert_not_called ()
84129
85- print ("Test passed: Subscription handled messages correctly." )
86130
87-
88- def test_chunk_message_handling (mock_client ):
131+ def test_chunk_message_handling_improved (mock_client ):
89132 """Test that multiple chunks are correctly buffered and released as a single message."""
90133 query = TopicMessageQuery (topic_id = "0.0.123" , chunking_enabled = True )
91134
92- # Mocking two chunks for the same transaction
93- tx_id = TransactionId .generate (mock_client .operator_account_id )._to_proto ()
135+ tx_id = TransactionId .generate (mock_client .operator_account_id )
136+ tx_id_proto = tx_id ._to_proto ()
137+
94138 chunk1 = mirror_proto .ConsensusTopicResponse (
95- message = b"part1 " , chunkInfo = ConsensusMessageChunkInfo (initialTransactionID = tx_id , total = 2 , number = 1 )
139+ message = b"chunk-1 " , chunkInfo = ConsensusMessageChunkInfo (initialTransactionID = tx_id_proto , total = 2 , number = 1 )
96140 )
97141 chunk2 = mirror_proto .ConsensusTopicResponse (
98- message = b"part2 " , chunkInfo = ConsensusMessageChunkInfo (initialTransactionID = tx_id , total = 2 , number = 2 )
142+ message = b"chunk-2 " , chunkInfo = ConsensusMessageChunkInfo (initialTransactionID = tx_id_proto , total = 2 , number = 2 )
99143 )
100144
101- mock_client .mirror_stub .subscribeTopic .return_value = [chunk1 , chunk2 ]
145+ mock_client .mirror_stub .subscribeTopic .return_value = iter ( [chunk1 , chunk2 ])
102146
103147 received_messages = []
104- query .subscribe (mock_client , on_message = lambda m : received_messages .append (m ))
148+ handle = query .subscribe (mock_client , on_message = lambda m : received_messages .append (m ))
105149
106- # Wait for thread execution
107- time .sleep (0.1 )
150+ handle ._thread .join (timeout = 1.0 )
108151
109152 assert len (received_messages ) == 1
110- # Assuming TopicMessage.of_many joins messages
111- assert b"part1" in received_messages [0 ].message
153+ assert b"chunk-1" in received_messages [0 ].contents
154+ assert b"chunk-2" in received_messages [0 ].contents
155+
156+
157+ @pytest .mark .parametrize (
158+ "error" ,
159+ [
160+ RealRpcError (grpc .StatusCode .NOT_FOUND , "unavailable" ),
161+ RealRpcError (grpc .StatusCode .UNAVAILABLE , "unavailable" ),
162+ RealRpcError (grpc .StatusCode .RESOURCE_EXHAUSTED , "busy" ),
163+ RealRpcError (grpc .StatusCode .INTERNAL , "received rst stream" ), # internal with rst stream
164+ Exception ("non grpc exception" ), # non grpc exception
165+ ],
166+ )
167+ def test_retry_logic_on_retryable_error (mock_client , error ):
168+ """Test that the query retries on retryable errors but stops after max_attempts."""
169+ query = TopicMessageQuery (topic_id = "0.0.123" ).set_max_attempts (2 ).set_max_backoff (0.5 )
170+
171+ mock_client .mirror_stub .subscribeTopic .side_effect = [error , error ]
172+
173+ handle = query .subscribe (mock_client , on_message = MagicMock (), on_error = MagicMock ())
174+
175+ handle ._thread .join (timeout = 2.0 )
176+
177+ assert mock_client .mirror_stub .subscribeTopic .call_count == 2
178+
179+
180+ @pytest .mark .parametrize (
181+ "non_retryable_error" ,
182+ [
183+ RealRpcError (grpc .StatusCode .PERMISSION_DENIED , "permission denied" ),
184+ RealRpcError (grpc .StatusCode .INVALID_ARGUMENT , "invalid argument" ),
185+ RealRpcError (grpc .StatusCode .UNAUTHENTICATED , "unauthenticated" ),
186+ RealRpcError (grpc .StatusCode .INTERNAL , "internal error" ),
187+ ],
188+ )
189+ def test_retry_logic_on_non_retryable_error (mock_client , non_retryable_error ):
190+ """Test that the query stops immediately on non-transient errors."""
191+ query = TopicMessageQuery (topic_id = "0.0.123" ).set_max_attempts (5 ).set_max_backoff (0.5 )
192+
193+ mock_client .mirror_stub .subscribeTopic .side_effect = [non_retryable_error ] * 5
194+
195+ on_error = MagicMock ()
196+ handle = query .subscribe (mock_client , on_message = MagicMock (), on_error = on_error )
197+
198+ handle ._thread .join (timeout = 1.0 )
199+
200+ assert mock_client .mirror_stub .subscribeTopic .call_count == 1
201+ on_error .assert_called_once_with (non_retryable_error )
202+
203+ assert not handle ._thread .is_alive ()
204+
205+
206+ def test_subscription_cancellation (mock_client ):
207+ """Test that cancelling a handle stops the subscription thread."""
208+ query = TopicMessageQuery (topic_id = "0.0.123" )
209+
210+ def infinite_stream ():
211+ while True :
212+ yield mirror_proto .ConsensusTopicResponse (message = b"ping" )
213+ time .sleep (0.1 )
214+
215+ mock_call = MagicMock ()
216+ mock_call .__iter__ .return_value = infinite_stream ()
217+
218+ mock_client .mirror_stub .subscribeTopic .return_value = mock_call
219+
220+ on_message = MagicMock ()
221+ handle = query .subscribe (mock_client , on_message = on_message )
222+
223+ time .sleep (0.2 )
224+ assert handle ._thread .is_alive ()
225+
226+ handle .cancel ()
227+
228+ handle ._thread .join (timeout = 1.0 )
229+
230+ assert not handle ._thread .is_alive ()
231+ mock_call .cancel .assert_called ()
0 commit comments