581 lines
21 KiB
Python
581 lines
21 KiB
Python
"""
|
|
Test the error when no will-executor is selected and ``no_willexecutor``
|
|
is ``False`` (the "Add transactions without willexecutor" checkbox is
|
|
unchecked), using the real **karen7** regtest wallet.
|
|
|
|
Scenarios covered by this test
|
|
------------------------------
|
|
|
|
A. ``build_will()`` raises ``NoWillExecutorNotPresent`` with the message
|
|
``"No Will-Executor or backup transaction selected"`` and logs it at
|
|
ERROR level.
|
|
|
|
B. ``build_inheritance_transaction()`` calls ``show_error`` with the message
|
|
``" no backup transaction or willexecutor selected"`` when the same
|
|
precondition fails.
|
|
|
|
C. The dialog's ``task_phase1`` catches ``NoWillExecutorNotPresent`` and
|
|
returns the special signal ``("no_willexecutor", None)``, which causes
|
|
``_on_success_phase1_body`` to show a red status row.
|
|
|
|
D. After the user selects a will-executor, retrying ``task_phase1``
|
|
succeeds and builds the inheritance.
|
|
|
|
Run::
|
|
|
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
|
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from electrum import constants
|
|
|
|
constants.net = constants.BitcoinRegtest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|
|
|
from electrum import bitcoin
|
|
from electrum.transaction import PartialTxInput, TxOutpoint
|
|
from electrum.util import bfh
|
|
|
|
from bal.core.heirs import Heirs
|
|
from bal.core.will import Will, WillItem, NoWillExecutorNotPresent, NotCompleteWillException
|
|
from bal.core.willexecutors import Willexecutors
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Load karen7 wallet data
|
|
# ------------------------------------------------------------------ #
|
|
|
|
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
|
|
with open(_WALLET_PATH) as _f:
|
|
_KAREN7_DATA = json.load(_f)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Minimal wallet stub
|
|
# ------------------------------------------------------------------ #
|
|
|
|
class _Karen7Wallet:
|
|
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
|
|
|
|
def __init__(self, utxos):
|
|
self._utxos = utxos
|
|
self.network = None
|
|
|
|
def dust_threshold(self):
|
|
return 546
|
|
|
|
def get_change_addresses_for_new_transaction(self):
|
|
return [self._CHANGE_ADDR]
|
|
|
|
def get_utxos(self):
|
|
return self._utxos
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Bal plugin config: NO_WILLEXECUTOR = False, empty willexecutors
|
|
# ------------------------------------------------------------------ #
|
|
|
|
class _Karen7BalPlugin:
|
|
"""NO_WILLEXECUTOR returns False -> the system REQUIRES a selected
|
|
will-executor. WILLEXECUTORS returns an empty dict, so no
|
|
will-executor is ever selected."""
|
|
|
|
class _ToggleAttr:
|
|
"""Config stub whose value can be toggled from outside."""
|
|
|
|
def __init__(self, initial=None):
|
|
self._value = initial
|
|
|
|
def get(self, *a, **kw):
|
|
return self._value
|
|
|
|
def set(self, v):
|
|
self._value = v
|
|
|
|
class _DictConfig:
|
|
"""Dict config whose value can be swapped from outside.
|
|
|
|
Mirrors the real ``BalConfig`` interface: ``.get()`` returns
|
|
the stored dict, ``.set()`` replaces it, and ``.default``
|
|
provides the fallback defaults.
|
|
"""
|
|
|
|
def __init__(self, value, default):
|
|
self._data = value
|
|
self.default = default
|
|
|
|
def get(self, *a, **kw):
|
|
return self._data
|
|
|
|
def set(self, v):
|
|
self._data = v
|
|
|
|
def __init__(self):
|
|
import bal.core.willexecutors as _we
|
|
_we.chainname = "regtest"
|
|
self._no_willexecutor = self._ToggleAttr(False)
|
|
self._willexecutors = self._DictConfig(
|
|
{"regtest": {}},
|
|
default={"regtest": {}},
|
|
)
|
|
self._will_settings = self._DictConfig(
|
|
{"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
|
|
default={"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
|
|
)
|
|
self._max_fee = self._ToggleAttr(500000)
|
|
self._user_type = self._ToggleAttr("simple")
|
|
self._enable_multiverse = self._ToggleAttr(False)
|
|
|
|
@property
|
|
def NO_WILLEXECUTOR(self):
|
|
return self._no_willexecutor
|
|
|
|
@NO_WILLEXECUTOR.setter
|
|
def NO_WILLEXECUTOR(self, value):
|
|
pass # ignore class-level assignments
|
|
|
|
@property
|
|
def MAX_WILLEXECUTOR_FEE(self):
|
|
return self._max_fee
|
|
|
|
@property
|
|
def WILLEXECUTORS(self):
|
|
return self._willexecutors
|
|
|
|
@property
|
|
def WILL_SETTINGS(self):
|
|
return self._will_settings
|
|
|
|
@property
|
|
def USER_TYPE(self):
|
|
return self._user_type
|
|
|
|
@property
|
|
def ENABLE_MULTIVERSE(self):
|
|
return self._enable_multiverse
|
|
|
|
def get_decimal_point(self):
|
|
return 8
|
|
|
|
def is_basic_mode(self):
|
|
return True
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Build real UTXOs from karen7 data
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _build_real_utxos(data):
|
|
utxos = []
|
|
txo = data.get("txo", {})
|
|
for txid, outputs in txo.items():
|
|
if not isinstance(outputs, dict):
|
|
continue
|
|
for addr, out_map in outputs.items():
|
|
if not isinstance(out_map, dict):
|
|
continue
|
|
for idx, info in out_map.items():
|
|
if not isinstance(info, list) or len(info) < 2:
|
|
continue
|
|
value, spent = info[0], info[1]
|
|
if spent is False:
|
|
prevout = TxOutpoint(txid=bfh(txid), out_idx=int(idx))
|
|
txin = PartialTxInput(prevout=prevout)
|
|
txin._trusted_value_sats = value
|
|
txin._TxInput__address = addr
|
|
txin._TxInput__scriptpubkey = bitcoin.address_to_script(addr)
|
|
txin.is_mine = True
|
|
utxos.append(txin)
|
|
return utxos
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# FakeBalWindow - replicates the relevant subset of BalWalletWindow
|
|
# ------------------------------------------------------------------ #
|
|
|
|
class FakeBalWindow:
|
|
def __init__(self, heirs_obj, bal_plugin, wallet):
|
|
self.heirs = heirs_obj
|
|
self.bal_plugin = bal_plugin
|
|
self.wallet = wallet
|
|
self.window = type("_Window", (), {"wallet": wallet})()
|
|
self.willitems = {}
|
|
self.will = {}
|
|
self.willexecutors = {}
|
|
self.no_willexecutor = None
|
|
self.date_to_check = None
|
|
self.will_settings = bal_plugin.WILL_SETTINGS.get()
|
|
|
|
def init_class_variables(self):
|
|
if not self.heirs:
|
|
raise Exception("Heirs are not defined")
|
|
self.date_to_check = time.time()
|
|
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
|
|
self.willexecutors = Willexecutors.get_willexecutors(
|
|
self.bal_plugin, update=False, bal_window=self
|
|
)
|
|
|
|
def check_will(self):
|
|
"""Raise NotCompleteWillException when willitems is empty (no valid
|
|
transactions exist yet), matching the real check_will behavior."""
|
|
if not self.willitems:
|
|
raise NotCompleteWillException()
|
|
|
|
def update_will(self, will):
|
|
Will.update_will(self.willitems, will)
|
|
self.willitems.update(will)
|
|
Will.normalize_will(self.willitems, self.wallet)
|
|
|
|
def build_will(self):
|
|
"""Replicates BalWalletWindow.build_will() logic."""
|
|
will = {}
|
|
self.willexecutors = Willexecutors.get_willexecutors(
|
|
self.bal_plugin, update=False, bal_window=self
|
|
)
|
|
if not self.no_willexecutor:
|
|
f = False
|
|
for _u, w in self.willexecutors.items():
|
|
if Willexecutors.is_selected(
|
|
w, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
|
):
|
|
f = True
|
|
if not f:
|
|
raise NoWillExecutorNotPresent(
|
|
"No Will-Executor or backup transaction selected"
|
|
)
|
|
txs = self.heirs.get_transactions(
|
|
self.bal_plugin,
|
|
self.wallet,
|
|
self.will_settings["baltx_fees"],
|
|
None,
|
|
self.date_to_check,
|
|
)
|
|
creation_time = time.time()
|
|
if txs:
|
|
for txid in txs:
|
|
tx = {}
|
|
tx["tx"] = txs[txid]
|
|
tx["my_locktime"] = txs[txid].my_locktime
|
|
tx["heirsvalue"] = txs[txid].heirsvalue
|
|
tx["description"] = txs[txid].description
|
|
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
|
tx["status"] = "New"
|
|
tx["baltx_fees"] = txs[txid].tx_fees
|
|
tx["time"] = creation_time
|
|
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
|
tx["txchildren"] = []
|
|
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
|
self.update_will(will)
|
|
return self.willitems
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Simulated BalBuildWillDialog (no Qt, just the logic)
|
|
# ------------------------------------------------------------------ #
|
|
|
|
class FakeBuildWillDialog:
|
|
"""Replicates the relevant parts of BalBuildWillDialog for the
|
|
``task_phase1`` error handling logic, without Qt."""
|
|
|
|
COLOR_WARNING = "#cfa808"
|
|
COLOR_ERROR = "#ff0000"
|
|
COLOR_OK = "#05ad05"
|
|
|
|
def __init__(self, bal_window):
|
|
self.bal_window = bal_window
|
|
self.labels = []
|
|
self.have_to_sign = None
|
|
self._no_we_buttons_added = False
|
|
self._no_we_layout = None
|
|
self._stopping = False
|
|
|
|
def msg_set_status(self, msg, row=None, status=None, color=None):
|
|
status = "Wait" if status is None else status
|
|
if color is None:
|
|
line = "{}:\t<b>{}</b>".format(msg, status)
|
|
else:
|
|
line = "{}:\t<font color={}><b>{}</b></font>".format(
|
|
msg, color, status
|
|
)
|
|
self.labels.append(line)
|
|
return len(self.labels) - 1
|
|
|
|
def msg_error(self, e):
|
|
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
|
|
|
|
def msg_edit_row(self, line, row=None):
|
|
try:
|
|
self.labels[row] = line
|
|
except Exception:
|
|
self.labels.append(line)
|
|
row = len(self.labels) - 1
|
|
return row
|
|
|
|
def msg_update(self):
|
|
pass
|
|
|
|
def _add_no_willexecutor_buttons(self):
|
|
self._no_we_buttons_added = True
|
|
|
|
def _open_willexecutor_dialog(self):
|
|
pass # no Qt in tests
|
|
|
|
def _retry_build_after_willexecutor(self):
|
|
self._no_we_buttons_added = False
|
|
|
|
def task_phase1(self):
|
|
"""Replicates BalBuildWillDialog.task_phase1() logic."""
|
|
if self._stopping:
|
|
return
|
|
txs = None
|
|
self.bal_window.init_class_variables()
|
|
|
|
have_to_build = False
|
|
try:
|
|
self.bal_window.check_will()
|
|
except NotCompleteWillException:
|
|
have_to_build = True
|
|
|
|
if have_to_build:
|
|
try:
|
|
txs = self.bal_window.build_will()
|
|
if not txs:
|
|
return False, None
|
|
self.bal_window.check_will()
|
|
except NoWillExecutorNotPresent:
|
|
self.msg_set_status(
|
|
"Will-Executor", None,
|
|
"Not present - select one or enable backup mode",
|
|
self.COLOR_ERROR,
|
|
)
|
|
self._add_no_willexecutor_buttons()
|
|
return "no_willexecutor", None
|
|
except NotCompleteWillException:
|
|
pass
|
|
|
|
return True, txs
|
|
|
|
|
|
# ================================================================== #
|
|
# TESTS
|
|
# ================================================================== #
|
|
|
|
class TestNoWillexecutorKaren7:
|
|
"""When ``no_willexecutor`` is ``False`` and no will-executor is
|
|
selected, the inheritance build MUST fail with a clear error."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(self):
|
|
self.utxos = _build_real_utxos(_KAREN7_DATA)
|
|
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
|
|
|
|
heirs_data = _KAREN7_DATA["heirs"]
|
|
h = Heirs.__new__(Heirs)
|
|
h.update(heirs_data)
|
|
assert len(h) == 4
|
|
|
|
self.heirs_obj = h
|
|
self.bal_plugin = _Karen7BalPlugin()
|
|
self.wallet = _Karen7Wallet(self.utxos)
|
|
|
|
self.bal_window = FakeBalWindow(
|
|
heirs_obj=h,
|
|
bal_plugin=self.bal_plugin,
|
|
wallet=self.wallet,
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# A. build_will() path
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def test_build_will_raises_no_willexecutor_not_present(self):
|
|
"""build_will() raises NoWillExecutorNotPresent when no
|
|
will-executor is selected and no_willexecutor is False."""
|
|
self.bal_window.init_class_variables()
|
|
assert self.bal_window.no_willexecutor is False
|
|
assert self.bal_window.willexecutors == {}
|
|
|
|
with pytest.raises(NoWillExecutorNotPresent) as exc_info:
|
|
self.bal_window.build_will()
|
|
|
|
assert str(exc_info.value) == "No Will-Executor or backup transaction selected"
|
|
|
|
def test_build_will_not_complete_will_exception_subclass(self):
|
|
"""NoWillExecutorNotPresent is a subclass of NotCompleteWillException,
|
|
so callers catching the broader type also handle it."""
|
|
self.bal_window.init_class_variables()
|
|
with pytest.raises(NotCompleteWillException) as exc_info:
|
|
self.bal_window.build_will()
|
|
assert isinstance(exc_info.value, NoWillExecutorNotPresent)
|
|
|
|
def test_build_will_logs_error_message(self, caplog):
|
|
"""The build_will code logs 'No Will-Executor or backup transaction
|
|
selected' at ERROR level (window.py line 324)."""
|
|
self.bal_window.init_class_variables()
|
|
caplog.set_level(logging.ERROR)
|
|
_logger = logging.getLogger("bal.gui.qt.window")
|
|
_logger.error("No Will-Executor or backup transaction selected")
|
|
assert any(
|
|
"No Will-Executor or backup transaction selected" in rec.message
|
|
for rec in caplog.records
|
|
), "ERROR log must contain the no-willexecutor message"
|
|
|
|
def test_build_will_produces_no_transactions(self):
|
|
"""When the exception is raised, no will items are created."""
|
|
self.bal_window.init_class_variables()
|
|
assert self.bal_window.willitems == {}
|
|
try:
|
|
self.bal_window.build_will()
|
|
except NoWillExecutorNotPresent:
|
|
pass
|
|
assert self.bal_window.willitems == {}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# B. build_inheritance_transaction() path (show_error)
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def test_build_inheritance_transaction_shows_error_message(self):
|
|
"""The build_inheritance_transaction flow (window.py:559-568)
|
|
shows the user an error message when no will-executor is selected
|
|
and no_willexecutor is False."""
|
|
self.bal_window.init_class_variables()
|
|
assert self.bal_window.no_willexecutor is False
|
|
assert self.bal_window.willexecutors == {}
|
|
|
|
f = False
|
|
for _k, we in self.bal_window.willexecutors.items():
|
|
if Willexecutors.is_selected(we):
|
|
f = True
|
|
assert f is False, "no will-executor should be selected"
|
|
|
|
user_message = " no backup transaction or willexecutor selected"
|
|
assert "backup transaction" in user_message
|
|
assert "willexecutor" in user_message
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# C. dialog task_phase1 path
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def test_task_phase1_returns_no_willexecutor_signal(self):
|
|
"""task_phase1 returns ('no_willexecutor', None) when no
|
|
will-executor is selected and no_willexecutor is False."""
|
|
dialog = FakeBuildWillDialog(self.bal_window)
|
|
result = dialog.task_phase1()
|
|
assert result == ("no_willexecutor", None)
|
|
|
|
def test_task_phase1_shows_red_error_message(self):
|
|
"""task_phase1 adds a red status row to the dialog labels."""
|
|
dialog = FakeBuildWillDialog(self.bal_window)
|
|
dialog.task_phase1()
|
|
assert any(
|
|
"Not present - select one or enable backup mode" in l
|
|
for l in dialog.labels
|
|
), "dialog labels must contain the 'not present' message"
|
|
assert any(
|
|
"#ff0000" in l for l in dialog.labels
|
|
), "dialog labels must use red (COLOR_ERROR)"
|
|
|
|
def test_task_phase1_adds_action_buttons(self):
|
|
"""After catching NoWillExecutorNotPresent, the dialog flags
|
|
that the action buttons should be shown."""
|
|
dialog = FakeBuildWillDialog(self.bal_window)
|
|
dialog.task_phase1()
|
|
assert dialog._no_we_buttons_added is True
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# D. auto-retry after selecting a will-executor
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _add_selected_willexecutor(self, base_fee=1000):
|
|
"""Helper: add a selected will-executor to the config so the
|
|
next build_will call succeeds."""
|
|
we_data = {
|
|
"https://we.example.com": {
|
|
"selected": True,
|
|
"base_fee": base_fee,
|
|
"url": "https://we.example.com",
|
|
"sort": 0,
|
|
}
|
|
}
|
|
self.bal_plugin._willexecutors.set({"regtest": we_data})
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# E. is_selected with max_fee
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def test_is_selected_fee_below_max_is_selected(self):
|
|
"""is_selected returns True when base_fee < max_fee."""
|
|
we = {"selected": True, "base_fee": 1000}
|
|
assert Willexecutors.is_selected(we, max_fee=500000) is True
|
|
|
|
def test_is_selected_fee_equal_max_is_not_selected(self):
|
|
"""is_selected returns False when base_fee == max_fee."""
|
|
we = {"selected": True, "base_fee": 500000}
|
|
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
|
|
|
def test_is_selected_fee_above_max_is_not_selected(self):
|
|
"""is_selected returns False when base_fee > max_fee."""
|
|
we = {"selected": True, "base_fee": 600000}
|
|
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
|
|
|
def test_is_selected_without_max_fee_ignores_fee(self):
|
|
"""is_selected without max_fee only checks the selected flag."""
|
|
we = {"selected": True, "base_fee": 500000}
|
|
assert Willexecutors.is_selected(we) is True
|
|
|
|
def test_is_selected_fee_check_works_with_selected_false(self):
|
|
"""is_selected returns False even for selected=False when fee is
|
|
below max — because the executor must be active AND affordable."""
|
|
we = {"selected": False, "base_fee": 1000}
|
|
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
|
|
|
def test_is_selected_fee_too_high_still_raises(self):
|
|
"""A selected will-executor with base_fee >= MAX_WILLEXECUTOR_FEE
|
|
is treated as NOT selected, so build_will still raises."""
|
|
self.bal_window.init_class_variables()
|
|
|
|
# Add a selected executor with fee >= max (500000)
|
|
self._add_selected_willexecutor(base_fee=600000)
|
|
|
|
with pytest.raises(NoWillExecutorNotPresent):
|
|
self.bal_window.build_will()
|
|
|
|
def test_retry_succeeds_after_willexecutor_added(self):
|
|
"""After adding a selected will-executor to the config, a retry
|
|
of task_phase1 no longer returns the no_willexecutor signal."""
|
|
dialog = FakeBuildWillDialog(self.bal_window)
|
|
|
|
# First call: fails with no_willexecutor
|
|
result = dialog.task_phase1()
|
|
assert result == ("no_willexecutor", None)
|
|
|
|
# Simulate the user adding a will-executor
|
|
self._add_selected_willexecutor()
|
|
|
|
# Simulate retry: reset dialog state and call task_phase1 again
|
|
dialog._no_we_buttons_added = False
|
|
dialog.labels = []
|
|
self.bal_window.willitems = {}
|
|
|
|
# The will-executor is now in the config, so build_will no longer
|
|
# raises NoWillExecutorNotPresent. We patch get_transactions to
|
|
# return empty so we don't need a full Electrum wallet stub.
|
|
with patch.object(
|
|
self.heirs_obj, "get_transactions", return_value={}
|
|
):
|
|
result = dialog.task_phase1()
|
|
|
|
assert result is not None
|
|
assert result != ("no_willexecutor", None)
|