Files
databisnisid/test_dashboard.py
T

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()