File size: 10,551 Bytes
7cbea93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""
End-to-end integration tests for the complete validation system.

These tests exercise the full request β†’ validation β†’ data-fetch β†’ response flow
using the real Flask app with network calls mocked out.

Network-required tests are skipped automatically in offline environments.

Run the full suite:
    python -m pytest tests/test_e2e_validation.py -v

Run only offline-safe tests:
    python -m pytest tests/test_e2e_validation.py -v -m "not network"
"""

import asyncio
import os
import sys
import unittest
from unittest.mock import MagicMock, patch

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import app as app_module
from app import app, _rate_limit_store
from ticker_validator import _cached_api_lookup


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _yf_mock(company="Test Corp", exchange="NYQ"):
    """Return a mock yf.Ticker() that looks like a real, active stock."""
    t = MagicMock()
    t.info = {"shortName": company, "exchange": exchange}
    hist = MagicMock()
    hist.empty = False
    t.history.return_value = hist
    return t


def _yf_empty():
    """Return a mock that looks like an unknown ticker (empty info, no history)."""
    t = MagicMock()
    t.info = {}
    hist = MagicMock()
    hist.empty = True
    t.history.return_value = hist
    return t


def _fake_market_data(ticker):
    """Minimal market-data dict β€” mirrors what data_fetcher.fetch_market_data returns."""
    return {
        "ticker": ticker,
        "company_name": "Apple Inc.",
        "current_price": 185.50,
        "market_cap": 2_900_000_000_000,
        "pe_ratio": 28.5,
        "52_week_high": 199.62,
        "52_week_low": 124.17,
    }


def _fake_prompt(ticker, company_name, market_data):
    """Minimal grounded prompt that includes real data values."""
    price = market_data.get("current_price", "N/A")
    return (
        f"Analyze {ticker} ({company_name}). "
        f"Current price: ${price}. "
        "Base your analysis strictly on the real data provided above."
    )


def _clear_state():
    """Reset all shared state between tests."""
    _rate_limit_store.clear()
    _cached_api_lookup.cache_clear()


# ---------------------------------------------------------------------------
# Test suite
# ---------------------------------------------------------------------------

class TestE2EValidation(unittest.TestCase):

    def setUp(self):
        _clear_state()
        self.client = app.test_client()

    # 1 ---
    def test_e2e_valid_ticker_full_flow(self):
        """AAPL through the analyze API: validation passes, real market data attached,
        grounded prompt references Apple, response includes both analysis and market data."""
        fake_report = {
            "ticker": "AAPL",
            "risk_score": 42,
            "llm_insights": {},
        }
        mock_iris = MagicMock()
        mock_iris.run_one_ticker.return_value = fake_report

        with patch("ticker_validator.yf.Ticker", return_value=_yf_mock("Apple Inc.")), \
             patch("ticker_validator.is_known_ticker", return_value=True), \
             patch("app.iris_app", mock_iris), \
             patch("app._fetch_market_data", side_effect=_fake_market_data), \
             patch("app._build_risk_prompt", side_effect=_fake_prompt), \
             patch("app.get_latest_llm_reports", return_value={}):
            resp = self.client.get("/api/analyze?ticker=AAPL")

        self.assertEqual(resp.status_code, 200)
        data = resp.get_json()

        # Market data must be included so the frontend can render real numbers
        self.assertIn("market_data", data, "Response must include real market data")
        self.assertIn("current_price", data["market_data"])

        # Grounded prompt must reference real company data (not hallucinated)
        self.assertIn("grounded_prompt", data, "Response must include the grounded LLM prompt")
        self.assertIn("Apple", data["grounded_prompt"])
        self.assertIn("185.5", data["grounded_prompt"])

        # Confirm iris_app.run_one_ticker was actually called (analysis happened)
        mock_iris.run_one_ticker.assert_called_once()

    # 2 ---
    def test_e2e_invalid_ticker_blocked(self):
        """XYZZY (unknown ticker) must be blocked at the validation gate β€”
        the LLM analysis function must never be called."""
        mock_iris = MagicMock()

        with patch("ticker_validator.yf.Ticker", return_value=_yf_empty()), \
             patch("ticker_validator.is_known_ticker", return_value=False), \
             patch("ticker_validator.find_similar_tickers", return_value=["XYZ", "XYZT"]), \
             patch("app.iris_app", mock_iris):
            resp = self.client.get("/api/analyze?ticker=XYZZY")

        self.assertEqual(resp.status_code, 422)
        data = resp.get_json()
        self.assertFalse(data.get("valid", True), "Response must report invalid")
        self.assertIn("error", data, "Response must include error message")
        self.assertIn("suggestions", data, "Response must include suggestions")
        self.assertIsInstance(data["suggestions"], list)

        # The LLM must never have been invoked
        mock_iris.run_one_ticker.assert_not_called()

    # 3 ---
    def test_e2e_format_error_never_hits_backend(self):
        """Format-invalid input ('123!!!') must be rejected before yfinance is called.
        The rejection happens in ticker_validator.validate_ticker_format, not in the DB
        or network layer."""
        mock_yf = MagicMock()

        with patch("ticker_validator.yf.Ticker", mock_yf):
            resp = self.client.post(
                "/api/validate-ticker",
                json={"ticker": "123!!!"},
                content_type="application/json",
            )

        self.assertEqual(resp.status_code, 200)
        data = resp.get_json()
        self.assertFalse(data["valid"], "123!!! must be rejected")
        self.assertIn("code", data, "Rejection must carry a structured error code")

        # yfinance must never have been touched β€” format check is instant
        mock_yf.assert_not_called()

    # 4 ---
    def test_e2e_suggestion_is_valid(self):
        """Submitting 'AAPPL' (typo) returns suggestions; submitting the first
        suggestion passes full validation."""
        # Step 1: submit the typo β€” expect a rejection with suggestions
        with patch("ticker_validator.yf.Ticker", return_value=_yf_empty()), \
             patch("ticker_validator.is_known_ticker", return_value=False), \
             patch("ticker_validator.find_similar_tickers", return_value=["AAPL", "PPL"]):
            resp1 = self.client.post(
                "/api/validate-ticker",
                json={"ticker": "AAPPL"},
                content_type="application/json",
            )

        self.assertEqual(resp1.status_code, 200)
        data1 = resp1.get_json()
        self.assertFalse(data1["valid"])
        suggestions = data1.get("suggestions", [])
        self.assertGreater(len(suggestions), 0, "Expected at least one suggestion for 'AAPPL'")

        # Step 2: submit the first suggestion β€” it must pass validation
        _cached_api_lookup.cache_clear()
        first = suggestions[0]

        with patch("ticker_validator.yf.Ticker", return_value=_yf_mock("Apple Inc.")), \
             patch("ticker_validator.is_known_ticker", return_value=True):
            resp2 = self.client.post(
                "/api/validate-ticker",
                json={"ticker": first},
                content_type="application/json",
            )

        self.assertEqual(resp2.status_code, 200)
        data2 = resp2.get_json()
        self.assertTrue(
            data2["valid"],
            f"Suggestion '{first}' should pass validation; got: {data2}",
        )

    # 5 ---
    def test_e2e_concurrent_requests(self):
        """10 concurrent validation requests (via asyncio.gather + asyncio.to_thread)
        must all succeed and leave the ticker DB in a consistent state."""
        tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "META",
                   "TSLA", "NVDA", "JPM", "V", "JNJ"]
        results: dict = {}

        def _validate_one(ticker: str) -> None:
            with patch("ticker_validator.yf.Ticker",
                       return_value=_yf_mock(f"{ticker} Corp")), \
                 patch("ticker_validator.is_known_ticker", return_value=True):
                client = app.test_client()
                resp = client.post(
                    "/api/validate-ticker",
                    json={"ticker": ticker},
                    content_type="application/json",
                )
                results[ticker] = resp.get_json()

        async def _run_all() -> None:
            await asyncio.gather(
                *[asyncio.to_thread(_validate_one, t) for t in tickers]
            )

        asyncio.run(_run_all())

        self.assertEqual(len(results), 10, "All 10 requests must complete")
        for ticker, data in results.items():
            self.assertTrue(
                data.get("valid"),
                f"Ticker {ticker} should be valid; got: {data}",
            )

    # 6 ---
    def test_e2e_rate_limiting(self):
        """35 rapid requests to /api/validate-ticker from the same IP:
        the first 30 must succeed (HTTP 200), the remaining 5 must be rate-limited (HTTP 429)."""
        _rate_limit_store.clear()

        # Pre-warm the LRU cache so yfinance is never actually called after the first lookup
        with patch("ticker_validator.yf.Ticker", return_value=_yf_mock("Apple Inc.")), \
             patch("ticker_validator.is_known_ticker", return_value=True):
            statuses = []
            for _ in range(35):
                resp = self.client.post(
                    "/api/validate-ticker",
                    json={"ticker": "AAPL"},
                    content_type="application/json",
                )
                statuses.append(resp.status_code)

        successes    = statuses.count(200)
        rate_limited = statuses.count(429)

        self.assertEqual(
            successes, 30,
            f"Expected 30 successful responses, got {successes}. Statuses: {statuses}",
        )
        self.assertEqual(
            rate_limited, 5,
            f"Expected 5 rate-limited (429) responses, got {rate_limited}. Statuses: {statuses}",
        )


if __name__ == "__main__":
    unittest.main()