1313from hiero_sdk_python .hapi .mirror import consensus_service_pb2 as mirror_proto
1414from hiero_sdk_python .hapi .services import timestamp_pb2 as hapi_timestamp_pb2
1515from hiero_sdk_python .hapi .services .consensus_submit_message_pb2 import ConsensusMessageChunkInfo
16- from hiero_sdk_python .query .topic_message_query import TopicMessageQuery
16+ from hiero_sdk_python .query .topic_message_query import SubscriptionState , TopicMessageQuery
1717from hiero_sdk_python .transaction .transaction_id import TransactionId
1818from tests .unit .mock_server import RealRpcError
1919
@@ -48,15 +48,35 @@ def mock_subscription_response():
4848 )
4949
5050
51+ # Initialization
52+
53+
5154def test_topic_message_query_initialization ():
5255 """Test initializing the query with various parameter types and setters."""
5356 start = datetime (2023 , 1 , 1 , tzinfo = timezone .utc )
54- query = TopicMessageQuery ().set_topic_id ("0.0.123" ).set_start_time (start ).set_limit (5 ).set_chunking_enabled (True )
57+
58+ def mock_complete ():
59+ pass
60+
61+ def mock_error (e ):
62+ pass
63+
64+ query = (
65+ TopicMessageQuery ()
66+ .set_topic_id ("0.0.123" )
67+ .set_start_time (start )
68+ .set_limit (5 )
69+ .set_chunking_enabled (True )
70+ .set_completion_handler (mock_complete )
71+ .set_error_handler (mock_error )
72+ )
5573
5674 assert query ._topic_id .topicNum == 123
5775 assert query ._start_time .seconds == int (start .timestamp ())
5876 assert query ._limit == 5
5977 assert query ._chunking_enabled is True
78+ assert query ._completion_handler == mock_complete
79+ assert query ._error_handler == mock_error
6080
6181
6282def test_topic_message_query_invalid_max_backoff ():
@@ -102,6 +122,138 @@ def test_subscribe_missing_config(mock_client):
102122 query_ok .subscribe (mock_client , on_message = MagicMock ())
103123
104124
125+ @pytest .mark .parametrize (
126+ "handler" ,
127+ ["string" , 1 , True , None , [], {}],
128+ )
129+ def test_topic_message_query_invalid_handler_param (handler ):
130+ """Test that a non-callable handler raises a TypeError."""
131+ query = TopicMessageQuery ()
132+
133+ # For complete_handler
134+ with pytest .raises (TypeError , match = "handler must be a callable object" ):
135+ query .set_completion_handler (handler )
136+ # For error_handler
137+ with pytest .raises (TypeError , match = "handler must be a callable object" ):
138+ query .set_error_handler (handler )
139+
140+
141+ # build_query_request
142+
143+
144+ def test_build_query_request_uses_provided_start_time ():
145+ """Test that the request uses the provided start_time when no last_message exists."""
146+ start = datetime (2023 , 1 , 1 , tzinfo = timezone .utc )
147+ query = TopicMessageQuery (topic_id = "0.0.123" ).set_start_time (start )
148+ state = SubscriptionState ()
149+
150+ state .last_message = None
151+
152+ expected_start = query ._start_time
153+ request = query ._build_query_request (state )
154+
155+ assert request .consensusStartTime .seconds == expected_start .seconds
156+ assert request .consensusStartTime .nanos == expected_start .nanos
157+
158+
159+ def test_build_query_request_from_last_message_timestamp ():
160+ """Test that a reconnection request overrides start_time using the last message timestamp + 1 nano"""
161+ start = datetime (2023 , 1 , 1 , tzinfo = timezone .utc )
162+ query = TopicMessageQuery (topic_id = "0.0.123" ).set_start_time (start )
163+ state = SubscriptionState ()
164+
165+ state .last_message = state .last_message = mirror_proto .ConsensusTopicResponse (
166+ consensusTimestamp = hapi_timestamp_pb2 .Timestamp (seconds = 50 , nanos = 10 )
167+ )
168+
169+ request = query ._build_query_request (state )
170+
171+ assert request .consensusStartTime .seconds == 50
172+ assert request .consensusStartTime .nanos == 11
173+
174+
175+ def test_build_query_request_nanosecond_rollover ():
176+ """Test that nanos reaching 1_000_000_000 correctly increments the seconds field."""
177+ query = TopicMessageQuery (topic_id = "0.0.123" )
178+ state = SubscriptionState ()
179+
180+ # Mock message arriving at the 999_999_999 nanosecond of second 50
181+ state .last_message = mirror_proto .ConsensusTopicResponse (
182+ consensusTimestamp = hapi_timestamp_pb2 .Timestamp (seconds = 50 , nanos = 999_999_999 )
183+ )
184+
185+ request = query ._build_query_request (state )
186+
187+ assert request .consensusStartTime .seconds == 51
188+ assert request .consensusStartTime .nanos == 0
189+
190+
191+ def test_build_query_request_limit_decrements_on_retry ():
192+ """Test that retry requests ask only for the remaining messages within the limit."""
193+ query = TopicMessageQuery (topic_id = "0.0.123" ).set_limit (10 )
194+ state = SubscriptionState ()
195+
196+ # Mock state already have collect 4 message
197+ state .count = 4
198+ state .last_message = mirror_proto .ConsensusTopicResponse (
199+ consensusTimestamp = hapi_timestamp_pb2 .Timestamp (seconds = 100 , nanos = 500 )
200+ )
201+
202+ request = query ._build_query_request (state )
203+
204+ # New request only ask for the remaining 6 message (10 - 4)
205+ assert request .limit == 6
206+
207+
208+ def test_build_query_request_limit_floor_at_zero ():
209+ """Test that remaining request limits never drop below zero."""
210+ query = TopicMessageQuery (topic_id = "0.0.123" ).set_limit (5 )
211+ state = SubscriptionState ()
212+
213+ state .count = 6
214+ state .last_message = mirror_proto .ConsensusTopicResponse (
215+ consensusTimestamp = hapi_timestamp_pb2 .Timestamp (seconds = 100 , nanos = 500 )
216+ )
217+
218+ request = query ._build_query_request (state )
219+
220+ # The limit should be 0 rather than becoming negative
221+ assert request .limit == 0
222+
223+
224+ def test_build_query_request_set_end_time_if_provided ():
225+ """Test that request is created with the end_time if provided."""
226+ end = datetime (2023 , 1 , 1 , tzinfo = timezone .utc )
227+ query = TopicMessageQuery (topic_id = "0.0.123" ).set_end_time (end )
228+
229+ state = SubscriptionState ()
230+ state .last_message = None
231+
232+ expected_end = query ._end_time
233+ request = query ._build_query_request (state )
234+
235+ assert request .consensusEndTime .seconds == expected_end .seconds
236+ assert request .consensusEndTime .nanos == expected_end .nanos
237+
238+
239+ def test_build_query_request_set_start_end_time_to_default ():
240+ """Test that request is created with start_time and end_time as None if not present."""
241+ query = TopicMessageQuery (topic_id = "0.0.123" )
242+ state = SubscriptionState ()
243+ state .last_message = None
244+
245+ request = query ._build_query_request (state )
246+
247+ assert request .consensusStartTime .seconds == 0
248+ assert request .consensusStartTime .nanos == 0
249+
250+ assert request .consensusEndTime .seconds == 0
251+ assert request .consensusEndTime .nanos == 0
252+
253+
254+ # handle_response / subscribe
255+
256+
105257def test_topic_message_query_subscription (mock_client , mock_topic_id , mock_subscription_response ):
106258 """Test subscribing to topic messages using TopicMessageQuery."""
107259 query = TopicMessageQuery ().set_topic_id (mock_topic_id ).set_start_time (datetime .now (tz = timezone .utc ))
@@ -128,7 +280,7 @@ def side_effect(client, on_message, on_error): # noqa: ARG001
128280 on_error .assert_not_called ()
129281
130282
131- def test_chunk_message_handling_improved (mock_client ):
283+ def test_chunk_message_handling (mock_client ):
132284 """Test that multiple chunks are correctly buffered and released as a single message."""
133285 query = TopicMessageQuery (topic_id = "0.0.123" , chunking_enabled = True )
134286
@@ -154,6 +306,32 @@ def test_chunk_message_handling_improved(mock_client):
154306 assert b"chunk-2" in received_messages [0 ].contents
155307
156308
309+ def test_chunk_message_handling_when_chunking_is_disabled (mock_client ):
310+ """Test that when chunking is disabled only single chunk is released as a single message."""
311+ query = TopicMessageQuery (topic_id = "0.0.123" , chunking_enabled = False )
312+
313+ tx_id = TransactionId .generate (mock_client .operator_account_id )
314+ tx_id_proto = tx_id ._to_proto ()
315+
316+ chunk1 = mirror_proto .ConsensusTopicResponse (
317+ message = b"chunk-1" , chunkInfo = ConsensusMessageChunkInfo (initialTransactionID = tx_id_proto , total = 2 , number = 1 )
318+ )
319+ chunk2 = mirror_proto .ConsensusTopicResponse (
320+ message = b"chunk-2" , chunkInfo = ConsensusMessageChunkInfo (initialTransactionID = tx_id_proto , total = 2 , number = 2 )
321+ )
322+
323+ mock_client .mirror_stub .subscribeTopic .return_value = iter ([chunk1 , chunk2 ])
324+
325+ received_messages = []
326+ handle = query .subscribe (mock_client , on_message = lambda m : received_messages .append (m ))
327+
328+ handle ._thread .join (timeout = 1.0 )
329+
330+ assert len (received_messages ) == 2 # since we will get 2 seperate message
331+ assert b"chunk-1" in received_messages [0 ].contents
332+ assert b"chunk-2" not in received_messages [0 ].contents
333+
334+
157335@pytest .mark .parametrize (
158336 "error" ,
159337 [
@@ -169,7 +347,6 @@ def test_retry_logic_on_retryable_error(mock_client, error):
169347 query = TopicMessageQuery (topic_id = "0.0.123" ).set_max_attempts (2 ).set_max_backoff (0.5 )
170348
171349 mock_client .mirror_stub .subscribeTopic .side_effect = [error , error ]
172-
173350 handle = query .subscribe (mock_client , on_message = MagicMock (), on_error = MagicMock ())
174351
175352 handle ._thread .join (timeout = 2.0 )
0 commit comments