forked from bitcoinafterlife/bal-electrum-plugin
add tests
This commit is contained in:
223
tests/test_no_invalidation_after_time.py
Normal file
223
tests/test_no_invalidation_after_time.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Test: no invalidation is requested when time passes with unchanged settings.
|
||||
|
||||
Scenario:
|
||||
The user creates a will with RELATIVE locktime ("1y") and RELATIVE threshold
|
||||
("30d"), signs it and pushes it to the will-executor. After 2 days, they
|
||||
run Check again. The system must NOT raise any of:
|
||||
|
||||
* CheckAliveError (threshold resolved against ``now`` → always future)
|
||||
* WillExpiredException (locktime resolved against *creation time* →
|
||||
stable, does not drift)
|
||||
* WillPostponedException (same locktime → no postpone detected)
|
||||
|
||||
This verifies that the "cancel/invalidate" prompt never appears
|
||||
spuriously when the user simply lets time pass without changing any setting.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
from electrum import constants
|
||||
|
||||
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, WillExpiredException, WillPostponedException,
|
||||
)
|
||||
from bal.core.util import Util # noqa: E402
|
||||
from bal.core.willexecutors import Willexecutors # noqa: E402
|
||||
from bal.core.plugin_base import BalTimestamp # noqa: E402
|
||||
from electrum.transaction import PartialTransaction # noqa: E402
|
||||
|
||||
DAY = 86400
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers (same patterns as test_inheritance_flow.py)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
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 _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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# The actual test
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_no_invalidation_after_two_days_with_relative_settings():
|
||||
"""No invalidation after 2 days when locktime/threshold are RELATIVE strings.
|
||||
|
||||
This is the critical scenario the user reported: a will created with
|
||||
"1y" locktime and "30d" threshold (both relative), signed and pushed,
|
||||
should NOT trigger any cancellation request when Check is re-run 2 days
|
||||
later with no settings changed.
|
||||
"""
|
||||
now = int(time.time())
|
||||
ha = fx.heir_addresses(3)
|
||||
we_addr = fx.willexecutor_addresses(1)[0]
|
||||
|
||||
# ── 1. Create heirs with RELATIVE locktime "1y" ──────────────────
|
||||
heirs_dict = {
|
||||
"alice": [ha[0], "40%", "1y"],
|
||||
"bob": [ha[1], "30%", "1y"],
|
||||
"carol": [ha[2], "30%", "1y"],
|
||||
}
|
||||
h = Heirs.__new__(Heirs)
|
||||
dict.__init__(h)
|
||||
h.db = MagicMock()
|
||||
h.wallet = MagicMock()
|
||||
h.update(copy.deepcopy(heirs_dict))
|
||||
|
||||
wes = _willexecutors([("https://we.example", we_addr, 100000)])
|
||||
|
||||
# ── 2. Build the will ───────────────────────────────────────────
|
||||
txs = _build(h, wes, from_locktime=now)
|
||||
tx = _tx_for_we(txs, "https://we.example")
|
||||
assert tx is not None, "no will-executor transaction built"
|
||||
txid = tx._test_txid if hasattr(tx, "_test_txid") else tx.txid()
|
||||
|
||||
# ── 3. Wrap in a WillItem, mark as SIGNED (COMPLETE + PUSHED) ──
|
||||
# IMPORTANT: use copy.deepcopy(tx.heirs) — the builder stores the
|
||||
# original percentage strings (not resolved integers), and the
|
||||
# postpone comparison in check_willexecutors_and_heirs expects
|
||||
# those same strings so they can be compared with the current
|
||||
# heirs dict entries.
|
||||
item = WillItem.__new__(WillItem)
|
||||
item.tx = tx
|
||||
item._id = txid
|
||||
item.heirs = copy.deepcopy(tx.heirs)
|
||||
item.we = copy.deepcopy(tx.willexecutor)
|
||||
item.status = ""
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.tx_fees = 100
|
||||
item.time = now # will creation time — the stable base for "1y"
|
||||
item.father = None
|
||||
item.children = {}
|
||||
item.description = ""
|
||||
item.change = ""
|
||||
item.set_status("VALID", True)
|
||||
item.set_status("COMPLETE", True) # ← signed
|
||||
item.set_status("PUSHED", True) # ← sent to will-executor
|
||||
|
||||
willitems = {txid: item}
|
||||
old_locktime = int(tx.locktime)
|
||||
|
||||
# ── 4. Baseline: check flow at ORIGINAL time (must pass) ────────
|
||||
check_date = BalTimestamp("30d").to_timestamp()
|
||||
assert check_date > now, "baseline: threshold is in the future"
|
||||
|
||||
all_inputs = Will.get_all_inputs(willitems, only_valid=True)
|
||||
all_inputs_min = Will.get_all_inputs_min_locktime(all_inputs)
|
||||
Will.check_will_expired(all_inputs_min, check_date)
|
||||
|
||||
# ── 5. Advance "now" by 2 days ──────────────────────────────────
|
||||
advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY)
|
||||
|
||||
# BalTimestamp("30d").to_timestamp() calls datetime.now() internally.
|
||||
# Patch only that module's reference so fromtimestamp etc. still work.
|
||||
import bal.core.plugin_base as pb_mod
|
||||
import datetime as real_dt
|
||||
|
||||
# We need the patched datetime to proxy fromtimestamp/timedelta to the
|
||||
# real ones, otherwise BalTimestamp._safe_fromtimestamp and timedelta
|
||||
# arithmetic break.
|
||||
class _PatchedDt:
|
||||
"""Minimal proxy: only .now() returns the advanced time, everything
|
||||
else delegates to the real datetime class."""
|
||||
@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_2d = BalTimestamp("30d").to_timestamp()
|
||||
|
||||
# The threshold resolved against the advanced "now" is STILL future.
|
||||
assert check_date_2d > advanced_now_dt.timestamp(), (
|
||||
"30d threshold is still in the future even after 2 days"
|
||||
)
|
||||
|
||||
# ── 6. check_will_expired — no drift ────────────────────────────
|
||||
# The existing tx locktime was resolved from "1y" against from_locktime
|
||||
# (= the original `now`) during build, so it is immutable. The check
|
||||
# compares it against check_date_2d (which is *future*), so it never
|
||||
# raises WillExpiredException.
|
||||
all_inputs_2d = Will.get_all_inputs(willitems, only_valid=True)
|
||||
all_inputs_min_2d = Will.get_all_inputs_min_locktime(all_inputs_2d)
|
||||
Will.check_will_expired(all_inputs_min_2d, check_date_2d)
|
||||
|
||||
# ── 7. check_willexecutors_and_heirs — no spurious postpone ─────
|
||||
# The function resolves "1y" against w.time (= the original `now`),
|
||||
# giving the same absolute locktime as tx.locktime → no postpone.
|
||||
Will.check_willexecutors_and_heirs(
|
||||
willitems, h, wes,
|
||||
False, # no_willexecutor
|
||||
check_date_2d,
|
||||
100,
|
||||
)
|
||||
|
||||
# If we reach here, no invalidation was requested. ✓
|
||||
Reference in New Issue
Block a user