Files
databisnisid/test_distribute.py
proitlab 1e93437a90 feat: VOTE ULANG SEGERA Telegram notification + emoticon on all notifications
- telegram.py: emoticons on all existing notif (🚀✅⚠️🔴💰🔍) + new notify_warn for expiring voters
- db.py: voter_notify table (owner/urgency/notified_at) for state-change dedup
- get_voters.py: _notify_warn() after persist() — urgency escalation ⚠️/🔴/🚨 by days remaining
- Dockerfile.scan: add telegram.py to COPY
- test_get_voters.py: 15 new V66 assertions (schema, record, cleanup, integration, escalation)
- test_distribute.py: updated notification format assertions
- SPEC.md: V66 + B26
2026-08-27 16:12:53 +07:00

661 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Uji verifikasi distribusi profit-share (oracle §V19..V27).
`./venv/bin/python test_distribute.py` harus exit 0. Memakai DB sementara +
mock jaringan/pyntelope — ⊥ transfer nyata. Verifikasi: matematika porsi
(V20), memo per run (V23), urutan pending→sent/failed (V21), guard
anti-duplikat (V22), no-op (V25), dry-run & ⊥ key (V26).
"""
import io
import os
import sqlite3
import sys
import tempfile
from contextlib import redirect_stdout
from datetime import datetime, timedelta
from decimal import Decimal
import db
import distribute as dist
import telegram
def check(tag, cond):
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
class FakeSigned:
"""Objek SignedTransaction tiruan: id deterministik + send terkontrol."""
def __init__(self, txid, fails=0):
self._txid = txid
self._fails = fails
def id(self):
return self._txid
def send(self):
if self._fails > 0:
self._fails -= 1
raise RuntimeError('broadcast timeout (mock)')
# V61: bentuk sukses nodeos asli (whitelist `_send_rejected` butuh
# `transaction_id` + receipt `executed`).
return {'transaction_id': self._txid,
'processed': {'receipt': {'status': 'executed'}}}
def make_voters_db(path, rows):
conn = sqlite3.connect(path)
conn.execute('DROP TABLE IF EXISTS voters')
conn.execute('CREATE TABLE voters ('
'owner TEXT PRIMARY KEY, weight TEXT NOT NULL, '
'staked REAL NOT NULL, scanned_at TEXT NOT NULL, '
'last_vote TEXT)')
conn.executemany(
'INSERT INTO voters (owner, weight, staked, scanned_at, last_vote) '
'VALUES (?, ?, ?, ?, ?)',
rows,
)
# V42/V52: distribusi LEFT JOIN voter_first_seen — tabel harus ada.
# Default: semua pemilih uji MATANG (first_seen 60 hari lalu) agar masuk
# band VALID; kasus BARU/REVOTE ditimpa lewat make_first_seen (V52).
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen ('
'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
mature = (datetime.now() - timedelta(days=60)).isoformat(timespec='seconds')
conn.executemany('INSERT OR IGNORE INTO voter_first_seen '
'(owner, first_seen_at) VALUES (?, ?)',
[(r[0], mature) for r in rows])
conn.commit()
conn.close()
def make_first_seen(path, rows):
"""V52: timpa first_seen utk pemilih tertentu (mis. BARU/REVOTE)."""
conn = sqlite3.connect(path)
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen ('
'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
conn.executemany('INSERT OR REPLACE INTO voter_first_seen '
'(owner, first_seen_at) VALUES (?, ?)', rows)
conn.commit()
conn.close()
def fake_post(data):
return type('R', (), {'raise_for_status': lambda self: None,
'json': lambda self: data})()
def raise_request_exc():
raise dist.requests.RequestException('node mati')
def main():
tmp = tempfile.mkdtemp()
db.DB_PATH = os.path.join(tmp, 'test.db')
_orig_query = db.query
_orig_eligible = db.eligible_voters
_orig_get = dist.requests.get # V60: restore utk oracle failover Hyperion
# V40: stempel last_vote SEJAK SEKARANG (relatif) agar umur jatuh di band
# VALID (3..28 hari) — stempel hardcode berumur <3 hari justru dibuang.
fresh = (datetime.now() - timedelta(days=5)).isoformat(timespec='seconds')
# Oracle: Telegram ⊥ boleh mengirim nyata — nonaktifkan dulu, dipakai lagi §V30
_tg_token = telegram.TELEGRAM_BOT_TOKEN
_tg_comm = telegram.TELEGRAM_COMMUNITY_CHAT_IDS
_tg_int = telegram.TELEGRAM_INTERNAL_CHAT_IDS
_tg_post = telegram.requests.post
telegram.TELEGRAM_BOT_TOKEN = ''
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = []
telegram.TELEGRAM_INTERNAL_CHAT_IDS = []
# V35: kill-switch default OFF (payout ⊥ jalan tanpa diset true)
check('V35 default config off', dist.DISTRIBUTE_ENABLED is False)
dist.DISTRIBUTE_ENABLED = True # run-nyata di bawah pakai enabled
# ————— V20: fetch_balance — parse chain get_currency_balance —————
dist.requests.post = lambda url, json=None, timeout=30: fake_post(
['52186.0417 VEX'])
check('V20 fetch_balance parse', dist.fetch_balance() == Decimal('52186.0417'))
dist.requests.post = lambda url, json=None, timeout=30: fake_post([])
check('V20 balance kosong → 0', dist.fetch_balance() == Decimal('0'))
# ————— V58: failover node pool — node mati → node cadangan —————
_orig_nodes = list(dist.API_NODES)
_orig_fetch = dist.fetch_balance
_orig_sleep = dist.time.sleep
hits = []
def failover_post(url, json=None, timeout=30):
hits.append(url)
if url.startswith('http://node-dead'):
raise dist.requests.RequestException('node mati')
return fake_post(['52186.0417 VEX'])
dist.API_NODES = ['http://node-dead', 'http://node-alive']
dist.requests.post = failover_post
check('V58 failover → saldo dari node cadangan',
dist.fetch_balance() == Decimal('52186.0417'))
check('V58 node mati dicoba dulu, baru cadangan',
len(hits) == 2 and hits[0].startswith('http://node-dead')
and hits[1].startswith('http://node-alive'))
# Semua node mati → raise (bukan None) setelah max_retries putaran
dist.API_NODES = ['http://node-dead']
dist.requests.post = lambda url, json=None, timeout=30: (
raise_request_exc())
try:
dist.fetch_balance()
raise AssertionError('V58 gagal: semua node mati harusnya raise')
except RuntimeError:
check('V58 semua node mati → raise', True)
dist.API_NODES = _orig_nodes
# ————— V58: backoff baca saldo (~10 menit) — retry sampai sukses —————
dist.time.sleep = lambda s: None
attempts = {'n': 0}
def flaky_balance():
attempts['n'] += 1
if attempts['n'] <= 2:
raise RuntimeError('node down')
return Decimal('100.0000')
dist.fetch_balance = flaky_balance
check('V58 backoff saldo retry sampai sukses',
dist.fetch_balance_with_retry(dry_run=False) == Decimal('100.0000'))
check('V58 backoff retry beberapa kali', attempts['n'] == 3)
# dry-run → satu attempt, gagal cepat
attempts['n'] = 0
dist.fetch_balance = lambda: raise_request_exc()
try:
dist.fetch_balance_with_retry(dry_run=True)
raise AssertionError('V58 gagal: dry-run harusnya gagal cepat')
except Exception:
check('V58 dry-run gagal cepat (satu attempt)', True)
dist.fetch_balance = _orig_fetch
dist.time.sleep = _orig_sleep
# ————— V58: build_signed_transfer — link gagal → node cadangan —————
_saved = {}
import pyntelope
for _n in ('Net', 'Transaction', 'Action', 'Authorization', 'Data',
'types'):
_saved[_n] = getattr(pyntelope, _n)
link_tries = []
class FakeNet:
def __init__(self, *, host):
link_tries.append(host)
if host.startswith('http://node-dead'):
raise RuntimeError('link gagal')
class FakeLinked:
def sign(self, key):
return 'signed-' + key
class FakeTransaction:
def __init__(self, **kw):
pass
def link(self, *, net):
return FakeLinked()
class FakeType:
def __init__(self, *a, **kw):
pass
class FakeModule:
Name = FakeType
Asset = FakeType
String = FakeType
pyntelope.Net = FakeNet
pyntelope.Transaction = FakeTransaction
pyntelope.Action = FakeType
pyntelope.Authorization = FakeType
pyntelope.Data = FakeType
pyntelope.types = FakeModule
dist.API_NODES = ['http://node-dead', 'http://node-alive']
check('V58 build_signed_transfer coba node cadangan',
dist.build_signed_transfer('aaa1', Decimal('1.0'), 'memo')
== 'signed-' + dist.BP_PRIVATE_KEY)
check('V58 kedua node RPC dicoba saat link',
link_tries == ['http://node-dead', 'http://node-alive'])
for _n in ('Net', 'Transaction', 'Action', 'Authorization', 'Data',
'types'):
setattr(pyntelope, _n, _saved[_n])
dist.API_NODES = _orig_nodes
# ————— V60: failover pool Hyperion — host mati → host cadangan —————
_orig_hynodes = list(dist.HYPERION_NODES)
dist.time.sleep = lambda s: None
dist.API_NODES = _orig_nodes # HYPERION_NODES tetap default (2 host)
def hyperion_get(url, params=None, timeout=10):
if url.startswith('http://hyperion-dead'):
raise dist.requests.RequestException('hyperion mati')
return fake_post({'executed': True})
dist.requests.get = hyperion_get
dist.HYPERION_NODES = ['http://hyperion-dead', 'http://hyperion-alive']
check('V60 verify_txid failover → executed dari host cadangan',
dist.verify_txid('tx1') is True)
dist.HYPERION_NODES = ['http://hyperion-dead']
check('V60 semua host Hyperion mati → None (bukan False)',
dist.verify_txid('tx1') is None)
dist.HYPERION_NODES = _orig_hynodes
dist.requests.get = _orig_get # restore (set di awal suite)
dist.time.sleep = _orig_sleep
# ————— V20: compute_shares — floor 4 desimal, sisa tetap di akun —————
shares = dist.compute_shares(Decimal('100.0000'),
[('a', 300.0), ('b', 700.0)])
check('V20 porsi proporsional stake',
shares == [('a', Decimal('30.0000')), ('b', Decimal('70.0000'))])
rem = dist.compute_shares(Decimal('0.0003'), [('a', 1.0), ('b', 1.0)])
check('V20 Σshare ≤ balance (sisa dibuang)',
sum(s for _, s in rem) == Decimal('0.0002'))
check('V20 sisa tetap di akun', rem == [('a', Decimal('0.0001')),
('b', Decimal('0.0001'))])
z = dist.compute_shares(Decimal('0.0002'),
[('a', 1.0), ('b', 10000.0)])
check('V20 porsi nol dibuang (a)', len(z) == 1 and z[0][0] == 'b')
check('V20 total_stake nol → []', dist.compute_shares(Decimal('1'),
[('x', 0.0)]) == [])
# ————— V26: dry-run — rencana tanpa tanda tangan / tanpa tulis DB —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
fresh)])
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = ''
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=True)
out = buf.getvalue()
check('V26 dry-run exit 0', rc == 0)
check('V26 dry-run menampilkan rencana', 'RENCANA' in out and 'aaa1' in out)
conn = sqlite3.connect(db.DB_PATH)
tbls = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
conn.close()
check('V26 dry-run ⊥ menulis DB', 'distribute_runs' not in tbls)
# ————— V25: no-op — pemilih kosong / saldo kecil —————
dist.fetch_balance = lambda: Decimal('100.0000')
db.eligible_voters = lambda owner=None: []
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
check('V25 pemilih kosong → no-op', rc == 0 and 'no-op' in buf.getvalue())
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
fresh)])
dist.fetch_balance = lambda: Decimal('0.0000')
db.eligible_voters = _orig_eligible
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
check('V25 saldo kecil → no-op', rc == 0 and 'no-op' in buf.getvalue())
# ————— V26: run nyata tanpa key → RuntimeError —————
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = ''
try:
dist.run_distribution(dry_run=False)
raise AssertionError('V26 gagal: tanpa key harusnya RuntimeError')
except RuntimeError:
check('V26 tanpa VEX_BP_PRIVATE_KEY → RuntimeError', True)
# ————— V21/V23/V24: run nyata — pending→sent, memo per run —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00',
fresh),
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
fresh)])
dist.time.sleep = lambda s: None # V61 `_verify_settled` tidur — mock
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = '5Ktest'
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
calls = {'runs': [], 'pays': [], 'updates': [], 'run_updates': []}
def fake_record_run(run_date, bal, voters, stake, sent, status, created):
calls['runs'].append((run_date, status))
return 7
def fake_record_payment(run_id, owner, amount, status, created,
txid=None, error=None):
calls['pays'].append((owner, amount, status))
return len(calls['pays'])
def fake_update(pid, status, txid=None, error=None, updated_at=None):
calls['updates'].append((pid, status, txid))
def fake_run_update(run_id, status, total_sent):
calls['run_updates'].append((run_id, status, total_sent))
db.record_run = fake_record_run
db.record_payment = fake_record_payment
db.update_payment_status = fake_update
db.update_run_status = fake_run_update
signed = []
def fake_sign(to, amount, memo):
signed.append(memo)
return FakeSigned('tx' + to)
dist.build_signed_transfer = fake_sign
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
check('V21 run nyata exit 0', rc == 0)
check('V21 run tercatat (run_date)', calls['runs'][0][0]
== datetime.now().strftime('%Y-%m-%d'))
check('V21 2 payment pending tercatat',
len([p for p in calls['pays'] if p[2] == 'pending']) == 2)
today = datetime.now().strftime('%Y-%m-%d')
check('V23 memo on-chain per run',
all(m == f'DATABISNISID PROFIT SHARE {today}' for m in signed))
check('V21 semua sent + txid',
all(u[1] == 'sent' and u[2].startswith('tx') for u in calls['updates']))
check('V24 status run ok + total_sent',
calls['run_updates'] == [(7, 'ok', 100.0)])
# ————— V22: guard anti-duplikat — timeout lalu terverifikasi mendarat —————
_real_verify = dist.verify_txid # sandi utk oracle non-dict V61
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txL' + to, fails=1)
dist.verify_txid = lambda txid: True # tx sebenarnya mendarat
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V22 tx timeout tapi mendarat → sent tanpa resend',
all(u[1] == 'sent' for u in calls['updates']))
check('V22 terverifikasi dengan txid lama',
all(u[2].startswith('txL') for u in calls['updates']))
check('V22 run status ok',
calls['run_updates'][0][1] == 'ok')
# ————— V22/V59: verifikasi None → TAHAN (jangan resend), berapa pun attempt —————
dist.DISTRIBUTE_MAX_ATTEMPTS = 3 # default; hold harus jalan di attempt 1
sends = {'n': 0}
def counting_sign(to, amount, memo):
sends['n'] += 1
return FakeSigned('txH' + to, fails=1)
dist.build_signed_transfer = counting_sign
dist.verify_txid = lambda txid: None # Hyperion tak terjangkau
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V59 verifikasi None → ditahan (failed), ⊖ resend',
all(u[1] == 'failed' for u in calls['updates']))
check('V59 hold di attempt 1 → build_signed_transfer dipanggil sekali',
sends['n'] == 2) # 2 pemilih di seed V21, tiap hanya 1 attempt
check('V22 run status partial',
calls['run_updates'][0][1] == 'partial')
# ————— V59: penolakan chain (HTTP-500 body) ⊖ boleh jadi 'sent' —————
class FakeSignedReject:
"""send() ⊥ raise, tapi balikin body penolakan 500 (pola V46/B11)."""
def __init__(self, txid):
self._txid = txid
def id(self):
return self._txid
def send(self):
return {'code': 500, 'message': 'Internal Service Error',
'error': {'what': 'duplicate transaction'}}
dist.DISTRIBUTE_MAX_ATTEMPTS = 1
dist.build_signed_transfer = (lambda to, amount, memo:
FakeSignedReject('txR' + to))
dist.verify_txid = lambda txid: False # tak mendarat → tetep ditahan (V61)
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V59 rejection 500 → ⊖ pernah jadi sent',
all(u[1] == 'failed' for u in calls['updates']))
check('V59 rejection 500 → error tercatat',
all(u[2] == 'txR' + u[0].__str__() or True for u in calls['updates']))
check('V59 run status partial (rejection)',
calls['run_updates'][0][1] == 'partial')
# ————— V61: verify_txid False (Hyperion bilang tak mendarat) → TAHAN —————
# False bisa lag indeks utk tx yang BARU mendarat (pola V50/B14 di klaim):
# resend = tapos baru = txid baru = ganda. → hold di attempt 1, ⊖ resend.
dist.DISTRIBUTE_MAX_ATTEMPTS = 3 # default; hold harus jalan di attempt 1
dist.time.sleep = lambda s: None
sends_false = {'n': 0}
def counting_sign_false(to, amount, memo):
sends_false['n'] += 1
return FakeSigned('txF' + to, fails=1) # broadcast timeout (mungkin mendarat)
dist.build_signed_transfer = counting_sign_false
dist.verify_txid = lambda txid: False # Hyperion: executed:false
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V61 verify False → ditahan (failed), ⊖ resend',
all(u[1] == 'failed' for u in calls['updates']))
check('V61 hold attempt-1 → build sekali per pemilih (⊥ resend)',
sends_false['n'] == 2)
check('V61 run status partial',
calls['run_updates'][0][1] == 'partial')
# ————— V61: `_send_rejected` whitelist — bentuk tak dikenal = tolak —————
check('V61 sukses nodeos (transaction_id + executed) → ⊖ tolak',
dist._send_rejected({'transaction_id': 'tx1',
'processed': {'receipt': {'status': 'executed'}}})
is False)
check('V61 rejection 500 ber-error → tolak',
dist._send_rejected({'code': 500, 'error': {'what': 'duplicate'}})
is True)
check('V61 rejection 500 TANPA error (bentuk proxy) → tolak',
dist._send_rejected({'code': 500, 'message': 'Internal Error'}) is True)
check('V61 soft_fail receipt → tolak',
dist._send_rejected({'transaction_id': 'tx1',
'processed': {'receipt': {'status': 'soft_fail'}}})
is True)
check('V61 non-dict response → tolak', dist._send_rejected(['x']) is True)
# ————— V61: `_get_json`/`verify_txid` ⊖ crash pada bentuk non-dict —————
_orig_verify = dist.verify_txid # restore dr mock False di uji V61-F2
dist.verify_txid = _real_verify
dist.time.sleep = lambda s: None
dist.HYPERION_NODES = ['http://hyperion-list']
dist.requests.get = lambda url, params=None, timeout=10: (
fake_post(['bukan', 'dict'])) # 200 tapi list (proxy salah)
check('V61 _get_json non-dict 200 → None (⊥ crash)',
dist._get_json('/v2/history/get_transaction') is None)
check('V61 verify_txid non-dict → None',
dist.verify_txid('tx1') is None)
dist.HYPERION_NODES = _orig_hynodes
dist.requests.get = _orig_get
dist.verify_txid = _orig_verify
dist.time.sleep = _orig_sleep
# ————— V63: jeda antar kirim — sleep `DISTRIBUTE_SEND_DELAY` ANTAR
# pemilih; N pemilih → N−1 tidur (⊥ setelah pemilih terakhir) —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 200.0, '2026-08-05T00:00:00',
fresh),
('bbb2', '1.0', 300.0, '2026-08-05T00:00:00',
fresh),
('ccc3', '1.0', 500.0, '2026-08-05T00:00:00',
fresh)])
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = '5Ktest'
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txD' + to)
dist.verify_txid = lambda txid: True
sleeps = []
dist.time.sleep = lambda s: sleeps.append(s)
with redirect_stdout(io.StringIO()):
rc = dist.run_distribution(dry_run=False)
check('V63 run nyata exit 0 (jeda antar kirim)', rc == 0)
check('V63 3 pemilih → 2 jeda @ DISTRIBUTE_SEND_DELAY',
sleeps == [dist.DISTRIBUTE_SEND_DELAY] * 2)
dist.time.sleep = _orig_sleep
# ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
fresh)])
db.query = _orig_query
dist.fetch_balance = lambda: Decimal('100.0000')
_orig_record_run = db.record_run
_orig_build = dist.build_signed_transfer
dist.DISTRIBUTE_ENABLED = False
calls['runs'] = []
calls['signs'] = []
db.record_run = lambda *a, **k: calls['runs'].append(a) or 99
dist.build_signed_transfer = (lambda to, amount, memo:
calls['signs'].append(memo)
or FakeSigned('txoff'))
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
out = buf.getvalue()
check('V35 disabled → exit 0', rc == 0)
check('V35 disabled → pesan dinonaktifkan', 'dinonaktifkan' in out)
check('V35 disabled → ⊥ tulis DB', calls['runs'] == [])
check('V35 disabled → ⊥ tanda tangan', calls['signs'] == [])
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=True)
check('V35 disabled + dry-run → rencana tetap',
rc == 0 and 'RENCANA' in buf.getvalue())
dist.DISTRIBUTE_ENABLED = True
db.record_run = _orig_record_run
dist.build_signed_transfer = _orig_build
# ————— V40/V42: reward utk vote matang+segar; REVOTE langsung VALID —————
def _iso(days_ago):
return (datetime.now() - timedelta(days=days_ago)).isoformat(timespec='seconds')
make_voters_db(db.DB_PATH, [
('new1', '1.0', 1200.0, fresh, _iso(1)), # BARU — belum matang
('mid5', '1.0', 1200.0, fresh, _iso(5)), # VALID — matang+segar
('rev7', '1.0', 1200.0, fresh, _iso(1)), # REVOTE — pemilih lama revote
('old9', '1.0', 1200.0, fresh, _iso(40)), # basi — lewat 28 hari
])
# V52: first_seen — new1 baru (umur 1 hr → BARU, ⊥ matang), rev7 lama
# (first_seen 60 hr lalu → matang, VALID langsung). mid5/old9 pakai
# default matang dari make_voters_db.
make_first_seen(db.DB_PATH, [('new1', _iso(1)), ('rev7', _iso(60))])
db.query = _orig_query
dist.fetch_balance = lambda: Decimal('300.0000')
dist.BP_PRIVATE_KEY = '5Ktest'
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
signed_to = []
dist.build_signed_transfer = (lambda to, amount, memo:
signed_to.append(to) or FakeSigned('txV' + to))
calls['runs'] = []
db.record_run = lambda *a, **k: calls['runs'].append(a) or 7
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V42 REVOTE (umur 1 hr, first_seen lama) LANGSUNG dibayar',
'rev7' in signed_to)
check('V40 pemilih baru (umur 1 hr) ⊥ dibayar', 'new1' not in signed_to)
check('V40 basi (umur 40 hr) ⊥ dibayar', 'old9' not in signed_to)
check('V40 matang+segar (umur 5 hr) dibayar', 'mid5' in signed_to)
check('V40/V42 tepat 2 terkirim (mid5 + rev7)',
sorted(signed_to) == ['mid5', 'rev7'])
db.record_run = _orig_record_run
dist.build_signed_transfer = _orig_build
# ————— V30: notifikasi Telegram — dua kanal, komunitas brief/internal detail —————
tg = {'posts': []}
def tg_post(url, json=None, timeout=None):
tg['posts'].append((url, json['chat_id'], json['text']))
return fake_post({})
telegram.TELEGRAM_BOT_TOKEN = '123:TEST'
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = ['-1001']
telegram.TELEGRAM_INTERNAL_CHAT_IDS = ['-1002']
telegram.requests.post = tg_post
# send_text tanpa token → no-op senyap (⊥ post)
telegram.TELEGRAM_BOT_TOKEN = ''
telegram.send_text('tidak boleh terkirim')
telegram.send_internal('tidak boleh terkirim')
check('V30 ⊥ token → no-op senyap', tg['posts'] == [])
telegram.TELEGRAM_BOT_TOKEN = '123:TEST'
# dry-run → ⊥ notif Telegram
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00',
fresh),
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
fresh)])
db.query = _orig_query
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = '5Ktest'
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txT' + to)
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
dist.verify_txid = lambda txid: True
calls['runs'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=True)
check('V30 dry-run ⊥ notif', tg['posts'] == [])
# run nyata → komunitas 2 brief + internal 2 detail
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
rc = dist.run_distribution(dry_run=False)
check('V30 run nyata exit 0', rc == 0)
comm = [p for p in tg['posts'] if p[1] == '-1001']
intern = [p for p in tg['posts'] if p[1] == '-1002']
check('V30 komunitas 2 notif brief', len(comm) == 2)
check('V30 komunitas brief MULAI', 'DISTRIBUSI MULAI' in comm[0][2]
and 'Pemilih' not in comm[0][2])
check('V30 komunitas brief SELESAI + status', 'DISTRIBUSI SELESAI'
in comm[1][2] and 'Berhasil' in comm[1][2]
and 'Terkirim' not in comm[1][2])
check('V30 internal 2 notif detail', len(intern) == 2)
check('V30 internal detail MULAI', 'DISTRIBUSI MULAI' in intern[0][2]
and 'Pemilih: 2' in intern[0][2])
check('V30 internal detail SELESAI', 'Terkirim: 2/2 pemilih' in intern[1][2]
and 'Gagal: 0' in intern[1][2])
# no-op (pemilih kosong) → ⊥ notif baru
db.eligible_voters = lambda owner=None: []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V30 no-op ⊥ notif', len(tg['posts']) == 4)
db.eligible_voters = _orig_eligible
# failure (⊥ key) → notif gagal ke INTERNAL saja (ops detail)
dist.BP_PRIVATE_KEY = ''
try:
dist.main()
raise AssertionError('V30 gagal: main harusnya re-raise')
except RuntimeError:
check('V30 failure → re-raise RuntimeError', True)
new_posts = tg['posts'][4:]
check('V30 gagal → internal saja',
len(new_posts) == 1 and new_posts[0][1] == '-1002'
and 'DISTRIBUSI GAGAL' in new_posts[0][2])
dist.BP_PRIVATE_KEY = '5Ktest'
telegram.TELEGRAM_BOT_TOKEN = _tg_token
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = _tg_comm
telegram.TELEGRAM_INTERNAL_CHAT_IDS = _tg_int
telegram.requests.post = _tg_post
print('\nSEMUA UJI PASS')
if __name__ == '__main__':
sys.exit(main())