Spaces:
Running
Running
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()
|