i18n review fixes (#63) and invalidation/Check fixes (#64-#67)
#63: Truman's i18n review (release gate runs i18n/CLI tests, Babel check, ruff known-first-party, no translated text stored in the wallet). #64: invalidation pays the current network fee (2-block estimate). #65: Check KeyError on a stale father link. #66: invalidation cascade with list-shaped children. #67: deselected will-executor (delete allowed if never received; broadcast skips it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
82
tests/test_core_deselected_executor.py
Normal file
82
tests/test_core_deselected_executor.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Tests for ``Willexecutors.get_willexecutor_transactions`` with the CURRENT
|
||||
executor selection (``current_willexecutors``).
|
||||
|
||||
Every will item keeps its own copy of the executor record taken at build time
|
||||
(``selected`` = True). Without the current selection, an executor the user
|
||||
deselected after building the will would still receive the will.
|
||||
|
||||
Run (standalone or with pytest):
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 tests/test_core_deselected_executor.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
|
||||
class FakeItem:
|
||||
"""Minimal will item: statuses, a tx and the build-time executor copy."""
|
||||
|
||||
def __init__(self, url, **flags):
|
||||
self._status = {"VALID": True, "COMPLETE": True}
|
||||
self._status.update(flags)
|
||||
self.we = {"url": url, "selected": True}
|
||||
self.tx = f"rawtx-{url}"
|
||||
|
||||
def get_status(self, name):
|
||||
return self._status.get(name, False)
|
||||
|
||||
|
||||
A = "https://we.a.example"
|
||||
B = "https://we.b.example"
|
||||
|
||||
|
||||
def _will():
|
||||
return {"tx_a": FakeItem(A), "tx_b": FakeItem(B)}
|
||||
|
||||
|
||||
def test_without_current_list_previous_behaviour():
|
||||
assert set(Willexecutors.get_willexecutor_transactions(_will())) == {A, B}
|
||||
|
||||
|
||||
def test_deselected_executor_is_skipped():
|
||||
current = {A: {"selected": True}, B: {"selected": False}}
|
||||
out = Willexecutors.get_willexecutor_transactions(
|
||||
_will(), current_willexecutors=current
|
||||
)
|
||||
assert set(out) == {A}
|
||||
assert out[A]["txsids"] == ["tx_a"]
|
||||
|
||||
|
||||
def test_executor_removed_from_list_is_skipped():
|
||||
current = {A: {"selected": True}}
|
||||
out = Willexecutors.get_willexecutor_transactions(
|
||||
_will(), current_willexecutors=current
|
||||
)
|
||||
assert set(out) == {A}
|
||||
|
||||
|
||||
def test_already_pushed_still_excluded_unless_forced():
|
||||
will = {"tx_a": FakeItem(A, PUSHED=True)}
|
||||
current = {A: {"selected": True}}
|
||||
assert Willexecutors.get_willexecutor_transactions(
|
||||
will, current_willexecutors=current
|
||||
) == {}
|
||||
assert set(
|
||||
Willexecutors.get_willexecutor_transactions(
|
||||
will, force=True, current_willexecutors=current
|
||||
)
|
||||
) == {A}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All deselected-executor tests passed")
|
||||
231
tests/test_core_invalidation_fee.py
Normal file
231
tests/test_core_invalidation_fee.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Tests for the fee of the on-chain INVALIDATION transaction.
|
||||
|
||||
The invalidation is broadcast TODAY (it spends the will's coins so the old
|
||||
pre-signed inheritance transactions become unusable), therefore it must pay the
|
||||
CURRENT network fee for a confirmation within
|
||||
``INVALIDATION_FEE_ETA_BLOCKS`` (2) blocks. The will's ``baltx_fees`` (a
|
||||
deliberately high rate meant for inheritance transactions broadcast years from
|
||||
now) is only the FALLBACK used when no network estimate is available.
|
||||
|
||||
Covers:
|
||||
* Will.get_invalidation_fee_rate() - rate selection and fallback rules
|
||||
* Will.invalidate_will() - the chosen rate is really used for the fee
|
||||
|
||||
Run (standalone or with pytest):
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 tests/test_core_invalidation_fee.py
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 -m pytest tests/test_core_invalidation_fee.py -q
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum.transaction import Transaction
|
||||
|
||||
from bal.core.will import INVALIDATION_FEE_ETA_BLOCKS, Will, WillItem
|
||||
|
||||
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||
_patcher.start()
|
||||
|
||||
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
# The outpoint spent by _VALID_TX_HEX (input 0).
|
||||
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
|
||||
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
|
||||
_BALANCE = 1000000
|
||||
# The will's baltx_fees used as the fallback rate in every test (sat/vbyte).
|
||||
_BALTX_FEES = 20
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _wallet_with_estimate(fee_per_kb):
|
||||
"""Wallet whose network returns ``fee_per_kb`` (sat/kvbyte) for any ETA.
|
||||
|
||||
``fee_per_kb`` may also be an Exception instance: the estimate call then
|
||||
raises it, to simulate a broken network object.
|
||||
"""
|
||||
wallet = MagicMock()
|
||||
wallet.get_change_addresses_for_new_transaction.return_value = [_CHANGE_ADDR]
|
||||
network = MagicMock()
|
||||
if isinstance(fee_per_kb, Exception):
|
||||
network.fee_estimates.eta_target_to_fee.side_effect = fee_per_kb
|
||||
else:
|
||||
network.fee_estimates.eta_target_to_fee.return_value = fee_per_kb
|
||||
wallet.network = network
|
||||
return wallet
|
||||
|
||||
|
||||
def _make_will():
|
||||
"""One VALID will item spending _PREVOUT_STR with a known input value."""
|
||||
item = WillItem({
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": {"alice": ["addr_alice", 5000, "30d"]},
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
"time": 0,
|
||||
"change": "",
|
||||
"baltx_fees": _BALTX_FEES,
|
||||
})
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.tx.inputs()[0]._TxInput__value_sats = _BALANCE
|
||||
return {"willtxid1": item}
|
||||
|
||||
|
||||
def _make_utxo():
|
||||
"""Wallet-side UTXO matching the will input."""
|
||||
utxo = MagicMock()
|
||||
utxo.prevout.to_str.return_value = _PREVOUT_STR
|
||||
utxo.is_coinbase_output.return_value = False
|
||||
utxo.block_height = 1
|
||||
utxo.value_sats.return_value = _BALANCE
|
||||
return utxo
|
||||
|
||||
|
||||
def _invalidation_output_value(wallet, estimated_size=200):
|
||||
"""Run Will.invalidate_will and return the value of the final output.
|
||||
|
||||
Electrum's tx building is mocked (as in test_core_will_invalidate.py):
|
||||
``estimated_size`` is the vsize the fee is computed from.
|
||||
"""
|
||||
wallet.get_utxos.return_value = [_make_utxo()]
|
||||
mock_output = MagicMock()
|
||||
mock_tx = MagicMock()
|
||||
mock_tx.estimated_size.return_value = estimated_size
|
||||
with patch("bal.core.will.Util.get_current_height", return_value=800000), \
|
||||
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
|
||||
return_value=mock_output) as mock_out, \
|
||||
patch("electrum.transaction.PartialTransaction.from_io",
|
||||
return_value=mock_tx):
|
||||
result = Will.invalidate_will(_make_will(), wallet, _BALTX_FEES)
|
||||
assert result is mock_tx
|
||||
# 1st call: full balance (size estimation); 2nd call: balance - fee.
|
||||
return mock_out.call_args_list[1][0][1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will.get_invalidation_fee_rate
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_target_is_two_blocks():
|
||||
"""The owner's decision: confirm within 2 blocks."""
|
||||
assert INVALIDATION_FEE_ETA_BLOCKS == 2
|
||||
wallet = _wallet_with_estimate(3000)
|
||||
Will.get_invalidation_fee_rate(wallet, _BALTX_FEES)
|
||||
wallet.network.fee_estimates.eta_target_to_fee.assert_called_once_with(2)
|
||||
|
||||
|
||||
def test_network_estimate_is_used():
|
||||
"""A live estimate (sat/kvbyte) becomes the rate in sat/vbyte."""
|
||||
wallet = _wallet_with_estimate(3000)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "network")
|
||||
|
||||
|
||||
def test_network_estimate_higher_than_baltx_fees_is_used_as_is():
|
||||
"""No cap: if the market is above baltx_fees, the market rate is used."""
|
||||
wallet = _wallet_with_estimate(45000)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "network")
|
||||
|
||||
|
||||
def test_fractional_estimate_is_kept():
|
||||
"""Electrum estimates may be fractional (e.g. 1.5 sat/vbyte)."""
|
||||
wallet = _wallet_with_estimate(1500)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "network")
|
||||
|
||||
|
||||
def test_offline_wallet_falls_back_to_baltx_fees():
|
||||
"""No network object (offline): previous behaviour, baltx_fees."""
|
||||
wallet = MagicMock()
|
||||
wallet.network = None
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||
_BALTX_FEES,
|
||||
"fallback",
|
||||
)
|
||||
|
||||
|
||||
def test_missing_estimate_falls_back():
|
||||
"""Estimates not received yet: Electrum returns None."""
|
||||
wallet = _wallet_with_estimate(None)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||
_BALTX_FEES,
|
||||
"fallback",
|
||||
)
|
||||
|
||||
|
||||
def test_estimate_error_falls_back_and_does_not_raise():
|
||||
"""A broken network object must never break the invalidation."""
|
||||
wallet = _wallet_with_estimate(RuntimeError("server gone"))
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||
_BALTX_FEES,
|
||||
"fallback",
|
||||
)
|
||||
|
||||
|
||||
def test_non_integer_or_invalid_estimates_fall_back():
|
||||
"""Only a positive int (Electrum's sat/kvbyte type) is trusted."""
|
||||
for bad in (0, -1000, True, "3000", 3.5, MagicMock()):
|
||||
wallet = _wallet_with_estimate(bad)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||
_BALTX_FEES,
|
||||
"fallback",
|
||||
), bad
|
||||
|
||||
|
||||
def test_no_wallet_falls_back():
|
||||
assert Will.get_invalidation_fee_rate(None, _BALTX_FEES) == (
|
||||
_BALTX_FEES,
|
||||
"fallback",
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will.invalidate_will uses the chosen rate
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_invalidation_pays_network_fee_not_baltx_fees():
|
||||
"""3 sat/vbyte on 200 vbytes = 600 sat (not 200 * 20 = 4000 sat)."""
|
||||
wallet = _wallet_with_estimate(3000)
|
||||
assert _invalidation_output_value(wallet) == _BALANCE - 600
|
||||
|
||||
|
||||
def test_invalidation_offline_pays_baltx_fees():
|
||||
"""Offline: unchanged behaviour, 200 * 20 = 4000 sat."""
|
||||
wallet = _wallet_with_estimate(None)
|
||||
assert _invalidation_output_value(wallet) == _BALANCE - 4000
|
||||
|
||||
|
||||
def test_fractional_fee_is_rounded_up_to_whole_satoshis():
|
||||
"""1.5 sat/vbyte on 201 vbytes = 301.5 -> 302 sat (never below the rate)."""
|
||||
wallet = _wallet_with_estimate(1500)
|
||||
value = _invalidation_output_value(wallet, estimated_size=201)
|
||||
assert value == _BALANCE - 302
|
||||
assert isinstance(value, int)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All invalidation fee tests passed")
|
||||
136
tests/test_core_status_language_neutral.py
Normal file
136
tests/test_core_status_language_neutral.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Stored will data must not depend on the GUI language (rule in bal/i18n.py).
|
||||
|
||||
Covers the two findings of the i18n review:
|
||||
|
||||
* the status history of a new will item is saved as English "New" (not
|
||||
``_("New")``), and histories saved by test builds as "Nuovo"/"Nuevo" are
|
||||
mapped back to "New" on load;
|
||||
* the push helper no longer writes transient fields (``txs``, ``txsids`` and
|
||||
the translated ``broadcast_status``) into the will item's executor record,
|
||||
and such fields saved by older versions are dropped on load.
|
||||
|
||||
Run (standalone or with pytest):
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 tests/test_core_status_language_neutral.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum.transaction import Transaction
|
||||
|
||||
from bal.core.will import WillItem
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||
_patcher.start()
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
URL = "https://we.example"
|
||||
|
||||
|
||||
def _item(status="", we=None):
|
||||
return WillItem(
|
||||
{"tx": _VALID_TX_HEX, "heirs": {}, "willexecutor": we, "status": status}
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Status history "New"
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_builders_store_english_new():
|
||||
"""window.py and the CLI controller must not translate the stored status."""
|
||||
for rel in ("bal/gui/qt/window.py", "bal/cli/controller.py"):
|
||||
src = (ROOT / rel).read_text(encoding="utf-8")
|
||||
assert re.search(r'tx\["status"\]\s*=\s*"New"', src), rel
|
||||
assert not re.search(r'tx\["status"\]\s*=\s*_\(', src), rel
|
||||
|
||||
|
||||
def test_translated_new_is_mapped_back_on_load():
|
||||
assert _item("Nuovo.Signed.Pushed").status == "New.Signed.Pushed"
|
||||
assert _item("Nuevo").status == "New"
|
||||
assert _item("New.Signed").status == "New.Signed"
|
||||
|
||||
|
||||
def test_only_the_leading_new_is_touched():
|
||||
assert _item(".Nuovo").status == ".Nuovo"
|
||||
assert _item("Signed.Nuovo").status == "Signed.Nuovo"
|
||||
|
||||
|
||||
def test_translated_new_labels_match_the_catalogs():
|
||||
"""Every catalog's translation of "New" must be recognised on load."""
|
||||
found = set()
|
||||
for po in (ROOT / "bal" / "locale").glob("*/LC_MESSAGES/bal.po"):
|
||||
text = po.read_text(encoding="utf-8")
|
||||
m = re.search(r'^msgid "New"\nmsgstr "(.*)"$', text, re.MULTILINE)
|
||||
if m and m.group(1):
|
||||
found.add(m.group(1))
|
||||
assert found, "no catalog translates 'New'"
|
||||
assert found <= set(WillItem.TRANSLATED_NEW_LABELS), found
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Transient push fields
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeItem:
|
||||
def __init__(self):
|
||||
self.we = {"url": URL, "selected": True, "address": "a", "base_fee": 1}
|
||||
self.tx = "rawtx"
|
||||
|
||||
def get_status(self, name):
|
||||
return name in ("VALID", "COMPLETE")
|
||||
|
||||
|
||||
def test_push_helper_does_not_modify_the_will_item():
|
||||
item = FakeItem()
|
||||
before = dict(item.we)
|
||||
out = Willexecutors.get_willexecutor_transactions({"t1": item})
|
||||
assert out[URL]["txsids"] == ["t1"]
|
||||
assert "broadcast_status" in out[URL]
|
||||
assert item.we == before
|
||||
assert out[URL] is not item.we
|
||||
|
||||
|
||||
def test_transient_fields_saved_by_older_versions_are_dropped_on_load():
|
||||
we = {
|
||||
"url": URL,
|
||||
"selected": True,
|
||||
"txs": "raw\n",
|
||||
"txsids": ["t1"],
|
||||
"broadcast_status": "Riuscito",
|
||||
}
|
||||
item = _item("New", we)
|
||||
assert item.we == {"url": URL, "selected": True}
|
||||
assert "broadcast_status" not in item.to_dict()["willexecutor"]
|
||||
|
||||
|
||||
def test_clean_executor_record_is_kept_as_is():
|
||||
we = {"url": URL, "selected": True}
|
||||
assert _item("New", we).we is we
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All language-neutral status tests passed")
|
||||
145
tests/test_core_willtree_father.py
Normal file
145
tests/test_core_willtree_father.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Tests for the parent/child tree of the will transactions (``father`` links).
|
||||
|
||||
Bug reproduced from a real wallet (Electrum log, Check wizard):
|
||||
|
||||
File "bal/core/will.py", in check_invalidated
|
||||
or willtree[w.father].get_status("CONFIRMED")
|
||||
KeyError: 'a65a682f…'
|
||||
|
||||
``father`` is persisted in the wallet (``"Father"``) and was never cleared, so
|
||||
after the father tx was removed from the will the child pointed to a txid that
|
||||
no longer exists. ``Will.add_willtree`` now rebuilds the links from scratch and
|
||||
``Will.check_invalidated`` treats a missing father as "no father".
|
||||
|
||||
Run (standalone or with pytest):
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 tests/test_core_willtree_father.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum.transaction import Transaction
|
||||
|
||||
from bal.core.will import Will, WillItem
|
||||
|
||||
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||
_patcher.start()
|
||||
|
||||
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
# The input of _VALID_TX_HEX spends this outpoint (little-endian in the hex).
|
||||
_FUNDING_ID = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a"
|
||||
# PARENT: _VALID_TX_HEX itself. CHILD: the same tx with its input re-pointed to
|
||||
# the parent's txid, so the child spends the parent's output 0.
|
||||
_PARENT_ID = Transaction(_VALID_TX_HEX).txid()
|
||||
_CHILD_TX_HEX = _VALID_TX_HEX.replace(
|
||||
bytes.fromhex(_FUNDING_ID)[::-1].hex(), bytes.fromhex(_PARENT_ID)[::-1].hex()
|
||||
)
|
||||
# A txid that is not in the will (the "removed father" of the bug report).
|
||||
_MISSING_ID = "a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115"
|
||||
|
||||
|
||||
def _item(tx_hex=_VALID_TX_HEX, father=None):
|
||||
"""WillItem built from ``tx_hex``, optionally with a stored father."""
|
||||
data = {"tx": tx_hex, "heirs": {}, "willexecutor": None}
|
||||
if father is not None:
|
||||
data["Father"] = father
|
||||
return WillItem(data)
|
||||
|
||||
|
||||
def _wallet_without_adb():
|
||||
"""Wallet whose check_invalidated path skips on-chain lookups."""
|
||||
wallet = MagicMock()
|
||||
wallet.adb = None
|
||||
return wallet
|
||||
|
||||
|
||||
def test_stale_father_is_discarded_by_add_willtree():
|
||||
child = _item(father=_MISSING_ID)
|
||||
will = {child._id: child}
|
||||
Will.add_willtree(will)
|
||||
assert child.father is None
|
||||
|
||||
|
||||
def test_check_invalidated_does_not_crash_on_stale_father():
|
||||
"""The exact failure of the bug report, now without KeyError."""
|
||||
child = _item(father=_MISSING_ID)
|
||||
will = {child._id: child}
|
||||
Will.add_willtree(will)
|
||||
Will.check_invalidated(will, [], _wallet_without_adb())
|
||||
|
||||
|
||||
def test_check_invalidated_tolerates_missing_father_without_add_willtree():
|
||||
"""Defensive path: even if the tree was not rebuilt, no KeyError."""
|
||||
child = _item(father=_MISSING_ID)
|
||||
will = {child._id: child}
|
||||
Will.check_invalidated(will, [], _wallet_without_adb())
|
||||
assert child.father == _MISSING_ID
|
||||
|
||||
|
||||
def test_real_parent_child_link_is_built():
|
||||
parent = _item()
|
||||
child = _item(_CHILD_TX_HEX)
|
||||
will = {_PARENT_ID: parent, child._id: child}
|
||||
Will.add_willtree(will)
|
||||
assert child.father == _PARENT_ID
|
||||
assert [c[0] for c in parent.children] == [child._id]
|
||||
assert parent.father is None
|
||||
|
||||
|
||||
def test_stale_father_is_replaced_by_the_real_parent():
|
||||
parent = _item()
|
||||
child = _item(_CHILD_TX_HEX, father=_MISSING_ID)
|
||||
will = {_PARENT_ID: parent, child._id: child}
|
||||
Will.add_willtree(will)
|
||||
assert child.father == _PARENT_ID
|
||||
|
||||
|
||||
def test_child_of_unconfirmed_parent_is_not_checked_against_utxos():
|
||||
"""Existing rule kept: a child of a VALID (not mined) parent is skipped."""
|
||||
parent = _item()
|
||||
child = _item(_CHILD_TX_HEX)
|
||||
will = {_PARENT_ID: parent, child._id: child}
|
||||
Will.add_willtree(will)
|
||||
wallet = MagicMock()
|
||||
# Funding not spent on-chain: nothing gets invalidated, we only observe
|
||||
# which items had their inputs checked.
|
||||
with patch.object(Will, "check_tx_height", return_value=-2) as height, \
|
||||
patch.object(Will, "_funding_really_spent", return_value=False):
|
||||
Will.check_invalidated({child._id: child, _PARENT_ID: parent}, [], wallet)
|
||||
# Only the parent (a root) looked up its height; the child was skipped.
|
||||
checked = [call.args[0] for call in height.call_args_list]
|
||||
assert checked == [parent.tx]
|
||||
|
||||
|
||||
def test_set_invalidate_cascades_to_children_built_by_add_willtree():
|
||||
"""add_willtree builds children as a LIST: invalidation must cascade."""
|
||||
parent = _item()
|
||||
child = _item(_CHILD_TX_HEX)
|
||||
will = {parent._id: parent, child._id: child}
|
||||
Will.add_willtree(will)
|
||||
Will.set_invalidate(parent._id, will)
|
||||
assert parent.get_status("INVALIDATED") is True
|
||||
assert child.get_status("INVALIDATED") is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All will-tree father tests passed")
|
||||
@@ -92,6 +92,31 @@ def test_can_delete_none_false():
|
||||
assert _can_delete(None) is False
|
||||
|
||||
|
||||
def _signed_valid(we="https://we.example", **flags):
|
||||
"""A VALID + COMPLETE (signed) item, optionally with a will-executor."""
|
||||
item = FakeWillItem(VALID=True, COMPLETE=True, **flags)
|
||||
item.we = {"url": we, "selected": True} if we else None
|
||||
return item
|
||||
|
||||
|
||||
def test_can_delete_signed_but_never_received_by_executor():
|
||||
"""Push failed / "Not on server": the executor does not hold it."""
|
||||
assert _can_delete(_signed_valid(PUSH_FAIL=True, CHECK_FAIL=True)) is True
|
||||
assert _can_delete(_signed_valid()) is True # signed, never sent
|
||||
|
||||
|
||||
def test_can_delete_signed_and_pushed_false():
|
||||
"""The executor holds it: deleting locally would not cancel it."""
|
||||
assert _can_delete(_signed_valid(PUSHED=True)) is False
|
||||
assert _can_delete(_signed_valid(PUSHED=True, CHECKED=True)) is False
|
||||
assert _can_delete(_signed_valid(CHECKED=True)) is False
|
||||
|
||||
|
||||
def test_can_delete_signed_backup_tx_without_executor_false():
|
||||
"""Signed txs without a will-executor (backup) stay protected."""
|
||||
assert _can_delete(_signed_valid(we=None)) is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _apply_select_all
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
Reference in New Issue
Block a user