Files
momentum-system/tests/unit/api/test_dashboard_api.py
T
momentum devandClaude Opus 4.8 b44b707581 Fix projection bugs + test isolation surfaced while verifying
Running the suite against the cleanup exposed pre-existing breakage (these
tests had not been run since the Postgres migration / recent sync changes):

- _latest_event_payload ordered by `timestamp DESC` with no tie-break, so two
  account snapshots emitted in the same second returned the arbitrary/older one
  (test wanted 101.5k, got 100k). Tie-break on `created_at DESC`.
- query_watch_states_snapshot windowed BOTH event types to the live 45-min
  window; symbol_state_changed (~2k all-time, the watch list itself) must be
  unbounded so the list isn't empty off-hours, while criteria_evaluated (~120k,
  the 112s killer) stays windowed.
- Test isolation: the dashboard tests pass file-path db_paths expecting a fresh
  DB, but under Postgres the path is ignored — they read AND wrote the
  production `public` schema. get_connection now honours MOMENTUM_PG_SCHEMA
  (test-only) to pin all connections in a test to one throwaway schema; an
  autouse fixture sets/drops it. (Cleaned 125 leaked fixture events from prod.)
- FakeClient.get_orders missed the `nested=` kwarg added in ddf93d1.

tests/unit: 134 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:43:40 -04:00

281 lines
9.6 KiB
Python

