Files
stonks-oracle/tests/test_competitive_api.py
Celes Renata c85c0068a2 fix: clean up utcnow deprecation warnings, fix 12 failing tests, add CI/CD pipeline manifests
- Replace all datetime.utcnow() with datetime.now(tz=timezone.utc) across 8 files
- Fix 12 failing tests to match current implementation behavior
- Fix pytest_plugins in non-top-level conftest (moved to root conftest.py)
- Auto-fix 189 lint issues (import sorting, unused imports)
- Add CI/CD pipeline infrastructure (ARC, ArgoCD, Kargo manifests)
- Add values-beta.yaml and values-paper.yaml for staged deployments
- Update GitHub Actions workflow to use self-hosted-gremlin runners
- Add integration-test job to CI pipeline

Result: 1596 passed, 0 failed, 0 warnings
2026-04-18 03:59:28 +00:00

358 lines
14 KiB
Python

"""Unit tests for competitive API endpoints.
Tests competitor CRUD endpoints, pattern query endpoints, competitive toggle,
and auto-inference endpoint return correct data and error codes.
Requirements: 1.4, 2.5, 6.5, 8.1, 8.2, 8.5, 10.1, 10.4
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from httpx import ASGITransport, AsyncClient
from services.api.app import app
NOW = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class FakeRecord(dict):
"""Mimics asyncpg.Record for testing."""
def items(self):
return super().items()
def _make_pattern_row(ticker: str = "AAPL") -> FakeRecord:
return FakeRecord({
"id": str(uuid4()),
"source_document_id": str(uuid4()),
"source_ticker": ticker,
"target_ticker": ticker,
"catalyst_type": "earnings",
"pattern_confidence": 0.65,
"signal_direction": "bullish",
"signal_strength": 0.5,
"relationship_strength": 0.8,
"computed_at": NOW,
})
def _make_competitive_signal_row(
source_ticker: str = "MSFT",
target_ticker: str = "AAPL",
) -> FakeRecord:
return FakeRecord({
"id": str(uuid4()),
"source_document_id": str(uuid4()),
"source_ticker": source_ticker,
"target_ticker": target_ticker,
"catalyst_type": "product_launch",
"pattern_confidence": 0.55,
"signal_direction": "bearish",
"signal_strength": 0.4,
"relationship_strength": 0.7,
"computed_at": NOW,
})
def _make_decision_row(ticker: str = "AAPL") -> FakeRecord:
return FakeRecord({
"id": str(uuid4()),
"document_id": str(uuid4()),
"ticker": ticker,
"catalyst_type": "m_and_a",
"summary": "Acquisition of XYZ Corp",
"impact_score": 0.8,
"created_at": NOW,
"published_at": NOW - __import__("datetime").timedelta(days=5),
})
# ---------------------------------------------------------------------------
# Route structure tests
# ---------------------------------------------------------------------------
class TestCompetitiveRouteStructure:
"""Verify all competitive-related routes are registered."""
def test_competitive_status_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/admin/competitive/status" in paths
def test_competitive_toggle_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/admin/competitive/toggle" in paths
def test_patterns_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/patterns/{ticker}" in paths
def test_competitor_patterns_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/patterns/{ticker}/competitors" in paths
def test_competitive_signals_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/patterns/{ticker}/competitive-signals" in paths
def test_decisions_route_exists(self):
paths = [route.path for route in app.routes]
assert "/api/patterns/{ticker}/decisions" in paths
# ---------------------------------------------------------------------------
# Competitive toggle endpoint (Requirement: 6.5)
# ---------------------------------------------------------------------------
class TestCompetitiveToggleEndpoint:
"""Test competitive toggle endpoint persists state and records audit event."""
@pytest.mark.asyncio
async def test_get_competitive_status_returns_default(self):
"""GET /api/admin/competitive/status should return default enabled state."""
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=None)
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/admin/competitive/status")
assert resp.status_code == 200
data = resp.json()
assert data["competitive_enabled"] is True
assert data["source"] == "default"
@pytest.mark.asyncio
async def test_get_competitive_status_from_config(self):
"""GET /api/admin/competitive/status should read from risk_configs."""
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({
"competitive_enabled": "false",
}))
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/admin/competitive/status")
assert resp.status_code == 200
data = resp.json()
assert data["competitive_enabled"] is False
assert data["source"] == "risk_configs"
@pytest.mark.asyncio
async def test_toggle_competitive_layer(self):
"""PUT /api/admin/competitive/toggle should persist state and record audit."""
config_id = str(uuid4())
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({
"id": config_id,
"competitive_enabled": "true",
}))
mock_pool.execute = AsyncMock()
with patch("services.api.app.pool", mock_pool), \
patch("services.api.app.record_audit_event", new_callable=AsyncMock) as mock_audit:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.put(
"/api/admin/competitive/toggle",
json={"enabled": False, "operator": "test_user"},
)
assert resp.status_code == 200
data = resp.json()
assert data["competitive_enabled"] is False
assert data["previous_enabled"] is True
assert data["toggled_by"] == "test_user"
# Verify audit event was recorded
mock_audit.assert_called_once()
audit_call = mock_audit.call_args
assert audit_call.kwargs.get("event_type") or audit_call.args[1] == "competitive.layer_toggled"
@pytest.mark.asyncio
async def test_toggle_competitive_layer_enable(self):
"""PUT /api/admin/competitive/toggle should enable the layer."""
config_id = str(uuid4())
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({
"id": config_id,
"competitive_enabled": "false",
}))
mock_pool.execute = AsyncMock()
with patch("services.api.app.pool", mock_pool), \
patch("services.api.app.record_audit_event", new_callable=AsyncMock):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.put(
"/api/admin/competitive/toggle",
json={"enabled": True, "operator": "admin"},
)
assert resp.status_code == 200
data = resp.json()
assert data["competitive_enabled"] is True
assert data["previous_enabled"] is False
# ---------------------------------------------------------------------------
# Pattern query endpoints (Requirements: 10.1, 10.4)
# ---------------------------------------------------------------------------
class TestPatternQueryEndpoints:
"""Test pattern query endpoints return correct data with filtering."""
@pytest.mark.asyncio
async def test_get_competitive_signals_for_ticker(self):
"""GET /api/patterns/{ticker}/competitive-signals should return signals."""
signal_row = _make_competitive_signal_row()
mock_pool = AsyncMock()
mock_pool.fetch = AsyncMock(return_value=[signal_row])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/patterns/AAPL/competitive-signals")
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "AAPL"
assert data["count"] == 1
assert len(data["competitive_signals"]) == 1
sig = data["competitive_signals"][0]
assert sig["source_ticker"] == "MSFT"
assert sig["target_ticker"] == "AAPL"
assert sig["signal_direction"] == "bearish"
@pytest.mark.asyncio
async def test_get_competitive_signals_empty(self):
"""GET /api/patterns/{ticker}/competitive-signals with no data returns empty."""
mock_pool = AsyncMock()
mock_pool.fetch = AsyncMock(return_value=[])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/patterns/UNKNOWN/competitive-signals")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["competitive_signals"] == []
@pytest.mark.asyncio
async def test_get_patterns_with_catalyst_filter(self):
"""GET /api/patterns/{ticker}?catalyst_type=earnings should filter."""
mock_pool = AsyncMock()
# find_self_patterns is called with the pool — mock it at module level
with patch("services.api.app.pool", mock_pool), \
patch("services.api.app.find_self_patterns", new_callable=AsyncMock) as mock_find:
mock_find.return_value = []
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/patterns/AAPL?catalyst_type=earnings")
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "AAPL"
assert data["count"] == 0
# Verify find_self_patterns was called with the catalyst_type
mock_find.assert_called_once()
call_args = mock_find.call_args
assert call_args.args[1] == "AAPL"
assert call_args.args[2] == "earnings"
@pytest.mark.asyncio
async def test_get_patterns_without_filter_queries_all_catalysts(self):
"""GET /api/patterns/{ticker} without filter queries all catalyst types."""
mock_pool = AsyncMock()
# Return one catalyst type from the distinct query
mock_pool.fetch = AsyncMock(return_value=[
FakeRecord({"catalyst_type": "earnings"}),
])
with patch("services.api.app.pool", mock_pool), \
patch("services.api.app.find_self_patterns", new_callable=AsyncMock) as mock_find:
mock_find.return_value = []
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/patterns/AAPL")
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "AAPL"
@pytest.mark.asyncio
async def test_get_decisions_returns_major_decisions(self):
"""GET /api/patterns/{ticker}/decisions should return major decisions."""
decision_row = _make_decision_row()
mock_pool = AsyncMock()
mock_pool.fetch = AsyncMock(return_value=[decision_row])
with patch("services.api.app.pool", mock_pool), \
patch("services.api.app.find_self_patterns", new_callable=AsyncMock) as mock_find:
mock_find.return_value = []
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/patterns/AAPL/decisions")
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "AAPL"
assert data["count"] == 1
assert data["decisions"][0]["catalyst_type"] == "m_and_a"
assert "pattern_statistics" in data["decisions"][0]
@pytest.mark.asyncio
async def test_get_decisions_empty(self):
"""GET /api/patterns/{ticker}/decisions with no data returns empty."""
mock_pool = AsyncMock()
mock_pool.fetch = AsyncMock(return_value=[])
with patch("services.api.app.pool", mock_pool):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/patterns/UNKNOWN/decisions")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["decisions"] == []
@pytest.mark.asyncio
async def test_get_competitor_patterns(self):
"""GET /api/patterns/{ticker}/competitors should return cross-company patterns."""
mock_pool = AsyncMock()
# First fetch: competitor tickers
# Second fetch: catalyst types
mock_pool.fetch = AsyncMock(side_effect=[
[FakeRecord({"competitor_ticker": "MSFT"})],
[FakeRecord({"catalyst_type": "earnings"})],
])
with patch("services.api.app.pool", mock_pool), \
patch("services.api.app.find_cross_company_patterns", new_callable=AsyncMock) as mock_cross:
mock_cross.return_value = []
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/patterns/AAPL/competitors")
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "AAPL"
assert "cross_company_patterns" in data