diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..109be1d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + + - name: Run tests + run: | + pytest tests/ diff --git a/Other/fee_adjuster.md b/Other/fee_adjuster.md index d657ae9..ae8706d 100644 --- a/Other/fee_adjuster.md +++ b/Other/fee_adjuster.md @@ -38,6 +38,20 @@ This adjustment is automatically skipped if the aggregate local liquidity for th - Run the script to automatically adjust fees based on configured settings. - Requires a running LNDg instance for local channel details and fee updates. +### Test Suite: +New features and refactors are guarded by a suite of unit tests. To run them locally: + +```bash +# Activate your venv first if not active +source .venv/bin/activate + +# Install test dependencies +pip install -r requirements-dev.txt + +# Run the tests (pytest auto-discovers tests in the current directory) +pytest +``` + ### Command Line Arguments: - --debug: Enable detailed debug output, including stuck channel check results. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..764bd45 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[tool.pytest.ini_options] +pythonpath = [ + ".", + "Other", + "Magma" +] +testpaths = ["tests"] +addopts = "-v" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ab92662 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +pytest-mock +requests-mock diff --git a/tests/Magma/__init__.py b/tests/Magma/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/Magma/test_magma_sale_process.py b/tests/Magma/test_magma_sale_process.py new file mode 100644 index 0000000..5fce444 --- /dev/null +++ b/tests/Magma/test_magma_sale_process.py @@ -0,0 +1,193 @@ + +import sys +import os +import pytest +from unittest.mock import MagicMock + +# --- FIXTURE: Mock Global Side Effects --- +@pytest.fixture(scope="module", autouse=True) +def mock_dependencies(): + """ + Patcher fixture that runs BEFORE the test module logic is fully utilized. + Since 'import magma_sale_process' has side effects, we patch sys.modules + so the import uses our mocks. + """ + mock_telebot = MagicMock() + mock_telebot.TeleBot = MagicMock() + mock_configparser = MagicMock() + mock_logging = MagicMock() + mock_schedule = MagicMock() + + # Mock config dict + mock_config_data = { + "telegram": {"magma_bot_token": "fake_token", "telegram_user_id": "123"}, + "credentials": {"amboss_authorization": "fake_auth"}, + "system": {"full_path_bos": "/path/to/bos"}, + "magma": {"invoice_expiry_seconds": "1800", "max_fee_percentage_of_invoice": "0.9", "channel_fee_rate_ppm": "350"}, + "urls": {"mempool_fees_api": "https://mempool.space/api/v1/fees/recommended"}, + "pubkey": {"banned_magma_pubkeys": ""}, + "paths": {"lncli_path": "lncli"} + } + + mock_config_instance = MagicMock() + mock_config_instance.__getitem__.side_effect = mock_config_data.__getitem__ + mock_config_instance.get = MagicMock(side_effect=lambda section, option, fallback=None: mock_config_data.get(section, {}).get(option, fallback)) + mock_config_instance.getint = MagicMock(return_value=10) + mock_config_instance.getfloat = MagicMock(return_value=0.5) + mock_configparser.ConfigParser.return_value = mock_config_instance + + module_patches = { + 'telebot': mock_telebot, + 'telebot.types': MagicMock(), + 'configparser': mock_configparser, + 'schedule': mock_schedule, + 'logging.handlers': MagicMock(), + # We don't actully want to strictly mock logging or it suppresses output, but we prevent file handler creation + } + + from unittest.mock import patch, mock_open + + # Apply patches + with patch.dict(sys.modules, module_patches): + with patch("builtins.open", mock_open(read_data="[magma]\nfoo=bar")): + with patch("os.makedirs"): + # Normally we'd import here. + # However, since we are inside a fixture, and pytest collects modules first, + # we need to ensure the import happens strictly under this context. + # But python imports are cached. + + # To make this robust, we import inside the test functions OR use 'importlib.reload' if needed. + # But since we use autouse=True scope=module, tests in this file will "see" the mocked modules + # if we import right here or if we import at top level BUT rely on this fixture running first? + # No, top level imports happen at collection time. + # So we MUST move the import `import magma_sale_process` INTO the test functions or a fixture that returns the module. + yield + +@pytest.fixture +def magma_module(mock_dependencies): + """ + Imports and returns the magma_sale_process module ensuring it is mocked. + """ + # Verify we can import it now + # We might need to handle sys.path if pyproject.toml didn't kick in yet or for safety + if os.path.abspath(os.path.join(os.path.dirname(__file__), '../../Magma')) not in sys.path: + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../Magma'))) + + import magma_sale_process + # Reset vital mocks + magma_sale_process.requests = MagicMock() + return magma_sale_process + +# --- TESTS --- + +def test_get_node_alias_success(magma_module): + """Test retrieving node alias successfully.""" + mock_response = {"data": {"getNodeAlias": "TestNode"}} + + mock_post = MagicMock() + mock_post.json.return_value = mock_response + mock_post.raise_for_status.return_value = None + magma_module.requests.post = MagicMock(return_value=mock_post) + + alias = magma_module.get_node_alias("pubkey123") + assert alias == "TestNode" + +def test_get_node_alias_failure(magma_module): + """Test retrieving node alias when API fails.""" + mock_post = MagicMock() + mock_post.json.return_value = {} + magma_module.requests.post = MagicMock(return_value=mock_post) + + alias = magma_module.get_node_alias("pubkey123") + assert alias == "ErrorFetchingAlias" + +def test_execute_lncli_addinvoice_success(magma_module, mocker): + """Test generating an invoice calls lncli correctly.""" + mock_popen = mocker.patch("subprocess.Popen") + process_mock = MagicMock() + expected_json = '{"r_hash": "hash123", "payment_request": "lnbc..."}' + process_mock.communicate.return_value = (expected_json.encode('utf-8'), b"") + mock_popen.return_value = process_mock + + r_hash, pay_req = magma_module.execute_lncli_addinvoice(1000, "memo", 3600) + + assert r_hash == "hash123" + assert pay_req == "lnbc..." + + # Strict Argument Checking + mock_popen.assert_called_once() + args = mock_popen.call_args[0][0] + + # Check that --amt matches the passed amount 1000 + assert "--amt" in args + amt_index = args.index("--amt") + assert args[amt_index + 1] == "1000" + +def test_execute_lncli_addinvoice_failure(magma_module, mocker): + """Test error handling when lncli fails.""" + mock_popen = mocker.patch("subprocess.Popen") + process_mock = MagicMock() + process_mock.communicate.return_value = (b"", b"Error: something went wrong") + mock_popen.return_value = process_mock + + r_hash, pay_req = magma_module.execute_lncli_addinvoice(1000, "memo", 3600) + + assert r_hash.startswith("Error") + assert pay_req is None + +def test_accept_order_success(magma_module): + """Test accepting an order on Amboss.""" + mock_response = {"data": {"sellerAcceptOrder": True}} + mock_post = MagicMock() + mock_post.json.return_value = mock_response + mock_post.raise_for_status.return_value = None + magma_module.requests.post = MagicMock(return_value=mock_post) + + result = magma_module.accept_order("order123", "lnbc123") + assert result == mock_response + +def test_reject_order_success(magma_module): + """Test rejecting an order on Amboss.""" + mock_response = {"data": {"sellerRejectOrder": True}} + mock_post = MagicMock() + mock_post.json.return_value = mock_response + magma_module.requests.post = MagicMock(return_value=mock_post) + + result = magma_module.reject_order("order123") + assert result == mock_response + +def test_execute_lnd_command_success(magma_module, mocker): + """Test successfully opening a channel.""" + mock_run = mocker.patch("subprocess.run") + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = '{"funding_txid": "txid123"}' + mock_result.stderr = "" + mock_run.return_value = mock_result + + txid, err = magma_module.execute_lnd_command("pubkey", 10, None, 100000, 500) + + assert txid == "txid123" + assert err is None + + # Strict Argument Checking + args = mock_run.call_args[0][0] + assert "openchannel" in args + + assert "--fee_rate_ppm" in args + fee_index = args.index("--fee_rate_ppm") + assert args[fee_index + 1] == "500" + +def test_execute_lnd_command_failure(magma_module, mocker): + """Test failure opening a channel.""" + mock_run = mocker.patch("subprocess.run") + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "not enough funds" + mock_run.return_value = mock_result + + txid, err = magma_module.execute_lnd_command("pubkey", 10, None, 100000, 500) + + assert txid is None + assert "not enough funds" in err diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f2bae29 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import pytest + +@pytest.fixture +def fee_conditions(): + """Returns a sample fee_conditions dictionary.""" + return { + "fee_bands": { + "enabled": True, + "discount": -0.15, + "premium": 0.40 + }, + "stuck_channel_adjustment": { + "enabled": True, + "stuck_time_period": 5, + "min_local_balance_for_stuck_discount": 0.1, + "min_updates_for_discount": 100 + } + } diff --git a/tests/test_fee_adjuster.py b/tests/test_fee_adjuster.py new file mode 100644 index 0000000..ea91f1f --- /dev/null +++ b/tests/test_fee_adjuster.py @@ -0,0 +1,107 @@ +import sys +import os +import pytest + +import pytest + +from fee_adjuster import calculate_fee_band_adjustment + +def test_high_liquidity_not_stuck_no_discount(fee_conditions): + """ + Test that a channel with high local liquidity (Band 0) that is NOT stuck + does NOT receive a discount. + """ + # 90% outbound ratio => Band 0 (Initial) + outbound_ratio = 0.90 + num_updates = 200 # Sufficient updates + stuck_bands_to_move_down = 0 # Not stuck + + adj_factor, init_band, final_band = calculate_fee_band_adjustment( + fee_conditions, + outbound_ratio, + num_updates, + stuck_bands_to_move_down + ) + + # Expectation: + # initial_raw_band = 0 + # adjusted_raw_band = 0 + # calculated_adjustment = -0.15 (discount) + # BUT is_channel_stuck is False, so adjustment should become 0 + + assert init_band == 0 + assert final_band == 0 + assert adj_factor == 1.0 # 1 + 0 + +def test_high_liquidity_stuck_receives_discount(fee_conditions): + """ + Test that a channel with high local liquidity that IS stuck + receives the discount. + """ + outbound_ratio = 0.90 + num_updates = 200 + stuck_bands_to_move_down = 1 # Stuck for at least one period + + adj_factor, _, _ = calculate_fee_band_adjustment( + fee_conditions, + outbound_ratio, + num_updates, + stuck_bands_to_move_down + ) + + # Expectation: Discount applied. + # adjustable_raw_band = 0 + # adjustment = -0.15 + # Factor = 0.85 + + assert adj_factor == 0.85 + +def test_new_channel_guard_stuck_but_low_updates(fee_conditions): + """ + Test that a stuck channel with insufficient updates still gets NO discount + (legacy safeguard check). + """ + outbound_ratio = 0.90 + num_updates = 50 # < 100 + stuck_bands_to_move_down = 1 # Stuck + + adj_factor, _, _ = calculate_fee_band_adjustment( + fee_conditions, + outbound_ratio, + num_updates, + stuck_bands_to_move_down + ) + + # Expectation: + # Condition: (not is_channel_stuck or num_updates < min_updates) + # (False or True) -> True. + # Adjustment -> 0 + + assert adj_factor == 1.0 + +def test_premium_applied_regardless_of_stuck(fee_conditions): + """ + Test that premiums are applied for low liquidity channels regardless of stuck status. + """ + # 10% outbound ratio => Band 4 (0-20%) -> capped at Band 3 effective logic + outbound_ratio = 0.10 + num_updates = 200 + stuck_bands_to_move_down = 0 + + adj_factor, init_band, final_band = calculate_fee_band_adjustment( + fee_conditions, + outbound_ratio, + num_updates, + stuck_bands_to_move_down + ) + + # Expectation: + # initial_raw_band = 4 + # adjusted_raw_band = 4 + # effective_band_for_calc = 3 + # adjustment = discount + 3 * (range/3) = premium = 0.40 + # Factor = 1.40 + + assert init_band == 4 + assert adj_factor == 1.40 +