Skip to content

Commit 2011c54

Browse files
committed
chore: added test for the changes
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
1 parent 16c444c commit 2011c54

2 files changed

Lines changed: 192 additions & 58 deletions

File tree

src/hiero_sdk_python/query/topic_message_query.py

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def __init__(
5252
self._topic_id: basic_types_pb2.TopicID | None = self._parse_topic_id(topic_id) if topic_id else None
5353
self._start_time: timestamp_pb2.Timestamp | None = self._parse_timestamp(start_time) if start_time else None
5454
self._end_time: timestamp_pb2.Timestamp | None = self._parse_timestamp(end_time) if end_time else None
55-
self._limit: int | None = limit
55+
self._limit: int = limit if limit is not None else 0
5656
self._chunking_enabled: bool = chunking_enabled
5757

5858
self._max_attempts: int = 10
@@ -147,6 +147,59 @@ def _parse_timestamp(self, dt: datetime) -> timestamp_pb2.Timestamp:
147147
nanos = int((dt.timestamp() - seconds) * 1e9)
148148
return timestamp_pb2.Timestamp(seconds=seconds, nanos=nanos)
149149

150+
def _build_query_request(self, state: SubscriptionState) -> mirror_proto.ConsensusTopicQuery:
151+
"""Build the request object based on current subscription state."""
152+
request = mirror_proto.ConsensusTopicQuery(topicID=self._topic_id)
153+
154+
if self._end_time is not None:
155+
request.consensusEndTime.CopyFrom(self._end_time)
156+
157+
if state.last_message is not None:
158+
last_message_time = state.last_message.consensusTimestamp
159+
160+
seconds = last_message_time.seconds
161+
nanos = last_message_time.nanos + 1
162+
163+
if nanos >= 1_000_000_000:
164+
seconds += 1
165+
nanos = 0
166+
167+
request.consensusStartTime.seconds = seconds
168+
request.consensusStartTime.nanos = nanos
169+
170+
if self._limit > 0:
171+
request.limit = max(0, self._limit - state.count)
172+
else:
173+
if self._start_time is not None:
174+
request.consensusStartTime.CopyFrom(self._start_time)
175+
request.limit = self._limit
176+
177+
return request
178+
179+
def _handle_response(self, response, state: SubscriptionState, on_message: Callable[[TopicMessage], None]) -> None:
180+
"""Handles single or chunked messages."""
181+
state.count += 1
182+
state.last_message = response
183+
184+
if not self._chunking_enabled or not response.HasField("chunkInfo") or response.chunkInfo.total <= 1:
185+
message = TopicMessage.of_single(response)
186+
on_message(message)
187+
return
188+
189+
initial_tx_id = TransactionId._from_proto(response.chunkInfo.initialTransactionID)
190+
191+
if initial_tx_id not in state.pending_messages:
192+
state.pending_messages[initial_tx_id] = []
193+
194+
chunks = state.pending_messages[initial_tx_id]
195+
chunks.append(response)
196+
197+
if len(chunks) == response.chunkInfo.total:
198+
del state.pending_messages[initial_tx_id]
199+
200+
message = TopicMessage.of_many(chunks)
201+
on_message(message)
202+
150203
def subscribe(
151204
self,
152205
client: Client,
@@ -164,22 +217,7 @@ def subscribe(
164217

165218
def run_stream():
166219
while state.attempt < self._max_attempts and not subscription_handle.is_cancelled():
167-
request = mirror_proto.ConsensusTopicQuery(topicID=self._topic_id)
168-
169-
if self._end_time is not None:
170-
request.consensusEndTime.CopyFrom(self._end_time)
171-
172-
if state.last_message is not None:
173-
last_message_time = state.last_message.consensusTimestamp
174-
request.consensusStartTime.seconds = last_message_time.seconds
175-
request.consensusStartTime.nanos = last_message_time.nanos + 1
176-
177-
if self._limit > 0:
178-
request.limit = max(0, self._limit - state.count)
179-
else:
180-
if self._start_time is not None:
181-
request.consensusStartTime.CopyFrom(self._start_time)
182-
request.limit = self._limit
220+
request = self._build_query_request(state)
183221

184222
try:
185223
message_stream = client.mirror_stub.subscribeTopic(request)
@@ -189,31 +227,7 @@ def run_stream():
189227
if subscription_handle.is_cancelled():
190228
return
191229

192-
state.count += 1
193-
state.last_message = response
194-
195-
if (
196-
not self._chunking_enabled
197-
or not response.HasField("chunkInfo")
198-
or response.chunkInfo.total <= 1
199-
):
200-
message = TopicMessage.of_single(response)
201-
on_message(message)
202-
continue
203-
204-
initial_tx_id = TransactionId._from_proto(response.chunkInfo.initialTransactionID)
205-
206-
if initial_tx_id not in state.pending_messages:
207-
state.pending_messages[initial_tx_id] = []
208-
209-
chunks = state.pending_messages[initial_tx_id]
210-
chunks.append(response)
211-
212-
if len(chunks) == response.chunkInfo.total:
213-
del state.pending_messages[initial_tx_id]
214-
215-
message = TopicMessage.of_many(chunks)
216-
on_message(message)
230+
self._handle_response(response, state, on_message)
217231

218232
if self._completion_handler:
219233
self._completion_handler()

tests/unit/topic_message_query_test.py

Lines changed: 136 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import datetime, timezone
55
from unittest.mock import MagicMock, patch
66

7+
import grpc
78
import pytest
89

910
from hiero_sdk_python.account.account_id import AccountId
@@ -14,6 +15,7 @@
1415
from hiero_sdk_python.hapi.services.consensus_submit_message_pb2 import ConsensusMessageChunkInfo
1516
from hiero_sdk_python.query.topic_message_query import TopicMessageQuery
1617
from hiero_sdk_python.transaction.transaction_id import TransactionId
18+
from tests.unit.mock_server import RealRpcError
1719

1820

1921
pytestmark = 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+
60105
def 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

Comments
 (0)