forked from bitcoinafterlife/bal-electrum-plugin
add tests
This commit is contained in:
543
tests/test_inheritance_rules.py
Normal file
543
tests/test_inheritance_rules.py
Normal file
@@ -0,0 +1,543 @@
|
||||
"""
|
||||
Regression tests for the high-level inheritance rules described in the
|
||||
project documentation (``docs/inheritance-options.md``).
|
||||
|
||||
These tests verify the decision flow for changing a will:
|
||||
|
||||
* an unsigned old will is simply replaced, never invalidated on-chain;
|
||||
* adding / changing / removing an heir triggers anticipation (1 day earlier);
|
||||
* adding or changing a will-executor rebuilds the tx while keeping the same
|
||||
locktime;
|
||||
* postponing an already signed will requires on-chain invalidation;
|
||||
* a Check-Alive threshold in the past triggers invalidation;
|
||||
* relative locktimes are compared against the will creation time, not
|
||||
against the current wall-clock time.
|
||||
|
||||
The tests use the same mock fixtures as the rest of the suite: genuine regtest
|
||||
addresses from the giovanna7 wallet, synthetic UTXOs and a patched
|
||||
``PartialTransaction.txid`` so the builder can run without a live Electrum.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import datetime as real_dt
|
||||
import itertools
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
from electrum import constants
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _regtest_network():
|
||||
previous = constants.net
|
||||
constants.BitcoinRegtest.set_as_network()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
previous.set_as_network()
|
||||
|
||||
|
||||
import bal_fixtures as fx # noqa: E402
|
||||
from bal.core.heirs import Heirs # noqa: E402
|
||||
from bal.core.will import ( # noqa: E402
|
||||
Will, WillItem, WillPostponedException, HeirNotFoundException,
|
||||
NotCompleteWillException,
|
||||
)
|
||||
from bal.core.willexecutors import Willexecutors # noqa: E402
|
||||
from bal.core.util import Util # noqa: E402
|
||||
from bal.core.plugin_base import BalTimestamp # noqa: E402
|
||||
from electrum.transaction import PartialTransaction # noqa: E402
|
||||
|
||||
DAY = 86400
|
||||
_TXID_COUNTER = itertools.count(1)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _willexecutors(specs):
|
||||
out = {}
|
||||
for url, address, base_fee in specs:
|
||||
out[url] = {
|
||||
"url": url,
|
||||
"address": address,
|
||||
"base_fee": base_fee,
|
||||
"selected": True,
|
||||
"status": 200,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _make_heirs(entries):
|
||||
h = Heirs.__new__(Heirs)
|
||||
dict.__init__(h)
|
||||
h.db = MagicMock()
|
||||
h.wallet = MagicMock()
|
||||
h.update(copy.deepcopy(entries))
|
||||
return h
|
||||
|
||||
|
||||
def _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100,
|
||||
no_willexecutor=False):
|
||||
wallet = fx.fake_wallet()
|
||||
utxos = fx.make_utxos(4, 5_000_000)
|
||||
plugin = fx.fake_bal_plugin(willexecutors, no_willexecutor=no_willexecutor)
|
||||
if from_locktime is None:
|
||||
from_locktime = int(time.time())
|
||||
counter = itertools.count(1)
|
||||
|
||||
def fake_txid(self):
|
||||
if not hasattr(self, "_test_txid"):
|
||||
self._test_txid = f"{next(counter):064x}"
|
||||
return self._test_txid
|
||||
|
||||
with patch.object(Willexecutors, "get_willexecutors",
|
||||
return_value=willexecutors), \
|
||||
patch.object(PartialTransaction, "txid", fake_txid):
|
||||
txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos,
|
||||
from_locktime)
|
||||
return txs or {}
|
||||
|
||||
|
||||
def _tx_for_we(txs, url):
|
||||
for tx in txs.values():
|
||||
we = getattr(tx, "willexecutor", None)
|
||||
if we and we.get("url") == url:
|
||||
return tx
|
||||
return None
|
||||
|
||||
|
||||
def _backup_tx(txs):
|
||||
for tx in txs.values():
|
||||
if not getattr(tx, "willexecutor", None):
|
||||
return tx
|
||||
return None
|
||||
|
||||
|
||||
def _item_from_tx(tx, *, txid=None, complete=False, pushed=False, time=None,
|
||||
we=None, heirs=None):
|
||||
if txid is None:
|
||||
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
|
||||
if heirs is None:
|
||||
heirs = copy.deepcopy(getattr(tx, "heirs", {}))
|
||||
if we is None and hasattr(tx, "willexecutor"):
|
||||
we = copy.deepcopy(tx.willexecutor)
|
||||
d = {
|
||||
"tx": tx,
|
||||
"heirs": heirs,
|
||||
"willexecutor": we,
|
||||
"status": "",
|
||||
"description": getattr(tx, "description", ""),
|
||||
"time": time,
|
||||
"change": "",
|
||||
"baltx_fees": getattr(tx, "tx_fees", 100),
|
||||
}
|
||||
item = WillItem(d, _id=txid)
|
||||
item.set_status("VALID", True)
|
||||
if complete:
|
||||
item.set_status("COMPLETE", True)
|
||||
if pushed:
|
||||
item.set_status("PUSHED", True)
|
||||
return item
|
||||
|
||||
|
||||
def _build_item(heirs, url, address, base_fee, *, from_locktime, time_stamp,
|
||||
complete=False, pushed=False):
|
||||
wes = _willexecutors([(url, address, base_fee)])
|
||||
txs = _build(heirs, wes, from_locktime=from_locktime)
|
||||
tx = _tx_for_we(txs, url)
|
||||
assert tx is not None, "no will-executor transaction built"
|
||||
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
|
||||
return _item_from_tx(tx, txid=txid, complete=complete, pushed=pushed,
|
||||
time=time_stamp), txid, tx
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 1. Unsigned old will is replaced, not invalidated
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_unsigned_will_postpone_triggers_rebuild_not_invalidation():
|
||||
"""Postponing a will that was never signed/sent does NOT raise
|
||||
WillPostponedException: it is treated as a plain rebuild."""
|
||||
now = int(time.time())
|
||||
ha = fx.heir_addresses(3)
|
||||
we_addr = fx.willexecutor_addresses(1)[0]
|
||||
wes = _willexecutors([("https://we.example", we_addr, 100000)])
|
||||
|
||||
heirs_initial = {
|
||||
"alice": [ha[0], "40%", "300d"],
|
||||
"bob": [ha[1], "30%", "300d"],
|
||||
"carol": [ha[2], "30%", "300d"],
|
||||
}
|
||||
h_initial = _make_heirs(heirs_initial)
|
||||
|
||||
txs = _build(h_initial, wes, from_locktime=now)
|
||||
tx = _tx_for_we(txs, "https://we.example")
|
||||
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
|
||||
item = _item_from_tx(tx, txid=txid, complete=False, pushed=False, time=now)
|
||||
|
||||
heirs_postponed = _make_heirs({
|
||||
"alice": [ha[0], "40%", "1y"],
|
||||
"bob": [ha[1], "30%", "1y"],
|
||||
"carol": [ha[2], "30%", "1y"],
|
||||
})
|
||||
check_date = BalTimestamp("30d").to_timestamp()
|
||||
|
||||
with pytest.raises(NotCompleteWillException) as exc_info:
|
||||
Will.check_willexecutors_and_heirs(
|
||||
{txid: item}, heirs_postponed, wes, False, check_date, 100
|
||||
)
|
||||
assert not isinstance(exc_info.value, WillPostponedException)
|
||||
|
||||
|
||||
def test_unsigned_will_change_is_anticipated():
|
||||
"""A change to an unsigned will is anticipated (replaced), not invalidated."""
|
||||
now = int(time.time())
|
||||
lt = int((now + 365 * DAY) / DAY) * DAY
|
||||
ha = fx.heir_addresses(4)
|
||||
we_addr = ha[3]
|
||||
|
||||
old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
|
||||
old_item, _, old_tx = _build_item(
|
||||
old_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now - 10,
|
||||
complete=False, pushed=False,
|
||||
)
|
||||
|
||||
new_heirs = _make_heirs({"alice": [ha[1], "100%", str(lt)]})
|
||||
new_item, _, _ = _build_item(
|
||||
new_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now,
|
||||
complete=False, pushed=False,
|
||||
)
|
||||
|
||||
assert new_item.set_anticipate(old_item)
|
||||
assert new_item.get_status("ANTICIPATED")
|
||||
assert int(new_item.tx.locktime) == int(
|
||||
Util.anticipate_locktime(old_tx.locktime, days=1)
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 2. Adding / changing / removing an heir anticipates the locktime
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_add_heir_anticipates_locktime():
|
||||
now = int(time.time())
|
||||
lt = int((now + 365 * DAY) / DAY) * DAY
|
||||
ha = fx.heir_addresses(4)
|
||||
we_addr = ha[3]
|
||||
|
||||
old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
|
||||
old_item, _, old_tx = _build_item(
|
||||
old_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now - 10,
|
||||
complete=True, pushed=True,
|
||||
)
|
||||
|
||||
new_heirs = _make_heirs({
|
||||
"alice": [ha[0], "60%", str(lt)],
|
||||
"bob": [ha[1], "40%", str(lt)],
|
||||
})
|
||||
new_item, _, _ = _build_item(
|
||||
new_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now,
|
||||
complete=False, pushed=False,
|
||||
)
|
||||
|
||||
assert new_item.set_anticipate(old_item)
|
||||
assert new_item.get_status("ANTICIPATED")
|
||||
assert int(new_item.tx.locktime) == int(
|
||||
Util.anticipate_locktime(old_tx.locktime, days=1)
|
||||
)
|
||||
|
||||
|
||||
def test_change_heir_amount_anticipates_locktime():
|
||||
now = int(time.time())
|
||||
lt = int((now + 365 * DAY) / DAY) * DAY
|
||||
ha = fx.heir_addresses(4)
|
||||
we_addr = ha[3]
|
||||
|
||||
old_heirs = _make_heirs({
|
||||
"alice": [ha[0], "60%", str(lt)],
|
||||
"bob": [ha[1], "40%", str(lt)],
|
||||
})
|
||||
old_item, _, old_tx = _build_item(
|
||||
old_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now - 10,
|
||||
complete=True, pushed=True,
|
||||
)
|
||||
|
||||
new_heirs = _make_heirs({
|
||||
"alice": [ha[0], "80%", str(lt)],
|
||||
"bob": [ha[1], "20%", str(lt)],
|
||||
})
|
||||
new_item, _, _ = _build_item(
|
||||
new_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now,
|
||||
complete=False, pushed=False,
|
||||
)
|
||||
|
||||
assert new_item.set_anticipate(old_item)
|
||||
assert new_item.get_status("ANTICIPATED")
|
||||
assert int(new_item.tx.locktime) == int(
|
||||
Util.anticipate_locktime(old_tx.locktime, days=1)
|
||||
)
|
||||
|
||||
|
||||
def test_remove_heir_anticipates_locktime():
|
||||
now = int(time.time())
|
||||
lt = int((now + 365 * DAY) / DAY) * DAY
|
||||
ha = fx.heir_addresses(4)
|
||||
we_addr = ha[3]
|
||||
|
||||
old_heirs = _make_heirs({
|
||||
"alice": [ha[0], "60%", str(lt)],
|
||||
"bob": [ha[1], "40%", str(lt)],
|
||||
})
|
||||
old_item, _, old_tx = _build_item(
|
||||
old_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now - 10,
|
||||
complete=True, pushed=True,
|
||||
)
|
||||
|
||||
new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
|
||||
new_item, _, _ = _build_item(
|
||||
new_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now,
|
||||
complete=False, pushed=False,
|
||||
)
|
||||
|
||||
assert new_item.set_anticipate(old_item)
|
||||
assert new_item.get_status("ANTICIPATED")
|
||||
assert int(new_item.tx.locktime) == int(
|
||||
Util.anticipate_locktime(old_tx.locktime, days=1)
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 3. Adding or changing a will-executor keeps the locktime
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_add_willexecutor_keeps_locktime():
|
||||
now = int(time.time())
|
||||
lt = int((now + 365 * DAY) / DAY) * DAY
|
||||
ha = fx.heir_addresses(4)
|
||||
we_addr = ha[3]
|
||||
|
||||
# Old will: local backup only, no will-executor.
|
||||
old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
|
||||
old_txs = _build(old_heirs, {}, from_locktime=now, no_willexecutor=True)
|
||||
old_tx = _backup_tx(old_txs)
|
||||
assert old_tx is not None, "no backup transaction built"
|
||||
old_item = _item_from_tx(
|
||||
old_tx, complete=True, pushed=True, time=now - 10, we=None
|
||||
)
|
||||
old_locktime = int(old_tx.locktime)
|
||||
|
||||
# New will: same heir, same locktime, but now with a will-executor.
|
||||
new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
|
||||
new_item, _, _ = _build_item(
|
||||
new_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now,
|
||||
complete=False, pushed=False,
|
||||
)
|
||||
|
||||
kept = Will.check_anticipate(old_item, new_item)
|
||||
assert int(kept) == old_locktime
|
||||
assert not new_item.set_anticipate(old_item)
|
||||
assert int(new_item.tx.locktime) == old_locktime
|
||||
|
||||
|
||||
def test_modify_willexecutor_keeps_locktime():
|
||||
now = int(time.time())
|
||||
lt = int((now + 365 * DAY) / DAY) * DAY
|
||||
ha = fx.heir_addresses(4)
|
||||
we_addr = ha[3]
|
||||
|
||||
old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
|
||||
old_item, _, old_tx = _build_item(
|
||||
old_heirs, "https://we.example", we_addr, 100000,
|
||||
from_locktime=now, time_stamp=now - 10,
|
||||
complete=True, pushed=True,
|
||||
)
|
||||
old_locktime = int(old_tx.locktime)
|
||||
|
||||
# Same URL, higher base_fee -> locktime must stay the same.
|
||||
new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
|
||||
new_item, _, _ = _build_item(
|
||||
new_heirs, "https://we.example", we_addr, 150000,
|
||||
from_locktime=now, time_stamp=now,
|
||||
complete=False, pushed=False,
|
||||
)
|
||||
|
||||
kept = Will.check_anticipate(old_item, new_item)
|
||||
assert int(kept) == old_locktime
|
||||
assert not new_item.set_anticipate(old_item)
|
||||
assert int(new_item.tx.locktime) == old_locktime
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 4. Postponing a signed will requires invalidation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_postpone_signed_will_requires_invalidation():
|
||||
now = int(time.time())
|
||||
ha = fx.heir_addresses(3)
|
||||
we_addr = fx.willexecutor_addresses(1)[0]
|
||||
wes = _willexecutors([("https://we.example", we_addr, 100000)])
|
||||
|
||||
heirs_initial = {
|
||||
"alice": [ha[0], "40%", "300d"],
|
||||
"bob": [ha[1], "30%", "300d"],
|
||||
"carol": [ha[2], "30%", "300d"],
|
||||
}
|
||||
h_initial = _make_heirs(heirs_initial)
|
||||
|
||||
txs = _build(h_initial, wes, from_locktime=now)
|
||||
tx = _tx_for_we(txs, "https://we.example")
|
||||
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
|
||||
willitems = _item_from_tx(tx, txid=txid, complete=True, pushed=True,
|
||||
time=now)
|
||||
|
||||
heirs_postponed = _make_heirs({
|
||||
"alice": [ha[0], "40%", "1y"],
|
||||
"bob": [ha[1], "30%", "1y"],
|
||||
"carol": [ha[2], "30%", "1y"],
|
||||
})
|
||||
check_date = BalTimestamp("30d").to_timestamp()
|
||||
|
||||
with pytest.raises(WillPostponedException):
|
||||
Will.check_willexecutors_and_heirs(
|
||||
{txid: willitems}, heirs_postponed, wes, False, check_date, 100
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 5. Check-Alive threshold in the past triggers invalidation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_threshold_in_past_raises_check_alive_error():
|
||||
"""A Check-Alive threshold that is already in the past makes
|
||||
``BalWindow.init_class_variables`` raise ``CheckAliveError``."""
|
||||
from bal.gui.qt.window import BalWindow
|
||||
from bal.gui.qt.common import CheckAliveError
|
||||
import bal.gui.qt.window as window_mod
|
||||
|
||||
now = int(time.time())
|
||||
bw = object.__new__(BalWindow)
|
||||
bw.heirs = {"alice": [fx.heir_addresses(1)[0], "100%", "300d"]}
|
||||
bw.will_settings = {"threshold": now - 1000, "locktime": "1y"}
|
||||
bw.bal_plugin = MagicMock()
|
||||
bw.bal_plugin.NO_WILLEXECUTOR.get.return_value = False
|
||||
bw.bal_plugin.ENABLE_MULTIVERSE.get.return_value = False
|
||||
bw.willexecutors = {}
|
||||
|
||||
class _PatchedDt:
|
||||
@classmethod
|
||||
def now(cls):
|
||||
return real_dt.datetime.fromtimestamp(now)
|
||||
|
||||
@classmethod
|
||||
def fromtimestamp(cls, ts):
|
||||
return real_dt.datetime.fromtimestamp(ts)
|
||||
|
||||
@classmethod
|
||||
def today(cls):
|
||||
return real_dt.datetime.fromtimestamp(now).date()
|
||||
|
||||
_PatchedDt.timedelta = real_dt.timedelta
|
||||
|
||||
with patch.object(window_mod, "datetime", _PatchedDt), \
|
||||
patch.object(window_mod, "Willexecutors") as mock_we:
|
||||
mock_we.get_willexecutors.return_value = {}
|
||||
with pytest.raises(CheckAliveError):
|
||||
bw.init_class_variables()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 6. Relative locktime postpone must compare against creation time
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_relative_locktime_postpone_uses_creation_time():
|
||||
"""For relative locktimes the postpone check resolves the requested
|
||||
locktime against the will creation time (``w.time``), not against the
|
||||
current ``datetime.now()``.
|
||||
|
||||
This also verifies that the comparison is effectively relative: the same
|
||||
relative string stays coherent even when wall-clock time advances, while a
|
||||
longer relative string is detected as a postpone."""
|
||||
now = int(time.time())
|
||||
ha = fx.heir_addresses(3)
|
||||
we_addr = fx.willexecutor_addresses(1)[0]
|
||||
wes = _willexecutors([("https://we.example", we_addr, 100000)])
|
||||
|
||||
heirs_initial = {
|
||||
"alice": [ha[0], "40%", "300d"],
|
||||
"bob": [ha[1], "30%", "300d"],
|
||||
"carol": [ha[2], "30%", "300d"],
|
||||
}
|
||||
h_initial = _make_heirs(heirs_initial)
|
||||
|
||||
txs = _build(h_initial, wes, from_locktime=now)
|
||||
tx = _tx_for_we(txs, "https://we.example")
|
||||
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
|
||||
item = _item_from_tx(tx, txid=txid, complete=True, pushed=True, time=now)
|
||||
|
||||
# Advance wall-clock by two days.
|
||||
advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY)
|
||||
import bal.core.plugin_base as pb_mod
|
||||
|
||||
class _PatchedDt:
|
||||
@classmethod
|
||||
def now(cls):
|
||||
return advanced_now_dt
|
||||
|
||||
@classmethod
|
||||
def fromtimestamp(cls, ts):
|
||||
return real_dt.datetime.fromtimestamp(ts)
|
||||
|
||||
@classmethod
|
||||
def today(cls):
|
||||
return advanced_now_dt.date()
|
||||
|
||||
_PatchedDt.timedelta = real_dt.timedelta
|
||||
|
||||
with patch.object(pb_mod, "datetime", _PatchedDt):
|
||||
check_date = BalTimestamp("30d").to_timestamp()
|
||||
|
||||
# Same relative locktime after two days must remain coherent (no spurious
|
||||
# postpone detection).
|
||||
Will.check_willexecutors_and_heirs(
|
||||
{txid: item}, h_initial, wes, False, check_date, 100
|
||||
)
|
||||
|
||||
# Moving the relative locktime from "300d" to "1y" is a real postpone and
|
||||
# must be detected using the creation-time base.
|
||||
heirs_postponed = _make_heirs({
|
||||
"alice": [ha[0], "40%", "1y"],
|
||||
"bob": [ha[1], "30%", "1y"],
|
||||
"carol": [ha[2], "30%", "1y"],
|
||||
})
|
||||
with pytest.raises(WillPostponedException):
|
||||
Will.check_willexecutors_and_heirs(
|
||||
{txid: item}, heirs_postponed, wes, False, check_date, 100
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import inspect
|
||||
for name, obj in sorted(globals().items()):
|
||||
if name.startswith("test_") and inspect.isfunction(obj):
|
||||
obj()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All inheritance-rules tests passed")
|
||||
Reference in New Issue
Block a user