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