Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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/
14 changes: 14 additions & 0 deletions Other/fee_adjuster.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[tool.pytest.ini_options]
pythonpath = [
".",
"Other",
"Magma"
]
testpaths = ["tests"]
addopts = "-v"
3 changes: 3 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pytest
pytest-mock
requests-mock
Empty file added tests/Magma/__init__.py
Empty file.
193 changes: 193 additions & 0 deletions tests/Magma/test_magma_sale_process.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added tests/__init__.py
Empty file.
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
}
}
107 changes: 107 additions & 0 deletions tests/test_fee_adjuster.py
Original file line number Diff line number Diff line change
@@ -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