307 lines
13 KiB
Python
307 lines
13 KiB
Python
"""Uji verifikasi dashboard (oracle).
|
|
|
|
`./venv/bin/python test_dashboard.py` harus exit 0.
|
|
Memakai DB sementara, bukan voters.db asli (read-only invariant tetap diverifikasi).
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import tempfile
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import requests
|
|
|
|
import dashboard
|
|
import db as dbm
|
|
|
|
|
|
def check(tag, cond):
|
|
if not cond:
|
|
raise AssertionError(f'GAGAL: {tag}')
|
|
print(f'ok: {tag}')
|
|
|
|
|
|
def make_db(path, rows):
|
|
conn = sqlite3.connect(path)
|
|
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,
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def main():
|
|
tmp = tempfile.mkdtemp()
|
|
db = os.path.join(tmp, 'voters.db')
|
|
rows = []
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
fresh = (now - timedelta(days=5)).isoformat(timespec='seconds')
|
|
for i in range(1, 126): # 125 baris -> 3 halaman
|
|
owner = f'acct{i:03d}'
|
|
rows.append((owner, f'{i}.00000000000000000', float(i) * 10.0,
|
|
'2026-08-04T00:00:00', fresh))
|
|
rows[5] = (rows[5][0], rows[5][1], 5.0 * 10.0, rows[5][3], fresh) # non-monotonic guard
|
|
make_db(db, rows)
|
|
|
|
dbm.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan)
|
|
real_get_liquid = dashboard._get_liquid_vex
|
|
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
|
|
|
|
# ————— V27: seed riwayat distribusi utk uji /history —————
|
|
# run lama di-seed duluan agar run_id-nya lebih kecil (⊥ run terbaru).
|
|
today = datetime.now().strftime('%Y-%m-%d')
|
|
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
|
dbm.ensure_distribute_schema()
|
|
old_rid = dbm.record_run(yesterday, 50000.0, 1, 1000.0, 80.0, 'ok',
|
|
'2026-08-04T10:00:00')
|
|
dbm.record_payment(old_rid, 'oldacct', 40.0, 'sent',
|
|
'2026-08-04T10:00:01', txid='txold999')
|
|
rid = dbm.record_run(today, 52186.0417, 1, 1200.0, 100.0, 'ok',
|
|
'2026-08-05T10:00:00')
|
|
dbm.record_payment(rid, 'acct001', 50.0, 'sent',
|
|
'2026-08-05T10:00:01', txid='txabc123')
|
|
|
|
client = dashboard.app.test_client()
|
|
|
|
# V10: judul halaman & kolom list (bobot suara tak ditampilkan, vote segar ada)
|
|
resp = client.get('/?page=1')
|
|
check('V10 halaman 1 → 200', resp.status_code == 200)
|
|
html = resp.get_data(as_text=True)
|
|
check('V10 judul halaman', 'DAFTAR PEMILIH' in html)
|
|
check('V13 kolom VOTE TERAKHIR', 'VOTE TERAKHIR' in html)
|
|
check('V15 kolom BOBOT SUARA tak tampil', 'BOBOT SUARA' not in html)
|
|
check('V15 tak ada sel .weight', 'class="weight"' not in html)
|
|
check('V15 tak ada penanda basi', 'stale-mark' not in html and 'BASI' not in html)
|
|
check('V16 4 sel stats (3 pemilih + saldo liquid)',
|
|
html.count('spec-cell') == 4)
|
|
check('V16 sel SALDO LIQUID', 'SALDO LIQUID' in html)
|
|
check('V16 SALDO LIQUID paling kiri (sblm TOTAL PEMILIH)',
|
|
html.index('SALDO LIQUID') < html.index('TOTAL PEMILIH'))
|
|
check('V16 nilai saldo diformat id-ID', '50.691,4676' in html)
|
|
|
|
staked_vals = []
|
|
for i in range(1, 4):
|
|
resp = client.get(f'/?page={i}')
|
|
check(f'V10 halaman {i} → 200', resp.status_code == 200)
|
|
staked_vals += _extract_stakes(resp.get_data(as_text=True))
|
|
check('V10 total 125 baris ditarik', len(staked_vals) == 125)
|
|
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
|
|
for i in range(len(staked_vals) - 1)))
|
|
|
|
# ————— V29: kolom TOTAL REWARD (Σ transfer sent per voter, 0 bila tanpa) —————
|
|
resp = client.get('/?page=1')
|
|
html = resp.get_data(as_text=True)
|
|
check('V29 kolom TOTAL REWARD (VEX)', 'TOTAL REWARD (VEX)' in html)
|
|
check('V29 reward 0 utk tanpa transfer',
|
|
'<span class="reward" data-label="TOTAL REWARD (VEX)">0,0000</span>'
|
|
in html)
|
|
resp = client.get('/?page=3')
|
|
html = resp.get_data(as_text=True)
|
|
check('V29 reward acct001 = total sent (50,0000)',
|
|
'<span class="reward" data-label="TOTAL REWARD (VEX)">50,0000</span>'
|
|
in html)
|
|
|
|
# ————— V38: banner BERITA jadwal distribusi berikutnya —————
|
|
dashboard._next_schedule = lambda: datetime(2026, 8, 17, 10, 0)
|
|
resp = client.get('/')
|
|
html = resp.get_data(as_text=True)
|
|
check('V38 tag BERITA', 'BERITA' in html)
|
|
check('V38 judul DISTRIBUSI BERIKUTNYA', 'DISTRIBUSI BERIKUTNYA' in html)
|
|
check('V38 tanggal id-ID (Senin 17 Agt)',
|
|
'SENIN · 17 AGUSTUS 2026 · 10:00' in html)
|
|
check('V38 baris berulang', 'SETIAP SABTU · 10:00' in html)
|
|
|
|
# drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor)
|
|
import distribute_loop
|
|
drift_dts = [datetime(2026, 8, 6, 9, 0), datetime(2026, 8, 17, 11, 0),
|
|
datetime(2026, 8, 22, 9, 0), datetime(2026, 8, 22, 11, 0),
|
|
datetime(2026, 8, 15, 9, 0)]
|
|
fr = dashboard._parse_first_run('2026-08-17')
|
|
check('V38 drift-guard one-off = distribute_loop',
|
|
all(dashboard._next_boundary(n, 10, 5, fr)
|
|
== distribute_loop.next_boundary(n, 10, 5, fr)
|
|
for n in drift_dts))
|
|
check('V38 drift-guard mingguan = distribute_loop',
|
|
all(dashboard._next_boundary(n, 10, 5)
|
|
== distribute_loop.next_boundary(n, 10, 5)
|
|
for n in drift_dts))
|
|
|
|
# halaman melebihi total → dibatasi (tidak error)
|
|
resp = client.get('/?page=99')
|
|
check('V10 halaman ekstra → 200', resp.status_code == 200)
|
|
|
|
# V10: read-only — tidak ada tabel baru / perubahan pada DB asli
|
|
before = _table_hashes(db)
|
|
client.get('/')
|
|
client.get('/history')
|
|
check('V10 read-only (DB tak berubah)', _table_hashes(db) == before)
|
|
|
|
# ————— V27: /history — riwayat distribusi (read-only) —————
|
|
resp = client.get('/history')
|
|
check('V27 /history → 200', resp.status_code == 200)
|
|
html = resp.get_data(as_text=True)
|
|
check('V27 judul RIWAYAT DISTRIBUSI', 'RIWAYAT DISTRIBUSI' in html)
|
|
check('V27 run date tampil', today in html)
|
|
check('V27 tanggal di judul TRANSFER PER PEMILIH',
|
|
f'TRANSFER PER PEMILIH · {today}' in html)
|
|
check('V27 transfer per-pemilih tampil', 'acct001' in html)
|
|
check('V27 hanya run terbaru (transfer lama tak tampil)',
|
|
'oldacct' not in html)
|
|
check('V27 status SENT tampil', 'SENT' in html)
|
|
check('V27 memo tak dirender', f'DATABISNISID PROFIT SHARE {today}' not in html)
|
|
check('V27 txid tercatat', 'txabc123' in html)
|
|
check('V27 txid jadi link explorer',
|
|
'href="https://vexascan.com/transaction/txabc123"' in html)
|
|
|
|
# ————— V11: search server-side ?q= (substring, case-insensitive) —————
|
|
resp = client.get('/?q=acct12')
|
|
check('V11 search ?q= → 200', resp.status_code == 200)
|
|
html = resp.get_data(as_text=True)
|
|
check('V11 hanya akun cocok', 'acct125' in html and 'acct120' in html
|
|
and 'acct119' not in html)
|
|
check('V11 caption HASIL · 6', 'HASIL · 6 AKUN' in html)
|
|
q_vals = _extract_stakes(html)
|
|
check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True)
|
|
and len(q_vals) == 6)
|
|
|
|
# pager membawa q
|
|
check('V11 pager mempertahankan q', 'q=acct12' in html)
|
|
|
|
# halaman ekstra saat filter → dipatok ke total_pages filter
|
|
resp = client.get('/?q=acct12&page=99')
|
|
check('V11 clamp halaman saat search', resp.status_code == 200)
|
|
|
|
# search tanpa hasil → status kosong
|
|
resp = client.get('/?q=zzzz')
|
|
html = resp.get_data(as_text=True)
|
|
check('V11 tak ada hasil → pesan kosong', 'TAK ADA AKUN COCOK' in html)
|
|
|
|
# ————— V11: /api/search (JSON, rank global via ROW_NUMBER) —————
|
|
resp = client.get('/api/search?q=acct12')
|
|
data = resp.get_json()
|
|
check('V11 /api/search shape', set(data.keys()) == {'query', 'count', 'cap', 'results'})
|
|
check('V11 /api/search count', data['count'] == 6)
|
|
owners = [r['owner'] for r in data['results']]
|
|
ranks = [r['rank'] for r in data['results']]
|
|
check('V11 /api/search cocok substring',
|
|
all('acct12' in o for o in owners))
|
|
check('V11 /api/search rank global',
|
|
ranks == sorted(ranks) and ranks == list(range(1, 7)))
|
|
check('V11 /api/search rank sejajar staked tertinggi',
|
|
[r['owner'] for r in data['results']] ==
|
|
['acct125', 'acct124', 'acct123', 'acct122', 'acct121', 'acct120'])
|
|
check('V11 /api/search urut staked DESC',
|
|
[r['staked'] for r in data['results']] == sorted(
|
|
[r['staked'] for r in data['results']], reverse=True))
|
|
check('V11 /api/search bobot string verbatim',
|
|
isinstance(data['results'][0]['weight'], str))
|
|
check('V11 /api/search last_vote hadir',
|
|
all(r['last_vote'] for r in data['results']))
|
|
check('V29 search total_reward hadir',
|
|
all('total_reward' in r for r in data['results']))
|
|
check('V29 search total_reward 0 (tanpa transfer)',
|
|
all(r['total_reward'] == 0 for r in data['results']))
|
|
|
|
resp = client.get('/api/search?q=acct001')
|
|
data = resp.get_json()
|
|
check('V29 search total_reward acct001 = 50.0',
|
|
len(data['results']) == 1 and data['results'][0]['total_reward'] == 50.0)
|
|
|
|
resp = client.get('/api/search?q=zzzz')
|
|
check('V11 /api/search kosong', resp.get_json()['count'] == 0)
|
|
|
|
# wildcard `%`/`_` dari pengguna dibuang (escape `!`, bukan pola):
|
|
# tak ada owner berisi `_` atau `%` → 0 hasil bila benar-benar literal
|
|
resp = client.get('/api/search?q=acct!_')
|
|
check('V17 wildcard `_` di-escape (bukan pola)', resp.get_json()['count'] == 0)
|
|
resp = client.get('/api/search?q=acct!%')
|
|
check('V17 wildcard `%` di-escape (bukan pola)', resp.get_json()['count'] == 0)
|
|
|
|
# rank HARUS global, bukan dalam-filter: 'acct00' menyentuh baris stake kecil
|
|
resp = client.get('/api/search?q=acct00')
|
|
data = resp.get_json()
|
|
check('V11 rank global (bukan dalam-filter)',
|
|
[r['rank'] for r in data['results']] == list(range(117, 126)))
|
|
|
|
# ————— env config: default = konstanta lama —————
|
|
import config
|
|
check('V11 config default TARGET_BP', config.TARGET_BP == 'databisnisid')
|
|
check('V11 config default API_NODE', config.API_NODE == 'https://v2.vexascan.com:2096')
|
|
check('V11 config default PAGE_SIZE', config.PAGE_SIZE == 50)
|
|
check('V11 config default MIN_STAKED_VEX', config.MIN_STAKED_VEX == 1000.0)
|
|
check('V11 config default DB_PATH', config.DB_PATH == 'voters.db')
|
|
check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2)
|
|
check('V13 config default VEX_STALE_DAYS', config.VEX_STALE_DAYS == 28)
|
|
check('V16 config default DATABISNIS_API', config.DATABISNIS_API == 'https://api.databisnis.id')
|
|
check('V16 config default DASH_LIQUID_TTL', config.DASH_LIQUID_TTL == 60)
|
|
check('V17 config default DB_BACKEND', config.DB_BACKEND == 'sqlite')
|
|
check('V17 config default DB_HOST/PORT', config.DB_HOST == '127.0.0.1' and config.DB_PORT == 3306)
|
|
check('V17 config default DB_USER/PASS/NAME',
|
|
(config.DB_USER, config.DB_PASS, config.DB_NAME) == ('', '', ''))
|
|
|
|
# ————— V16: saldo liquid — cache TTL + cooldown kegagalan —————
|
|
dashboard._get_liquid_vex = real_get_liquid
|
|
dashboard._liquid_cache.update(at=0.0, value=None)
|
|
calls = {'n': 0}
|
|
|
|
def fake_ok(url, params=None, timeout=None):
|
|
calls['n'] += 1
|
|
return type('R', (), {
|
|
'ok': True,
|
|
'raise_for_status': lambda self: None,
|
|
'json': lambda self: {'account': {'core_liquid_balance': '12345.6789 VEX'}},
|
|
})()
|
|
|
|
def fake_fail(url, params=None, timeout=None):
|
|
calls['n'] += 1
|
|
raise requests.exceptions.ConnectionError('api down')
|
|
|
|
dashboard.requests.get = fake_ok
|
|
check('V16 fetch saldo → float', dashboard._get_liquid_vex() == 12345.6789)
|
|
check('V16 TTL belum lewat → 1 fetch', calls['n'] == 1)
|
|
check('V16 panggilan kedua dari cache',
|
|
dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 1)
|
|
|
|
dashboard._liquid_cache.update(at=0.0, value=12345.6789) # cache kedaluwarsa
|
|
dashboard.requests.get = fake_fail
|
|
check('V16 gagal → nilai lama tetap',
|
|
dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 2)
|
|
check('V16 cooldown → API tak dipukul lagi',
|
|
dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 2)
|
|
|
|
dashboard._liquid_cache.update(at=0.0, value=None)
|
|
check('V16 gagal tanpa cache → None',
|
|
dashboard._get_liquid_vex() is None and calls['n'] == 3)
|
|
|
|
|
|
def _extract_stakes(text):
|
|
"""Ambil nilai stake (dalam urutan kemunculan) dari baris HTML."""
|
|
return [_parse_vex(m.group(1))
|
|
for m in re.finditer(r'<span class="stake"[^>]*>(.*?)</span>', text)]
|
|
|
|
|
|
def _parse_vex(text):
|
|
# format '1.749.791,2303' → float
|
|
return float(text.replace('.', '').replace(',', '.'))
|
|
|
|
|
|
def _table_hashes(path):
|
|
conn = sqlite3.connect(path)
|
|
try:
|
|
rows = conn.execute('SELECT owner, weight, staked, scanned_at '
|
|
'FROM voters ORDER BY owner').fetchall()
|
|
finally:
|
|
conn.close()
|
|
return tuple(rows)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|