"""Property-based tests for the Override Trade Tab. Feature: override-trade-tab Property 1: Ticker validation and normalization Property 2: Override job payload completeness Property 3: Invalid override order rejection """ from __future__ import annotations import json import re from unittest.mock import AsyncMock, MagicMock, patch from hypothesis import given, settings from hypothesis import strategies as st from pydantic import ValidationError from starlette.testclient import TestClient import services.trading.app as _app_module from services.trading.app import OverrideOrderRequest, app TICKER_PATTERN = re.compile(r"^[A-Z]{1,10}$") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_fake_engine(redis_rpush_side_effect=None): """Create a fake engine object with a mock Redis client.""" fake_engine = MagicMock() fake_engine.running = True fake_engine.redis = AsyncMock() fake_engine.redis.rpush = AsyncMock(side_effect=redis_rpush_side_effect) return fake_engine def _override_client(fake_engine): """Return a TestClient with the module-level engine replaced.""" original = _app_module.engine _app_module.engine = fake_engine client = TestClient(app, raise_server_exceptions=False) return client, original def _restore_engine(original): _app_module.engine = original # --------------------------------------------------------------------------- # Strategies # --------------------------------------------------------------------------- # Strategy for valid tickers: 1-10 uppercase alpha characters valid_ticker_st = st.from_regex(r"[A-Za-z]{1,10}", fullmatch=True) # Strategy for valid sides valid_side_st = st.sampled_from(["buy", "sell"]) # Strategy for valid positive quantities valid_quantity_st = st.floats(min_value=0.01, max_value=1_000_000.0, allow_nan=False, allow_infinity=False) # Strategy for valid order types valid_order_type_st = st.sampled_from(["market", "limit", "stop", "stop_limit"]) # Strategy for valid positive prices valid_price_st = st.floats(min_value=0.01, max_value=1_000_000.0, allow_nan=False, allow_infinity=False) @st.composite def valid_override_order_st(draw): """Generate a valid override order request dict.""" ticker = draw(valid_ticker_st) side = draw(valid_side_st) quantity = draw(valid_quantity_st) order_type = draw(valid_order_type_st) order = { "ticker": ticker, "side": side, "quantity": quantity, "order_type": order_type, } if order_type in ("limit", "stop_limit"): order["limit_price"] = draw(valid_price_st) if order_type in ("stop", "stop_limit"): order["stop_price"] = draw(valid_price_st) return order @st.composite def invalid_override_order_st(draw): """Generate an override order request that violates at least one validation rule. Possible violations: - Invalid ticker format (digits, spaces, special chars, empty, too long) - Non-positive quantity (zero or negative) - Missing limit_price for limit/stop_limit orders - Missing stop_price for stop/stop_limit orders """ violation = draw(st.sampled_from([ "bad_ticker", "non_positive_quantity", "missing_limit_price", "missing_stop_price", ])) if violation == "bad_ticker": # Generate a ticker that does NOT match ^[A-Z]{1,10}$ after uppercasing bad_ticker = draw(st.sampled_from([ "", # empty "ABCDEFGHIJK", # 11 chars — too long draw(st.from_regex(r"[0-9]{1,5}", fullmatch=True)), # digits draw(st.from_regex(r"[A-Z]{1,5} [A-Z]{1,5}", fullmatch=True)), # spaces draw(st.from_regex(r"[A-Z]{1,5}[^A-Za-z0-9]", fullmatch=True)), # special char ])) return { "ticker": bad_ticker, "side": "buy", "quantity": 10.0, "order_type": "market", } if violation == "non_positive_quantity": qty = draw(st.floats(min_value=-1_000_000.0, max_value=0.0, allow_nan=False, allow_infinity=False)) return { "ticker": "AAPL", "side": "buy", "quantity": qty, "order_type": "market", } if violation == "missing_limit_price": order_type = draw(st.sampled_from(["limit", "stop_limit"])) order = { "ticker": "AAPL", "side": "buy", "quantity": 10.0, "order_type": order_type, } # For stop_limit, provide stop_price but NOT limit_price if order_type == "stop_limit": order["stop_price"] = 100.0 return order # violation == "missing_stop_price" order_type = draw(st.sampled_from(["stop", "stop_limit"])) order = { "ticker": "AAPL", "side": "buy", "quantity": 10.0, "order_type": order_type, } # For stop_limit, provide limit_price but NOT stop_price if order_type == "stop_limit": order["limit_price"] = 150.0 return order # =========================================================================== # Property 1: Ticker validation and normalization # **Validates: Requirements 2.2, 8.1** # =========================================================================== class TestProperty1TickerValidationAndNormalization: """Property 1: Ticker validation and normalization. For any string input, the ticker validation function SHALL accept it if and only if, after uppercasing, it matches ^[A-Z]{1,10}$. The normalized output SHALL always be the uppercased version of the input. **Validates: Requirements 2.2, 8.1** """ @settings(max_examples=100) @given(ticker=st.text(min_size=0, max_size=20)) def test_ticker_accepted_iff_matches_pattern(self, ticker: str) -> None: """After uppercasing, accepted iff matches ^[A-Z]{1,10}$; normalized output is always uppercased input.""" uppercased = ticker.upper() should_accept = bool(TICKER_PATTERN.match(uppercased)) try: req = OverrideOrderRequest( ticker=ticker, side="buy", quantity=1.0, order_type="market", ) # Accepted — verify it should have been accepted assert should_accept, ( f"Ticker {ticker!r} was accepted but uppercased form " f"{uppercased!r} does not match ^[A-Z]{{1,10}}$" ) # Normalized output is always uppercased assert req.ticker == uppercased, ( f"Expected normalized ticker {uppercased!r}, got {req.ticker!r}" ) except ValidationError: # Rejected — verify it should have been rejected assert not should_accept, ( f"Ticker {ticker!r} was rejected but uppercased form " f"{uppercased!r} matches ^[A-Z]{{1,10}}$" ) # =========================================================================== # Property 2: Override job payload completeness # **Validates: Requirements 3.2, 9.1** # =========================================================================== class TestProperty2OverrideJobPayloadCompleteness: """Property 2: Override job payload completeness. For any valid override order request, the job payload enqueued to the broker queue SHALL contain all required fields, source == "manual_override", and idempotency_key starts with "override-". **Validates: Requirements 3.2, 9.1** """ @settings(max_examples=100) @given(order=valid_override_order_st()) def test_enqueued_payload_has_all_required_fields(self, order: dict) -> None: """Enqueued payload contains all required fields with correct values.""" captured_payloads: list[str] = [] async def capture_rpush(key, value): captured_payloads.append(value) fake_engine = _make_fake_engine() fake_engine.redis.rpush = AsyncMock(side_effect=capture_rpush) client, original = _override_client(fake_engine) try: with patch.object( _app_module, "auto_register_symbol", new_callable=AsyncMock, return_value=(False, "comp-1"), ): import httpx def _mock_handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, content=json.dumps([{"id": "comp-1", "ticker": order["ticker"].upper()}]).encode(), headers={"content-type": "application/json"}, ) original_init = httpx.AsyncClient.__init__ def patched_init(self, *args, **kwargs): kwargs.pop("timeout", None) kwargs["transport"] = httpx.MockTransport(_mock_handler) original_init(self, *args, **kwargs) with patch.object(httpx.AsyncClient, "__init__", patched_init): resp = client.post("/api/trading/override/order", json=order) assert resp.status_code == 202, ( f"Expected 202 for valid order {order!r}, got {resp.status_code}: {resp.text}" ) assert len(captured_payloads) == 1, "Expected exactly one RPUSH call" payload = json.loads(captured_payloads[0]) # All required fields present expected_ticker = order["ticker"].upper() assert payload["ticker"] == expected_ticker assert payload["side"] == order["side"] assert payload["quantity"] == order["quantity"] assert payload["order_type"] == order["order_type"] assert payload["source"] == "manual_override" assert isinstance(payload["idempotency_key"], str) assert payload["idempotency_key"].startswith("override-") # Conditional price fields if "limit_price" in order: assert payload["limit_price"] == order["limit_price"] if "stop_price" in order: assert payload["stop_price"] == order["stop_price"] finally: _restore_engine(original) # =========================================================================== # Property 3: Invalid override order rejection # **Validates: Requirements 3.5, 2.6** # =========================================================================== class TestProperty3InvalidOverrideOrderRejection: """Property 3: Invalid override order rejection. For any override order request that violates at least one validation rule, the endpoint SHALL return a 422 status code and the response body SHALL contain at least one descriptive error message. **Validates: Requirements 3.5, 2.6** """ @settings(max_examples=100) @given(order=invalid_override_order_st()) def test_invalid_order_returns_422_with_error_message(self, order: dict) -> None: """Invalid orders return 422 with at least one descriptive error.""" fake_engine = _make_fake_engine() client, original = _override_client(fake_engine) try: resp = client.post("/api/trading/override/order", json=order) assert resp.status_code == 422, ( f"Expected 422 for invalid order {order!r}, got {resp.status_code}: {resp.text}" ) body = resp.json() # FastAPI returns validation errors in a "detail" field assert "detail" in body, ( f"Expected 'detail' in 422 response body, got: {body}" ) # At least one error message detail = body["detail"] assert isinstance(detail, list) and len(detail) >= 1, ( f"Expected at least one validation error, got: {detail}" ) finally: _restore_engine(original)