"""Dashboard API endpoint tests over a real local HTTP server."""
import json
import os
import urllib.request
import uuid
from datetime import datetime
import psycopg2
import pytest
from api.main import DashboardState, serve_in_background
from storage.event_schema import EventMode, SignalReadyEvent
from storage.event_store import EventStore
from tests.unit.test_trading_execution import FakeExecutor, make_service
T0 = datetime(2026, 6, 11, 9, 45)
@pytest.fixture(autouse=True)
def _isolated_pg_schema(monkeypatch):
"""Pin every connection in this test to one throwaway schema, so the
seed store and the dashboard's per-request stores share an ISOLATED
database. Without this the file-path db_paths these tests pass fall
through to the production ``public`` schema (the path is ignored under
Postgres) — they'd read live data and pollute it with fixtures. The
``mem_`` prefix lets the session-scoped conftest cleanup catch any leak."""
schema = f"mem_{uuid.uuid4().hex[:12]}"
monkeypatch.setenv("MOMENTUM_PG_SCHEMA", schema)
yield
url = os.environ.get("DATABASE_URL")
if url:
conn = psycopg2.connect(url)
conn.autocommit = True
conn.cursor().execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
conn.close()
@pytest.fixture
def server(tmp_path):
db_path = str(tmp_path / "events.duckdb")
# seed a ready signal
store = EventStore(db_path)
store.emit(
SignalReadyEvent(
timestamp=T0, mode=EventMode.PAPER, correlation_id="t",
message="GOOD ready", symbol="GOOD", signal_type="bull_flag",
confidence=0.9,
signal_data={"entry_price": 14.0, "stop_loss_price": 13.45,
"quality_score": 0.7},
)
)
service = make_service(store, FakeExecutor())
service.request_approvals_for_ready_signals()
state = DashboardState(db_path, execution_service=service,
execution_mode="alpaca_paper")
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
yield base, store
httpd.shutdown()
store.close()
def get(base, path):
with urllib.request.urlopen(base + path, timeout=5) as resp:
return resp.status, json.loads(resp.read())
def post(base, path, body):
request = urllib.request.Request(
base + path, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"}, method="POST",
)
try:
with urllib.request.urlopen(request, timeout=5) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read())
def test_snapshots_endpoint_shape(server):
base, _ = server
status, data = get(base, "/api/snapshots")
assert status == 200
for key in ("execution_mode", "approval_queue", "ready_signals",
"watch_states", "accounts", "positions", "orders",
"order_lifecycle"):
assert key in data
assert data["execution_mode"] == "alpaca_paper"
assert data["ready_signals"][0]["symbol"] == "GOOD"
assert len(data["approval_queue"]) == 1
def test_approval_queue_endpoint(server):
base, _ = server
status, data = get(base, "/api/approval-queue")
assert status == 200
assert data["approval_queue"][0]["symbol"] == "GOOD"
def test_approve_endpoint_executes_and_clears_queue(server):
base, _ = server
_, data = get(base, "/api/approval-queue")
order_id = data["approval_queue"][0]["order_id"]
status, result = post(base, "/api/trading/approvals/approve",
{"order_id": order_id})
assert status == 200 and result["ok"]
_, after = get(base, "/api/approval-queue")
assert after["approval_queue"] == []
def test_reject_endpoint(server):
base, _ = server
_, data = get(base, "/api/approval-queue")
order_id = data["approval_queue"][0]["order_id"]
status, result = post(base, "/api/trading/approvals/reject",
{"order_id": order_id, "reason": "test"})
assert status == 200 and result["ok"]
def test_approve_unknown_order_returns_400(server):
base, _ = server
status, result = post(base, "/api/trading/approvals/approve",
{"order_id": "nope"})
assert status == 400
assert not result["ok"]
def test_unknown_route_404(server):
base, _ = server
try:
urllib.request.urlopen(base + "/api/nope", timeout=5)
raised = False
except urllib.error.HTTPError as exc:
raised = exc.code == 404
assert raised
def test_dashboard_html_served(server):
base, _ = server
with urllib.request.urlopen(base + "/", timeout=5) as resp:
assert resp.status == 200
body = resp.read().decode()
assert "Momentum" in body and "/api/snapshots" in body
def test_actions_503_without_execution_service(tmp_path):
state = DashboardState(str(tmp_path / "e.duckdb"), execution_service=None)
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
status, result = post(base, "/api/trading/exit-order", {"symbol": "GOOD"})
assert status == 503
assert not result["ok"]
finally:
httpd.shutdown()
# ---------------------------------------------------------------------------
# Interactive endpoints (SSE, criteria, bars, manual inject) + new snapshot keys
# ---------------------------------------------------------------------------
def test_snapshot_includes_pnl_and_fills(server):
base, _ = server
_, data = get(base, "/api/snapshots")
assert "pnl" in data and "fills" in data and "has_execution" in data
assert data["has_execution"] is True
# pnl has the expected stat keys even with no closed trades yet
for key in ("realized_pnl", "unrealized_pnl", "total_pnl", "win_rate",
"open_positions", "closed_trades"):
assert key in data["pnl"]
def test_criteria_endpoint(tmp_path):
from storage.event_schema import CriteriaEvaluatedEvent
db_path = str(tmp_path / "crit.duckdb")
store = EventStore(db_path)
store.emit(
CriteriaEvaluatedEvent(
timestamp=T0, mode=EventMode.PAPER, message="c", symbol="GOOD",
criteria_results={"passed": ["gap", "vwap"], "failed": ["impulse"]},
total_criteria=9, passed_criteria=2, success_score_pct=22.0,
)
)
state = DashboardState(db_path, execution_mode="alpaca_paper")
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
status, data = get(base, "/api/criteria?symbol=GOOD")
assert status == 200
assert data["symbol"] == "GOOD"
assert data["score"] == 22.0
by_key = {c["key"]: c["passed"] for c in data["criteria"]}
assert by_key["gap"] is True
assert by_key["impulse"] is False
finally:
httpd.shutdown()
store.close()
def test_bars_endpoint_with_research_db(tmp_path):
from research.ingestion.market_data import (
ingest_daily_history,
ingest_live_minute_bars,
)
from storage.db import get_connection
from tests.integration.test_end_to_end import FakeAlpaca
con = get_connection(":memory:")
client = FakeAlpaca()
ingest_daily_history(con, client, ["GOOD"])
ingest_live_minute_bars(con, client, ["GOOD"])
db_path = str(tmp_path / "bars.duckdb")
EventStore(db_path).close()
state = DashboardState(db_path, execution_mode="alpaca_paper", research_con=con)
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
status, data = get(base, "/api/bars?symbol=GOOD&minutes=40")
assert status == 200
# falls back to the latest available session for the symbol
assert len(data["bars"]) == 12
assert all("c" in b for b in data["bars"])
finally:
httpd.shutdown()
def test_bars_endpoint_no_research_db(server):
base, _ = server
status, data = get(base, "/api/bars?symbol=GOOD")
assert status == 200
assert data["bars"] == []
def test_watch_add_endpoint(tmp_path):
db_path = str(tmp_path / "watch.duckdb")
EventStore(db_path).close()
injected = []
state = DashboardState(
db_path, execution_mode="alpaca_paper",
watch_inject=lambda sym: injected.append(sym),
)
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
status, result = post(base, "/api/watch/add", {"symbol": "tsla"})
assert status == 200 and result["ok"]
assert result["symbol"] == "TSLA"
assert injected == ["TSLA"]
finally:
httpd.shutdown()
def test_watch_add_503_without_inject(server):
base, _ = server # this server has no watch_inject configured
status, result = post(base, "/api/watch/add", {"symbol": "TSLA"})
assert status == 503
assert not result["ok"]
def test_stream_delivers_snapshot_frame(server):
base, _ = server
# read the SSE stream until we see one snapshot event, then bail
req = urllib.request.urlopen(base + "/api/stream", timeout=5)
try:
buf = b""
got = False
for _ in range(50):
line = req.readline()
if not line:
break
buf += line
if b"event: snapshot" in buf and buf.rstrip().endswith(b"}"):
got = True
break
assert got, "no snapshot frame received from SSE stream"
finally:
req.close()