File size: 8,187 Bytes
b67fea5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a743902
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b67fea5
 
 
 
 
 
 
 
 
 
 
 
 
 
fa65b59
b67fea5
 
 
 
 
fa65b59
 
b67fea5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
10 edge-case tests for the hardened validation layer.

All network calls are mocked so the suite is fast and deterministic.
"""

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__))))

from ticker_validator import (
    ErrorCode,
    _cached_api_lookup,
    sanitize_ticker_input,
    validate_ticker,
    validate_ticker_format,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _yf_ticker_mock(company="Test Corp", exchange="NYQ", history_empty=False):
    """Return a mock yf.Ticker() object with controllable behaviour."""
    t = MagicMock()
    t.info = {"shortName": company, "exchange": exchange}
    hist = MagicMock()
    hist.empty = history_empty
    t.history.return_value = hist
    return t


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


def setUp_cache():
    """Clear the lru_cache before each test that exercises live-path logic."""
    _cached_api_lookup.cache_clear()


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestInputSanitisation(unittest.TestCase):

    def setUp(self):
        setUp_cache()

    # 1 ---
    def test_dollar_prefix_stripped(self):
        """$AAPL should validate the same as AAPL."""
        with patch(
            "ticker_validator.yf.Ticker", return_value=_yf_ticker_mock("Apple Inc.")
        ), patch("ticker_validator.is_known_ticker", return_value=True):
            result = validate_ticker("$AAPL")
        self.assertTrue(result.valid, f"Expected valid, got: {result.error}")
        self.assertEqual(result.ticker, "AAPL")

    # 2 ---
    def test_internal_spaces_stripped(self):
        """'A A P L' should resolve to 'AAPL' and validate."""
        with patch(
            "ticker_validator.yf.Ticker", return_value=_yf_ticker_mock("Apple Inc.")
        ), patch("ticker_validator.is_known_ticker", return_value=True):
            result = validate_ticker("A A P L")
        self.assertTrue(result.valid, f"Expected valid, got: {result.error}")
        self.assertEqual(result.ticker, "AAPL")

    # 3 ---
    def test_ticker_with_dot(self):
        """BRK.B should pass format validation (dot-suffix allowed)."""
        result = validate_ticker_format("BRK.B")
        self.assertTrue(result.valid, f"Expected valid format for BRK.B, got: {result.error}")
        self.assertEqual(result.ticker, "BRK.B")

    # 4 ---
    def test_crypto_ticker_rejected(self):
        """BTC should be rejected with RESERVED_WORD code and a helpful message."""
        result = validate_ticker_format("BTC")
        self.assertFalse(result.valid)
        self.assertEqual(result.code, ErrorCode.RESERVED_WORD)
        self.assertIn("crypto", result.error.lower())

    # 5 ---
    def test_etf_ticker_valid(self):
        """SPY (ETF) should be valid — ETFs live in the SEC database."""
        with patch(
            "ticker_validator.yf.Ticker", return_value=_yf_ticker_mock("SPDR S&P 500 ETF", "PCX")
        ), patch("ticker_validator.is_known_ticker", return_value=True):
            result = validate_ticker("SPY")
        self.assertTrue(result.valid, f"Expected ETF SPY to be valid, got: {result.error}")

    # 6 ---
    def test_very_long_input_rejected(self):
        """A 50-character input string must be rejected after sanitisation."""
        long_input = "A" * 50
        # After the 20-char cap, sanitised value is "AAAAAAAAAAAAAAAAAAAA" (20 chars)
        # which fails the 1-5 letter regex → INVALID_FORMAT
        result = validate_ticker_format(long_input)
        self.assertFalse(result.valid)
        self.assertIn(result.code, (ErrorCode.INVALID_FORMAT, ErrorCode.EMPTY_INPUT))

    # 7 ---
    def test_index_symbols_valid_format(self):
        """^GSPC, ^DJI, ^IXIC should pass format validation."""
        for sym in ["^GSPC", "^DJI", "^IXIC", "^RUT", "^VIX"]:
            result = validate_ticker_format(sym)
            self.assertTrue(result.valid, f"Expected {sym} to pass format check, got: {result.error}")
            self.assertEqual(result.ticker, sym)

    # 8 ---
    def test_futures_symbols_valid_format(self):
        """CL=F, GC=F, SI=F should pass format validation."""
        for sym in ["CL=F", "GC=F", "SI=F", "HG=F", "NG=F"]:
            result = validate_ticker_format(sym)
            self.assertTrue(result.valid, f"Expected {sym} to pass format check, got: {result.error}")
            self.assertEqual(result.ticker, sym)

    # 9 ---
    def test_composite_symbols_valid_format(self):
        """DX-Y.NYB should pass format validation."""
        result = validate_ticker_format("DX-Y.NYB")
        self.assertTrue(result.valid, f"Expected DX-Y.NYB to pass format check, got: {result.error}")
        self.assertEqual(result.ticker, "DX-Y.NYB")

    # 10 ---
    def test_special_characters_rejected(self):
        """'AAPL!' must be rejected as INVALID_FORMAT."""
        result = validate_ticker_format("AAPL!")
        self.assertFalse(result.valid)
        self.assertEqual(result.code, ErrorCode.INVALID_FORMAT)


class TestGracefulDegradation(unittest.TestCase):

    def setUp(self):
        setUp_cache()

    # 8 ---
    def test_graceful_degradation_api_down(self):
        """Known SEC tickers should validate locally even if yfinance is unavailable."""
        with patch(
            "ticker_validator.yf.Ticker", side_effect=TimeoutError("Connection timed out")
        ), patch("ticker_validator.is_known_ticker", return_value=True):
            result = validate_ticker("AAPL")

        self.assertTrue(result.valid, "Known SEC tickers should validate offline")
        self.assertFalse(result.warning)
        self.assertEqual(result.source, "local_db")

    # 9 ---
    def test_both_services_down(self):
        """When both yfinance AND the local DB are unavailable, return a specific error."""
        with patch(
            "ticker_validator.yf.Ticker", side_effect=Exception("API unreachable")
        ), patch(
            "ticker_validator.is_known_ticker", side_effect=Exception("DB corrupted")
        ):
            result = validate_ticker("AAPL")

        self.assertFalse(result.valid)
        self.assertIn("temporarily unavailable", result.error.lower())
        self.assertEqual(result.code, ErrorCode.API_ERROR)


class TestErrorCodePresence(unittest.TestCase):

    def setUp(self):
        setUp_cache()

    # 10 ---
    def test_error_code_present_on_every_rejection(self):
        """Every rejection scenario must carry a non-empty 'code' field."""
        cases = [
            ("",          ErrorCode.EMPTY_INPUT),
            ("123",       ErrorCode.INVALID_FORMAT),
            ("BTC",       ErrorCode.RESERVED_WORD),
            ("NULL",      ErrorCode.RESERVED_WORD),
            ("TOOLONGTIC", ErrorCode.INVALID_FORMAT),
        ]
        for raw, expected_code in cases:
            result = validate_ticker_format(raw)
            self.assertFalse(result.valid, f"Expected '{raw}' to be invalid")
            self.assertEqual(
                result.code, expected_code,
                f"Input '{raw}': expected code {expected_code!r}, got {result.code!r}",
            )

        # API-path rejections also carry codes
        with patch(
            "ticker_validator.yf.Ticker", return_value=_yf_ticker_empty()
        ), patch(
            "ticker_validator.is_known_ticker", return_value=False
        ), patch(
            "ticker_validator.find_similar_tickers", return_value=[]
        ):
            result = validate_ticker("ZZZZ")
        self.assertFalse(result.valid)
        self.assertTrue(result.code, f"Expected a non-empty code, got: {result.code!r}")
        self.assertEqual(result.code, ErrorCode.TICKER_NOT_FOUND)


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