Add LAMP web dashboard: bot→MySQL publisher, PHP API, numbers-only page
- webapp/: deployable LAMP app for /var/www/ — ingest.php (token-gated writes), api.php (public read-only), schema.sql (live_price + signal_reports), vanilla-JS dashboard with plain-English indicator readings, .htaccess, deploy README - web_publisher.py: fire-and-forget publisher (bounded queue + daemon thread) with plain-English payload builder; public API contractually never raises; disabled unless WEBAPP_INGEST_URL/TOKEN are set - telegram_bot.py: publish hooks (tick in WS loop, report in _update_level_cache); _candle_loop per-iteration guard so a failing scan can never kill an auto-scan loop; quiet heartbeat teardown - test_all.py: Module 13 — 6 resilience tests (never-raise contract, worker survival, candle-loop survival); suite 189/189 - .gitignore: webapp/config.php (server-side secrets) Verified end-to-end on a scratch MariaDB 10.11 + PHP 8.3 built-in server incl. headless-Firefox render of the live page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KYNEHBuwwy6sgRM9f9sPx5
This commit is contained in:
+116
@@ -2493,6 +2493,122 @@ def _():
|
||||
assert args2.cp is False
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MODULE 13: web_publisher resilience — dashboard publishing must
|
||||
# NEVER be able to break the Telegram bot (no-raise contract) and a
|
||||
# failing scan must never kill an auto-scan loop.
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
print("\n═══ Module 13: web_publisher resilience ═══")
|
||||
|
||||
import asyncio as _aio
|
||||
import threading as _threading
|
||||
import web_publisher as wp
|
||||
|
||||
|
||||
@test("publish_* are silent no-ops when not configured")
|
||||
def _():
|
||||
old = wp.INGEST_URL, wp.INGEST_TOKEN
|
||||
wp.INGEST_URL, wp.INGEST_TOKEN = "", ""
|
||||
try:
|
||||
assert not wp.enabled()
|
||||
before = wp._queue.qsize()
|
||||
wp.publish_tick("XAU/USD", 4000.0, 3999.5)
|
||||
wp.publish_report(object()) # junk input must not even be touched
|
||||
assert wp._queue.qsize() == before
|
||||
finally:
|
||||
wp.INGEST_URL, wp.INGEST_TOKEN = old
|
||||
|
||||
|
||||
@test("publish_tick never raises: enqueue failure and junk input swallowed")
|
||||
def _():
|
||||
old = wp.INGEST_URL, wp.INGEST_TOKEN
|
||||
old_enq = wp._enqueue
|
||||
wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:1/x", "t"
|
||||
|
||||
def boom(item):
|
||||
raise RuntimeError("forced enqueue failure")
|
||||
|
||||
wp._enqueue = boom
|
||||
try:
|
||||
wp._last_tick_pub.clear()
|
||||
wp.publish_tick("XAU/USD", 4000.0, None) # enqueue explodes
|
||||
wp.publish_tick("TEST/A", "not-a-number", None) # float() explodes
|
||||
finally:
|
||||
wp._enqueue = old_enq
|
||||
wp.INGEST_URL, wp.INGEST_TOKEN = old
|
||||
|
||||
|
||||
@test("publish_report never raises: broken report and enqueue failure swallowed")
|
||||
def _():
|
||||
from signal_aggregator import SignalReport as _SR
|
||||
old = wp.INGEST_URL, wp.INGEST_TOKEN
|
||||
wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:1/x", "t"
|
||||
old_enq = wp._enqueue
|
||||
try:
|
||||
wp.publish_report(object()) # build_payload explodes internally
|
||||
|
||||
def boom(item):
|
||||
raise RuntimeError("forced enqueue failure")
|
||||
|
||||
wp._enqueue = boom
|
||||
r = _SR(symbol="XAU/USD", display_name="XAU/USD",
|
||||
timestamp="2026-07-17 12:45:00+00:00")
|
||||
wp.publish_report(r) # valid report, enqueue explodes
|
||||
finally:
|
||||
wp._enqueue = old_enq
|
||||
wp.INGEST_URL, wp.INGEST_TOKEN = old
|
||||
|
||||
|
||||
@test("build_payload works on a minimal report (no analysis, no setup)")
|
||||
def _():
|
||||
from signal_aggregator import SignalReport as _SR
|
||||
r = _SR(symbol="XAU/USD", display_name="XAU/USD",
|
||||
timestamp="2026-07-17 12:45:00+00:00")
|
||||
p = wp.build_payload(r)
|
||||
assert p["signal"] == "NEUTRAL" and p["trade_setup"] is None
|
||||
assert p["bar_ts"] == "2026-07-17 12:45:00"
|
||||
assert "summary" in p and p["indicators"] == {}
|
||||
|
||||
|
||||
@test("worker thread survives a dead ingest endpoint (queue always drains)")
|
||||
def _():
|
||||
old = wp.INGEST_URL, wp.INGEST_TOKEN
|
||||
# port 9 (discard) on localhost → immediate connection refused
|
||||
wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:9/ingest.php", "t"
|
||||
try:
|
||||
wp._last_tick_pub.clear()
|
||||
wp.publish_tick("XAU/USD", 4001.0, None)
|
||||
wp._queue.join() # hangs forever if task_done is ever skipped
|
||||
workers = [t for t in _threading.enumerate() if t.name == "web-publisher"]
|
||||
assert workers and workers[0].is_alive(), "publisher worker died"
|
||||
finally:
|
||||
wp.INGEST_URL, wp.INGEST_TOKEN = old
|
||||
|
||||
|
||||
@test("_candle_loop survives a failing scan iteration and keeps looping")
|
||||
def _():
|
||||
import telegram_bot as tb
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_scan(bot, tf, confirms):
|
||||
calls["n"] += 1
|
||||
raise RuntimeError("forced scan failure")
|
||||
|
||||
orig = (tb._scan_timeframe, tb._seconds_until_boundary, tb._feed_active)
|
||||
tb._scan_timeframe = fake_scan
|
||||
tb._seconds_until_boundary = lambda m, buffer=10: 0.01
|
||||
tb._feed_active = lambda: calls["n"] < 2
|
||||
try:
|
||||
_aio.run(_aio.wait_for(
|
||||
tb._candle_loop(None, Timeframe.M5, 5), timeout=10))
|
||||
assert calls["n"] == 2, \
|
||||
f"loop died after a failing iteration (ran {calls['n']} of 2)"
|
||||
finally:
|
||||
(tb._scan_timeframe, tb._seconds_until_boundary,
|
||||
tb._feed_active) = orig
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SUMMARY
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user