mirror of
https://github.com/logos-messaging/logos-messaging-interop-tests.git
synced 2026-05-18 16:29:59 +00:00
395 lines
17 KiB
Python
395 lines
17 KiB
Python
|
|
import json
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import pytest
|
||
|
|
from src.steps.common import StepsCommon
|
||
|
|
from src.libs.common import to_base64
|
||
|
|
from src.libs.custom_logger import get_custom_logger
|
||
|
|
from src.node.wrappers_manager import WrapperManager
|
||
|
|
from src.node.wrapper_helpers import (
|
||
|
|
EventCollector,
|
||
|
|
assert_event_invariants,
|
||
|
|
create_message_bindings,
|
||
|
|
get_node_multiaddr,
|
||
|
|
wait_for_connected,
|
||
|
|
wait_for_propagated,
|
||
|
|
wait_for_sent,
|
||
|
|
wait_for_error,
|
||
|
|
)
|
||
|
|
|
||
|
|
logger = get_custom_logger(__name__)
|
||
|
|
|
||
|
|
PROPAGATED_TIMEOUT_S = 30.0
|
||
|
|
|
||
|
|
S01_EXPECTED_ERROR_FRAGMENT = "not initialized"
|
||
|
|
# Destroyed-handle path fails synchronously in the C layer (no callback),
|
||
|
|
# so the binding surfaces a different string than the nil-handle path.
|
||
|
|
S01_DESTROYED_HANDLE_ERROR_FRAGMENT = "immediate call failed"
|
||
|
|
S01_SUBPROCESS_TIMEOUT_S = 30
|
||
|
|
S01_RESULT_MARKER = "__S01_RESULT__"
|
||
|
|
SEND_AFTER_DESTROY_RESULT_MARKER = "__SEND_AFTER_DESTROY_RESULT__"
|
||
|
|
SEND_AFTER_DESTROY_SUBPROCESS_TIMEOUT_S = 60
|
||
|
|
|
||
|
|
S01_INVALID_HANDLE_HELPER = "tests.wrappers_tests.helpers.send_invalid_handle"
|
||
|
|
|
||
|
|
# S05: malformed content topics
|
||
|
|
S05_EXPECTED_ERROR_FRAGMENT = "Failed to auto-subscribe"
|
||
|
|
S05_MALFORMED_CONTENT_TOPICS = [
|
||
|
|
# No leading slash — parser rejects with "must start with slash".
|
||
|
|
("s05-invalid-no-leading-slash", "no-leading-slash"),
|
||
|
|
# Empty string — parser rejects empty content topic.
|
||
|
|
("", "empty"),
|
||
|
|
# Only 3 segments — content topics need /app/version/name/encoding.
|
||
|
|
("/app/1/name", "missing-encoding-segment"),
|
||
|
|
# Empty middle segment between slashes.
|
||
|
|
("/app//name/proto", "empty-middle-segment"),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
class TestS01NilOrUninitializedHandle(StepsCommon):
|
||
|
|
"""S01 — send() on a nil/destroyed handle must Err, no events, no crash."""
|
||
|
|
|
||
|
|
@pytest.mark.skip(reason="see https://github.com/logos-messaging/logos-delivery/issues/3873")
|
||
|
|
def test_s01_send_on_uninitialized_handle(self):
|
||
|
|
completed = subprocess.run(
|
||
|
|
[sys.executable, "-m", S01_INVALID_HANDLE_HELPER, "nil", S01_RESULT_MARKER],
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=S01_SUBPROCESS_TIMEOUT_S,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert completed.returncode == 0, (
|
||
|
|
f"send() crashed on a nil handle (returncode={completed.returncode}). " f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
result_line = next(
|
||
|
|
(l for l in completed.stdout.splitlines() if l.startswith(S01_RESULT_MARKER)),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
assert result_line, f"missing result marker. stdout={completed.stdout!r} stderr={completed.stderr!r}"
|
||
|
|
|
||
|
|
result = json.loads(result_line[len(S01_RESULT_MARKER) :])
|
||
|
|
|
||
|
|
assert result["is_ok"] is False, f"expected Err, got Ok({result['ok']!r})"
|
||
|
|
assert S01_EXPECTED_ERROR_FRAGMENT in (
|
||
|
|
result["err"] or ""
|
||
|
|
), f"expected error to mention {S01_EXPECTED_ERROR_FRAGMENT!r}, got: {result['err']!r}"
|
||
|
|
|
||
|
|
@pytest.mark.skip(reason="see https://github.com/logos-messaging/logos-delivery/issues/3863")
|
||
|
|
def test_s01_send_on_destroyed_handle(self):
|
||
|
|
completed = subprocess.run(
|
||
|
|
[sys.executable, "-m", S01_INVALID_HANDLE_HELPER, "destroyed", SEND_AFTER_DESTROY_RESULT_MARKER],
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=SEND_AFTER_DESTROY_SUBPROCESS_TIMEOUT_S,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert completed.returncode == 0, (
|
||
|
|
f"send() crashed on a destroyed handle (returncode={completed.returncode}). " f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
result_line = next(
|
||
|
|
(l for l in completed.stdout.splitlines() if l.startswith(SEND_AFTER_DESTROY_RESULT_MARKER)),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
assert result_line, f"missing result marker. stdout={completed.stdout!r} stderr={completed.stderr!r}"
|
||
|
|
|
||
|
|
result = json.loads(result_line[len(SEND_AFTER_DESTROY_RESULT_MARKER) :])
|
||
|
|
|
||
|
|
assert result["stage"] == "send_message", f"setup failed at stage {result['stage']!r}: {result['err']!r}"
|
||
|
|
assert result["is_ok"] is False, f"expected Err, got Ok({result['ok']!r})"
|
||
|
|
err_msg = result["err"] or ""
|
||
|
|
assert S01_EXPECTED_ERROR_FRAGMENT in err_msg or S01_DESTROYED_HANDLE_ERROR_FRAGMENT in err_msg, (
|
||
|
|
f"expected error to mention {S01_EXPECTED_ERROR_FRAGMENT!r} " f"or {S01_DESTROYED_HANDLE_ERROR_FRAGMENT!r}, got: {result['err']!r}"
|
||
|
|
)
|
||
|
|
assert result["events_after_send"] == [], f"expected no events after send(), got: {result['events_after_send']}"
|
||
|
|
|
||
|
|
|
||
|
|
class TestS02AutoSubscribeOnFirstSend(StepsCommon):
|
||
|
|
"""
|
||
|
|
S02 — Auto-subscribe on first send.
|
||
|
|
Sender never calls subscribe_content_topic() before send().
|
||
|
|
The send API must auto-subscribe to the content topic used in the message.
|
||
|
|
Expected: send() returns Ok(RequestId), message_propagated arrives.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_s02_send_without_explicit_subscribe(self, node_config):
|
||
|
|
sender_collector = EventCollector()
|
||
|
|
|
||
|
|
node_config.update(
|
||
|
|
{
|
||
|
|
"relay": True,
|
||
|
|
"store": False,
|
||
|
|
"lightpush": False,
|
||
|
|
"filter": False,
|
||
|
|
"discv5Discovery": False,
|
||
|
|
"numShardsInNetwork": 1,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
sender_result = WrapperManager.create_and_start(
|
||
|
|
config=node_config,
|
||
|
|
event_cb=sender_collector.event_callback,
|
||
|
|
)
|
||
|
|
assert sender_result.is_ok(), f"Failed to start sender: {sender_result.err()}"
|
||
|
|
|
||
|
|
with sender_result.ok_value as sender:
|
||
|
|
peer_config = {
|
||
|
|
**node_config,
|
||
|
|
"staticnodes": [get_node_multiaddr(sender)],
|
||
|
|
"portsShift": 1,
|
||
|
|
}
|
||
|
|
|
||
|
|
peer_result = WrapperManager.create_and_start(config=peer_config)
|
||
|
|
assert peer_result.is_ok(), f"Failed to start relay peer: {peer_result.err()}"
|
||
|
|
|
||
|
|
with peer_result.ok_value:
|
||
|
|
assert wait_for_connected(sender_collector) is not None, "Sender did not reach Connected/PartiallyConnected state"
|
||
|
|
|
||
|
|
message = create_message_bindings(
|
||
|
|
payload=to_base64("S02 auto-subscribe test payload"),
|
||
|
|
contentTopic="/test/1/s02-auto-subscribe/proto",
|
||
|
|
)
|
||
|
|
|
||
|
|
send_result = sender.send_message(message=message)
|
||
|
|
assert send_result.is_ok(), f"send() failed: {send_result.err()}"
|
||
|
|
|
||
|
|
request_id = send_result.ok_value
|
||
|
|
assert request_id, "send() returned an empty RequestId"
|
||
|
|
|
||
|
|
propagated = wait_for_propagated(
|
||
|
|
collector=sender_collector,
|
||
|
|
request_id=request_id,
|
||
|
|
timeout_s=PROPAGATED_TIMEOUT_S,
|
||
|
|
)
|
||
|
|
assert propagated is not None, (
|
||
|
|
f"No message_propagated event within {PROPAGATED_TIMEOUT_S}s. " f"Collected events: {sender_collector.events}"
|
||
|
|
)
|
||
|
|
assert propagated["requestId"] == request_id
|
||
|
|
|
||
|
|
error = wait_for_error(sender_collector, request_id, timeout_s=0)
|
||
|
|
assert error is None, f"Unexpected message_error event: {error}"
|
||
|
|
|
||
|
|
|
||
|
|
class TestS03SendOnAlreadySubscribedTopic(StepsCommon):
|
||
|
|
"""
|
||
|
|
S03 — Send on already-subscribed content topic.
|
||
|
|
Sender explicitly calls subscribe_content_topic() before send().
|
||
|
|
The send path must behave identically to the auto-subscribe case:
|
||
|
|
Propagated arrives, no Sent (store disabled), no Error.
|
||
|
|
Topology mirrors S06 (relay-only sender + relay peer, no store).
|
||
|
|
Purpose: proves the send path is identical when auto-subscription is skipped.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_s03_send_on_already_subscribed_content_topic(self, node_config):
|
||
|
|
sender_collector = EventCollector()
|
||
|
|
content_topic = "/test/1/s03-already-subscribed/proto"
|
||
|
|
|
||
|
|
node_config.update(
|
||
|
|
{
|
||
|
|
"relay": True,
|
||
|
|
"store": False,
|
||
|
|
"lightpush": False,
|
||
|
|
"filter": False,
|
||
|
|
"discv5Discovery": False,
|
||
|
|
"numShardsInNetwork": 1,
|
||
|
|
"reliabilityEnabled": True,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
sender_result = WrapperManager.create_and_start(
|
||
|
|
config=node_config,
|
||
|
|
event_cb=sender_collector.event_callback,
|
||
|
|
)
|
||
|
|
assert sender_result.is_ok(), f"Failed to start sender: {sender_result.err()}"
|
||
|
|
|
||
|
|
with sender_result.ok_value as sender:
|
||
|
|
peer_config = {
|
||
|
|
**node_config,
|
||
|
|
"staticnodes": [get_node_multiaddr(sender)],
|
||
|
|
"portsShift": 1,
|
||
|
|
}
|
||
|
|
|
||
|
|
peer_result = WrapperManager.create_and_start(config=peer_config)
|
||
|
|
assert peer_result.is_ok(), f"Failed to start relay peer: {peer_result.err()}"
|
||
|
|
|
||
|
|
with peer_result.ok_value:
|
||
|
|
assert wait_for_connected(sender_collector) is not None, "Sender did not reach Connected/PartiallyConnected state"
|
||
|
|
|
||
|
|
# Explicit subscribe before send — this is what S03 is about.
|
||
|
|
# The send path must still return Ok(RequestId) and emit the
|
||
|
|
# same events as the auto-subscribe topology in S06.
|
||
|
|
subscribe_result = sender.subscribe_content_topic(content_topic)
|
||
|
|
assert subscribe_result.is_ok(), f"subscribe_content_topic failed: {subscribe_result.err()}"
|
||
|
|
|
||
|
|
message = create_message_bindings(
|
||
|
|
payload=to_base64("S03 already-subscribed test payload"),
|
||
|
|
contentTopic=content_topic,
|
||
|
|
)
|
||
|
|
|
||
|
|
send_result = sender.send_message(message=message)
|
||
|
|
assert send_result.is_ok(), f"send() failed: {send_result.err()}"
|
||
|
|
|
||
|
|
request_id = send_result.ok_value
|
||
|
|
assert request_id, "send() returned an empty RequestId"
|
||
|
|
|
||
|
|
propagated = wait_for_propagated(
|
||
|
|
collector=sender_collector,
|
||
|
|
request_id=request_id,
|
||
|
|
timeout_s=PROPAGATED_TIMEOUT_S,
|
||
|
|
)
|
||
|
|
assert propagated is not None, (
|
||
|
|
f"No message_propagated event within {PROPAGATED_TIMEOUT_S}s. " f"Collected events: {sender_collector.events}"
|
||
|
|
)
|
||
|
|
assert propagated["requestId"] == request_id
|
||
|
|
|
||
|
|
error = wait_for_error(sender_collector, request_id, timeout_s=0)
|
||
|
|
assert error is None, f"Unexpected message_error event: {error}"
|
||
|
|
|
||
|
|
sent = wait_for_sent(sender_collector, request_id, timeout_s=0)
|
||
|
|
assert sent is None, f"Unexpected message_sent event (store is disabled): {sent}"
|
||
|
|
|
||
|
|
assert_event_invariants(sender_collector, request_id)
|
||
|
|
|
||
|
|
|
||
|
|
class TestS04UnsubscribeThenSendSameTopic(StepsCommon):
|
||
|
|
"""
|
||
|
|
S04 — Unsubscribe, then send the same content topic again.
|
||
|
|
Sender subscribes to topic A, unsubscribes from A, then sends on A.
|
||
|
|
The send path must re-establish topic interest and deliver normally.
|
||
|
|
Topology mirrors S06 (relay-only sender + relay peer, no store).
|
||
|
|
Expected: send() returns Ok(RequestId), Propagated arrives,
|
||
|
|
no Sent (store disabled), no Error.
|
||
|
|
Purpose: verifies send() re-establishes topic interest after local unsubscribe.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_s04_unsubscribe_then_send_same_content_topic(self, node_config):
|
||
|
|
sender_collector = EventCollector()
|
||
|
|
content_topic = "/test/1/s04-unsubscribe-resend/proto"
|
||
|
|
|
||
|
|
node_config.update(
|
||
|
|
{
|
||
|
|
"relay": True,
|
||
|
|
"store": False,
|
||
|
|
"lightpush": False,
|
||
|
|
"filter": False,
|
||
|
|
"discv5Discovery": False,
|
||
|
|
"numShardsInNetwork": 1,
|
||
|
|
"reliabilityEnabled": True,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
sender_result = WrapperManager.create_and_start(
|
||
|
|
config=node_config,
|
||
|
|
event_cb=sender_collector.event_callback,
|
||
|
|
)
|
||
|
|
assert sender_result.is_ok(), f"Failed to start sender: {sender_result.err()}"
|
||
|
|
|
||
|
|
with sender_result.ok_value as sender:
|
||
|
|
peer_config = {
|
||
|
|
**node_config,
|
||
|
|
"staticnodes": [get_node_multiaddr(sender)],
|
||
|
|
"portsShift": 1,
|
||
|
|
}
|
||
|
|
|
||
|
|
peer_result = WrapperManager.create_and_start(config=peer_config)
|
||
|
|
assert peer_result.is_ok(), f"Failed to start relay peer: {peer_result.err()}"
|
||
|
|
|
||
|
|
with peer_result.ok_value:
|
||
|
|
assert wait_for_connected(sender_collector) is not None, "Sender did not reach Connected/PartiallyConnected state"
|
||
|
|
|
||
|
|
# subscribe -> unsubscribe -> send: send() must re-establish
|
||
|
|
# topic interest internally.
|
||
|
|
subscribe_result = sender.subscribe_content_topic(content_topic)
|
||
|
|
assert subscribe_result.is_ok(), f"subscribe_content_topic failed: {subscribe_result.err()}"
|
||
|
|
|
||
|
|
unsubscribe_result = sender.unsubscribe_content_topic(content_topic)
|
||
|
|
assert unsubscribe_result.is_ok(), f"unsubscribe_content_topic failed: {unsubscribe_result.err()}"
|
||
|
|
|
||
|
|
message = create_message_bindings(
|
||
|
|
payload=to_base64("S04 unsubscribe-then-send test payload"),
|
||
|
|
contentTopic=content_topic,
|
||
|
|
)
|
||
|
|
|
||
|
|
send_result = sender.send_message(message=message)
|
||
|
|
assert send_result.is_ok(), f"send() failed after unsubscribe: {send_result.err()}"
|
||
|
|
|
||
|
|
request_id = send_result.ok_value
|
||
|
|
assert request_id, "send() returned an empty RequestId"
|
||
|
|
|
||
|
|
propagated = wait_for_propagated(
|
||
|
|
collector=sender_collector,
|
||
|
|
request_id=request_id,
|
||
|
|
timeout_s=PROPAGATED_TIMEOUT_S,
|
||
|
|
)
|
||
|
|
assert propagated is not None, (
|
||
|
|
f"No message_propagated event within {PROPAGATED_TIMEOUT_S}s "
|
||
|
|
f"after unsubscribe + send. Collected events: {sender_collector.events}"
|
||
|
|
)
|
||
|
|
assert propagated["requestId"] == request_id
|
||
|
|
|
||
|
|
error = wait_for_error(sender_collector, request_id, timeout_s=0)
|
||
|
|
assert error is None, f"Unexpected message_error event: {error}"
|
||
|
|
|
||
|
|
sent = wait_for_sent(sender_collector, request_id, timeout_s=0)
|
||
|
|
assert sent is None, f"Unexpected message_sent event (store is disabled): {sent}"
|
||
|
|
|
||
|
|
assert_event_invariants(sender_collector, request_id)
|
||
|
|
|
||
|
|
|
||
|
|
class TestS05AutoSubscribeFailureBeforeTaskCreation(StepsCommon):
|
||
|
|
"""
|
||
|
|
S05 — Auto-subscribe failure before task creation.
|
||
|
|
Sender is initialized but auto-subscription is forced to fail by using a
|
||
|
|
malformed content topic that breaks shard resolution inside
|
||
|
|
SubscriptionManager.subscribe().
|
||
|
|
Expected: send() returns Err with an "auto-subscribe" message, no events.
|
||
|
|
Purpose: covers the last synchronous error path before request ID creation,
|
||
|
|
across several distinct validator branches.
|
||
|
|
"""
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"content_topic",
|
||
|
|
[topic for topic, _ in S05_MALFORMED_CONTENT_TOPICS],
|
||
|
|
ids=[case_id for _, case_id in S05_MALFORMED_CONTENT_TOPICS],
|
||
|
|
)
|
||
|
|
def test_s05_send_fails_when_auto_subscribe_fails(self, node_config, content_topic):
|
||
|
|
sender_collector = EventCollector()
|
||
|
|
|
||
|
|
node_config.update(
|
||
|
|
{
|
||
|
|
"relay": True,
|
||
|
|
"numShardsInNetwork": 1,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
sender_result = WrapperManager.create_and_start(
|
||
|
|
config=node_config,
|
||
|
|
event_cb=sender_collector.event_callback,
|
||
|
|
)
|
||
|
|
assert sender_result.is_ok(), f"Failed to start sender: {sender_result.err()}"
|
||
|
|
|
||
|
|
with sender_result.ok_value as sender:
|
||
|
|
# Malformed content topic — SubscriptionManager.subscribe() cannot
|
||
|
|
# resolve it to a shard, so auto-subscribe inside send() fails.
|
||
|
|
message = create_message_bindings(
|
||
|
|
payload=to_base64("S05 auto-subscribe failure payload"),
|
||
|
|
contentTopic=content_topic,
|
||
|
|
)
|
||
|
|
|
||
|
|
send_result = sender.send_message(message=message)
|
||
|
|
|
||
|
|
assert send_result.is_err(), (
|
||
|
|
f"send() must return Err when auto-subscribe fails for " f"content_topic={content_topic!r}, got Ok({send_result.ok_value!r})"
|
||
|
|
)
|
||
|
|
|
||
|
|
error_message = send_result.err() or ""
|
||
|
|
assert S05_EXPECTED_ERROR_FRAGMENT in error_message, (
|
||
|
|
f"expected error to mention {S05_EXPECTED_ERROR_FRAGMENT!r} " f"for content_topic={content_topic!r}, got: {error_message!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
# No request id was created, so no events should be emitted.
|
||
|
|
assert sender_collector.events == [] or all(
|
||
|
|
event.get("eventType") == "connection_status_change" for event in sender_collector.events
|
||
|
|
), f"Unexpected events after a pre-task-creation failure: {sender_collector.events}"
|