Files
Igor Sirotin b7ba8e52d6 refactor: move test suites under test/
tests-functional/    -> test/functional/
  tests-unit-network/  -> test/unit-network/

No Go code changes beyond the two package paths. Every reference follows:
Makefile targets, the pytest-lint and reliability workflows, the three
Jenkinsfiles, the root Dockerfile, pyrightconfig.json, .gitignore, the
benchmark and functional-test scripts, and the docs.

All paths inside the moved directories are self-contained (docker compose
uses `context: .`, the Python helpers resolve from __file__), so the extra
level of nesting does not reach outside.

refs #7067
2026-08-20 20:59:45 +02:00

245 lines
9.9 KiB
Python

import asyncio
import logging
import time
from uuid import uuid4
from clients.signals import SignalType
from resources.enums import MessageContentType
from utils import fake
from steps.messenger import ( # noqa: F401
get_message_by_content_type,
get_message_id,
get_message_by_message_id,
validate_signal_event_against_response,
send_multiple_one_to_one_messages,
)
async def send_contact_request_and_wait(sender, receiver, message_text: str = "contact_request") -> str:
response = sender.wakuext_service.send_contact_request(receiver.public_key, message_text)
expected_message = get_message_by_content_type(response, MessageContentType.CONTACT_REQUEST.value)[0]
message_id = expected_message.get("id")
assert message_id, "Message ID should not be empty"
await receiver.wait_for_signal(SignalType.MESSAGES_NEW, pattern=message_id, timeout=60, check_buffer=True)
return message_id
async def accept_contact_request_and_wait(message_id: str, sender, receiver) -> None:
"""Accept contact request and wait for sender to confirm acceptance.
Uses signal-based confirmation first (fast path), then falls back to
RPC polling if the WebSocket signal is lost (e.g., broken pipe).
"""
accepted_signal = f"@{receiver.public_key} accepted your contact request"
# Light-client filter subscription is async; without this delay the ACCEPT_CONTACT_REQUEST is lost (#7393).
if getattr(sender, "waku_light_client", False):
await asyncio.sleep(10)
receiver.wakuext_service.accept_contact_request(message_id, sender.public_key)
try:
await sender.wait_for_signal(SignalType.MESSAGES_NEW, pattern=accepted_signal, timeout=30, check_buffer=True)
return
except (asyncio.TimeoutError, asyncio.CancelledError):
logging.warning("Signal for contact acceptance not received, falling back to RPC polling")
deadline = time.time() + 30
while time.time() < deadline:
contact = sender.wakuext_service.get_contact_by_id(receiver.public_key)
if contact and contact.get("mutual") is True:
return
await asyncio.sleep(2)
raise TimeoutError(f"Contact {receiver.public_key} did not become mutual on sender within timeout")
async def make_contacts(sender, receiver) -> str:
existing_contacts = receiver.wakuext_service.get_contacts()
if sender.public_key in str(existing_contacts):
return ""
message_id = await send_contact_request_and_wait(sender, receiver)
await accept_contact_request_and_wait(message_id, sender, receiver)
return message_id
def create_community(node, history_archive_support_enabled=False) -> str:
wakuext = getattr(node, "wakuext_service", None)
if wakuext is None and hasattr(node, "backend"):
wakuext = node.backend.wakuext_service
assert wakuext is not None, "Node must have wakuext_service attribute"
response = wakuext.create_community(
fake.community_name(), fake.community_description(), history_archive_support_enabled=history_archive_support_enabled
)
community_id = response.get("communities", [{}])[0].get("id")
return community_id
def fetch_community(node, community_id: str) -> dict:
wakuext = getattr(node, "wakuext_service", None)
if wakuext is None and hasattr(node, "backend"):
wakuext = node.backend.wakuext_service
if wakuext is None:
raise ValueError("Node must have wakuext_service attribute")
return wakuext.fetch_community(community_id)
async def join_private_group(admin, member) -> str:
private_group_name = f"private_group_{uuid4()}"
response = admin.wakuext_service.create_group_chat_with_members([member.public_key], private_group_name)
expected_group_creation_msg = f"@{admin.public_key} created the group {private_group_name}"
expected_message = get_message_by_content_type(
response,
content_type=MessageContentType.SYSTEM_MESSAGE_CONTENT_PRIVATE_GROUP.value,
message_pattern=expected_group_creation_msg,
)[0]
await member.wait_for_signal(SignalType.MESSAGES_NEW, pattern=expected_message.get("id"), timeout=60, check_buffer=True)
return response.get("chats", [])[0].get("id")
async def _async_retry_call(func, *args, max_attempts: int = 40, retry_interval: float = 0.5):
for attempt in range(max_attempts):
try:
response = func(*args)
if response is not None:
return response
except Exception as e:
logging.debug(f"Attempt {attempt + 1}/{max_attempts} for {func.__name__} failed: {e}")
await asyncio.sleep(retry_interval)
raise Exception(f"Failed to execute {func.__name__} within {max_attempts * retry_interval}s")
def _pick_chat_id(chats: dict) -> str | None:
if not chats:
return None
try:
items = list(chats.items())
items.sort(key=lambda kv: (kv[1] or {}).get("position", 1_000_000))
for key, value in items:
if isinstance(value, dict):
chat_id = value.get("id") or key
else:
chat_id = key
if chat_id:
return chat_id
return items[0][0] if items else None
except Exception:
return next(iter(chats.keys()), None)
async def join_community(member, admin, community_id: str) -> str:
# Fetch community with retry for light client (may need time to sync)
community = None
for attempt in range(12):
community = fetch_community(member, community_id)
if community and community.get("chats"):
break
logging.debug(f"Community {community_id} not ready on member (attempt {attempt + 1}/12)")
await asyncio.sleep(5)
if not community:
raise Exception(f"Community {community_id} not visible to member after retries")
# Ensure admin has community data (triggers filter subscriptions for community topics)
fetch_community(admin, community_id)
# In light client mode, filter subscriptions are async and take several seconds.
# If the member sends the join request before the admin subscribes,
# the message is lost (filter protocol doesn't deliver historical messages).
if getattr(admin, "waku_light_client", False):
await asyncio.sleep(10)
# Request to join
response_to_join = member.wakuext_service.request_to_join_community(community_id)
join_req = (response_to_join.get("requestsToJoinCommunity") or [{}])[0]
join_id = join_req.get("id")
join_state = join_req.get("state")
assert join_id, f"Failed to request to join community: {response_to_join}"
def _is_accepted(state) -> bool:
return state == 3 or str(state) == "3"
# Auto-accept: if state is already accepted, skip admin accept phase
if _is_accepted(join_state):
community = fetch_community(member, community_id)
chats = community.get("chats", {}) if community else {}
chat_id = _pick_chat_id(chats)
if chat_id:
return community_id + chat_id
# Phase 1: Wait for admin to see the pending request.
accept_id = None
last_pending = None
last_latest = None
for attempt in range(120):
admin_observed_ids: set[str] = set()
try:
last_pending = admin.wakuext_service.pending_requests_to_join_for_community(community_id) or []
for req in last_pending:
if req.get("id"):
admin_observed_ids.add(req["id"])
except Exception as e:
logging.debug(f"Attempt {attempt + 1}/120 pending_requests check failed: {e}")
try:
last_latest = admin.wakuext_service.latest_request_to_join_for_community(community_id)
if last_latest and last_latest.get("id"):
admin_observed_ids.add(last_latest["id"])
except Exception as e:
logging.debug(f"Attempt {attempt + 1}/120 latest_request check failed: {e}")
try:
all_non_approved = admin.wakuext_service.all_non_approved_communities_requests_to_join() or []
for r in all_non_approved:
if r.get("communityId") == community_id and r.get("id"):
admin_observed_ids.add(r["id"])
except Exception as e:
logging.debug(f"Attempt {attempt + 1}/120 all_non_approved check failed: {e}")
if join_id in admin_observed_ids:
accept_id = join_id
break
elif len(admin_observed_ids) == 1:
accept_id = next(iter(admin_observed_ids))
break
# Re-send the join request periodically in case the original was lost
if attempt > 0 and attempt % 30 == 0:
logging.debug(f"Re-sending join request (attempt {attempt}/120)")
try:
retry_resp = member.wakuext_service.request_to_join_community(community_id)
retry_req = (retry_resp.get("requestsToJoinCommunity") or [{}])[0]
if retry_req.get("id"):
join_id = retry_req["id"]
except Exception as e:
logging.debug(f"Re-send join request failed: {e}")
await asyncio.sleep(1)
if not accept_id:
raise Exception(
f"Admin never saw pending request for community {community_id} "
f"(join_id={join_id}) after 120s. "
f"last_pending={last_pending}, last_latest={last_latest}"
)
# Phase 2: Accept the request
response = await _async_retry_call(
admin.wakuext_service.accept_request_to_join_community,
accept_id,
max_attempts=20,
retry_interval=1.0,
)
accept_state = (response.get("requestsToJoinCommunity") or [{}])[0].get("state")
if not _is_accepted(accept_state):
raise Exception(f"Accept request failed. State: {accept_state}, Response: {response}")
chats = response.get("communities", [{}])[0].get("chats", {})
chat_id = _pick_chat_id(chats)
if not chat_id:
raise Exception(f"No valid chat found in community response: {response}")
return community_id + chat_id