- 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
661 lines
28 KiB
Python
661 lines
28 KiB
Python
"""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())
|