diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000000..2ea638f173a --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["tests-functional"], + "reportMissingImports": true, + "reportOptionalMemberAccess": false, + "reportGeneralTypeIssues": false, + "reportInvalidStringEscapeSequence": false, + "reportWildcardImportFromLibrary": false, + "venvPath": ".", + "venv": ".venv", + "typeCheckingMode": "off" +} \ No newline at end of file diff --git a/tests-functional/.pre-commit-config.yaml b/tests-functional/.pre-commit-config.yaml new file mode 100644 index 00000000000..a52586110e5 --- /dev/null +++ b/tests-functional/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: +- repo: https://github.com/psf/black + rev: 23.7.0 + hooks: + - id: black + args: [--line-length=150] + +- repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.326 + hooks: + - id: pyright diff --git a/tests-functional/README.MD b/tests-functional/README.MD index 16ceefa9c0f..9e8ec508bfe 100644 --- a/tests-functional/README.MD +++ b/tests-functional/README.MD @@ -7,15 +7,22 @@ Functional tests for status-go - [Overview](#overview) - [How to Install](#how-to-install) - [How to Run](#how-to-run) - - [Running Tests](#running-tests) +- [Running Tests](#running-tests) - [Implementation details](#implementation-details) ## How to Install -* Install [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/) -* Install [Python 3.10.14](https://www.python.org/downloads/) -* In `./tests-functional`, run `pip install -r requirements.txt` -* **Optional (for test development)**: Use Python virtual environment for better dependency management. You can follow the guide [here](https://akrabat.com/creating-virtual-environments-with-pyenv/): +1. Install [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/) +2. Install [Python 3.10.14](https://www.python.org/downloads/) +3. **Set up a virtual environment (recommended):** + - In `./tests-functional`, run: + ```bash + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + - **Optional (for test development)**: Use Python virtual environment for better dependency management. You can follow the guide [here](https://akrabat.com/creating-virtual-environments-with-pyenv/) + ## How to Run diff --git a/tests-functional/clients/signals.py b/tests-functional/clients/signals.py deleted file mode 100644 index 28b670af413..00000000000 --- a/tests-functional/clients/signals.py +++ /dev/null @@ -1,48 +0,0 @@ -import json -import logging -import time - -import websocket - - -class SignalClient: - - def __init__(self, ws_url, await_signals): - self.url = f"{ws_url}/signals" - - self.await_signals = await_signals - self.received_signals = { - signal: [] for signal in self.await_signals - } - - def on_message(self, ws, signal): - signal = json.loads(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): - 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 _on_error(self, ws, error): - logging.error(f"Error: {error}") - - def _on_close(self, ws, close_status_code, close_msg): - logging.info(f"Connection closed: {close_status_code}, {close_msg}") - - def _on_open(self, ws): - logging.info("Connection opened") - - def _connect(self): - ws = websocket.WebSocketApp(self.url, - on_message=self.on_message, - on_error=self._on_error, - on_close=self._on_close) - ws.on_open = self._on_open - ws.run_forever() diff --git a/tests-functional/config.json b/tests-functional/config.json index 4daa09ea98d..18f9527fb10 100644 --- a/tests-functional/config.json +++ b/tests-functional/config.json @@ -9,24 +9,20 @@ "HTTPPort": 3333, "HTTPVirtualHosts": ["*", "status-go"], "APIModules": "eth,admin,wallet,accounts,waku,wakuext,ethclient", - "WalletConfig": { - "Enabled": true - }, - "WakuConfig": { - "Enabled": false - }, + "WalletConfig": {"Enabled": true}, + "WakuConfig": {"Enabled": false}, "Networks": [ { - "ChainID": 31337, - "ChainName": "Anvil", - "RPCURL": "http://anvil:8545", - "ShortName": "eth", - "NativeCurrencyName": "Ether", - "NativeCurrencySymbol": "ETH", - "NativeCurrencyDecimals": 18, - "IsTest": false, - "Layer": 1, - "Enabled": true - } - ] + "ChainID": 31337, + "ChainName": "Anvil", + "RPCURL": "http://anvil:8545", + "ShortName": "eth", + "NativeCurrencyName": "Ether", + "NativeCurrencySymbol": "ETH", + "NativeCurrencyDecimals": 18, + "IsTest": false, + "Layer": 1, + "Enabled": true, + } + ], } diff --git a/tests-functional/conftest.py b/tests-functional/conftest.py index 9433da149bc..c49f3a63175 100644 --- a/tests-functional/conftest.py +++ b/tests-functional/conftest.py @@ -1,8 +1,11 @@ +import inspect import os import threading from dataclasses import dataclass - import pytest as pytest +from src.libs.custom_logger import get_custom_logger + +logger = get_custom_logger(__name__) def pytest_addoption(parser): @@ -58,30 +61,27 @@ def pytest_configure(config): option.base_dir = os.path.dirname(os.path.abspath(__file__)) -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="session", autouse=False) def init_status_backend(): - await_signals = [ + logger.info(f"Running fixture setup: {inspect.currentframe().f_code.co_name}") + await_signals = [ "mediaserver.started", "node.started", "node.ready", "node.login", - "wallet", # TODO: a test per event of a different type ] - from clients.status_backend import StatusBackend - backend_client = StatusBackend( - await_signals=await_signals - ) + from src.node.clients.status_backend import StatusBackend - websocket_thread = threading.Thread( - target=backend_client._connect - ) + backend_client = StatusBackend(await_signals=await_signals) + + websocket_thread = threading.Thread(target=backend_client._connect) websocket_thread.daemon = True websocket_thread.start() - backend_client.init_status_backend() + backend_client.init_status_backend(data_dir="/") backend_client.restore_account_and_wait_for_rpc_client_to_start() yield backend_client diff --git a/tests-functional/constants.py b/tests-functional/constants.py deleted file mode 100644 index a164e05aa2d..00000000000 --- a/tests-functional/constants.py +++ /dev/null @@ -1,23 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class Account: - address: str - private_key: str - password: str - passphrase: str - - -user_1 = Account( - address="0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", - private_key="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - password="Strong12345", - passphrase="test test test test test test test test test test test junk" -) -user_2 = Account( - address="0x70997970c51812dc3a010c7d01b50e0d17dc79c8", - private_key="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", - password="Strong12345", - passphrase="test test test test test test test test test test nest junk" -) diff --git a/tests-functional/pytest.ini b/tests-functional/pytest.ini index c2a4e479b1d..a58b1ce2695 100644 --- a/tests-functional/pytest.ini +++ b/tests-functional/pytest.ini @@ -1,9 +1,11 @@ [pytest] -addopts = -s -v --tb=short - +addopts = -s -v --instafail --tb=short --color=auto log_cli=true log_level=INFO - +log_file = log/test.log +log_cli_format = %(asctime)s %(name)s %(levelname)s %(message)s +log_file_format = %(asctime)s %(name)s %(levelname)s %(message)s +timeout = 300 markers = rpc wallet @@ -14,3 +16,5 @@ markers = init transaction create_account +filterwarnings = + ignore::DeprecationWarning \ No newline at end of file diff --git a/tests-functional/requirements.txt b/tests-functional/requirements.txt index 6d778112da0..593b0090607 100644 --- a/tests-functional/requirements.txt +++ b/tests-functional/requirements.txt @@ -4,3 +4,9 @@ pytest==6.2.4 requests==2.31.0 genson~=1.2.2 websocket-client~=1.4.2 +tenacity~=9.0.0 +black~=24.10.0 +pyright~=1.1.388 +pytest-instafail==0.5.0 +pre-commit~=4.0.1 +pytest-timeout~=2.2.0 \ No newline at end of file diff --git a/tests-functional/src/constants.py b/tests-functional/src/constants.py new file mode 100644 index 00000000000..afe3c13f9e0 --- /dev/null +++ b/tests-functional/src/constants.py @@ -0,0 +1,57 @@ +import os +import random +from dataclasses import dataclass +import uuid + + +def create_unique_data_dir(base_dir: str, index: int) -> str: + 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 + + +@dataclass +class Account: + address: str + private_key: str + password: str + passphrase: str + + +user_1 = Account( + address="0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + private_key="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + password="Strong12345", + passphrase="test test test test test test test test test test test junk", +) +user_2 = Account( + address="0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + private_key="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + password="Strong12345", + passphrase="test test test test test test test test test test nest junk", +) + +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")) + +SOURCE_DIR = os.path.join(PROJECT_ROOT, "build/bin") +DEST_DIR = os.path.join(PROJECT_ROOT, "tests-functional") +BINARY_PATH = os.path.join(SOURCE_DIR, "status-backend") +DATA_DIR = os.path.join(PROJECT_ROOT, "tests-functional/local") +SIGNALS_DIR = os.path.join(DEST_DIR, "signals") +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_DEFAULTS = { + "displayName": "user", + "password": "test_password", + "customizationColor": "primary", +} + +NUM_CONTACT_REQUESTS = int(os.getenv("NUM_CONTACT_REQUESTS", "5")) +NUM_MESSAGES = int(os.getenv("NUM_MESSAGES", "20")) +DELAY_BETWEEN_MESSAGES = int(os.getenv("NUM_MESSAGES", "1")) +EVENT_SIGNAL_TIMEOUT_SEC = int(os.getenv("EVENT_SIGNAL_TIMEOUT_SEC", "5")) diff --git a/tests-functional/src/libs/__init__.py b/tests-functional/src/libs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests-functional/src/libs/base_api_client.py b/tests-functional/src/libs/base_api_client.py new file mode 100644 index 00000000000..9eab3e2885d --- /dev/null +++ b/tests-functional/src/libs/base_api_client.py @@ -0,0 +1,29 @@ +import requests +import json +from tenacity import retry, stop_after_delay, wait_fixed +from src.libs.custom_logger import get_custom_logger + +logger = get_custom_logger(__name__) + + +class BaseAPIClient: + def __init__(self, base_url): + self.base_url = base_url + + @retry(stop=stop_after_delay(10), wait=wait_fixed(0.5), reraise=True) + def send_post_request(self, endpoint, payload=None, headers=None, timeout=10): + if headers is None: + headers = {"Content-Type": "application/json"} + if payload is None: + payload = {} + + url = f"{self.base_url}/{endpoint}" + logger.info(f"Sending POST request to {url} with payload: {json.dumps(payload)}") + try: + response = requests.post(url, headers=headers, data=json.dumps(payload), timeout=timeout) + response.raise_for_status() + logger.info(f"Response received: {response.status_code} - {response.text}") + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"Request to {url} failed: {str(e)}") + raise diff --git a/tests-functional/src/libs/common.py b/tests-functional/src/libs/common.py new file mode 100644 index 00000000000..6bff7851e53 --- /dev/null +++ b/tests-functional/src/libs/common.py @@ -0,0 +1,50 @@ +import json +from time import sleep +from src.libs.custom_logger import get_custom_logger +import subprocess +import shutil +import os +from datetime import datetime +from src.constants import PROJECT_ROOT, BINARY_PATH, DEST_DIR, SIGNALS_DIR +from pathlib import Path + + +logger = get_custom_logger(__name__) +Path(SIGNALS_DIR).mkdir(parents=True, exist_ok=True) + + +def delay(num_seconds): + logger.debug(f"Sleeping for {num_seconds} seconds") + sleep(num_seconds) + + +def write_signal_to_file(signal_data): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + signal_file_path = os.path.join(SIGNALS_DIR, f"signals_log_{timestamp}.json") + with open(signal_file_path, "a+") as file: + json.dump(signal_data, file) + file.write("\n") + + +def build_and_copy_binary(): + logger.info(f"Building status-backend binary in {PROJECT_ROOT}") + result = subprocess.run(["make", "status-backend"], cwd=PROJECT_ROOT, capture_output=True, text=True) + + if result.returncode != 0: + logger.info("Build failed with the following output:") + logger.info(result.stderr) + return False + + if not os.path.exists(BINARY_PATH): + logger.info("Binary build failed or not found! Exiting.") + return False + + logger.info(f"Copying binary to {DEST_DIR}") + shutil.copy(BINARY_PATH, DEST_DIR) + + if os.path.exists(os.path.join(DEST_DIR, "status-backend")): + logger.info("Binary successfully copied to tests-functional directory.") + return True + else: + logger.info("Failed to copy binary to the tests-functional directory.") + return False diff --git a/tests-functional/src/libs/custom_logger.py b/tests-functional/src/libs/custom_logger.py new file mode 100644 index 00000000000..ab6e9ad95be --- /dev/null +++ b/tests-functional/src/libs/custom_logger.py @@ -0,0 +1,25 @@ +import logging + +max_log_line_length = 10000 + + +def log_length_filter(max_length): + class logLengthFilter(logging.Filter): + def filter(self, record): + if len(record.getMessage()) > max_length: + logging.getLogger(record.name).log( + record.levelno, + f"Log line not shown in stdout because it's longer than max_log_line_length={max_log_line_length}. Please check the log/test.log for the full log", + ) + return False + return True + + return logLengthFilter() + + +def get_custom_logger(name): + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("docker").setLevel(logging.WARNING) + logger = logging.getLogger(name) + logger.addFilter(log_length_filter(max_log_line_length)) + return logger diff --git a/tests-functional/src/node/__init__.py b/tests-functional/src/node/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests-functional/src/node/clients/__init__.py b/tests-functional/src/node/clients/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests-functional/src/node/clients/signals.py b/tests-functional/src/node/clients/signals.py new file mode 100644 index 00000000000..b45d230243b --- /dev/null +++ b/tests-functional/src/node/clients/signals.py @@ -0,0 +1,68 @@ +import json +import time +from src.libs.common import write_signal_to_file + +import websocket + +from src.libs.custom_logger import get_custom_logger + +logger = get_custom_logger(__name__) + + +class SignalClient: + def __init__(self, ws_url, await_signals): + self.url = f"{ws_url}/signals" + self.await_signals = await_signals + self.received_signals = {signal: [] for signal in self.await_signals} + + def on_message(self, ws, signal): + signal_data = json.loads(signal) + signal_type = signal_data.get("type") + + write_signal_to_file(signal_data) + + if signal_type in self.await_signals: + self.received_signals[signal_type].append(signal_data) + + 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} not received in {timeout} seconds") + time.sleep(0.2) + logger.info(f"Signal {signal_type} received in {round(time.time() - start_time)} seconds") + return self.received_signals[signal_type][0] + + def wait_for_complete_signal(self, signal_type, timeout=5): + start_time = time.time() + events = [] + + while time.time() - start_time < timeout: + if self.received_signals.get(signal_type): + events.extend(self.received_signals[signal_type]) + self.received_signals[signal_type] = [] + time.sleep(0.2) + + if events: + logger.info(f"Collected {len(events)} events of type {signal_type} within {timeout} seconds") + return events + raise TimeoutError(f"No signals of type {signal_type} received in {timeout} seconds") + + def _on_error(self, ws, error): + logger.error(f"WebSocket error: {error}") + + def _on_close(self, ws, close_status_code, close_msg): + logger.info(f"WebSocket connection closed: {close_status_code}, {close_msg}") + + def _on_open(self, ws): + logger.info("WebSocket connection opened") + + def _connect(self): + ws = websocket.WebSocketApp( + self.url, + on_message=self.on_message, + on_error=self._on_error, + on_close=self._on_close, + ) + ws.on_open = self._on_open + ws.run_forever() diff --git a/tests-functional/clients/status_backend.py b/tests-functional/src/node/clients/status_backend.py similarity index 54% rename from tests-functional/clients/status_backend.py rename to tests-functional/src/node/clients/status_backend.py index aa2f6a530ec..25962a4a036 100644 --- a/tests-functional/clients/status_backend.py +++ b/tests-functional/src/node/clients/status_backend.py @@ -1,19 +1,20 @@ import json -import logging import time from datetime import datetime from json import JSONDecodeError import jsonschema import requests - -from clients.signals import SignalClient +from tenacity import retry, stop_after_attempt, stop_after_delay, wait_fixed +from src.node.clients.signals import SignalClient from conftest import option -from constants import user_1 +from src.constants import API_REQUEST_TIMEOUT, user_1 +from src.libs.custom_logger import get_custom_logger +logger = get_custom_logger(__name__) -class RpcClient: +class RpcClient: def __init__(self, rpc_url, client=requests.Session()): self.client = client self.rpc_url = rpc_url @@ -22,11 +23,9 @@ def _check_decode_and_key_errors_in_response(self, response, key): try: return response.json()[key] except json.JSONDecodeError: - raise AssertionError( - f"Invalid JSON in response: {response.content}") + raise AssertionError(f"Invalid JSON in response: {response.content}") except KeyError: - raise AssertionError( - f"Key '{key}' not found in the JSON response: {response.content}") + raise AssertionError(f"Key '{key}' not found in the JSON response: {response.content}") def verify_is_valid_json_rpc_response(self, response, _id=None): assert response.status_code == 200, f"Got response {response.content}, status code {response.status_code}" @@ -36,9 +35,7 @@ def verify_is_valid_json_rpc_response(self, response, _id=None): if _id: try: if _id != response.json()["id"]: - raise AssertionError( - f"got id: {response.json()['id']} instead of expected id: {_id}" - ) + raise AssertionError(f"got id: {response.json()['id']} instead of expected id: {_id}") except KeyError: raise AssertionError(f"no id in response {response.json()}") return response @@ -48,84 +45,94 @@ def verify_is_json_rpc_error(self, response): assert response.content self._check_decode_and_key_errors_in_response(response, "error") - def rpc_request(self, method, params=[], request_id=13, url=None): + @retry(stop=stop_after_delay(10), wait=wait_fixed(0.5), reraise=True) + def rpc_request(self, method, params=[], request_id=13, url=None, timeout=API_REQUEST_TIMEOUT): url = url if url else self.rpc_url data = {"jsonrpc": "2.0", "method": method, "id": request_id} if params: data["params"] = params - logging.info(f"Sending POST request to url {url} with data: {json.dumps(data, sort_keys=True, indent=4)}") - response = self.client.post(url, json=data) + logger.info(f"Sending POST request to url {url} with data: {json.dumps(data, sort_keys=True, indent=4)}") + response = self.client.post(url, json=data, timeout=timeout) try: - logging.info(f"Got response: {json.dumps(response.json(), sort_keys=True, indent=4)}") + logger.info(f"Got response: {json.dumps(response.json(), sort_keys=True, indent=4)}") except JSONDecodeError: - logging.info(f"Got response: {response.content}") + logger.info(f"Got response: {response.content}") return response def rpc_valid_request(self, method, params=[], _id=None, url=None): response = self.rpc_request(method, params, _id, url) self.verify_is_valid_json_rpc_response(response, _id) return response - + def verify_json_schema(self, response, method): with open(f"{option.base_dir}/schemas/{method}", "r") as schema: - jsonschema.validate(instance=response, - schema=json.load(schema)) + jsonschema.validate(instance=response, schema=json.load(schema)) class StatusBackend(RpcClient, SignalClient): - - def __init__(self, await_signals=list()): - - self.api_url = f"{option.rpc_url_status_backend}/statusgo" - self.ws_url = f"{option.ws_url_status_backend}" - self.rpc_url = f"{option.rpc_url_status_backend}/statusgo/CallRPC" + def __init__(self, api_url=None, ws_url=None, await_signals=list()): + self.api_url = f"{api_url if api_url else option.rpc_url_status_backend}/statusgo" + self.ws_url = f"{ws_url if ws_url else option.ws_url_status_backend}" + self.rpc_url = f"{api_url if api_url else option.rpc_url_status_backend}/statusgo/CallRPC" RpcClient.__init__(self, self.rpc_url) SignalClient.__init__(self, self.ws_url, await_signals) - def api_request(self, method, data, url=None): + def api_request(self, method, data, timeout=API_REQUEST_TIMEOUT, url=None): url = url if url else self.api_url url = f"{url}/{method}" - logging.info(f"Sending POST request to url {url} with data: {json.dumps(data, sort_keys=True, indent=4)}") - response = requests.post(url, json=data) - logging.info(f"Got response: {response.content}") + + logger.info(f"Sending POST request to url {url} with data: {json.dumps(data, sort_keys=True, indent=4)}") + response = requests.post(url, json=data, timeout=timeout) + logger.info(f"Got response: {response.content}") return response def verify_is_valid_api_response(self, response): assert response.status_code == 200, f"Got response {response.content}, status code {response.status_code}" assert response.content - logging.info(f"Got response: {response.content}") + logger.info(f"Got response: {response.content}") try: assert not response.json()["error"] except json.JSONDecodeError: - raise AssertionError( - f"Invalid JSON in response: {response.content}") + raise AssertionError(f"Invalid JSON in response: {response.content}") except KeyError: pass - def api_valid_request(self, method, data): - response = self.api_request(method, data) + def api_valid_request(self, method, data, timeout=API_REQUEST_TIMEOUT): + response = self.api_request(method, data, timeout) self.verify_is_valid_api_response(response) + return response.json() + + @retry(stop=stop_after_attempt(3), wait=wait_fixed(1), reraise=True) + def init_status_backend(self, data_dir, timeout=API_REQUEST_TIMEOUT): + payload = {"dataDir": data_dir} + logger.info(f"Sending direct POST request to InitializeApplication with payload: {payload}") + + response = self.api_valid_request("InitializeApplication", payload, timeout=timeout) + + if response.get("error"): + logger.error(f"InitializeApplication request failed with error: {response['error']}") + raise RuntimeError(f"Failed to initialize application: {response['error']}") + return response - def init_status_backend(self, data_dir="/"): - method = "InitializeApplication" - data = { - "dataDir": data_dir + @retry(stop=stop_after_attempt(3), wait=wait_fixed(1), reraise=True) + def create_account_and_login(self, account_data, timeout=API_REQUEST_TIMEOUT): + payload = { + "rootDataDir": account_data.get("rootDataDir"), + "displayName": account_data.get("displayName", "test1"), + "password": account_data.get("password", "test1"), + "customizationColor": account_data.get("customizationColor", "primary"), } - return self.api_valid_request(method, data) + logger.info(f"Sending direct POST request to CreateAccountAndLogin with payload: {payload}") - def create_account_and_login(self, display_name="Mr_Meeseeks", password=user_1.password): - data_dir = f"dataDir_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - method = "CreateAccountAndLogin" - data = { - "rootDataDir": data_dir, - "kdfIterations": 256000, - "displayName": display_name, - "password": password, - "customizationColor": "primary" - } - return self.api_valid_request(method, data) + response = self.api_valid_request("CreateAccountAndLogin", payload, timeout=timeout) + + 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']}") + + return response def restore_account_and_login(self, display_name="Mr_Meeseeks", user=user_1): method = "RestoreAccountAndLogin" @@ -149,9 +156,9 @@ def restore_account_and_login(self, display_name="Mr_Meeseeks", user=user_1): "NativeCurrencyDecimals": 18, "IsTest": False, "Layer": 1, - "Enabled": True + "Enabled": True, } - ] + ], } return self.api_valid_request(method, data) @@ -161,7 +168,7 @@ def restore_account_and_wait_for_rpc_client_to_start(self, timeout=60): # ToDo: change this part for waiting for `node.login` signal when websockets are migrated to StatusBackend while time.time() - start_time <= timeout: try: - self.rpc_valid_request(method='accounts_getKeypairs') + self.rpc_valid_request(method="accounts_getKeypairs") return except AssertionError: time.sleep(3) @@ -172,13 +179,15 @@ def start_messenger(self, params=[]): response = self.rpc_request(method, params) json_response = response.json() - if 'error' in json_response: - assert json_response['error']['code'] == -32000 - assert json_response['error']['message'] == "messenger already started" + if "error" in json_response: + assert json_response["error"]["code"] == -32000 + assert json_response["error"]["message"] == "messenger already started" return self.verify_is_valid_json_rpc_response(response) + return response + def start_wallet(self, params=[]): method = "wallet_startWallet" response = self.rpc_request(method, params) diff --git a/tests-functional/src/node/status_node.py b/tests-functional/src/node/status_node.py new file mode 100644 index 00000000000..7361fb7be80 --- /dev/null +++ b/tests-functional/src/node/status_node.py @@ -0,0 +1,158 @@ +import os +import random +import shutil +import signal +import string +import subprocess +import threading +import time + +from src.libs.custom_logger import get_custom_logger +from src.node.clients.status_backend import StatusBackend +from src.libs.common import build_and_copy_binary +from pathlib import Path + +logger = get_custom_logger(__name__) + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +class StatusNode: + binary_built = False + + def __init__(self, name=None, port=None): + self.name = self.random_node_name() if not name else name.lower() + self.port = str(random.randint(1024, 65535)) if not port else port + self.pubkey = None + self.process = None + self.log_thread = None + self.capture_logs = True + self.logs = [] + self.pid = None + await_signals = [ + "history.request.started", + "messages.new", + "message.delivered", + "history.request.completed", + ] + self.status_api = StatusBackend(api_url=f"http://127.0.0.1:{self.port}", ws_url=f"ws://localhost:{self.port}", await_signals=await_signals) + + def initialize_node(self, name, port, data_dir, account_data): + self.name = name + self.port = port + self.start(data_dir) + 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): + logger.info(f"Starting node with command: {command}") + self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.pid = self.process.pid + self.log_thread = self.capture_process_logs(self.process, self.logs) + + def start(self, data_dir, capture_logs=True): + dest_binary_path = Path(PROJECT_ROOT) / "status-backend" + + if not StatusNode.binary_built and not dest_binary_path.exists(): + if not build_and_copy_binary(): + raise RuntimeError("Failed to build or copy the status-backend binary.") + StatusNode.binary_built = True + + self.capture_logs = capture_logs + + command = ["./status-backend", f"--address=localhost:{self.port}"] + self.start_node(command) + self.wait_fully_started() + self.status_api.init_status_backend(data_dir) + self.start_signal_client() + + def create_account_and_login(self, account_data): + logger.info(f"Creating account and logging in for node {self.name}") + self.status_api.create_account_and_login(account_data) + + def start_messenger(self): + logger.info(f"Starting Waku messenger for node {self.name}") + self.status_api.start_messenger() + + def start_signal_client(self): + websocket_thread = threading.Thread(target=self.status_api._connect) + websocket_thread.daemon = True + websocket_thread.start() + logger.info("WebSocket client started and subscribed to signals.") + + def wait_fully_started(self): + logger.info(f"Waiting for {self.name} to fully start...") + start_time = time.time() + while time.time() - start_time < 30: + if any("status-backend started" in log for log in self.logs): + logger.info(f"Node {self.name} has fully started.") + return + time.sleep(0.5) + raise TimeoutError(f"Node {self.name} did not fully start in time.") + + def capture_process_logs(self, process, logs): + def read_output(): + while True: + line = process.stdout.readline() + if not line: + break + logs.append(line.strip()) + logger.debug(f"{self.name.upper()} - {line.strip()}") + + log_thread = threading.Thread(target=read_output) + log_thread.daemon = True + log_thread.start() + return log_thread + + 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, display_name): + response = self.status_api.rpc_request("accounts_getAccounts") + accounts = response.json().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, timeout=20): + return self.status_api.wait_for_signal(signal_type, timeout) + + def wait_for_complete_signal(self, signal_type, timeout=20): + return self.status_api.wait_for_complete_signal(signal_type, timeout) + + def stop(self, remove_local_data=True): + if self.process: + logger.info(f"Stopping node with name: {self.name}") + self.process.kill() + if self.capture_logs: + self.log_thread.join() + if remove_local_data: + node_dir = f"test-{self.name}" + if os.path.exists(node_dir): + try: + shutil.rmtree(node_dir) + except Exception as ex: + logger.warning(f"Couldn't delete node dir {node_dir} because of {str(ex)}") + self.process = None + + def send_contact_request(self, pubkey, message): + params = [{"id": pubkey, "message": message}] + return self.status_api.rpc_request("wakuext_sendContactRequest", params) + + def send_message(self, pubkey, message): + params = [{"id": pubkey, "message": message}] + return self.status_api.rpc_request("wakuext_sendOneToOneMessage", params) + + def pause_process(self): + if self.pid: + logger.info(f"Pausing node with pid: {self.pid}") + os.kill(self.pid, signal.SIGTSTP) + + def resume_process(self): + if self.pid: + logger.info(f"Resuming node with pid: {self.pid}") + os.kill(self.pid, signal.SIGCONT) diff --git a/tests-functional/schema_builder.py b/tests-functional/src/schema_builder.py similarity index 99% rename from tests-functional/schema_builder.py rename to tests-functional/src/schema_builder.py index c77964a0b28..8531a133324 100644 --- a/tests-functional/schema_builder.py +++ b/tests-functional/src/schema_builder.py @@ -7,7 +7,6 @@ class CustomSchemaBuilder(SchemaBuilder): - def __init__(self, schema_name): super().__init__() self.path = f"{option.base_dir}/schemas/{schema_name}" diff --git a/tests-functional/src/steps/__init__.py b/tests-functional/src/steps/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests-functional/src/steps/common.py b/tests-functional/src/steps/common.py new file mode 100644 index 00000000000..0258638a166 --- /dev/null +++ b/tests-functional/src/steps/common.py @@ -0,0 +1,244 @@ +from contextlib import contextmanager +import inspect +import subprocess +import pytest +import json +import threading +import time +from collections import namedtuple +from src.libs.common import delay +from src.libs.custom_logger import get_custom_logger +from src.node.status_node import StatusNode +from datetime import datetime +from src.constants import * +from src.node.clients.signals import SignalClient +from src.node.clients.status_backend import RpcClient, StatusBackend +from conftest import option + +logger = get_custom_logger(__name__) + + +class StatusDTestCase: + network_id = 31337 + + def setup_method(self): + self.rpc_client = RpcClient(option.rpc_url_statusd) + + +class StatusBackendTestCase: + def setup_class(self): + self.rpc_client = StatusBackend() + self.network_id = 31337 + + +class WalletTestCase(StatusBackendTestCase): + def wallet_create_multi_transaction(self, **kwargs): + method = "wallet_createMultiTransaction" + transfer_tx_data = { + "data": "", + "from": user_1.address, + "gas": "0x5BBF", + "input": "", + "maxFeePerGas": "0xbcc0f04fd", + "maxPriorityFeePerGas": "0xbcc0f04fd", + "to": user_2.address, + "type": "0x02", + "value": "0x5af3107a4000", + } + for key, new_value in kwargs.items(): + if key in transfer_tx_data: + transfer_tx_data[key] = new_value + else: + logger.info(f"Warning: The key '{key}' does not exist in the transferTx parameters and will be ignored.") + params = [ + { + "fromAddress": user_1.address, + "fromAmount": "0x5af3107a4000", + "fromAsset": "ETH", + "type": 0, # MultiTransactionSend + "toAddress": user_2.address, + "toAsset": "ETH", + }, + [ + { + "bridgeName": "Transfer", + "chainID": 31337, + "transferTx": transfer_tx_data, + } + ], + f"{option.password}", + ] + return self.rpc_client.rpc_request(method, params) + + def send_valid_multi_transaction(self, **kwargs): + response = self.wallet_create_multi_transaction(**kwargs) + + tx_hash = None + self.rpc_client.verify_is_valid_json_rpc_response(response) + try: + tx_hash = response.json()["result"]["hashes"][str(self.network_id)][0] + except (KeyError, json.JSONDecodeError): + raise Exception(response.content) + return tx_hash + + +class TransactionTestCase(WalletTestCase): + def setup_method(self): + self.tx_hash = self.send_valid_multi_transaction() + + +class EthRpcTestCase(WalletTestCase): + @pytest.fixture(autouse=True, scope="class") + def tx_data(self): + tx_hash = self.send_valid_multi_transaction() + self.wait_until_tx_not_pending(tx_hash) + + receipt = self.get_transaction_receipt(tx_hash) + try: + block_number = receipt.json()["result"]["blockNumber"] + block_hash = receipt.json()["result"]["blockHash"] + except (KeyError, json.JSONDecodeError): + raise Exception(receipt.content) + + tx_data = namedtuple("TxData", ["tx_hash", "block_number", "block_hash"]) + return tx_data(tx_hash, block_number, block_hash) + + def get_block_header(self, block_number): + method = "ethclient_headerByNumber" + params = [self.network_id, block_number] + return self.rpc_client.rpc_valid_request(method, params) + + def get_transaction_receipt(self, tx_hash): + method = "ethclient_transactionReceipt" + params = [self.network_id, tx_hash] + return self.rpc_client.rpc_valid_request(method, params) + + def wait_until_tx_not_pending(self, tx_hash, timeout=10): + method = "ethclient_transactionByHash" + params = [self.network_id, tx_hash] + response = self.rpc_client.rpc_valid_request(method, params) + + start_time = time.time() + while response.json()["result"]["isPending"] == True: + time_passed = time.time() - start_time + if time_passed >= timeout: + raise TimeoutError(f"Tx {tx_hash} is still pending after {timeout} seconds") + time.sleep(0.5) + response = self.rpc_client.rpc_valid_request(method, params) + return response.json()["result"]["tx"] + + +class SignalTestCase(StatusDTestCase): + await_signals = [] + + def setup_method(self): + super().setup_method() + self.signal_client = SignalClient(option.ws_url_statusd, self.await_signals) + + websocket_thread = threading.Thread(target=self.signal_client._connect) + websocket_thread.daemon = True + websocket_thread.start() + + +class StepsCommon: + @pytest.fixture(scope="function", autouse=False) + def start_2_nodes(self): + logger.debug(f"Running fixture setup: {inspect.currentframe().f_code.co_name}") + self.first_node_display_name = "first_node_user" + self.second_node_display_name = "second_node_user" + + account_data_first = { + **ACCOUNT_PAYLOAD_DEFAULTS, + "rootDataDir": LOCAL_DATA_DIR1, + "displayName": self.first_node_display_name, + } + account_data_second = { + **ACCOUNT_PAYLOAD_DEFAULTS, + "rootDataDir": LOCAL_DATA_DIR2, + "displayName": self.second_node_display_name, + } + + self.first_node = StatusNode(name="first_node") + self.first_node.start(data_dir=LOCAL_DATA_DIR1) + self.first_node.wait_fully_started() + + self.second_node = StatusNode(name="second_node") + self.second_node.start(data_dir=LOCAL_DATA_DIR2) + self.second_node.wait_fully_started() + + self.first_node.create_account_and_login(account_data_first) + self.second_node.create_account_and_login(account_data_second) + + delay(4) + self.first_node.start_messenger() + delay(1) + self.second_node.start_messenger() + + self.first_node_pubkey = self.first_node.get_pubkey(self.first_node_display_name) + self.second_node_pubkey = self.second_node.get_pubkey(self.second_node_display_name) + + 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): + logger.debug("Entering context manager: add_latency") + subprocess.Popen( + "sudo tc qdisc add dev eth0 root netem delay 1s 100ms distribution normal", + shell=True, + ) + try: + yield + finally: + logger.debug(f"Exiting context manager: add_latency") + subprocess.Popen("sudo tc qdisc del dev eth0 root", shell=True) + + @contextmanager + def add_packet_loss(self): + logger.debug("Entering context manager: add_packet_loss") + subprocess.Popen("sudo tc qdisc add dev eth0 root netem loss 50%", shell=True) + try: + yield + finally: + logger.debug(f"Exiting context manager: add_packet_loss") + subprocess.Popen("sudo tc qdisc del dev eth0 root netem", shell=True) + + @contextmanager + def add_low_bandwith(self): + logger.debug("Entering context manager: add_low_bandwith") + subprocess.Popen("sudo tc qdisc add dev eth0 root tbf rate 1kbit burst 1kbit", shell=True) + try: + yield + finally: + logger.debug(f"Exiting context manager: add_low_bandwith") + subprocess.Popen("sudo tc qdisc del dev eth0 root", shell=True) + + @contextmanager + def node_pause(self, node): + logger.debug("Entering context manager: node_pause") + node.pause_process() + try: + yield + finally: + logger.debug(f"Exiting context manager: node_pause") + node.resume_process() + + def send_with_timestamp(self, send_method, id, message): + timestamp = datetime.now().strftime("%H:%M:%S") + response = send_method(id, message) + response_messages = response.json().get("result", {}).get("messages", []) + message_id = None + + for m in response_messages: + if m["text"] == message: + message_id = m["id"] + break + + return timestamp, message_id, response + + def accept_contact_request(self, sending_node=None, receiving_node_pk=None): + if not sending_node: + sending_node = self.second_node + if not receiving_node_pk: + receiving_node_pk = self.first_node_pubkey + sending_node.send_contact_request(receiving_node_pk, "hi") diff --git a/tests-functional/src/validators/__init__.py b/tests-functional/src/validators/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests-functional/src/validators/message_validator.py b/tests-functional/src/validators/message_validator.py new file mode 100644 index 00000000000..b78f251adc5 --- /dev/null +++ b/tests-functional/src/validators/message_validator.py @@ -0,0 +1,53 @@ +from src.libs.custom_logger import get_custom_logger + +logger = get_custom_logger(__name__) + + +class MessageValidator: + def __init__(self, response, contact_request=False): + self.response = response.json() + self.contact_request = contact_request + + def validate_response_structure(self): + 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): + chats = self.response["result"].get("chats", []) + assert len(chats) > 0, "No chats found in the response" + + chat = chats[0] + actual_chat_id = chat.get("id") + assert actual_chat_id == expected_chat_id, f"Chat ID mismatch: Expected '{expected_chat_id}', found '{actual_chat_id}'" + + actual_chat_name = chat.get("name") + assert actual_chat_name.startswith("0x"), f"Invalid chat name format: Expected name to start with '0x', found '{actual_chat_name}'" + + last_message = chat.get("lastMessage", {}) + actual_text = last_message.get("text") + display_name = last_message.get("displayName") + assert actual_text == expected_text, f"Message text mismatch: Expected '{expected_text}', found '{actual_text}'" + assert display_name == expected_display_name.strip(), f"DisplayName mismatch: Expected '{display_name}', found '{expected_display_name}'" + + if self.contact_request: + actual_contact_request_state = last_message.get("contactRequestState") + assert actual_contact_request_state == 1, f"Unexpected contact request state: Expected '1', found '{actual_contact_request_state}'" + + assert "compressedKey" in last_message, "Missing 'compressedKey' in last message" + + def validate_event_against_response(self, event, fields_to_validate): + chats_in_event = event.get("event", {}).get("chats", []) + assert len(chats_in_event) > 0, "No chats found in the event" + + response_chat = self.response["result"]["chats"][0] + event_chat = chats_in_event[0] + + for response_field, event_field in fields_to_validate.items(): + response_value = response_chat.get("lastMessage", {}).get(response_field) + event_value = event_chat.get("lastMessage", {}).get(event_field) + assert response_value == event_value, f"Mismatch for '{response_field}': Expected '{response_value}', found '{event_value}'" + + def run_all_validations(self, expected_chat_id, expected_display_name, expected_text): + self.validate_response_structure() + self.validate_chat_data(expected_chat_id, expected_display_name, expected_text) + logger.info("All validations passed for the response.") diff --git a/tests-functional/tests/__init__.py b/tests-functional/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests-functional/tests/test_accounts.py b/tests-functional/tests/test_accounts.py index 0b3c35030ea..83e618ee451 100644 --- a/tests-functional/tests/test_accounts.py +++ b/tests-functional/tests/test_accounts.py @@ -1,21 +1,18 @@ import random - import pytest - -from test_cases import StatusBackendTestCase +from src.steps.common import StatusBackendTestCase +@pytest.mark.usefixtures("init_status_backend") @pytest.mark.accounts @pytest.mark.rpc class TestAccounts(StatusBackendTestCase): - @pytest.mark.parametrize( "method, params", [ ("accounts_getKeypairs", []), ("accounts_hasPairedDevices", []), - ("accounts_remainingAccountCapacity", []) - + ("accounts_remainingAccountCapacity", []), ], ) def test_(self, method, params): diff --git a/tests-functional/tests/test_cases.py b/tests-functional/tests/test_cases.py deleted file mode 100644 index ef77c85e023..00000000000 --- a/tests-functional/tests/test_cases.py +++ /dev/null @@ -1,142 +0,0 @@ -import json -import logging -import threading -import time -from collections import namedtuple - -import pytest - -from clients.signals import SignalClient -from clients.status_backend import RpcClient, StatusBackend -from conftest import option -from constants import user_1, user_2 - - -class StatusDTestCase: - network_id = 31337 - - def setup_method(self): - self.rpc_client = RpcClient( - option.rpc_url_statusd - ) - - -class StatusBackendTestCase: - def setup_class(self): - self.rpc_client = StatusBackend() - self.network_id = 31337 - - -class WalletTestCase(StatusBackendTestCase): - - def wallet_create_multi_transaction(self, **kwargs): - method = "wallet_createMultiTransaction" - transfer_tx_data = { - "data": "", - "from": user_1.address, - "gas": "0x5BBF", - "input": "", - "maxFeePerGas": "0xbcc0f04fd", - "maxPriorityFeePerGas": "0xbcc0f04fd", - "to": user_2.address, - "type": "0x02", - "value": "0x5af3107a4000", - } - for key, new_value in kwargs.items(): - if key in transfer_tx_data: - transfer_tx_data[key] = new_value - else: - logging.info( - f"Warning: The key '{key}' does not exist in the transferTx parameters and will be ignored.") - params = [ - { - "fromAddress": user_1.address, - "fromAmount": "0x5af3107a4000", - "fromAsset": "ETH", - "type": 0, # MultiTransactionSend - "toAddress": user_2.address, - "toAsset": "ETH", - }, - [ - { - "bridgeName": "Transfer", - "chainID": 31337, - "transferTx": transfer_tx_data - } - ], - f"{option.password}", - ] - return self.rpc_client.rpc_request(method, params) - - def send_valid_multi_transaction(self, **kwargs): - response = self.wallet_create_multi_transaction(**kwargs) - - tx_hash = None - self.rpc_client.verify_is_valid_json_rpc_response(response) - try: - tx_hash = response.json( - )["result"]["hashes"][str(self.network_id)][0] - except (KeyError, json.JSONDecodeError): - raise Exception(response.content) - return tx_hash - - -class TransactionTestCase(WalletTestCase): - - def setup_method(self): - self.tx_hash = self.send_valid_multi_transaction() - - -class EthRpcTestCase(WalletTestCase): - - @pytest.fixture(autouse=True, scope='class') - def tx_data(self): - tx_hash = self.send_valid_multi_transaction() - self.wait_until_tx_not_pending(tx_hash) - - receipt = self.get_transaction_receipt(tx_hash) - try: - block_number = receipt.json()["result"]["blockNumber"] - block_hash = receipt.json()["result"]["blockHash"] - except (KeyError, json.JSONDecodeError): - raise Exception(receipt.content) - - tx_data = namedtuple("TxData", ["tx_hash", "block_number", "block_hash"]) - return tx_data(tx_hash, block_number, block_hash) - - def get_block_header(self, block_number): - method = "ethclient_headerByNumber" - params = [self.network_id, block_number] - return self.rpc_client.rpc_valid_request(method, params) - - def get_transaction_receipt(self, tx_hash): - method = "ethclient_transactionReceipt" - params = [self.network_id, tx_hash] - return self.rpc_client.rpc_valid_request(method, params) - - def wait_until_tx_not_pending(self, tx_hash, timeout=10): - method = "ethclient_transactionByHash" - params = [self.network_id, tx_hash] - response = self.rpc_client.rpc_valid_request(method, params) - - start_time = time.time() - while response.json()["result"]["isPending"] == True: - time_passed = time.time() - start_time - if time_passed >= timeout: - raise TimeoutError( - f"Tx {tx_hash} is still pending after {timeout} seconds") - time.sleep(0.5) - response = self.rpc_client.rpc_valid_request(method, params) - return response.json()["result"]["tx"] - - -class SignalTestCase(StatusDTestCase): - await_signals = [] - - def setup_method(self): - super().setup_method() - self.signal_client = SignalClient(option.ws_url_statusd, self.await_signals) - - websocket_thread = threading.Thread(target=self.signal_client._connect) - websocket_thread.daemon = True - websocket_thread.start() diff --git a/tests-functional/tests/test_contact_request.py b/tests-functional/tests/test_contact_request.py new file mode 100644 index 00000000000..cbedce44563 --- /dev/null +++ b/tests-functional/tests/test_contact_request.py @@ -0,0 +1,133 @@ +from uuid import uuid4 +from src.constants import * +from src.libs.common import delay +from src.libs.custom_logger import get_custom_logger +from src.node.status_node import StatusNode +from src.steps.common import StepsCommon +from src.validators.message_validator import MessageValidator + +logger = get_custom_logger(__name__) + + +class TestContactRequest(StepsCommon): + def test_contact_request_baseline(self): + timeout_secs = EVENT_SIGNAL_TIMEOUT_SEC + num_contact_requests = 1 + nodes = [] + + for index in range(num_contact_requests): + first_node = StatusNode(name=f"first_node_{index}") + second_node = StatusNode(name=f"second_node_{index}") + + data_dir_first = create_unique_data_dir(os.path.join(PROJECT_ROOT, DATA_DIR), index) + data_dir_second = create_unique_data_dir(os.path.join(PROJECT_ROOT, DATA_DIR), index) + + delay(2) + first_node.start(data_dir=data_dir_first) + second_node.start(data_dir=data_dir_second) + + account_data_first = { + "rootDataDir": data_dir_first, + "displayName": f"test_user_first_{index}", + "password": f"test_password_first_{index}", + "customizationColor": "primary", + } + account_data_second = { + "rootDataDir": data_dir_second, + "displayName": f"test_user_second_{index}", + "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) + first_node.start_messenger() + second_node.start_messenger() + + 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, account_data_first["displayName"], account_data_second["displayName"], index)) + + missing_contact_requests = [] + for first_node, second_node, first_node_display_name, second_node_display_name, index in nodes: + result = self.send_and_wait_for_message((first_node, second_node), first_node_display_name, 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)) + + if missing_contact_requests: + formatted_missing_requests = [f"Timestamp: {ts}, Message: {msg}, ID: {mid}" for ts, msg, mid in missing_contact_requests] + raise AssertionError( + f"{len(missing_contact_requests)} contact requests out of {num_contact_requests} didn't reach the peer node: " + + "\n".join(formatted_missing_requests) + ) + + def send_and_wait_for_message(self, nodes, first_node_display_name, second_node_display_name, index, timeout=10): + first_node, second_node = nodes + first_node_pubkey = first_node.get_pubkey(first_node_display_name) + contact_request_message = f"contact_request_{index}" + + timestamp, message_id, response = self.send_with_timestamp(second_node.send_contact_request, first_node_pubkey, contact_request_message) + + validator = MessageValidator(response) + validator.run_all_validations(first_node_pubkey, second_node_display_name, contact_request_message) + + try: + messages_new_events = first_node.wait_for_complete_signal("messages.new", timeout) + + messages_new_event = None + + for event in messages_new_events: + if "chats" in event.get("event", {}): + messages_new_event = event + try: + validator.validate_event_against_response( + messages_new_event, + fields_to_validate={ + "text": "text", + "displayName": "displayName", + "id": "id", + }, + ) + break + except AssertionError as validation_error: + logger.error(f"Validation failed for event: {messages_new_event}, Error: {validation_error}") + continue + + if messages_new_event is None: + raise ValueError("No 'messages.new' event with 'chats' data found within the timeout period.") + + except (TimeoutError, ValueError) as e: + logger.error(f"Signal validation failed: {str(e)}") + return timestamp, message_id, contact_request_message, None + + first_node.stop() + second_node.stop() + + return timestamp, message_id, contact_request_message, response + + def test_contact_request_with_latency(self): + with self.add_latency(): + self.test_contact_request_baseline() + + def test_contact_request_with_packet_loss(self): + with self.add_packet_loss(): + self.test_contact_request_baseline() + + 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): + message = str(uuid4()) + self.first_node.send_contact_request(self.second_node_pubkey, message) + delay(30) + assert self.second_node.wait_for_signal("messages.new") + assert self.first_node.wait_for_signal("message.delivered") diff --git a/tests-functional/tests/test_eth_api.py b/tests-functional/tests/test_eth_api.py index ef86d430236..5487d56480a 100644 --- a/tests-functional/tests/test_eth_api.py +++ b/tests-functional/tests/test_eth_api.py @@ -1,6 +1,6 @@ import pytest -from test_cases import EthRpcTestCase +from src.steps.common import EthRpcTestCase def validate_header(header, block_number, block_hash): @@ -24,10 +24,10 @@ def validate_receipt(receipt, tx_hash, block_number, block_hash): assert receipt["blockHash"] == block_hash +@pytest.mark.usefixtures("init_status_backend") @pytest.mark.rpc @pytest.mark.ethclient class TestEth(EthRpcTestCase): - def test_block_number(self): self.rpc_client.rpc_valid_request("ethclient_blockNumber", [self.network_id]) @@ -35,13 +35,17 @@ def test_suggest_gas_price(self): self.rpc_client.rpc_valid_request("ethclient_suggestGasPrice", [self.network_id]) def test_header_by_number(self, tx_data): - response = self.rpc_client.rpc_valid_request("ethclient_headerByNumber", - [self.network_id, tx_data.block_number]) + response = self.rpc_client.rpc_valid_request("ethclient_headerByNumber", [self.network_id, tx_data.block_number]) validate_header(response.json()["result"], tx_data.block_number, tx_data.block_hash) def test_block_by_number(self, tx_data): response = self.rpc_client.rpc_valid_request("ethclient_blockByNumber", [self.network_id, tx_data.block_number]) - validate_block(response.json()["result"], tx_data.block_number, tx_data.block_hash, tx_data.tx_hash) + validate_block( + response.json()["result"], + tx_data.block_number, + tx_data.block_hash, + tx_data.tx_hash, + ) def test_header_by_hash(self, tx_data): response = self.rpc_client.rpc_valid_request("ethclient_headerByHash", [self.network_id, tx_data.block_hash]) @@ -49,7 +53,12 @@ def test_header_by_hash(self, tx_data): def test_block_by_hash(self, tx_data): response = self.rpc_client.rpc_valid_request("ethclient_blockByHash", [self.network_id, tx_data.block_hash]) - validate_block(response.json()["result"], tx_data.block_number, tx_data.block_hash, tx_data.tx_hash) + validate_block( + response.json()["result"], + tx_data.block_number, + tx_data.block_hash, + tx_data.tx_hash, + ) def test_transaction_by_hash(self, tx_data): response = self.rpc_client.rpc_valid_request("ethclient_transactionByHash", [self.network_id, tx_data.tx_hash]) @@ -57,4 +66,9 @@ def test_transaction_by_hash(self, tx_data): def test_transaction_receipt(self, tx_data): response = self.rpc_client.rpc_valid_request("ethclient_transactionReceipt", [self.network_id, tx_data.tx_hash]) - validate_receipt(response.json()["result"], tx_data.tx_hash, tx_data.block_number, tx_data.block_hash) + validate_receipt( + response.json()["result"], + tx_data.tx_hash, + tx_data.block_number, + tx_data.block_hash, + ) diff --git a/tests-functional/tests/test_init_status_app.py b/tests-functional/tests/test_init_status_app.py index 4001dabb5a6..79e80814bdb 100644 --- a/tests-functional/tests/test_init_status_app.py +++ b/tests-functional/tests/test_init_status_app.py @@ -1,22 +1,21 @@ import pytest +@pytest.mark.usefixtures("init_status_backend") @pytest.mark.create_account @pytest.mark.rpc class TestInitialiseApp: - @pytest.mark.init def test_init_app(self, init_status_backend): # this test is going to fail on every call except first since status-backend will be already initialized backend_client = init_status_backend assert backend_client is not None - - backend_client.verify_json_schema( - backend_client.wait_for_signal("mediaserver.started"), "signal_mediaserver_started") - backend_client.verify_json_schema( - backend_client.wait_for_signal("node.started"), "signal_node_started") - backend_client.verify_json_schema( - backend_client.wait_for_signal("node.ready"), "signal_node_ready") + backend_client.verify_json_schema( - backend_client.wait_for_signal("node.login"), "signal_node_login") + backend_client.wait_for_signal("mediaserver.started"), + "signal_mediaserver_started", + ) + backend_client.verify_json_schema(backend_client.wait_for_signal("node.started"), "signal_node_started") + backend_client.verify_json_schema(backend_client.wait_for_signal("node.ready"), "signal_node_ready") + backend_client.verify_json_schema(backend_client.wait_for_signal("node.login"), "signal_node_login") diff --git a/tests-functional/tests/test_one_to_one_messages.py b/tests-functional/tests/test_one_to_one_messages.py new file mode 100644 index 00000000000..5b1c859554c --- /dev/null +++ b/tests-functional/tests/test_one_to_one_messages.py @@ -0,0 +1,138 @@ +from uuid import uuid4 +import pytest +from src.constants import * +from src.libs.common import delay +from src.libs.custom_logger import get_custom_logger +from src.steps.common import StepsCommon +from src.validators.message_validator import MessageValidator + +logger = get_custom_logger(__name__) + + +@pytest.mark.usefixtures("start_2_nodes") +class TestOneToOneMessages(StepsCommon): + def test_one_to_one_message_baseline(self): + timeout_secs = EVENT_SIGNAL_TIMEOUT_SEC + num_messages = NUM_MESSAGES + node_test_data = [ + ( + self.first_node, + self.second_node, + self.second_node_display_name, + self.first_node_display_name, + ), + ( + self.second_node, + self.first_node, + self.first_node_display_name, + self.second_node_display_name, + ), + ] + messages = [] + self.accept_contact_request() + + missing_messages = [] + + for i in range(num_messages): + ( + sending_node, + receiving_node, + receiving_display_name, + sending_node_display_name, + ) = node_test_data[i % 2] + result = self.send_and_wait_for_message( + sending_node, + receiving_node, + receiving_display_name, + sending_node_display_name, + i, + timeout_secs, + ) + timestamp, message_text, message_id, response = result + + if not response: + missing_messages.append((timestamp, message_text, message_id, sending_node.name)) + else: + messages.append((timestamp, message_text, message_id, sending_node.name)) + + self.first_node.stop() + self.second_node.stop() + + if missing_messages: + formatted_missing_messages = [f"Timestamp: {ts}, Message: {msg}, ID: {mid}, Sender: {snd}" for ts, msg, mid, snd in missing_messages] + raise AssertionError( + f"{len(missing_messages)} messages out of {num_messages} were not received: " + "\n".join(formatted_missing_messages) + ) + + def send_and_wait_for_message( + self, + sending_node, + receiving_node, + receiving_display_name, + sending_node_display_name, + index, + timeout=10, + ): + receiving_node_pubkey = receiving_node.get_pubkey(receiving_display_name) + message_text = f"message_from_{sending_node.name}_{index}" + + timestamp, message_id, response = self.send_with_timestamp(sending_node.send_message, receiving_node_pubkey, message_text) + + validator = MessageValidator(response) + validator.run_all_validations( + expected_chat_id=receiving_node_pubkey, + expected_display_name=sending_node_display_name, + expected_text=message_text, + ) + + try: + messages_new_events = receiving_node.wait_for_complete_signal("messages.new", timeout) + receiving_node.wait_for_signal("message.delivered", timeout) + + messages_new_event = None + for event in messages_new_events: + if "chats" in event.get("event", {}): + messages_new_event = event + try: + validator.validate_event_against_response( + messages_new_event, + fields_to_validate={ + "text": "text", + "displayName": "displayName", + "id": "id", + }, + ) + break + except AssertionError as validation_error: + logger.error(f"Validation failed for event: {messages_new_event}, Error: {validation_error}") + continue + + if messages_new_event is None: + raise ValueError("No 'messages.new' event with 'chats' data found within the timeout period.") + + except (TimeoutError, ValueError) as e: + logger.error(f"Signal validation failed: {str(e)}") + return timestamp, message_text, message_id, None + + return timestamp, message_text, message_id, response + + def test_one_to_one_message_with_latency(self): + with self.add_latency(): + self.test_one_to_one_message_baseline() + + def test_one_to_one_message_with_packet_loss(self): + with self.add_packet_loss(): + self.test_one_to_one_message_baseline() + + def test_one_to_one_message_with_low_bandwidth(self): + with self.add_low_bandwidth(): + self.test_one_to_one_message_baseline() + + def test_one_to_one_message_with_node_pause_30_seconds(self): + self.accept_contact_request() + with self.node_pause(self.first_node): + message = str(uuid4()) + self.second_node.send_message(self.first_node_pubkey, message) + delay(30) + assert self.first_node.wait_for_signal("messages.new") + assert self.second_node.wait_for_signal("message.delivered") diff --git a/tests-functional/tests/test_router.py b/tests-functional/tests/test_router.py index 5af0fa508c3..526ba78a91b 100644 --- a/tests-functional/tests/test_router.py +++ b/tests-functional/tests/test_router.py @@ -3,10 +3,11 @@ import pytest from conftest import option -from constants import user_1, user_2 -from test_cases import SignalTestCase +from src.constants import user_1, user_2 +from src.steps.common import SignalTestCase +@pytest.mark.usefixtures("init_status_backend") @pytest.mark.rpc @pytest.mark.transaction @pytest.mark.wallet @@ -16,11 +17,10 @@ class TestTransactionFromRoute(SignalTestCase): "wallet.router.sign-transactions", "wallet.router.sending-transactions-started", "wallet.transaction.status-changed", - "wallet.router.transactions-sent" + "wallet.router.transactions-sent", ] def test_tx_from_route(self): - _uuid = str(uuid.uuid4()) amount_in = "0xde0b6b3a7640000" @@ -39,41 +39,30 @@ def test_tx_from_route(self): "disabledFromChainIDs": [10, 42161], "disabledToChainIDs": [10, 42161], "gasFeeMode": 1, - "fromLockedAmount": {} + "fromLockedAmount": {}, } ] response = self.rpc_client.rpc_valid_request(method, params) routes = self.signal_client.wait_for_signal("wallet.suggested.routes") - assert routes['event']['Uuid'] == _uuid + assert routes["event"]["Uuid"] == _uuid method = "wallet_buildTransactionsFromRoute" - params = [ - { - "uuid": _uuid, - "slippagePercentage": 0 - } - ] + params = [{"uuid": _uuid, "slippagePercentage": 0}] response = self.rpc_client.rpc_valid_request(method, params) - wallet_router_sign_transactions = self.signal_client.wait_for_signal( - "wallet.router.sign-transactions") + wallet_router_sign_transactions = self.signal_client.wait_for_signal("wallet.router.sign-transactions") - assert wallet_router_sign_transactions['event']['signingDetails']['signOnKeycard'] == False - transaction_hashes = wallet_router_sign_transactions['event']['signingDetails']['hashes'] + assert wallet_router_sign_transactions["event"]["signingDetails"]["signOnKeycard"] == False + transaction_hashes = wallet_router_sign_transactions["event"]["signingDetails"]["hashes"] assert transaction_hashes, "Transaction hashes are empty!" tx_signatures = {} for hash in transaction_hashes: - method = "wallet_signMessage" - params = [ - hash, - user_1.address, - option.password - ] + params = [hash, user_1.address, option.password] response = self.rpc_client.rpc_valid_request(method, params) @@ -83,22 +72,16 @@ def test_tx_from_route(self): signature = { "r": tx_signature[:64], "s": tx_signature[64:128], - "v": tx_signature[128:] + "v": tx_signature[128:], } tx_signatures[hash] = signature method = "wallet_sendRouterTransactionsWithSignatures" - params = [ - { - "uuid": _uuid, - "Signatures": tx_signatures - } - ] + params = [{"uuid": _uuid, "Signatures": tx_signatures}] response = self.rpc_client.rpc_valid_request(method, params) - tx_status = self.signal_client.wait_for_signal( - "wallet.transaction.status-changed") + tx_status = self.signal_client.wait_for_signal("wallet.transaction.status-changed") assert tx_status["event"]["chainId"] == 31337 assert tx_status["event"]["status"] == "Success" diff --git a/tests-functional/tests/test_waku_rpc.py b/tests-functional/tests/test_waku_rpc.py index cde29a041d9..2d7e549a81b 100644 --- a/tests-functional/tests/test_waku_rpc.py +++ b/tests-functional/tests/test_waku_rpc.py @@ -6,11 +6,11 @@ import pytest from conftest import option -from test_cases import StatusBackendTestCase +from src.steps.common import StatusBackendTestCase +@pytest.mark.usefixtures("init_status_backend") class TestRpc(StatusBackendTestCase): - @pytest.mark.parametrize( "method, params", [ @@ -40,17 +40,11 @@ def test_add_contact(self): # get chat public key for user in self.user_1, self.user_2: - response = self.rpc_client.rpc_request( - "accounts_getAccounts", [], _id, url=user.rpc_url - ) + response = self.rpc_client.rpc_request("accounts_getAccounts", [], _id, url=user.rpc_url) self.rpc_client.verify_is_valid_json_rpc_response(response) user.chat_public_key = next( - ( - item["public-key"] - for item in response.json()["result"] - if item["chat"] - ), + (item["public-key"] for item in response.json()["result"] if item["chat"]), None, ) diff --git a/tests-functional/tests/test_wakuext_profile.py b/tests-functional/tests/test_wakuext_profile.py index ce7b0c3ba77..bb424c99ce0 100644 --- a/tests-functional/tests/test_wakuext_profile.py +++ b/tests-functional/tests/test_wakuext_profile.py @@ -2,18 +2,25 @@ import pytest -from test_cases import StatusBackendTestCase +from src.steps.common import StatusBackendTestCase +@pytest.mark.usefixtures("init_status_backend") class TestProfile(StatusBackendTestCase): - @pytest.mark.parametrize( "method, params", [ ("wakuext_setDisplayName", ["new valid username"]), ("wakuext_setBio", ["some valid bio"]), - ("wakuext_setCustomizationColor", [{'customizationColor': 'magenta', - 'keyUid': '0xea42dd9a4e668b0b76c7a5210ca81576d51cd19cdd0f6a0c22196219dc423f29'}]), + ( + "wakuext_setCustomizationColor", + [ + { + "customizationColor": "magenta", + "keyUid": "0xea42dd9a4e668b0b76c7a5210ca81576d51cd19cdd0f6a0c22196219dc423f29", + } + ], + ), ("wakuext_setUserStatus", [3, ""]), ("wakuext_setSyncingOnMobileNetwork", [{"enabled": False}]), ("wakuext_togglePeerSyncing", [{"enabled": True}]), diff --git a/tests-functional/tests/test_wallet_rpc.py b/tests-functional/tests/test_wallet_rpc.py index 968e599b9da..65b522f68d0 100644 --- a/tests-functional/tests/test_wallet_rpc.py +++ b/tests-functional/tests/test_wallet_rpc.py @@ -5,24 +5,24 @@ import pytest from conftest import option -from test_cases import StatusBackendTestCase, TransactionTestCase +from src.steps.common import StatusBackendTestCase, TransactionTestCase +@pytest.mark.usefixtures("init_status_backend") @pytest.mark.wallet @pytest.mark.tx @pytest.mark.rpc class TestTransactionRpc(TransactionTestCase): - @pytest.mark.parametrize( "method, params", [ ( - "wallet_checkRecentHistoryForChainIDs", - [[31337], ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]], + "wallet_checkRecentHistoryForChainIDs", + [[31337], ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]], ), ( - "wallet_getPendingTransactionsForIdentities", - [[{"chainId": None, "hash": None}]], + "wallet_getPendingTransactionsForIdentities", + [[{"chainId": None, "hash": None}]], ), ], ) @@ -41,43 +41,49 @@ def test_create_multi_transaction(self): self.rpc_client.verify_is_valid_json_rpc_response(response) # how to create schema: - # from schema_builder import CustomSchemaBuilder + # from src.schema_builder import CustomSchemaBuilder # CustomSchemaBuilder(method).create_schema(response.json()) - with open(f"{option.base_dir}/schemas/wallet_createMultiTransaction/transferTx_positive", "r") as schema: + with open( + f"{option.base_dir}/schemas/wallet_createMultiTransaction/transferTx_positive", + "r", + ) as schema: jsonschema.validate(instance=response.json(), schema=json.load(schema)) @pytest.mark.parametrize( "method, changed_values, expected_error_code, expected_error_text", [ ( - "transferTx_value_not_enough_balance", - {'value': '0x21e438ea8139cd35004'}, -32000, "Insufficient funds for gas", + "transferTx_value_not_enough_balance", + {"value": "0x21e438ea8139cd35004"}, + -32000, + "Insufficient funds for gas", ), ( - "transferTx_from_from_invalid_string", - {'from': 'some_invalid_address'}, -32602, "cannot unmarshal hex string without 0x prefix", + "transferTx_from_from_invalid_string", + {"from": "some_invalid_address"}, + -32602, + "cannot unmarshal hex string without 0x prefix", ), ], ) - def test_create_multi_transaction_validation(self, method, - changed_values, - expected_error_code, expected_error_text): + def test_create_multi_transaction_validation(self, method, changed_values, expected_error_code, expected_error_text): response = self.wallet_create_multi_transaction(**changed_values) self.rpc_client.verify_is_json_rpc_error(response) - actual_error_code, actual_error_text = response.json()['error']['code'], response.json()['error']['message'] - assert expected_error_code == actual_error_code, \ - f"got code: {actual_error_code} instead of expected: {expected_error_code}" - assert expected_error_text in actual_error_text, \ - f"got error: {actual_error_text} that does not include: {expected_error_text}" + actual_error_code, actual_error_text = ( + response.json()["error"]["code"], + response.json()["error"]["message"], + ) + assert expected_error_code == actual_error_code, f"got code: {actual_error_code} instead of expected: {expected_error_code}" + assert expected_error_text in actual_error_text, f"got error: {actual_error_text} that does not include: {expected_error_text}" self.rpc_client.verify_json_schema(response.json(), "wallet_createMultiTransaction/transferTx_error") +@pytest.mark.usefixtures("init_status_backend") @pytest.mark.wallet @pytest.mark.rpc class TestRpc(StatusBackendTestCase): - @pytest.mark.parametrize( "method, params", [ @@ -85,7 +91,7 @@ class TestRpc(StatusBackendTestCase): ("wallet_getEthereumChains", []), ("wallet_getTokenList", []), ("wallet_getCryptoOnRamps", []), - ("wallet_getCachedCurrencyFormats", []) + ("wallet_getCachedCurrencyFormats", []), ], ) def test_(self, method, params):