From 861695efed7b313673c31a586293a3f9d9dd23a2 Mon Sep 17 00:00:00 2001 From: TrezorHannes Date: Wed, 14 Jan 2026 19:03:41 +0100 Subject: [PATCH 1/4] feat: add testing suite infrastructure - Added and to - Created with standard fee fixtures - Created with unit tests for fee band logic covers: - Stuck vs Non-stuck discount application - New channel safeguard - Premium application - Added GitHub Actions workflow --- .github/workflows/tests.yml | 32 +++++++++++ requirements-dev.txt | 3 + tests/__init__.py | 0 tests/conftest.py | 18 ++++++ tests/test_fee_adjuster.py | 108 ++++++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_fee_adjuster.py 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/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/__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..d1971b1 --- /dev/null +++ b/tests/test_fee_adjuster.py @@ -0,0 +1,108 @@ +import sys +import os +import pytest + +# Add Other directory to sys.path to allow importing fee_adjuster +sys.path.append(os.path.join(os.path.dirname(__file__), '../Other')) + +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 + From 8c50858f215655694ead6918e22d76eb64653d18 Mon Sep 17 00:00:00 2001 From: TrezorHannes Date: Wed, 14 Jan 2026 19:15:13 +0100 Subject: [PATCH 2/4] docs: add testing suite instructions to fee_adjuster.md --- Other/fee_adjuster.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Other/fee_adjuster.md b/Other/fee_adjuster.md index d657ae9..9e3f182 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 tests/ +``` + ### Command Line Arguments: - --debug: Enable detailed debug output, including stuck channel check results. From 8bdefa309504c7ecc8aa40f979ea0117c1b5bd51 Mon Sep 17 00:00:00 2001 From: TrezorHannes Date: Wed, 14 Jan 2026 19:52:34 +0100 Subject: [PATCH 3/4] test: add unit tests for magma_sale_process.py --- tests/Magma/__init__.py | 0 tests/Magma/test_magma_sale_process.py | 194 +++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 tests/Magma/__init__.py create mode 100644 tests/Magma/test_magma_sale_process.py 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..d327a18 --- /dev/null +++ b/tests/Magma/test_magma_sale_process.py @@ -0,0 +1,194 @@ +import sys +import os +import unittest +from unittest.mock import MagicMock, patch + +# --- MOCKING GLOBAL SIDE EFFECTS BEFORE IMPORT --- +# We must mock modules that cause side effects on import (telebot connection, config reading, logging file creation) + +# Create mock objects +mock_telebot = MagicMock() +mock_telebot.TeleBot = MagicMock() +mock_configparser = MagicMock() +mock_logging = MagicMock() +mock_schedule = MagicMock() +mock_rotating_handler = MagicMock() + +# Configure the mock config parser to behave like a dict-like object where needed +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() +# Allow dictionary-style access config['section']['key'] +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 + +# Patch sys.modules to inject our mocks +# This prevents the real modules from being loaded/executed +module_patches = { + 'telebot': mock_telebot, + 'telebot.types': MagicMock(), + 'configparser': mock_configparser, + 'schedule': mock_schedule, + # We don't actully want to strictly mock logging or it suppresses output, + # but we want to stop it from creating files. + 'logging.handlers': MagicMock(), +} + +# We need to setup these patches before importing the target module +with patch.dict(sys.modules, module_patches): + # We also need to patch open() to prevent config file reading and log/flag file creation during import + with patch("builtins.open", unittest.mock.mock_open(read_data="[magma]\nfoo=bar")): + # We also need to prevent os.makedirs + with patch("os.makedirs"): + # Now we can import the module. + # We need to add the parent directory to sys.path so it can resolve relative imports if any (though it seems self-contained) + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../Magma'))) + import magma_sale_process + +# --- END PRE-IMPORT MOCKING --- + +class TestMagmaSaleProcess(unittest.TestCase): + + def setUp(self): + # Reset mocks before each test + magma_sale_process.requests = MagicMock() # Mock requests module inside the imported module + + # Ensure constants are set to know values if needed (though we mocked config) + magma_sale_process.AMBOSS_TOKEN = "test_token" + magma_sale_process.LNCLI_PATH = "lncli" + + def test_get_node_alias_success(self): + """Test retrieving node alias successfully.""" + mock_response = { + "data": { + "getNodeAlias": "TestNode" + } + } + + # Mock requests.post + mock_post = MagicMock() + mock_post.json.return_value = mock_response + mock_post.raise_for_status.return_value = None + magma_sale_process.requests.post = MagicMock(return_value=mock_post) + + alias = magma_sale_process.get_node_alias("pubkey123") + self.assertEqual(alias, "TestNode") + + def test_get_node_alias_failure(self): + """Test retrieving node alias when API fails.""" + # Mock requests.post to return no data + mock_post = MagicMock() + mock_post.json.return_value = {} # Empty JSON + magma_sale_process.requests.post = MagicMock(return_value=mock_post) + + alias = magma_sale_process.get_node_alias("pubkey123") + self.assertEqual(alias, "ErrorFetchingAlias") + + @patch("subprocess.Popen") + def test_execute_lncli_addinvoice_success(self, mock_popen): + """Test generating an invoice calls lncli correctly.""" + # Setup mock process + 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_sale_process.execute_lncli_addinvoice(1000, "memo", 3600) + + self.assertEqual(r_hash, "hash123") + self.assertEqual(pay_req, "lnbc...") + + # Verify command arguments + mock_popen.assert_called_once() + args = mock_popen.call_args[0][0] + self.assertIn("addinvoice", args) + self.assertIn("1000", args) + self.assertIn("3600", args) + + @patch("subprocess.Popen") + def test_execute_lncli_addinvoice_failure(self, mock_popen): + """Test error handling when lncli fails.""" + 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_sale_process.execute_lncli_addinvoice(1000, "memo", 3600) + + self.assertTrue(r_hash.startswith("Error")) + self.assertIsNone(pay_req) + + def test_accept_order_success(self): + """Test accepting an order on Amboss.""" + # Mock successful mutation response + mock_response = {"data": {"sellerAcceptOrder": True}} + mock_post = MagicMock() + mock_post.json.return_value = mock_response + mock_post.raise_for_status.return_value = None + magma_sale_process.requests.post = MagicMock(return_value=mock_post) + + result = magma_sale_process.accept_order("order123", "lnbc123") + + self.assertEqual(result, mock_response) + + # Verify payload contains mutation + call_args = magma_sale_process.requests.post.call_args + self.assertIn('sellerAcceptOrder', call_args[1]['json']['query']) + self.assertEqual(call_args[1]['json']['variables']['sellerAcceptOrderId'], "order123") + + def test_reject_order_success(self): + """Test rejecting an order on Amboss.""" + mock_response = {"data": {"sellerRejectOrder": True}} + mock_post = MagicMock() + mock_post.json.return_value = mock_response + magma_sale_process.requests.post = MagicMock(return_value=mock_post) + + result = magma_sale_process.reject_order("order123") + self.assertEqual(result, mock_response) + + @patch("subprocess.run") + def test_execute_lnd_command_success(self, mock_run): + """Test successfully opening a channel.""" + 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_sale_process.execute_lnd_command("pubkey", 10, None, 100000, 500) + + self.assertEqual(txid, "txid123") + self.assertIsNone(err) + + # Verify CLI args + args = mock_run.call_args[0][0] + self.assertIn("openchannel", args) + self.assertIn("pubkey", args) + self.assertIn("500", args) # Fee rate ppm + + @patch("subprocess.run") + def test_execute_lnd_command_failure(self, mock_run): + """Test failure opening a channel.""" + 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_sale_process.execute_lnd_command("pubkey", 10, None, 100000, 500) + + self.assertIsNone(txid) + self.assertIn("not enough funds", err) + +if __name__ == '__main__': + unittest.main() From 39b8c273e97b4be6dd0768daee340abb343c9be9 Mon Sep 17 00:00:00 2001 From: TrezorHannes Date: Wed, 14 Jan 2026 20:10:52 +0100 Subject: [PATCH 4/4] refactor: apply PR review feedback - tests/Magma: switch to pytest style, use fixtures for mocking, stricter args checks - infra: add pyproject.toml, remove sys.path hacks - docs: update fee_adjuster.md --- Other/fee_adjuster.md | 4 +- pyproject.toml | 8 + tests/Magma/test_magma_sale_process.py | 383 ++++++++++++------------- tests/test_fee_adjuster.py | 3 +- 4 files changed, 202 insertions(+), 196 deletions(-) create mode 100644 pyproject.toml diff --git a/Other/fee_adjuster.md b/Other/fee_adjuster.md index 9e3f182..ae8706d 100644 --- a/Other/fee_adjuster.md +++ b/Other/fee_adjuster.md @@ -48,8 +48,8 @@ source .venv/bin/activate # Install test dependencies pip install -r requirements-dev.txt -# Run the tests -pytest tests/ +# Run the tests (pytest auto-discovers tests in the current directory) +pytest ``` ### Command Line Arguments: 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/tests/Magma/test_magma_sale_process.py b/tests/Magma/test_magma_sale_process.py index d327a18..5fce444 100644 --- a/tests/Magma/test_magma_sale_process.py +++ b/tests/Magma/test_magma_sale_process.py @@ -1,194 +1,193 @@ + import sys import os -import unittest -from unittest.mock import MagicMock, patch - -# --- MOCKING GLOBAL SIDE EFFECTS BEFORE IMPORT --- -# We must mock modules that cause side effects on import (telebot connection, config reading, logging file creation) - -# Create mock objects -mock_telebot = MagicMock() -mock_telebot.TeleBot = MagicMock() -mock_configparser = MagicMock() -mock_logging = MagicMock() -mock_schedule = MagicMock() -mock_rotating_handler = MagicMock() - -# Configure the mock config parser to behave like a dict-like object where needed -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() -# Allow dictionary-style access config['section']['key'] -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 - -# Patch sys.modules to inject our mocks -# This prevents the real modules from being loaded/executed -module_patches = { - 'telebot': mock_telebot, - 'telebot.types': MagicMock(), - 'configparser': mock_configparser, - 'schedule': mock_schedule, - # We don't actully want to strictly mock logging or it suppresses output, - # but we want to stop it from creating files. - 'logging.handlers': MagicMock(), -} - -# We need to setup these patches before importing the target module -with patch.dict(sys.modules, module_patches): - # We also need to patch open() to prevent config file reading and log/flag file creation during import - with patch("builtins.open", unittest.mock.mock_open(read_data="[magma]\nfoo=bar")): - # We also need to prevent os.makedirs - with patch("os.makedirs"): - # Now we can import the module. - # We need to add the parent directory to sys.path so it can resolve relative imports if any (though it seems self-contained) - sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../Magma'))) - import magma_sale_process - -# --- END PRE-IMPORT MOCKING --- - -class TestMagmaSaleProcess(unittest.TestCase): - - def setUp(self): - # Reset mocks before each test - magma_sale_process.requests = MagicMock() # Mock requests module inside the imported module - - # Ensure constants are set to know values if needed (though we mocked config) - magma_sale_process.AMBOSS_TOKEN = "test_token" - magma_sale_process.LNCLI_PATH = "lncli" - - def test_get_node_alias_success(self): - """Test retrieving node alias successfully.""" - mock_response = { - "data": { - "getNodeAlias": "TestNode" - } - } - - # Mock requests.post - mock_post = MagicMock() - mock_post.json.return_value = mock_response - mock_post.raise_for_status.return_value = None - magma_sale_process.requests.post = MagicMock(return_value=mock_post) - - alias = magma_sale_process.get_node_alias("pubkey123") - self.assertEqual(alias, "TestNode") - - def test_get_node_alias_failure(self): - """Test retrieving node alias when API fails.""" - # Mock requests.post to return no data - mock_post = MagicMock() - mock_post.json.return_value = {} # Empty JSON - magma_sale_process.requests.post = MagicMock(return_value=mock_post) - - alias = magma_sale_process.get_node_alias("pubkey123") - self.assertEqual(alias, "ErrorFetchingAlias") - - @patch("subprocess.Popen") - def test_execute_lncli_addinvoice_success(self, mock_popen): - """Test generating an invoice calls lncli correctly.""" - # Setup mock process - 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_sale_process.execute_lncli_addinvoice(1000, "memo", 3600) - - self.assertEqual(r_hash, "hash123") - self.assertEqual(pay_req, "lnbc...") - - # Verify command arguments - mock_popen.assert_called_once() - args = mock_popen.call_args[0][0] - self.assertIn("addinvoice", args) - self.assertIn("1000", args) - self.assertIn("3600", args) - - @patch("subprocess.Popen") - def test_execute_lncli_addinvoice_failure(self, mock_popen): - """Test error handling when lncli fails.""" - 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_sale_process.execute_lncli_addinvoice(1000, "memo", 3600) - - self.assertTrue(r_hash.startswith("Error")) - self.assertIsNone(pay_req) - - def test_accept_order_success(self): - """Test accepting an order on Amboss.""" - # Mock successful mutation response - mock_response = {"data": {"sellerAcceptOrder": True}} - mock_post = MagicMock() - mock_post.json.return_value = mock_response - mock_post.raise_for_status.return_value = None - magma_sale_process.requests.post = MagicMock(return_value=mock_post) - - result = magma_sale_process.accept_order("order123", "lnbc123") - - self.assertEqual(result, mock_response) - - # Verify payload contains mutation - call_args = magma_sale_process.requests.post.call_args - self.assertIn('sellerAcceptOrder', call_args[1]['json']['query']) - self.assertEqual(call_args[1]['json']['variables']['sellerAcceptOrderId'], "order123") - - def test_reject_order_success(self): - """Test rejecting an order on Amboss.""" - mock_response = {"data": {"sellerRejectOrder": True}} - mock_post = MagicMock() - mock_post.json.return_value = mock_response - magma_sale_process.requests.post = MagicMock(return_value=mock_post) - - result = magma_sale_process.reject_order("order123") - self.assertEqual(result, mock_response) - - @patch("subprocess.run") - def test_execute_lnd_command_success(self, mock_run): - """Test successfully opening a channel.""" - 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_sale_process.execute_lnd_command("pubkey", 10, None, 100000, 500) - - self.assertEqual(txid, "txid123") - self.assertIsNone(err) - - # Verify CLI args - args = mock_run.call_args[0][0] - self.assertIn("openchannel", args) - self.assertIn("pubkey", args) - self.assertIn("500", args) # Fee rate ppm - - @patch("subprocess.run") - def test_execute_lnd_command_failure(self, mock_run): - """Test failure opening a channel.""" - 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_sale_process.execute_lnd_command("pubkey", 10, None, 100000, 500) - - self.assertIsNone(txid) - self.assertIn("not enough funds", err) - -if __name__ == '__main__': - unittest.main() +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/test_fee_adjuster.py b/tests/test_fee_adjuster.py index d1971b1..ea91f1f 100644 --- a/tests/test_fee_adjuster.py +++ b/tests/test_fee_adjuster.py @@ -2,8 +2,7 @@ import os import pytest -# Add Other directory to sys.path to allow importing fee_adjuster -sys.path.append(os.path.join(os.path.dirname(__file__), '../Other')) +import pytest from fee_adjuster import calculate_fee_band_adjustment