from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any import httpx from arbitrade.api.app import create_app from arbitrade.config.settings import Settings class _FakeAlertNotifier: def __init__(self) -> None: self.events: list[dict[str, Any]] = [] async def notify( self, *, category: str, severity: str, title: str, message: str, details: dict[str, str] | None = None, ) -> bool: self.events.append( { "category": category, "severity": severity, "title": title, "message": message, "details": details or {}, } ) return True def _seed_metrics_data(app) -> None: store = app.state.store started = datetime.now(UTC) finished = started + timedelta(seconds=20) with store.connect() as conn: conn.execute( """ INSERT INTO trades ( trade_ref, started_at, finished_at, status, realized_pnl, estimated_pnl, capital_used, cycle, leg_count ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ "trade-1", started, finished, "filled", 15.0, 10.0, 100.0, "USD->BTC->ETH->USD", 3, ], ) conn.execute( """ INSERT INTO opportunities ( detected_at, cycle, gross_pct, net_pct, est_profit, executed ) VALUES (?, ?, ?, ?, ?, ?) """, [started, "USD->BTC->ETH->USD", 4.0, 3.0, 0.03, True], ) conn.execute( """ INSERT INTO orders ( trade_ref, order_ref, leg_index, pair, side, volume, user_ref, status, filled_volume, avg_price, raw_response, recorded_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ "trade-1", "order-1", 0, "BTC/USD", "buy", 2.0, 100, "closed", 2.0, 100.0, "{}", started, ], ) conn.execute( """ INSERT INTO portfolio_snapshots ( snapshot_at, balances, total_value_usd ) VALUES (?, ?, ?) """, [started, '{"USD": 1000.0, "BTC": 0.25}', 1250.0], ) conn.execute( """ INSERT INTO trades ( trade_ref, started_at, finished_at, status, realized_pnl, estimated_pnl, capital_used, cycle, leg_count ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ "trade-open", started, None, "open", None, 5.0, 50.0, "USD->BTC->ETH->USD", 3, ], ) async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None: app = create_app(Settings(DUCKDB_PATH=tmp_path / "dash.duckdb")) _seed_metrics_data(app) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: page = await client.get("/") fragment = await client.get("/dashboard/fragment/metrics") stream = await client.get("/dashboard/stream/metrics") overview = await client.get("/dashboard/fragment/overview") overview_stream = await client.get("/dashboard/stream/overview") controls = await client.get("/dashboard/fragment/controls") charts = await client.get("/dashboard/fragment/charts") audit = await client.get("/dashboard/fragment/audit") assert page.status_code == 200 assert "EventSource" in page.text assert "alpinejs" in page.text.lower() assert "Chart.js" in page.text or "chart.umd.min.js" in page.text assert 'hx-get="/dashboard/fragment/metrics"' in page.text assert 'hx-get="/dashboard/fragment/controls"' in page.text assert 'hx-get="/dashboard/fragment/charts"' in page.text assert 'hx-get="/dashboard/fragment/audit"' in page.text assert fragment.status_code == 200 assert "Realized P&L" in fragment.text assert "15.00 USD" in fragment.text assert "100.0%" in fragment.text assert stream.status_code == 200 assert stream.headers["content-type"].startswith("text/event-stream") assert "event: metrics" in stream.text assert "Realized P&L" in stream.text assert overview.status_code == 200 assert "live" in overview.text assert "Balances Snapshot" in overview.text assert "Open Trades" in overview.text assert "Opportunity Feed" in overview.text assert "1250.00 USD" in overview.text assert "trade-open" in overview.text assert overview_stream.status_code == 200 assert overview_stream.headers["content-type"].startswith("text/event-stream") assert "event: overview" in overview_stream.text assert "trade-open" in overview_stream.text assert controls.status_code == 200 assert "Runtime Status" in controls.text assert ">running<" in controls.text assert "Alerting" in controls.text assert "Last result" in controls.text assert "Paper trading mode" in controls.text assert "Trade capital USD" in controls.text assert charts.status_code == 200 assert "Opportunity Trend" in charts.text assert "opportunity-chart" in charts.text assert "Hide chart" in charts.text or "Show chart" in charts.text assert audit.status_code == 200 assert "Audit Trail" in audit.text async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> None: app = create_app(Settings(DUCKDB_PATH=tmp_path / "controls.duckdb")) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: stop_response = await client.post("/dashboard/control/stop") start_response = await client.post("/dashboard/control/start") kill_response = await client.post( "/dashboard/control/kill-switch", data={"reason": "manual"}, ) config_response = await client.post( "/dashboard/control/config", data={ "trade_capital_usd": "250.50", "max_trade_capital_usd": "300.00", "max_concurrent_trades": "4", "paper_trading_mode": "on", }, ) assert stop_response.status_code == 200 assert ">stopped<" in stop_response.text assert start_response.status_code == 200 assert ">running<" in start_response.text assert kill_response.status_code == 200 assert ">active<" in kill_response.text assert "manual" in kill_response.text assert config_response.status_code == 200 assert "250.50 USD" in config_response.text assert "300.00 USD" in config_response.text assert "4" in config_response.text assert app.state.settings.trade_capital_usd == 250.5 assert app.state.settings.max_trade_capital_usd == 300.0 assert app.state.settings.max_concurrent_trades == 4 assert app.state.settings.paper_trading_mode is True transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: audit_recent = await client.get("/dashboard/api/audit/recent") assert audit_recent.status_code == 200 entries = audit_recent.json()["entries"] assert len(entries) >= 4 assert any(entry["event_type"] == "dashboard.control.stop" for entry in entries) assert any(entry["event_type"] == "dashboard.control.start" for entry in entries) assert any(entry["event_type"] == "dashboard.control.kill_switch" for entry in entries) assert any(entry["event_type"] == "dashboard.control.config" for entry in entries) async def test_dashboard_controls_emit_alerts(tmp_path) -> None: app = create_app(Settings(DUCKDB_PATH=tmp_path / "alerts.duckdb")) fake_notifier = _FakeAlertNotifier() app.state.alert_notifier = fake_notifier transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: await client.post("/dashboard/control/start") await client.post("/dashboard/control/stop") await client.post("/dashboard/control/kill-switch", data={"reason": "manual-test"}) assert len(fake_notifier.events) == 3 assert fake_notifier.events[0]["title"] == "Execution started" assert fake_notifier.events[1]["title"] == "Execution stopped" assert fake_notifier.events[2]["title"] == "Kill switch activated" assert fake_notifier.events[2]["details"]["reason"] == "manual-test" async def test_dashboard_requires_basic_auth_when_configured(tmp_path) -> None: app = create_app( Settings( DUCKDB_PATH=tmp_path / "auth.duckdb", DASHBOARD_AUTH_USERNAME="admin", DASHBOARD_AUTH_PASSWORD="secret", ) ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: unauthenticated = await client.get("/dashboard/fragment/overview") authenticated = await client.get( "/dashboard/fragment/overview", auth=("admin", "secret"), ) health = await client.get("/health") assert unauthenticated.status_code == 401 assert unauthenticated.headers["www-authenticate"] == 'Basic realm="Arbitrade Dashboard"' assert authenticated.status_code == 200 assert health.status_code == 200 async def test_dashboard_alert_status_api_exposes_notifier_snapshot(tmp_path) -> None: app = create_app(Settings(DUCKDB_PATH=tmp_path / "alerts-status.duckdb")) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/dashboard/api/alerts/status") assert response.status_code == 200 payload = response.json() assert payload["enabled"] is True assert "configured_channels" in payload assert "last_result" in payload