diff --git a/tests-functional/.gitignore b/tests-functional/.gitignore new file mode 100644 index 00000000000..6a45c55fe60 --- /dev/null +++ b/tests-functional/.gitignore @@ -0,0 +1,2 @@ +.idea/ +.local/ diff --git a/tests-functional/clients/signals.py b/tests-functional/clients/signals.py index 28b670af413..ba6694bdae3 100644 --- a/tests-functional/clients/signals.py +++ b/tests-functional/clients/signals.py @@ -20,15 +20,38 @@ def on_message(self, ws, signal): if signal.get("type") in self.await_signals: self.received_signals[signal["type"]].append(signal) - def wait_for_signal(self, signal_type, timeout=20): + # def wait_for_signal(self, signal_type, timeout=20): + # start_time = time.time() + # while not self.received_signals.get(signal_type): + # if time.time() - start_time >= timeout: + # raise TimeoutError( + # f"Signal {signal_type} is not received in {timeout} seconds") + # time.sleep(0.2) + # logging.debug(f"Signal {signal_type} is received in {round(time.time() - start_time)} seconds") + # return self.received_signals[signal_type][0] + + def wait_for_signal(self, signal_type, expected_event=None, timeout=20): + """ + Wait for a signal of a specific type with optional expected event details. + """ start_time = time.time() - while not self.received_signals.get(signal_type): - if time.time() - start_time >= timeout: - raise TimeoutError( - f"Signal {signal_type} is not received in {timeout} seconds") - time.sleep(0.2) - logging.debug(f"Signal {signal_type} is received in {round(time.time() - start_time)} seconds") - return self.received_signals[signal_type][0] + while time.time() - start_time < timeout: + # Check if the signal list for this type has received signals + if self.received_signals.get(signal_type): + received_signal = self.received_signals[signal_type][0] + if expected_event: + # Check if the event in received_signal matches expected_event + event = received_signal.get("event", {}) + if all(event.get(k) == v for k, v in expected_event.items()): + logging.debug(f"Signal {signal_type} with event {expected_event} received.") + return received_signal + else: + logging.debug(f"Signal {signal_type} received without specific event validation.") + return received_signal + time.sleep(0.2) # Wait before retrying to prevent excessive polling + + # If no matching signal is found within the timeout + raise TimeoutError(f"Signal {signal_type} with event {expected_event} not received in {timeout} seconds") def _on_error(self, ws, error): logging.error(f"Error: {error}") diff --git a/tests-functional/constants.py b/tests-functional/constants.py index 2730d4c3800..63d5d1f433c 100644 --- a/tests-functional/constants.py +++ b/tests-functional/constants.py @@ -1,5 +1,7 @@ +import os +import random from dataclasses import dataclass - +from src.libs.common import create_unique_data_dir @dataclass class Account: @@ -7,7 +9,7 @@ class Account: private_key: str password: str - +# User accounts user_1 = Account( address="0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", private_key="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", @@ -18,3 +20,27 @@ class Account: private_key="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", password="Strong12345" ) + +# Paths and URLs +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +STATUS_BACKEND_URL = os.getenv("STATUS_BACKEND_URL", "http://127.0.0.1") +API_REQUEST_TIMEOUT = int(os.getenv("API_REQUEST_TIMEOUT", "15")) + +# Paths relative to project root +DATA_DIR = os.path.join(PROJECT_ROOT, "tests-functional/local") +LOCAL_DATA_DIR1 = create_unique_data_dir(DATA_DIR, random.randint(1, 100)) +LOCAL_DATA_DIR2 = create_unique_data_dir(DATA_DIR, random.randint(1, 100)) +RESOURCES_FOLDER = os.path.join(PROJECT_ROOT, "resources") + +# Account payload default values +ACCOUNT_PAYLOAD_DEFAULTS = { + "displayName": "user", + "password": "test_password", + "customizationColor": "primary" +} + +# Network emulation commands +LATENCY_CMD = "sudo tc qdisc add dev eth0 root netem delay 1s 100ms distribution normal" +PACKET_LOSS_CMD = "sudo tc qdisc add dev eth0 root netem loss 50%" +LOW_BANDWIDTH_CMD = "sudo tc qdisc add dev eth0 root tbf rate 1kbit burst 1kbit" +REMOVE_TC_CMD = "sudo tc qdisc del dev eth0 root" diff --git a/tests-functional/src/config.py b/tests-functional/src/config.py deleted file mode 100644 index eff7c17d893..00000000000 --- a/tests-functional/src/config.py +++ /dev/null @@ -1,28 +0,0 @@ -import os - -class Config: - # Get the project root directory based on the location of the config file - PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) - - # Status Backend Configurations - STATUS_BACKEND_URL = os.getenv("STATUS_BACKEND_URL", "http://127.0.0.1") - API_REQUEST_TIMEOUT = int(os.getenv("API_REQUEST_TIMEOUT", "15")) - - # Paths (Relative to Project Root) - DATA_DIR = os.path.join(PROJECT_ROOT, "local") - LOCAL_DATA_DIR1 = os.path.join(DATA_DIR, "data1") - LOCAL_DATA_DIR2 = os.path.join(DATA_DIR, "data2") - RESOURCES_FOLDER = os.path.join(PROJECT_ROOT, "resources") - - # Payloads - ACCOUNT_PAYLOAD_DEFAULTS = { - "displayName": "user", - "password": "test_password", - "customizationColor": "primary" - } - - # Commands (For network emulation) - LATENCY_CMD = "sudo tc qdisc add dev eth0 root netem delay 1s 100ms distribution normal" - PACKET_LOSS_CMD = "sudo tc qdisc add dev eth0 root netem loss 50%" - LOW_BANDWIDTH_CMD = "sudo tc qdisc add dev eth0 root tbf rate 1kbit burst 1kbit" - REMOVE_TC_CMD = "sudo tc qdisc del dev eth0 root" \ No newline at end of file diff --git a/tests-functional/src/libs/common.py b/tests-functional/src/libs/common.py index 5d3d781ba0a..4faa384738f 100644 --- a/tests-functional/src/libs/common.py +++ b/tests-functional/src/libs/common.py @@ -2,6 +2,7 @@ from src.libs.custom_logger import get_custom_logger import os import allure +import uuid logger = get_custom_logger(__name__) @@ -14,3 +15,14 @@ def attach_allure_file(file): def delay(num_seconds): logger.debug(f"Sleeping for {num_seconds} seconds") sleep(num_seconds) + +def create_unique_data_dir(base_dir: str, index: int) -> str: + """Generate a unique data directory for each node instance.""" + unique_id = str(uuid.uuid4())[:8] + unique_dir = os.path.join(base_dir, f"data_{index}_{unique_id}") + os.makedirs(unique_dir, exist_ok=True) + return unique_dir + +def get_project_root() -> str: + """Returns the root directory of the project.""" + return os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) diff --git a/tests-functional/src/node/rpc_client.py b/tests-functional/src/node/rpc_client.py index 3a62a3c51ca..e8c4fd888ee 100644 --- a/tests-functional/src/node/rpc_client.py +++ b/tests-functional/src/node/rpc_client.py @@ -1,5 +1,5 @@ from src.libs.base_api_client import BaseAPIClient -from src.config import Config +from constants import * from src.libs.custom_logger import get_custom_logger from tenacity import retry, stop_after_attempt, wait_fixed @@ -16,15 +16,13 @@ def __init__(self, port, node_name): wait=wait_fixed(1), reraise=True ) - def send_rpc_request(self, method, params=None, timeout=Config.API_REQUEST_TIMEOUT): + def send_rpc_request(self, method, params=None, timeout=API_REQUEST_TIMEOUT): """Send JSON-RPC requests, used for standard JSON-RPC API calls.""" payload = {"jsonrpc": "2.0", "method": method, "params": params or [], "id": 1} logger.info(f"Sending JSON-RPC request to {self.base_url} with payload: {payload}") response = self.send_post_request("CallRPC", payload, timeout=timeout) - logger.info(f"Response received: {response}") - if response.get("error"): logger.error(f"RPC request failed with error: {response['error']}") raise RuntimeError(f"RPC request failed with error: {response['error']}") @@ -36,15 +34,13 @@ def send_rpc_request(self, method, params=None, timeout=Config.API_REQUEST_TIMEO wait=wait_fixed(1), reraise=True ) - def initialize_application(self, data_dir, timeout=Config.API_REQUEST_TIMEOUT): + def initialize_application(self, data_dir, timeout=API_REQUEST_TIMEOUT): """Send a direct POST request to the InitializeApplication endpoint.""" payload = {"dataDir": data_dir} logger.info(f"Sending direct POST request to InitializeApplication with payload: {payload}") response = self.send_post_request("InitializeApplication", payload, timeout=timeout) - logger.info(f"Response from InitializeApplication: {response}") - if response.get("error"): logger.error(f"InitializeApplication request failed with error: {response['error']}") raise RuntimeError(f"Failed to initialize application: {response['error']}") @@ -56,7 +52,7 @@ def initialize_application(self, data_dir, timeout=Config.API_REQUEST_TIMEOUT): wait=wait_fixed(1), reraise=True ) - def create_account_and_login(self, account_data, timeout=Config.API_REQUEST_TIMEOUT): + def create_account_and_login(self, account_data, timeout=API_REQUEST_TIMEOUT): """Send a direct POST request to CreateAccountAndLogin endpoint.""" payload = { "rootDataDir": account_data.get("rootDataDir"), @@ -68,8 +64,6 @@ def create_account_and_login(self, account_data, timeout=Config.API_REQUEST_TIME response = self.send_post_request("CreateAccountAndLogin", payload, timeout=timeout) - logger.info(f"Response from CreateAccountAndLogin: {response}") - if response.get("error"): logger.error(f"CreateAccountAndLogin request failed with error: {response['error']}") raise RuntimeError(f"Failed to create account and login: {response['error']}") @@ -81,7 +75,7 @@ def create_account_and_login(self, account_data, timeout=Config.API_REQUEST_TIME wait=wait_fixed(1), reraise=True ) - def start_messenger(self, timeout=Config.API_REQUEST_TIMEOUT): + def start_messenger(self, timeout=API_REQUEST_TIMEOUT): """Send JSON-RPC request to start Waku messenger.""" payload = { "jsonrpc": "2.0", @@ -93,8 +87,6 @@ def start_messenger(self, timeout=Config.API_REQUEST_TIMEOUT): response = self.send_post_request("CallRPC", payload, timeout=timeout) - logger.info(f"Response from Waku messenger start: {response}") - if response.get("error"): logger.error(f"Starting Waku messenger failed with error: {response['error']}") raise RuntimeError(f"Failed to start Waku messenger: {response['error']}") diff --git a/tests-functional/src/node/status_node.py b/tests-functional/src/node/status_node.py index 9b76be5a986..dd05027644d 100644 --- a/tests-functional/src/node/status_node.py +++ b/tests-functional/src/node/status_node.py @@ -5,21 +5,21 @@ import signal import string import subprocess -import re import threading import time -import requests -from tenacity import retry, stop_after_delay, wait_fixed, stop_after_attempt -from src.data_storage import DS + +from clients.status_backend import RpcClient +from conftest import option from src.libs.custom_logger import get_custom_logger from src.node.rpc_client import StatusNodeRPC -from tests.clients.signals import SignalClient +from clients.signals import SignalClient logger = get_custom_logger(__name__) class StatusNode: def __init__(self, name=None, port=None, pubkey=None): + self.data_dir = None try: os.remove(f"{name}.log") except: @@ -33,8 +33,21 @@ def __init__(self, name=None, port=None, pubkey=None): self.logs = [] self.pid = None self.signal_client = None + self.last_response = None self.api = StatusNodeRPC(self.port, self.name) + def setup_method(self): + # Set up RPC client + self.rpc_client = RpcClient(option.rpc_url_statusd) + # Set up WebSocket signal client + await_signals = ["history.request.started", "history.request.completed"] + self.signal_client = SignalClient(option.ws_url_statusd, await_signals) + + # Start WebSocket connection in a separate thread + websocket_thread = threading.Thread(target=self.signal_client._connect) + websocket_thread.daemon = True + websocket_thread.start() + def initialize_node(self, name, port, data_dir, account_data): """Centralized method to initialize a node.""" self.name = name @@ -43,6 +56,7 @@ def initialize_node(self, name, port, data_dir, account_data): self.wait_fully_started() self.create_account_and_login(account_data) self.start_messenger() + self.pubkey = self.get_pubkey(account_data["displayName"]) def start_node(self, command): """Start the node using a subprocess command.""" @@ -54,10 +68,11 @@ def start_node(self, command): def start(self, data_dir, capture_logs=True): """Start the status-backend node and initialize it before subscribing to signals.""" self.capture_logs = capture_logs + self.data_dir = data_dir command = ["./status-backend", f"--address=localhost:{self.port}"] self.start_node(command) self.wait_fully_started() - self.api.initialize_application(data_dir) + self.last_response = self.api.initialize_application(data_dir) self.api = StatusNodeRPC(self.port, self.name) self.start_signal_client() @@ -74,7 +89,7 @@ def start_messenger(self): def start_signal_client(self): """Start a SignalClient for the given node to listen for WebSocket signals.""" ws_url = f"ws://localhost:{self.port}" - await_signals = ["community.chatMessage", "mediaserver.started"] + await_signals = ["history.request.started", "history.request.completed"] self.signal_client = SignalClient(ws_url, await_signals) websocket_thread = threading.Thread(target=self.signal_client._connect) @@ -114,16 +129,19 @@ def random_node_name(self, length=10): allowed_chars = string.ascii_lowercase + string.digits + "_-" return ''.join(random.choice(allowed_chars) for _ in range(length)) - def get_pubkey(self): - """Retrieve the public key of the node.""" - if self.pubkey: - return self.pubkey - else: - raise Exception(f"Public key not set for node {self.name}") + def get_pubkey(self, display_name): + """Retrieve public-key based on display name from accounts_getAccounts response.""" + response = self.api.send_rpc_request("accounts_getAccounts") - def wait_for_signal(self, signal_type, timeout=20): - """Wait for a signal using the signal client.""" - return self.signal_client.wait_for_signal(signal_type, timeout) + accounts = response.get("result", []) + for account in accounts: + if account.get("name") == display_name: + return account.get("public-key") + raise ValueError(f"Public key not found for display name: {display_name}") + + def wait_for_signal(self, signal_type, expected_event=None, timeout=20): + """Wait for a signal using the signal client and validate against expected event details.""" + return self.signal_client.wait_for_signal(signal_type, expected_event, timeout) def stop(self, remove_local_data=True): """Stop the status-backend process.""" @@ -141,58 +159,10 @@ def stop(self, remove_local_data=True): logger.warning(f"Couldn't delete node dir {node_dir} because of {str(ex)}") self.process = None - @retry(stop=stop_after_delay(30), wait=wait_fixed(0.1), reraise=True) - # wakuext_fetchCommunity times out sometimes so that's why we need this retry mechanism - def fetch_community(self, community_key): - params = [{"communityKey": community_key, "waitForResponse": True, "tryDatabase": True}] - return self.api.send_rpc_request("wakuext_fetchCommunity", params, timeout=10) - - def request_to_join_community(self, community_id): - print("request_to_join_community: ", community_id, self.name, self.api) - params = [{"communityId": community_id, "addressesToReveal": ["fakeaddress"], "airdropAddress": "fakeaddress"}] - return self.api.send_rpc_request("wakuext_requestToJoinCommunity", params) - - def accept_request_to_join_community(self, request_to_join_id): - print("accept_request_to_join_community: ", request_to_join_id, self.name, self.api) - self._ensure_api_initialized() - params = [{"id": request_to_join_id}] - return self.api.send_rpc_request("wakuext_acceptRequestToJoinCommunity", params) - - def _ensure_api_initialized(self): - if not self.api: - logger.warning(f"API client is not initialized for node {self.name}. Reinitializing...") - self.api = StatusNodeRPC(self.port, self.name) - if not self.api: - raise Exception(f"Failed to initialize the RPC client for node {self.name}") - - def send_community_chat_message(self, chat_id, message): - params = [{"chatId": chat_id, "text": message, "contentType": 1}] - return self.api.send_rpc_request("wakuext_sendChatMessage", params) - - def leave_community(self, community_id): - params = [community_id] - return self.api.send_rpc_request("wakuext_leaveCommunity", params) - def send_contact_request(self, pubkey, message): params = [{"id": pubkey, "message": message}] return self.api.send_rpc_request("wakuext_sendContactRequest", params) - async def wait_for_logs_async(self, strings=None, timeout=10): - if not isinstance(strings, list): - raise ValueError("strings must be a list") - start_time = time.time() - while time.time() - start_time < timeout: - all_found = True - for string in strings: - logs = self.search_logs(string=string) - if not logs: - all_found = False - break - if all_found: - return True - await asyncio.sleep(0.5) - return False - def pause_process(self): if self.pid: logger.info(f"Pausing node with pid: {self.pid}") diff --git a/tests-functional/src/steps/common.py b/tests-functional/src/steps/common.py index f88f0605c09..f94c58dc8a1 100644 --- a/tests-functional/src/steps/common.py +++ b/tests-functional/src/steps/common.py @@ -9,23 +9,24 @@ from datetime import datetime from tenacity import retry, stop_after_delay, wait_fixed import random -from src.config import Config +from constants import * logger = get_custom_logger(__name__) + class StepsCommon: @pytest.fixture(scope="function", autouse=False) def start_1_node(self): # Use Config for static paths and account data account_data = { - **Config.ACCOUNT_PAYLOAD_DEFAULTS, - "rootDataDir": Config.LOCAL_DATA_DIR1, + **ACCOUNT_PAYLOAD_DEFAULTS, + "rootDataDir": LOCAL_DATA_DIR1, "displayName": "first_node_user" } random_port = str(random.randint(1024, 65535)) self.first_node = StatusNode() - self.first_node.initialize_node("first_node", random_port, Config.LOCAL_DATA_DIR1, account_data) + self.first_node.initialize_node("first_node", random_port, LOCAL_DATA_DIR1, account_data) self.first_node_pubkey = self.first_node.get_pubkey() @pytest.fixture(scope="function", autouse=False) @@ -33,24 +34,24 @@ def start_2_nodes(self): logger.debug(f"Running fixture setup: {inspect.currentframe().f_code.co_name}") account_data_first = { - **Config.ACCOUNT_PAYLOAD_DEFAULTS, - "rootDataDir": Config.LOCAL_DATA_DIR1, + **ACCOUNT_PAYLOAD_DEFAULTS, + "rootDataDir": LOCAL_DATA_DIR1, "displayName": "first_node_user" } account_data_second = { - **Config.ACCOUNT_PAYLOAD_DEFAULTS, - "rootDataDir": Config.LOCAL_DATA_DIR2, + **ACCOUNT_PAYLOAD_DEFAULTS, + "rootDataDir": LOCAL_DATA_DIR2, "displayName": "second_node_user" } # Initialize first node self.first_node = StatusNode(name="first_node") - self.first_node.start(data_dir=Config.LOCAL_DATA_DIR1) + self.first_node.start(data_dir=LOCAL_DATA_DIR1) self.first_node.wait_fully_started() # Initialize second node self.second_node = StatusNode(name="second_node") - self.second_node.start(data_dir=Config.LOCAL_DATA_DIR2) + self.second_node.start(data_dir=LOCAL_DATA_DIR2) self.second_node.wait_fully_started() # Create accounts and login for both nodes @@ -63,43 +64,45 @@ def start_2_nodes(self): delay(1) self.second_node.start_messenger() - # Retrieve public keys - self.first_node_pubkey = self.first_node.get_pubkey() - self.second_node_pubkey = self.second_node.get_pubkey() + # Retrieve public keys using the updated method + self.first_node_pubkey = self.first_node.get_key_uid("first_node_user") + self.second_node_pubkey = self.second_node.get_key_uid("second_node_user") + + logger.debug(f"First Node Public Key: {self.first_node_pubkey}") + logger.debug(f"Second Node Public Key: {self.second_node_pubkey}") @contextmanager def add_latency(self): """Add network latency""" logger.debug("Adding network latency") - subprocess.Popen(Config.LATENCY_CMD, shell=True) + subprocess.Popen(LATENCY_CMD, shell=True) try: yield finally: logger.debug("Removing network latency") - subprocess.Popen(Config.REMOVE_TC_CMD, shell=True) + subprocess.Popen(REMOVE_TC_CMD, shell=True) - @contextmanager @contextmanager def add_packet_loss(self): """Add packet loss""" logger.debug("Adding packet loss") - subprocess.Popen(Config.PACKET_LOSS_CMD, shell=True) + subprocess.Popen(PACKET_LOSS_CMD, shell=True) try: yield finally: logger.debug("Removing packet loss") - subprocess.Popen(Config.REMOVE_TC_CMD, shell=True) + subprocess.Popen(REMOVE_TC_CMD, shell=True) @contextmanager def add_low_bandwidth(self): """Add low bandwidth""" logger.debug("Adding low bandwidth") - subprocess.Popen(Config.LOW_BANDWIDTH_CMD, shell=True) + subprocess.Popen(LOW_BANDWIDTH_CMD, shell=True) try: yield finally: logger.debug("Removing low bandwidth") - subprocess.Popen(Config.REMOVE_TC_CMD, shell=True) + subprocess.Popen(REMOVE_TC_CMD, shell=True) @contextmanager def node_pause(self, node): @@ -193,7 +196,8 @@ def setup_community_nodes(self, node_limit=None): community_id = node_name.split("_")[0] port = node_name.split("_")[1] status_node = StatusNode(name=node_name, port=port) - self.community_nodes.append({"node_uid": node_uid, "community_id": community_id, "status_node": status_node}) + self.community_nodes.append( + {"node_uid": node_uid, "community_id": community_id, "status_node": status_node}) # Start all nodes for _, community_node in enumerate(self.community_nodes): @@ -261,4 +265,3 @@ def join_created_communities(self): self.chat_id_list.append(chat_id) logger.info(f"Successfully joined all communities. Chat IDs: {self.chat_id_list}") - diff --git a/tests-functional/tests/test_contact_request.py b/tests-functional/tests/test_contact_request.py index 922dae72872..918b2757fb9 100644 --- a/tests-functional/tests/test_contact_request.py +++ b/tests-functional/tests/test_contact_request.py @@ -1,43 +1,32 @@ +import logging import os from uuid import uuid4 -import asyncio -import pytest from src.env_vars import NUM_CONTACT_REQUESTS from src.libs.common import delay from src.node.status_node import StatusNode, logger from src.steps.common import StepsCommon - - -def get_project_root(): - """Returns the root directory of the project.""" - return os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) +from src.libs.common import create_unique_data_dir, get_project_root +from validators.contact_request_validator import ContactRequestValidator class TestContactRequest(StepsCommon): - - @pytest.mark.asyncio - async def test_contact_request_baseline(self, recover_network_fn=None): + def test_contact_request_baseline(self): timeout_secs = 180 - reset_network_in_secs = 30 num_contact_requests = NUM_CONTACT_REQUESTS - project_root = get_project_root() - - nodes: list[tuple[StatusNode, StatusNode, int]] = [] + nodes = [] for index in range(num_contact_requests): - # Step 1: Start status-backend and initialize application for both nodes first_node = StatusNode(name=f"first_node_{index}") second_node = StatusNode(name=f"second_node_{index}") - data_dir_first = os.path.join(project_root, f"tests-functional/local/data{index}_first") - data_dir_second = os.path.join(project_root, f"tests-functional/local/data{index}_second") + data_dir_first = create_unique_data_dir(os.path.join(project_root, "tests-functional/local"), index) + data_dir_second = create_unique_data_dir(os.path.join(project_root, "tests-functional/local"), index) delay(2) first_node.start(data_dir=data_dir_first) second_node.start(data_dir=data_dir_second) - # Step 2: Create account and login account_data_first = { "rootDataDir": data_dir_first, "displayName": f"test_user_first_{index}", @@ -50,51 +39,39 @@ async def test_contact_request_baseline(self, recover_network_fn=None): "password": f"test_password_second_{index}", "customizationColor": "primary" } - first_node.create_account_and_login(account_data_first) second_node.create_account_and_login(account_data_second) delay(5) - - # Step 3: Start the Waku messenger first_node.start_messenger() second_node.start_messenger() - # Step 4: Wait until nodes are fully started before proceeding + first_node.pubkey = first_node.get_pubkey(account_data_first["displayName"]) + second_node.pubkey = second_node.get_pubkey(account_data_second["displayName"]) + first_node.wait_fully_started() second_node.wait_fully_started() - nodes.append((first_node, second_node, index)) + nodes.append((first_node, second_node, account_data_first["displayName"], index)) - # Step 5: Create tasks for sending contact requests - tasks = [ - asyncio.create_task( - self.send_and_wait_for_message((first_node, second_node), index, timeout_secs) - ) - for first_node, second_node, index in nodes - ] - - # Step 6: Wait for tasks with network recovery logic - done, pending = await asyncio.wait(tasks, timeout=reset_network_in_secs) - if pending: - if recover_network_fn is not None: - recover_network_fn() - done2, _ = await asyncio.wait(pending) - done.update(done2) - else: - logger.info("No pending tasks.") - - # Step 7: Collect any missing contact requests + # Validate contact requests missing_contact_requests = [] - for task in done: - if task.exception(): - logger.info(f"Task raised an exception: {task.exception()}") + for first_node, second_node, display_name, index in nodes: + # Capture the response from send_and_wait_for_message + result = self.send_and_wait_for_message((first_node, second_node), display_name, index, timeout_secs) + timestamp, message_id, contact_request_message, response = result + + if not response: + missing_contact_requests.append((timestamp, contact_request_message, message_id)) else: - res = task.result() - if res is not None: - missing_contact_requests.append(res) + # Run validator on the captured response + validator = ContactRequestValidator(response) + validator.run_all_validations( + expected_chat_id=first_node.pubkey, + expected_display_name=display_name, + expected_text=f"contact_request_{index}" + ) - # Step 8: Assert if there are missing contact requests if missing_contact_requests: formatted_missing_requests = [ f"Timestamp: {ts}, Message: {msg}, ID: {mid}" for ts, msg, mid in missing_contact_requests @@ -104,41 +81,49 @@ async def test_contact_request_baseline(self, recover_network_fn=None): + "\n".join(formatted_missing_requests) ) - async def send_and_wait_for_message(self, nodes: tuple[StatusNode, StatusNode], index: int, timeout: int = 45): + def send_and_wait_for_message(self, nodes, display_name, index, timeout=45): first_node, second_node = nodes - - # Step 4: Get the public key from the first node - first_node_pubkey = first_node.get_pubkey() - - # Prepare the contact request message + first_node_pubkey = first_node.get_pubkey(display_name) contact_request_message = f"contact_request_{index}" + + # Send the contact request and capture timestamp and message_id timestamp, message_id = self.send_with_timestamp( second_node.send_contact_request, first_node_pubkey, contact_request_message ) - # Wait for the contact request message to be acknowledged - contact_requests_successful = await first_node.wait_for_logs_async( - [f"message received: {contact_request_message}", "AcceptContactRequest"], timeout - ) + # Capture the response directly from send_contact_request + response = second_node.send_contact_request(first_node_pubkey, contact_request_message) + + # Define expected signal details + expected_event_started = {"requestId": "", "peerId": "", "batchIndex": 0, "numBatches": 1} + expected_event_completed = {"requestId": "", "peerId": "", "batchIndex": 0} - # Stop both nodes after message processing + try: + first_node.wait_for_signal("history.request.started", expected_event_started, timeout) + first_node.wait_for_signal("history.request.completed", expected_event_completed, timeout) + except TimeoutError as e: + logging.error(f"Signal validation failed: {str(e)}") + # Return None if signals failed, along with timestamp and message_id for tracking + return timestamp, message_id, contact_request_message, None + + # Stop nodes after message processing first_node.stop() second_node.stop() - @pytest.mark.asyncio - async def test_contact_request_with_latency(self): - with self.add_latency() as recover_network_fn: - await self.test_contact_request_baseline(recover_network_fn) + # Return timestamp, message_id, and the response for validation + return timestamp, message_id, contact_request_message, response + + def test_contact_request_with_latency(self): + with self.add_latency(): + self.test_contact_request_baseline() - @pytest.mark.asyncio - async def test_contact_request_with_packet_loss(self): - with self.add_packet_loss() as recover_network_fn: - await self.test_contact_request_baseline(recover_network_fn) + def test_contact_request_with_packet_loss(self): + with self.add_packet_loss(): + self.test_contact_request_baseline() - @pytest.mark.asyncio - async def test_contact_request_with_low_bandwidth(self): - with self.add_low_bandwidth() as recover_network_fn: - await self.test_contact_request_baseline(recover_network_fn) + def test_contact_request_with_low_bandwidth(self): + with self.add_low_bandwidth(): + self.test_contact_request_baseline() def test_contact_request_with_node_pause(self, start_2_nodes): with self.node_pause(self.second_node): diff --git a/tests-functional/validators/contact_request_validator.py b/tests-functional/validators/contact_request_validator.py new file mode 100644 index 00000000000..7fa6f4b0d60 --- /dev/null +++ b/tests-functional/validators/contact_request_validator.py @@ -0,0 +1,31 @@ +class ContactRequestValidator: + """Validator class for contact request responses.""" + + def __init__(self, response): + self.response = response + + def validate_response_structure(self): + """Check the overall structure of the response.""" + assert self.response.get("jsonrpc") == "2.0", "Invalid JSON-RPC version" + assert "result" in self.response, "Missing 'result' in response" + + def validate_chat_data(self, expected_chat_id, expected_display_name, expected_text): + """Validate the chat data fields in the response.""" + chats = self.response["result"].get("chats", []) + assert len(chats) > 0, "No chats found in the response" + + chat = chats[0] # Validate the first chat as an example + assert chat.get("id") == expected_chat_id, f"Chat ID mismatch: Expected {expected_chat_id}" + assert chat.get("name").startswith("0x"), "Invalid chat name format" + + last_message = chat.get("lastMessage", {}) + # assert last_message.get("displayName") == expected_display_name, "Display name mismatch" + assert last_message.get("text") == expected_text, "Message text mismatch" + assert last_message.get("contactRequestState") == 1, "Unexpected contact request state" + assert "compressedKey" in last_message, "Missing 'compressedKey' in last message" + + def run_all_validations(self, expected_chat_id, expected_display_name, expected_text): + """Run all validation methods for the contact request response.""" + self.validate_response_structure() + self.validate_chat_data(expected_chat_id, expected_display_name, expected_text) + print("All validations passed for the contact request response.") \ No newline at end of file diff --git a/tests-functional/workflows/ci.yml b/tests-functional/workflows/ci.yml deleted file mode 100644 index 31c7df12d4e..00000000000 --- a/tests-functional/workflows/ci.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: CI - -on: - schedule: - - cron: '0 0 * * *' - workflow_dispatch: - -env: - FORCE_COLOR: "1" - -jobs: - - tests: - name: tests - runs-on: ubuntu-latest - timeout-minutes: 120 - steps: - - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v4 - with: - python-version: '3.12' - cache: 'pip' - - - run: pip install -r requirements.txt - - - name: Checkout status-go repository - uses: actions/checkout@v4 - with: - repository: status-im/status-go - path: status-go - - - name: Set up Nix - uses: cachix/install-nix-action@v27 - - - name: Build status-cli - run: | - cd status-go - make status-backend - - - name: Copy status-cli binary to test repo root - run: cp status-go/build/bin/status-cli . - - - name: Run tests - run: | - pytest tests/ --alluredir=allure-results - - - name: Get allure history - if: always() - uses: actions/checkout@v4 - with: - ref: gh-pages - path: gh-pages - - - name: Setup allure report - uses: simple-elf/allure-report-action@master - if: always() - id: allure-report - with: - allure_results: allure-results - gh_pages: gh-pages - allure_history: allure-history - keep_reports: 30 - - - name: Deploy report to Github Pages - uses: peaceiris/actions-gh-pages@v3 - if: always() - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_branch: gh-pages - publish_dir: allure-history - - - name: Create job summary - if: always() - env: - JOB_STATUS: ${{ job.status }} - run: | - echo "## Run Information" >> $GITHUB_STEP_SUMMARY - echo "- **Event**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY - echo "- **Actor**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY - echo "## Test Results" >> $GITHUB_STEP_SUMMARY - echo "Allure report will be available at: https://status-im.github.io/status-cli-tests/${{ github.run_number }}" >> $GITHUB_STEP_SUMMARY - { - echo 'JOB_SUMMARY<> $GITHUB_ENV - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: test_results \ No newline at end of file diff --git a/tests-functional/workflows/linters.yml b/tests-functional/workflows/linters.yml deleted file mode 100644 index 07902a4d187..00000000000 --- a/tests-functional/workflows/linters.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Code Linters - -on: - pull_request: - branches: - - master - -jobs: - linters: - timeout-minutes: 10 - runs-on: ubuntu-latest - steps: - - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v4 - with: - python-version: '3.12' - cache: 'pip' - - - name: Set up virtual environment - run: | - python -m venv .venv - echo ".venv/bin" >> $GITHUB_PATH # Add virtualenv to PATH for subsequent steps - - - name: Install dependencies based on requirements.txt - run: pip install -r requirements.txt - - - name: Install pre-commit - run: pip install pre-commit - - - name: Run pre-commit hooks - run: pre-commit run --all-files \ No newline at end of file