From e6e210b93d6f421bde3c6eb7a887cfa861d6a5fd Mon Sep 17 00:00:00 2001 From: ScuttoZ Date: Thu, 26 Mar 2026 15:48:22 +0100 Subject: [PATCH 1/7] bkpr: test added for {utctime} tag --- tests/test_bookkeeper.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index 067a54e165a5..10ef69ef91e8 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -1577,3 +1577,32 @@ def test_bkpr_report_lightning_cli_csv(node_factory): parsed = [next(csv.reader(io.StringIO(line))) for line in res.splitlines()] assert parsed assert all(len(row) == 3 for row in parsed) + + +def test_bkpr_report_utctime(node_factory): + """Test {utctime} format tag. + + {utctime} is the UTC counterpart of {localtime}. Verify it produces valid + "YYYY-MM-DD HH:MM:SS" strings and that its tag column matches {localtime}. + """ + l1, l2 = node_factory.line_graph(2) + + inv = l2.rpc.invoice(100000, "test_bkpr_report_utctime", "desc") + l1.rpc.pay(inv["bolt11"]) + wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == []) + + utc_lines = l1.rpc.bkpr_report(format="{utctime},{tag}")['report'] + loc_lines = l1.rpc.bkpr_report(format="{localtime},{tag}")['report'] + + assert utc_lines + assert len(utc_lines) == len(loc_lines) + + for u, lc in zip(utc_lines, loc_lines): + u_ts_str, u_tag = u.split(',') + l_ts_str, l_tag = lc.split(',') + # Both must produce valid "YYYY-MM-DD HH:MM:SS" strings. + datetime.strptime(u_ts_str, "%Y-%m-%d %H:%M:%S") + datetime.strptime(l_ts_str, "%Y-%m-%d %H:%M:%S") + # Both describe the same event. + assert u_tag == l_tag + From 97545cd4a985521b663806dcb91fdd325ff5c0e1 Mon Sep 17 00:00:00 2001 From: ScuttoZ Date: Thu, 26 Mar 2026 17:28:09 +0100 Subject: [PATCH 2/7] bkpr: test utctime, unified timestamp fetching to avoid races --- tests/test_bookkeeper.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index 10ef69ef91e8..35b7aec7944f 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -1591,18 +1591,13 @@ def test_bkpr_report_utctime(node_factory): l1.rpc.pay(inv["bolt11"]) wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == []) - utc_lines = l1.rpc.bkpr_report(format="{utctime},{tag}")['report'] - loc_lines = l1.rpc.bkpr_report(format="{localtime},{tag}")['report'] + # Fetch both timestamps in a single call to avoid a race between two + # separate bkpr-report calls where a background event could land in between. + lines = l1.rpc.bkpr_report(format="{utctime}|{localtime}|{tag}")['report'] - assert utc_lines - assert len(utc_lines) == len(loc_lines) - - for u, lc in zip(utc_lines, loc_lines): - u_ts_str, u_tag = u.split(',') - l_ts_str, l_tag = lc.split(',') + assert lines + for line in lines: + u_ts_str, l_ts_str, tag = line.split('|') # Both must produce valid "YYYY-MM-DD HH:MM:SS" strings. datetime.strptime(u_ts_str, "%Y-%m-%d %H:%M:%S") datetime.strptime(l_ts_str, "%Y-%m-%d %H:%M:%S") - # Both describe the same event. - assert u_tag == l_tag - From a1523eeac15ec81745c17e70be066267b7983c05 Mon Sep 17 00:00:00 2001 From: ScuttoZ Date: Thu, 26 Mar 2026 17:57:44 +0100 Subject: [PATCH 3/7] bkpr: test added for {fees} tag --- tests/test_bookkeeper.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index 35b7aec7944f..ca25705fb941 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -1601,3 +1601,30 @@ def test_bkpr_report_utctime(node_factory): # Both must produce valid "YYYY-MM-DD HH:MM:SS" strings. datetime.strptime(u_ts_str, "%Y-%m-%d %H:%M:%S") datetime.strptime(l_ts_str, "%Y-%m-%d %H:%M:%S") + + +def test_bkpr_report_fees(node_factory): + """Test {fees} format tag. + + {fees} is non-zero only when routing fees are incurred. A 3-node path + (l1 -> l2 -> l3) ensures l1's income events carry non-zero routing fees. + """ + l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True) + + inv = l3.rpc.invoice(100000, "test_bkpr_report_fees", "desc") + l1.rpc.pay(inv["bolt11"]) + wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == []) + + lines = l1.rpc.bkpr_report(format="{tag},{fees}")['report'] + assert lines + + # Every row must produce a parseable non-negative decimal. + for line in lines: + tag, fees_str = line.split(',') + assert float(fees_str) >= 0 + + # This type of payment should produce exactly 2 non-zero fee events. + nonzero = [line for line in lines if float(line.split(',')[1]) > 0] + assert len(nonzero) == 2 + tags = {line.split(',')[0] for line in nonzero} + assert tags == {'invoice', 'invoice_fee'} From 1a96fa9478eb1b262acdb08e32cbe6362af568b7 Mon Sep 17 00:00:00 2001 From: ScuttoZ Date: Thu, 26 Mar 2026 17:59:27 +0100 Subject: [PATCH 4/7] bkpr: test added for NULL currency format tag fallback --- tests/test_bookkeeper.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index ca25705fb941..f12a4acce4ab 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -1628,3 +1628,31 @@ def test_bkpr_report_fees(node_factory): assert len(nonzero) == 2 tags = {line.split(',')[0] for line in nonzero} assert tags == {'invoice', 'invoice_fee'} + + +def test_bkpr_report_no_currency(node_factory): + """All currency-related format tags must all resolve to NULL + and trigger their fallback text.""" + l1, l2 = node_factory.line_graph(2) + + inv = l2.rpc.invoice(100000, "test_bkpr_report_no_currency", "desc") + l1.rpc.pay(inv["bolt11"]) + wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == []) + + fmt = ("{tag}" + "|{bkpr-currency?NOCUR}" + "|{currencyrate?NORAT}" + "|{currencycredit?NOCREDIT}" + "|{currencydebit?NODEBIT}" + "|{currencycreditdebit?NOCD}") + lines = l1.rpc.bkpr_report(format=fmt)['report'] + assert lines + + for line in lines: + parts = line.split('|') + assert len(parts) == 6 + assert parts[1] == 'NOCUR' + assert parts[2] == 'NORAT' + assert parts[3] == 'NOCREDIT' + assert parts[4] == 'NODEBIT' + assert parts[5] == 'NOCD' From 41934c4b233ab0bd8651df72ee2ca965cf5aa51a Mon Sep 17 00:00:00 2001 From: ScuttoZ Date: Thu, 26 Mar 2026 18:02:04 +0100 Subject: [PATCH 5/7] bkpr: test added for escape=none report mode --- tests/test_bookkeeper.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index f12a4acce4ab..f879869542a1 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -1656,3 +1656,34 @@ def test_bkpr_report_no_currency(node_factory): assert parts[3] == 'NOCREDIT' assert parts[4] == 'NODEBIT' assert parts[5] == 'NOCD' + + +def test_bkpr_report_escape_none(node_factory): + """escape=none must leave special characters unescaped in the output, + in contrast to escape=csv which wraps fields containing commas/quotes.""" + l1, l2 = node_factory.line_graph(2) + + # Description with a comma so CSV-sensitive escaping is detectable. + inv = l2.rpc.invoice(100000, "test_bkpr_report_escape_none", 'hello, world') + l1.rpc.pay(inv["bolt11"]) + wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == []) + + # escape=none (explicit): description must appear verbatim with its comma. + lines_none = l1.rpc.bkpr_report(format="{description?-},{tag}", + escape='none')['report'] + # escape=csv: description containing a comma must be quoted. + lines_csv = l1.rpc.bkpr_report(format="{description?-},{tag}", + escape='csv')['report'] + + assert len(lines_none) == len(lines_csv) + + # Find the invoice row — it has the description with the embedded comma. + inv_none = only_one([l for l in lines_none if 'invoice' in l.split(',')[-1]]) + inv_csv = only_one([l for l in lines_csv if 'invoice' in l.split(',')[-1]]) + + # With escape=none the comma in the description is NOT escaped. + assert 'hello, world' in inv_none + # With escape=csv the description field is quoted, so csv.reader collapses + # it back to a single field containing the original string. + parsed = next(csv.reader(io.StringIO(inv_csv))) + assert parsed[0] == 'hello, world' From cc844a87968eec6ab663d40b01643776045691f8 Mon Sep 17 00:00:00 2001 From: ScuttoZ Date: Thu, 26 Mar 2026 18:05:27 +0100 Subject: [PATCH 6/7] bkpr: test added for reports with future starting date (empty) and linted --- tests/test_bookkeeper.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index f879869542a1..246e332b788f 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -1669,11 +1669,11 @@ def test_bkpr_report_escape_none(node_factory): wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == []) # escape=none (explicit): description must appear verbatim with its comma. - lines_none = l1.rpc.bkpr_report(format="{description?-},{tag}", - escape='none')['report'] + lines_none = l1.rpc.bkpr_report( + format="{description?-},{tag}", escape='none')['report'] # escape=csv: description containing a comma must be quoted. - lines_csv = l1.rpc.bkpr_report(format="{description?-},{tag}", - escape='csv')['report'] + lines_csv = l1.rpc.bkpr_report( + format="{description?-},{tag}", escape='csv')['report'] assert len(lines_none) == len(lines_csv) @@ -1687,3 +1687,19 @@ def test_bkpr_report_escape_none(node_factory): # it back to a single field containing the original string. parsed = next(csv.reader(io.StringIO(inv_csv))) assert parsed[0] == 'hello, world' + + +def test_bkpr_report_empty_window(node_factory, bitcoind): + """bkpr-report with a start_time beyond all events must return an empty + list without errors.""" + l1 = node_factory.get_node() + addr = l1.rpc.newaddr()['p2tr'] + + bitcoind.rpc.sendtoaddress(addr, 0.01) + bitcoind.generate_block(1, wait_for_mempool=1) + wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == 1) + + future = int(time.time()) + 10_000_000 + report = l1.rpc.bkpr_report( + format="{tag},{creditdebit}", start_time=future)['report'] + assert report == [] From b2a363ab7a437b2b2fc8054edcb975b516459f1f Mon Sep 17 00:00:00 2001 From: ScuttoZ Date: Mon, 3 Aug 2026 15:04:19 +0200 Subject: [PATCH 7/7] bkpr: test_bkpr_report_empty_window - Fixed wait_for instance that could previously pass on empty bkpr_report; test_bkpr_report_utctime - Fixed logic by using POSIX TZ to prevent tzdata and DST dependency and with fixed offset for cases where utctime==localtime. Also, removed comment about line 1381 that talked about race conditions, while the single call is just for atomicity. --- tests/test_bookkeeper.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index 246e332b788f..a3235717bf64 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -6,7 +6,7 @@ sync_blockheight, wait_for, only_one, first_channel_id, TIMEOUT ) -from datetime import datetime +from datetime import datetime, timezone, timedelta from pathlib import Path import csv import io @@ -1582,25 +1582,41 @@ def test_bkpr_report_lightning_cli_csv(node_factory): def test_bkpr_report_utctime(node_factory): """Test {utctime} format tag. - {utctime} is the UTC counterpart of {localtime}. Verify it produces valid - "YYYY-MM-DD HH:MM:SS" strings and that its tag column matches {localtime}. + {utctime} and {localtime} render the same event timestamp via gmtime_r and + localtime_r respectively, so they must denote the same instant, offset by + the node's local timezone. Verify that consistency, forcing a fixed non-UTC zone so the two genuinely differ. """ - l1, l2 = node_factory.line_graph(2) + # POSIX TZ string (sign inverted): "IST-5:30" == UTC+05:30, no DST, and + # needs no zoneinfo database on either the node or the test side. + tz_posix = "IST-5:30" + tz_offset = timezone(timedelta(hours=5, minutes=30)) + old_tz = os.environ.get("TZ") + os.environ["TZ"] = tz_posix + try: + l1, l2 = node_factory.line_graph(2) + finally: + if old_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = old_tz inv = l2.rpc.invoice(100000, "test_bkpr_report_utctime", "desc") l1.rpc.pay(inv["bolt11"]) wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == []) - # Fetch both timestamps in a single call to avoid a race between two - # separate bkpr-report calls where a background event could land in between. lines = l1.rpc.bkpr_report(format="{utctime}|{localtime}|{tag}")['report'] assert lines for line in lines: u_ts_str, l_ts_str, tag = line.split('|') # Both must produce valid "YYYY-MM-DD HH:MM:SS" strings. - datetime.strptime(u_ts_str, "%Y-%m-%d %H:%M:%S") - datetime.strptime(l_ts_str, "%Y-%m-%d %H:%M:%S") + u_ts = datetime.strptime(u_ts_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + l_ts = datetime.strptime(l_ts_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=tz_offset) + # The forced +05:30 offset must make the two renderings differ; if they + # match, either TZ didn't take effect or {utctime} is reusing {localtime}. + assert u_ts_str != l_ts_str + # In their respective zones, they must be the same instant. + assert u_ts == l_ts def test_bkpr_report_fees(node_factory): @@ -1697,7 +1713,8 @@ def test_bkpr_report_empty_window(node_factory, bitcoind): bitcoind.rpc.sendtoaddress(addr, 0.01) bitcoind.generate_block(1, wait_for_mempool=1) - wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == 1) + + wait_for(lambda: len(l1.rpc.bkpr_report(format="{tag},{creditdebit}")['report']) >= 1) future = int(time.time()) + 10_000_000 report = l1.rpc.bkpr_report(