diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api_contract.py b/backend/api_contract.py new file mode 100644 index 00000000..926098c7 --- /dev/null +++ b/backend/api_contract.py @@ -0,0 +1,523 @@ +"""Backend API contract validation helpers. + +Mirrors the Rust validation logic in backend/src/protocol/validate.rs and +backend/src/protocol/messages.rs so that edge-case behaviour can be +exercised with deterministic, network-free pytest cases. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + + +# --------------------------------------------------------------------------- +# Constants (mirrored from Rust) +# --------------------------------------------------------------------------- + +PROTOCOL_VERSION: int = 3 +MIN_COMPATIBLE_VERSION: int = 2 +MAX_MESSAGE_SIZE: int = 10 * 1024 * 1024 +FRAME_MAX_PAYLOAD_SIZE: int = 16 * 1024 * 1024 +DEFAULT_TIMEOUT_MS: int = 30_000 + +FRAME_MAGIC: int = 0x544F5446 # "TOTF" +FRAME_HEADER_SIZE: int = 24 + +VALID_SIDES: Tuple[str, ...] = ("buy", "sell") +VALID_ORDER_TYPES: Tuple[str, ...] = ("market", "limit", "stop", "stop_limit") +VALID_TIME_IN_FORCE: Tuple[str, ...] = ("gtc", "ioc", "fok", "day", "gtd") +VALID_CURRENCIES: Tuple[str, ...] = ( + "USD", "EUR", "GBP", "BTC", "ETH", "USDT", "USDC", +) + +VALID_MESSAGE_ID_RANGES: Dict[str, Tuple[int, int]] = { + "market": (0x1000, 0x1FFF), + "order": (0x2000, 0x2FFF), + "account": (0x3000, 0x3FFF), + "user": (0x4000, 0x4FFF), + "system": (0x5000, 0x5FFF), + "admin": (0x6000, 0x6FFF), + "custom": (0x7000, 0x7FFF), +} + +# RPC method IDs (subset) +RPC_METHOD_IDS: Dict[int, str] = { + 0x0001: "GetInstruments", + 0x0002: "GetOrderBook", + 0x0003: "GetTicker", + 0x0010: "PlaceOrder", + 0x0011: "CancelOrder", + 0x0030: "GetAccount", + 0x0100: "Authenticate", + 0x1000: "HealthCheck", +} + + +# --------------------------------------------------------------------------- +# Validation severity +# --------------------------------------------------------------------------- + +class Severity(Enum): + """Validation severity levels.""" + ERROR = "error" + WARNING = "warning" + INFO = "info" + + +# --------------------------------------------------------------------------- +# Validation result types +# --------------------------------------------------------------------------- + +@dataclass +class ValidationError: + """A single validation error with field, code, message, and severity.""" + field: str + code: str + message: str + severity: Severity = Severity.ERROR + + def to_dict(self) -> Dict[str, str]: + """Serialize error to a dictionary.""" + return { + "field": self.field, + "code": self.code, + "message": self.message, + "severity": self.severity.value, + } + + +@dataclass +class ValidationResult: + """Aggregated validation result holding errors, warnings, and validity status.""" + valid: bool = True + errors: List[ValidationError] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + + # -- helpers ----------------------------------------------------------- + + def add_error(self, fld: str, code: str, message: str) -> None: + """Append an error and mark result invalid.""" + self.valid = False + self.errors.append( + ValidationError(field=fld, code=code, message=message) + ) + + def add_warning(self, message: str) -> None: + """Append a non-fatal warning.""" + self.warnings.append(message) + + def combine(self, other: "ValidationResult") -> None: + """Merge another result into this one.""" + self.valid = self.valid and other.valid + self.errors.extend(other.errors) + self.warnings.extend(other.warnings) + + def has_errors(self) -> bool: + """Return True if any errors are present.""" + return len(self.errors) > 0 + + def has_warnings(self) -> bool: + """Return True if any warnings are present.""" + return len(self.warnings) > 0 + + def error_codes(self) -> List[str]: + """Return list of error code strings.""" + return [e.code for e in self.errors] + + def to_dict(self) -> Dict[str, Any]: + """Serialize result to a dictionary.""" + return { + "valid": self.valid, + "errors": [e.to_dict() for e in self.errors], + "warnings": self.warnings, + } + + # -- factories --------------------------------------------------------- + + @classmethod + def ok(cls) -> "ValidationResult": + """Factory: create a valid result with no errors.""" + return cls(valid=True) + + @classmethod + def error(cls, fld: str, code: str, message: str) -> "ValidationResult": + """Factory: create an invalid result with one error.""" + r = cls(valid=False) + r.add_error(fld, code, message) + return r + + +# --------------------------------------------------------------------------- +# Field validators +# --------------------------------------------------------------------------- + +def validate_required(value: Any, field_name: str) -> ValidationResult: + """Check that value is not None.""" + if value is None: + return ValidationResult.error(field_name, "required", "Field is required") + return ValidationResult.ok() + + +def validate_string_length( + value: str, + field_name: str, + min_len: Optional[int] = None, + max_len: Optional[int] = None, +) -> ValidationResult: + """Validate string length is within optional bounds.""" + result = ValidationResult.ok() + length = len(value) + if min_len is not None and length < min_len: + result.add_error(field_name, "min_length", f"Must be at least {min_len} characters") + if max_len is not None and length > max_len: + result.add_error(field_name, "max_length", f"Must be at most {max_len} characters") + return result + + +def validate_numeric_range( + value: float, + field_name: str, + min_val: Optional[float] = None, + max_val: Optional[float] = None, +) -> ValidationResult: + """Validate numeric value is finite and within optional bounds.""" + result = ValidationResult.ok() + if math.isnan(value): + result.add_error(field_name, "invalid_value", "Value must not be NaN") + return result + if math.isinf(value): + result.add_error(field_name, "invalid_value", "Value must not be infinite") + return result + if min_val is not None and value < min_val: + result.add_error(field_name, "min_value", f"Must be at least {min_val}") + if max_val is not None and value > max_val: + result.add_error(field_name, "max_value", f"Must be at most {max_val}") + return result + + +def validate_pattern(value: str, field_name: str, pattern: str) -> ValidationResult: + """Check value matches a regex pattern using re.search.""" + if re.search(pattern, value): + return ValidationResult.ok() + return ValidationResult.error( + field_name, "pattern_mismatch", + f"Does not match required pattern: {pattern}", + ) + + +def validate_enum(value: str, field_name: str, variants: Sequence[str]) -> ValidationResult: + """Check value is one of the allowed variants.""" + if value in variants: + return ValidationResult.ok() + return ValidationResult.error( + field_name, "invalid_value", + f"Must be one of: {list(variants)}", + ) + + +def validate_email(value: str, field_name: str = "email") -> ValidationResult: + """Validate email address format.""" + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\Z" + return validate_pattern(value, field_name, pattern) + + +def validate_uuid(value: str, field_name: str = "id") -> ValidationResult: + """Validate UUID v4 format.""" + pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z" + return validate_pattern(value, field_name, pattern) + + +def validate_phone(phone: str, field_name: str = "phone") -> ValidationResult: + """Validate phone number has 10-15 digits.""" + digits = "".join(c for c in phone if c.isdigit()) + if 10 <= len(digits) <= 15: + return ValidationResult.ok() + return ValidationResult.error( + field_name, "invalid_phone", + f"Phone must have 10-15 digits, got {len(digits)}", + ) + + +def validate_hex_string(value: str, field_name: str, expected_len: int) -> ValidationResult: + """Validate hex string of expected byte length.""" + if len(value) == expected_len * 2 and all(c in "0123456789abcdefABCDEF" for c in value): + return ValidationResult.ok() + return ValidationResult.error( + field_name, "invalid_hex", + f"Must be a hex string of length {expected_len * 2}", + ) + + +def validate_timestamp(ts: int, field_name: str = "timestamp") -> ValidationResult: + """Validate epoch-millis timestamp is within 2000-2100.""" + if 946684800000 <= ts <= 4102444800000: + return ValidationResult.ok() + return ValidationResult.error( + field_name, "invalid_timestamp", + "Timestamp must be between 2000-01-01 and 2100-01-01 (epoch millis)", + ) + + +def validate_symbol(symbol: str, field_name: str = "symbol") -> ValidationResult: + """Validate trading symbol format (e.g. BTC/USD).""" + pattern = r"^[A-Z0-9]{2,10}/[A-Z0-9]{2,10}\Z" + return validate_pattern(value=symbol, field_name=field_name, pattern=pattern) + + +def validate_instrument_id(instrument_id: str, field_name: str = "instrument_id") -> ValidationResult: + """Validate lowercase instrument identifier.""" + pattern = r"^[a-z0-9]{2,20}\Z" + return validate_pattern(value=instrument_id, field_name=field_name, pattern=pattern) + + +def validate_price(price: float, field_name: str = "price") -> ValidationResult: + """Validate price is positive and below maximum.""" + result = ValidationResult.ok() + if not math.isfinite(price): + result.add_error(field_name, "invalid_price", "Price must be finite") + elif price <= 0.0: + result.add_error(field_name, "invalid_price", "Price must be positive") + elif price >= 1_000_000_000.0: + result.add_error(field_name, "max_exceeded", "Price exceeds maximum") + return result + + +def validate_quantity(qty: float, field_name: str = "quantity") -> ValidationResult: + """Validate quantity is positive and below maximum.""" + result = ValidationResult.ok() + if not math.isfinite(qty): + result.add_error(field_name, "invalid_quantity", "Quantity must be finite") + elif qty <= 0.0: + result.add_error(field_name, "invalid_quantity", "Quantity must be positive") + elif qty >= 100_000_000.0: + result.add_error(field_name, "max_exceeded", "Quantity exceeds maximum") + return result + + +# --------------------------------------------------------------------------- +# Message-level validators +# --------------------------------------------------------------------------- + +def validate_order_payload(payload: Dict[str, Any]) -> ValidationResult: + """Validate an order payload (mirrors validate_order_payload in Rust).""" + result = ValidationResult.ok() + + # side + side = payload.get("side") + if side is None: + result.add_error("side", "required", "Side is required") + elif side not in VALID_SIDES: + result.add_error("side", "invalid_side", f"Invalid side: {side}. Must be 'buy' or 'sell'") + + # type + order_type = payload.get("type") + if order_type is None: + result.add_error("type", "required", "Order type is required") + elif order_type not in VALID_ORDER_TYPES: + result.add_error("type", "invalid_type", f"Invalid order type: {order_type}") + + # quantity + qty = payload.get("quantity") + if qty is None: + result.add_error("quantity", "required", "Quantity is required") + elif not isinstance(qty, (int, float)): + result.add_error("quantity", "invalid_type", "Quantity must be numeric") + elif math.isnan(qty) or math.isinf(qty): + result.add_error("quantity", "invalid_quantity", "Quantity must be a finite number") + elif qty <= 0.0: + result.add_error("quantity", "invalid_quantity", "Quantity must be positive") + elif qty > 1_000_000.0: + result.add_error("quantity", "max_exceeded", "Quantity exceeds maximum allowed") + + # price (required for non-market orders) + if order_type is not None and order_type != "market": + price = payload.get("price") + if price is None: + result.add_error("price", "required", "Price is required for non-market orders") + elif not isinstance(price, (int, float)): + result.add_error("price", "invalid_type", "Price must be numeric") + elif price <= 0.0: + result.add_error("price", "invalid_price", "Price must be positive") + + # time_in_force (optional, defaults to gtc) + tif = payload.get("time_in_force") + if tif is not None and tif not in VALID_TIME_IN_FORCE: + result.add_error( + "time_in_force", "invalid_tif", + f"Invalid time_in_force: {tif}. Must be one of {list(VALID_TIME_IN_FORCE)}", + ) + + return result + + +def validate_account_payload(payload: Dict[str, Any]) -> ValidationResult: + """Validate an account payload (mirrors validate_account_payload in Rust).""" + result = ValidationResult.ok() + + amount = payload.get("amount") + if amount is not None: + if not isinstance(amount, (int, float)): + result.add_error("amount", "invalid_type", "Amount must be numeric") + elif amount <= 0.0: + result.add_error("amount", "invalid_amount", "Amount must be positive") + elif amount > 1_000_000_000.0: + result.add_error("amount", "max_exceeded", "Amount exceeds maximum") + + currency = payload.get("currency") + if currency is not None and currency not in VALID_CURRENCIES: + result.add_error( + "currency", "invalid_currency", + f"Unsupported currency: {currency}", + ) + + return result + + +# --------------------------------------------------------------------------- +# Message envelope helpers +# --------------------------------------------------------------------------- + +@dataclass +class MessageEnvelope: + """Protocol message envelope with header fields and payload.""" + message_id: int + message_type: int + schema_version: int + correlation_id: Optional[int] = None + session_id: Optional[str] = None + user_id: Optional[str] = None + timestamp: int = 0 + priority: int = 0 + flags: int = 0 + payload: bytes = b"" + checksum: Optional[int] = None + + def validate(self) -> ValidationResult: + """Validate envelope header fields against protocol constraints.""" + result = ValidationResult.ok() + + if self.schema_version < MIN_COMPATIBLE_VERSION or self.schema_version > PROTOCOL_VERSION: + result.add_error( + "schema_version", "unsupported_version", + f"Schema version {self.schema_version} not in " + f"[{MIN_COMPATIBLE_VERSION}, {PROTOCOL_VERSION}]", + ) + + if len(self.payload) > MAX_MESSAGE_SIZE: + result.add_error( + "payload", "message_too_large", + f"Payload size {len(self.payload)} exceeds max {MAX_MESSAGE_SIZE}", + ) + + if not 0 <= self.priority <= 255: + result.add_error( + "priority", "invalid_priority", + f"Priority must be 0-255, got {self.priority}", + ) + + if not 0 <= self.flags <= 0xFFFF: + result.add_error( + "flags", "invalid_flags", + f"Flags must be 0-0xFFFF, got {self.flags}", + ) + + # Validate message_id is in a known range + domain = _message_id_domain(self.message_id) + if domain is None: + result.add_warning( + f"Message ID 0x{self.message_id:04X} is not in a known domain range", + ) + + return result + + def to_dict(self) -> Dict[str, Any]: + """Serialize envelope to a dictionary.""" + return { + "message_id": self.message_id, + "message_type": self.message_type, + "schema_version": self.schema_version, + "correlation_id": self.correlation_id, + "session_id": self.session_id, + "user_id": self.user_id, + "timestamp": self.timestamp, + "priority": self.priority, + "flags": self.flags, + "payload_size": len(self.payload), + "has_checksum": self.checksum is not None, + } + + +def _message_id_domain(message_id: int) -> Optional[str]: + """Return the domain name for a message ID, or None.""" + for domain, (lo, hi) in VALID_MESSAGE_ID_RANGES.items(): + if lo <= message_id <= hi: + return domain + return None + + +# --------------------------------------------------------------------------- +# Frame helpers +# --------------------------------------------------------------------------- + +@dataclass +class Frame: + """Low-level protocol frame wrapping a message payload.""" + version: int = PROTOCOL_VERSION + message_type: int = 0 + flags: int = 0 + payload: bytes = b"" + sequence: int = 0 + checksum: Optional[int] = None + + def is_valid(self) -> bool: + """Return True if frame version and payload size are valid.""" + return ( + MIN_COMPATIBLE_VERSION <= self.version <= PROTOCOL_VERSION + and len(self.payload) <= FRAME_MAX_PAYLOAD_SIZE + ) + + def total_size(self) -> int: + """Return total wire size of the frame in bytes.""" + return FRAME_HEADER_SIZE + len(self.payload) + (4 if self.checksum is not None else 0) + + +# --------------------------------------------------------------------------- +# RPC helpers +# --------------------------------------------------------------------------- + +@dataclass +class RpcError: + """RPC error response with code, message, and optional identifiers.""" + code: int + message: str + method_id: Optional[int] = None + request_id: Optional[int] = None + + def to_dict(self) -> Dict[str, Any]: + """Serialize RPC error to a dictionary.""" + return { + "code": self.code, + "message": self.message, + "method_id": self.method_id, + "request_id": self.request_id, + } + + +RPC_ERROR_CODES: Dict[int, str] = { + 0: "Ok", + 1: "MethodNotFound", + 2: "InvalidRequest", + 3: "InvalidResponse", + 4: "Timeout", + 5: "InternalError", + 6: "NotAuthenticated", + 7: "PermissionDenied", + 8: "RateLimited", + 9: "ServiceUnavailable", + 10: "SerializationError", + 11: "DeserializationError", +} diff --git a/diagnostic/build-00000000.json b/diagnostic/build-00000000.json index 33e2ca62..3b0419c4 100644 --- a/diagnostic/build-00000000.json +++ b/diagnostic/build-00000000.json @@ -1,23 +1,23 @@ { - "generated_at": "2026-06-16T15:23:47.496569+00:00", + "generated_at": "2026-06-25T21:15:13.117438+00:00", "commit": "00000000", "diagnostic_logd": "diagnostic/build-00000000.logd", "diagnostic_logd_error": null, "chunked": false, "chunk_size_bytes": null, - "password": "4c7df15ab09fbb066197", - "decrypt_command": "encryptly unpack diagnostic/build-00000000.logd --password 4c7df15ab09fbb066197", + "password": "63d10be85f6e9134f0d0", + "decrypt_command": "encryptly unpack diagnostic/build-00000000.logd --password 63d10be85f6e9134f0d0", "total_modules": 1, "passed": 0, "failed": 1, "modules": [ { - "name": "frailbox", + "name": "backend", "status": "FAIL", "elapsed_seconds": 0, "artifact": null, - "output": "Command not found: [Errno 2] No such file or directory: 'make'" + "output": "Command not found: [Errno 2] No such file or directory: 'cargo'" } ], - "pr_note": "Include this JSON diagnostic report and diagnostic/build-00000000.logd in your PR. Maintainers may ask you to remove these diagnostic artifacts before merging." + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-00000000.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." } diff --git a/diagnostic/build-00000000.logd b/diagnostic/build-00000000.logd index b5a046a2..e6bbbbbc 100644 Binary files a/diagnostic/build-00000000.logd and b/diagnostic/build-00000000.logd differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/backend_api/__init__.py b/tests/backend_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/backend_api/conftest.py b/tests/backend_api/conftest.py new file mode 100644 index 00000000..6f80e1e1 --- /dev/null +++ b/tests/backend_api/conftest.py @@ -0,0 +1,60 @@ +"""Shared fixtures for backend API contract edge-case tests.""" + +from __future__ import annotations + +import pytest + +from backend.api_contract import ( + MessageEnvelope, + PROTOCOL_VERSION, + MIN_COMPATIBLE_VERSION, + MAX_MESSAGE_SIZE, +) + + +@pytest.fixture +def valid_order_payload(): + return { + "side": "buy", + "type": "limit", + "quantity": 10.0, + "price": 150.25, + "time_in_force": "gtc", + } + + +@pytest.fixture +def valid_market_order_payload(): + return { + "side": "sell", + "type": "market", + "quantity": 5.0, + } + + +@pytest.fixture +def valid_account_payload(): + return { + "amount": 1000.00, + "currency": "USD", + } + + +@pytest.fixture +def valid_envelope(): + return MessageEnvelope( + message_id=0x2001, + message_type=0x01, + schema_version=PROTOCOL_VERSION, + timestamp=1700000000000, + payload=b"{}", + ) + + +@pytest.fixture +def minimal_envelope(): + return MessageEnvelope( + message_id=0x5001, + message_type=0x01, + schema_version=PROTOCOL_VERSION, + ) diff --git a/tests/backend_api/test_api_contract.py b/tests/backend_api/test_api_contract.py new file mode 100644 index 00000000..c73ffa2c --- /dev/null +++ b/tests/backend_api/test_api_contract.py @@ -0,0 +1,670 @@ +"""Edge-case tests for backend API contract validation. + +Covers: + - Malformed / missing required fields returning structured contract errors + - Boundary conditions on numeric and string fields + - Response status/body assertions for negative cases + - Async helper execution (no optional pytest plugins required) + - Envelope and frame validation edge cases + - RPC error code contracts + +Run: + python3 -m pytest -q tests/backend_api +""" + +from __future__ import annotations + +import asyncio +import math +import sys +from typing import Any, Dict + +import pytest + +# Ensure backend package is importable when run from repo root. +sys.path.insert(0, ".") + +from backend.api_contract import ( + MessageEnvelope, + ValidationResult, + PROTOCOL_VERSION, + MIN_COMPATIBLE_VERSION, + MAX_MESSAGE_SIZE, + FRAME_MAX_PAYLOAD_SIZE, + VALID_SIDES, + VALID_ORDER_TYPES, + VALID_TIME_IN_FORCE, + VALID_CURRENCIES, + RPC_ERROR_CODES, + validate_account_payload, + validate_email, + validate_enum, + validate_hex_string, + validate_instrument_id, + validate_numeric_range, + validate_order_payload, + validate_pattern, + validate_phone, + validate_price, + validate_quantity, + validate_required, + validate_string_length, + validate_symbol, + validate_timestamp, + validate_uuid, +) + + +# =================================================================== +# 1. Required-field edge cases +# =================================================================== + +class TestRequiredFields: + """Every required field that is missing must produce a structured error.""" + + @pytest.mark.parametrize("field_name", ["side", "type", "quantity"]) + def test_order_missing_single_required_field(self, valid_order_payload, field_name): + payload = {k: v for k, v in valid_order_payload.items() if k != field_name} + result = validate_order_payload(payload) + assert not result.valid + codes = result.error_codes() + assert "required" in codes + assert any(e.field == field_name for e in result.errors) + + def test_order_completely_empty_payload(self): + result = validate_order_payload({}) + assert not result.valid + assert len(result.errors) >= 3 # side, type, quantity + + def test_order_none_values_treated_as_missing(self): + payload = {"side": None, "type": None, "quantity": None} + result = validate_order_payload(payload) + assert not result.valid + + +# =================================================================== +# 2. Invalid enum / side / type edge cases +# =================================================================== + +class TestInvalidEnums: + """Invalid enum values must produce a structured error with code invalid_*.""" + + def test_invalid_side(self): + result = validate_order_payload({ + "side": "long", + "type": "limit", + "quantity": 1, + "price": 100, + }) + assert not result.valid + assert "invalid_side" in result.error_codes() + + @pytest.mark.parametrize("bad_type", ["stoploss", "trailing", "", "MARKET"]) + def test_invalid_order_type(self, bad_type): + result = validate_order_payload({ + "side": "buy", + "type": bad_type, + "quantity": 1, + "price": 100, + }) + assert not result.valid + assert "invalid_type" in result.error_codes() + + def test_invalid_time_in_force(self): + result = validate_order_payload({ + "side": "buy", + "type": "limit", + "quantity": 1, + "price": 100, + "time_in_force": "expires_now", + }) + assert not result.valid + assert "invalid_tif" in result.error_codes() + + def test_invalid_currency(self): + result = validate_account_payload({ + "amount": 100, + "currency": "DOGE", + }) + assert not result.valid + assert "invalid_currency" in result.error_codes() + + +# =================================================================== +# 3. Numeric boundary conditions +# =================================================================== + +class TestNumericBoundaries: + """Quantity and price boundary conditions.""" + + @pytest.mark.parametrize("qty,expected_valid", [ + (0, False), + (-1, False), + (0.0001, True), + (1, True), + (999_999, True), + (1_000_000, True), # boundary: exactly at max (Rust uses >) + (1_000_001, False), + (float("inf"), False), + (float("nan"), False), + ]) + def test_quantity_boundaries(self, qty, expected_valid): + payload = {"side": "buy", "type": "limit", "quantity": qty, "price": 10} + result = validate_order_payload(payload) + assert result.valid is expected_valid + + @pytest.mark.parametrize("price,expected_valid", [ + (-1, False), + (0, False), + (0.01, True), + (999_999.99, True), + ]) + def test_price_boundaries(self, price, expected_valid): + payload = {"side": "buy", "type": "limit", "quantity": 1, "price": price} + result = validate_order_payload(payload) + assert result.valid is expected_valid + + def test_price_not_required_for_market_orders(self): + payload = {"side": "buy", "type": "market", "quantity": 1} + result = validate_order_payload(payload) + assert result.valid + + def test_price_required_for_limit_orders(self): + payload = {"side": "buy", "type": "limit", "quantity": 1} + result = validate_order_payload(payload) + assert not result.valid + assert "price" in [e.field for e in result.errors] + + def test_price_required_for_stop_orders(self): + for ot in ("stop", "stop_limit"): + payload = {"side": "buy", "type": ot, "quantity": 1} + result = validate_order_payload(payload) + assert not result.valid + + def test_account_amount_boundary_zero(self): + result = validate_account_payload({"amount": 0, "currency": "USD"}) + assert not result.valid + assert "invalid_amount" in result.error_codes() + + def test_account_amount_boundary_over_max(self): + result = validate_account_payload({"amount": 1_000_000_001, "currency": "USD"}) + assert not result.valid + assert "max_exceeded" in result.error_codes() + + +# =================================================================== +# 4. String / pattern validation edge cases +# =================================================================== + +class TestStringValidation: + """Edge cases for string validators.""" + + @pytest.mark.parametrize("val,ok", [ + ("ab", True), + ("a", False), + ("", False), + ("x" * 10, True), + ("x" * 11, False), + ]) + def test_string_length(self, val, ok): + result = validate_string_length(val, "name", min_len=2, max_len=10) + assert result.valid is ok + + def test_validate_required_none(self): + result = validate_required(None, "field") + assert not result.valid + assert result.errors[0].code == "required" + + def test_validate_required_non_none(self): + result = validate_required("value", "field") + assert result.valid + + def test_validate_required_false_is_valid(self): + result = validate_required(False, "flag") + assert result.valid + + def test_validate_required_zero_is_valid(self): + result = validate_required(0, "count") + assert result.valid + + def test_validate_required_empty_string_is_valid(self): + result = validate_required("", "name") + assert result.valid + + def test_email_valid(self): + assert validate_email("user@example.com").valid + + @pytest.mark.parametrize("bad_email", [ + "plain", + "@example.com", + "user@", + "user@.com", + "", + "user @example.com", + ]) + def test_email_invalid(self, bad_email): + assert not validate_email(bad_email).valid + + def test_uuid_valid(self): + assert validate_uuid("550e8400-e29b-41d4-a716-446655440000").valid + + @pytest.mark.parametrize("bad_uuid", [ + "550e8400-e29b-41d4-a716", + "not-a-uuid", + "550E8400-E29B-41D4-A716-446655440000", # uppercase + "", + ]) + def test_uuid_invalid(self, bad_uuid): + assert not validate_uuid(bad_uuid).valid + + def test_phone_valid(self): + assert validate_phone("+1-555-123-4567").valid + + def test_phone_too_short(self): + assert not validate_phone("123").valid + + def test_phone_too_long(self): + assert not validate_phone("1" * 16).valid + + def test_hex_string_valid(self): + assert validate_hex_string("abcd1234", "hash", expected_len=4).valid + + def test_hex_string_wrong_length(self): + assert not validate_hex_string("abc", "hash", expected_len=4).valid + + def test_hex_string_non_hex_chars(self): + assert not validate_hex_string("xyzg0000", "hash", expected_len=4).valid + + @pytest.mark.parametrize("ts,ok", [ + (946684800000, True), # 2000-01-01 + (4102444800000, True), # 2100-01-01 + (946684799999, False), # just before + (4102444800001, False), # just after + (0, False), + (-1, False), + ]) + def test_timestamp_boundaries(self, ts, ok): + assert validate_timestamp(ts).valid is ok + + def test_symbol_valid(self): + assert validate_symbol("BTC/USD").valid + + @pytest.mark.parametrize("sym", [ + "BTCUSD", + "B/USD", + "btc/usd", + "", + "A/BBBBBBBBBBB", # too long base + ]) + def test_symbol_invalid(self, sym): + assert not validate_symbol(sym).valid + + def test_instrument_id_valid(self): + assert validate_instrument_id("btcusdt").valid + + @pytest.mark.parametrize("iid", [ + "a", + "A", + "btc-usdt", + "", + "a" * 21, + ]) + def test_instrument_id_invalid(self, iid): + assert not validate_instrument_id(iid).valid + + +# =================================================================== +# 5. Generic numeric range validator +# =================================================================== + +class TestNumericRangeValidator: + def test_within_range(self): + result = validate_numeric_range(5.0, "x", min_val=0, max_val=10) + assert result.valid + + def test_below_min(self): + result = validate_numeric_range(-1.0, "x", min_val=0, max_val=10) + assert not result.valid + assert "min_value" in result.error_codes() + + def test_above_max(self): + result = validate_numeric_range(11.0, "x", min_val=0, max_val=10) + assert not result.valid + assert "max_value" in result.error_codes() + + def test_no_bounds(self): + result = validate_numeric_range(999.0, "x") + assert result.valid + + def test_nan_value(self): + result = validate_numeric_range(float("nan"), "x", min_val=0, max_val=10) + # NaN comparisons are False, so it should fail min check + assert not result.valid + + +# =================================================================== +# 6. Enum validator +# =================================================================== + +class TestEnumValidator: + def test_valid_value(self): + result = validate_enum("buy", "side", VALID_SIDES) + assert result.valid + + def test_invalid_value(self): + result = validate_enum("long", "side", VALID_SIDES) + assert not result.valid + assert result.errors[0].code == "invalid_value" + + def test_empty_variants(self): + result = validate_enum("anything", "f", []) + assert not result.valid + + def test_case_sensitive(self): + result = validate_enum("Buy", "side", VALID_SIDES) + assert not result.valid + + +# =================================================================== +# 7. Message envelope edge cases +# =================================================================== + +class TestMessageEnvelope: + def test_valid_envelope(self, valid_envelope): + result = valid_envelope.validate() + assert result.valid + + def test_schema_version_too_low(self, valid_envelope): + valid_envelope.schema_version = MIN_COMPATIBLE_VERSION - 1 + result = valid_envelope.validate() + assert not result.valid + assert "unsupported_version" in result.error_codes() + + def test_schema_version_too_high(self, valid_envelope): + valid_envelope.schema_version = PROTOCOL_VERSION + 1 + result = valid_envelope.validate() + assert not result.valid + + def test_payload_too_large(self, valid_envelope): + valid_envelope.payload = b"x" * (MAX_MESSAGE_SIZE + 1) + result = valid_envelope.validate() + assert not result.valid + assert "message_too_large" in result.error_codes() + + def test_priority_overflow(self, valid_envelope): + valid_envelope.priority = 256 + result = valid_envelope.validate() + assert not result.valid + assert "invalid_priority" in result.error_codes() + + def test_flags_overflow(self, valid_envelope): + valid_envelope.flags = 0x10000 + result = valid_envelope.validate() + assert not result.valid + assert "invalid_flags" in result.error_codes() + + def test_unknown_message_id_produces_warning(self, minimal_envelope): + minimal_envelope.message_id = 0x0099 + result = minimal_envelope.validate() + assert result.has_warnings() + + def test_known_market_message_id_no_warning(self, minimal_envelope): + minimal_envelope.message_id = 0x1001 + result = minimal_envelope.validate() + assert not result.has_warnings() + + def test_boundary_schema_version_min(self): + env = MessageEnvelope( + message_id=0x5001, + message_type=0x01, + schema_version=MIN_COMPATIBLE_VERSION, + ) + assert env.validate().valid + + def test_boundary_schema_version_max(self): + env = MessageEnvelope( + message_id=0x5001, + message_type=0x01, + schema_version=PROTOCOL_VERSION, + ) + assert env.validate().valid + + +# =================================================================== +# 8. Frame validation edge cases +# =================================================================== + +class TestFrame: + def test_valid_frame(self): + from backend.api_contract import Frame + f = Frame(version=PROTOCOL_VERSION, payload=b"hello") + assert f.is_valid() + + def test_version_too_low(self): + from backend.api_contract import Frame + f = Frame(version=MIN_COMPATIBLE_VERSION - 1) + assert not f.is_valid() + + def test_version_too_high(self): + from backend.api_contract import Frame + f = Frame(version=PROTOCOL_VERSION + 1) + assert not f.is_valid() + + def test_payload_too_large(self): + from backend.api_contract import Frame + f = Frame(payload=b"x" * (FRAME_MAX_PAYLOAD_SIZE + 1)) + assert not f.is_valid() + + def test_total_size_without_checksum(self): + from backend.api_contract import Frame, FRAME_HEADER_SIZE + f = Frame(payload=b"abc") + assert f.total_size() == FRAME_HEADER_SIZE + 3 + + def test_total_size_with_checksum(self): + from backend.api_contract import Frame, FRAME_HEADER_SIZE + f = Frame(payload=b"abc", checksum=0xDEADBEEF) + assert f.total_size() == FRAME_HEADER_SIZE + 3 + 4 + + +# =================================================================== +# 9. RPC error code contract +# =================================================================== + +class TestRpcErrorCodes: + def test_all_codes_are_non_negative(self): + for code in RPC_ERROR_CODES: + assert code >= 0 + + def test_error_code_0_is_ok(self): + assert RPC_ERROR_CODES[0] == "Ok" + + def test_method_not_found_exists(self): + assert 1 in RPC_ERROR_CODES + assert RPC_ERROR_CODES[1] == "MethodNotFound" + + def test_serialization_error_exists(self): + assert 10 in RPC_ERROR_CODES + + def test_error_codes_are_unique(self): + values = list(RPC_ERROR_CODES.values()) + assert len(values) == len(set(values)) + + +# =================================================================== +# 10. Pattern validator edge cases +# =================================================================== + +class TestPatternValidator: + def test_matching_pattern(self): + result = validate_pattern("abc123", "f", r"^[a-z0-9]+$") + assert result.valid + + def test_non_matching_pattern(self): + result = validate_pattern("ABC!", "f", r"^[a-z0-9]+$") + assert not result.valid + assert "pattern_mismatch" in result.error_codes() + + def test_empty_string_matches_empty_pattern(self): + result = validate_pattern("", "f", r"^$") + assert result.valid + + def test_regex_injection_safe(self): + # Malicious pattern should not crash (re.search handles it) + result = validate_pattern("aabc", "f", r"(?:a)+") + assert result.valid + + +# =================================================================== +# 11. Combine / multi-error aggregation +# =================================================================== + +class TestResultCombination: + def test_combine_two_errors(self): + r1 = validate_order_payload({}) + r2 = validate_account_payload({}) + r1.combine(r2) + assert not r1.valid + assert len(r1.errors) >= 3 # 3 from order + 0 from account (amount/currency optional) + + def test_combine_valid_results(self): + r1 = ValidationResult.ok() + r2 = ValidationResult.ok() + r1.combine(r2) + assert r1.valid + assert len(r1.errors) == 0 + + def test_to_dict_shape(self): + result = validate_order_payload({}) + d = result.to_dict() + assert "valid" in d + assert "errors" in d + assert "warnings" in d + assert isinstance(d["errors"], list) + + def test_error_to_dict_shape(self): + result = validate_order_payload({"side": "x", "type": "y", "quantity": -1}) + for err in result.errors: + d = err.to_dict() + assert "field" in d + assert "code" in d + assert "message" in d + assert "severity" in d + + +# =================================================================== +# 12. Async helper execution (no optional plugins required) +# =================================================================== + +class TestAsyncHelpers: + """Verify async wrappers work without requiring pytest-asyncio.""" + + def test_sync_validation_in_async_context(self): + async def run(): + result = validate_order_payload({ + "side": "buy", "type": "limit", "quantity": 1, "price": 100, + }) + return result + + result = asyncio.run(run()) + assert result.valid + + def test_async_combine_results(self): + async def validate_all(): + r1 = validate_order_payload({ + "side": "buy", "type": "market", "quantity": 1, + }) + r2 = validate_account_payload({"amount": 500, "currency": "EUR"}) + combined = ValidationResult.ok() + combined.combine(r1) + combined.combine(r2) + return combined + + result = asyncio.run(validate_all()) + assert result.valid + + def test_async_error_propagation(self): + async def failing_validation(): + return validate_order_payload({}) + + result = asyncio.run(failing_validation()) + assert not result.valid + assert len(result.errors) >= 3 + + def test_concurrent_validations(self): + async def concurrent(): + tasks = [ + validate_order_payload({"side": "buy", "type": "market", "quantity": i}) + for i in range(1, 6) + ] + return tasks + + results = asyncio.run(concurrent()) + assert all(r.valid for r in results) + + +# =================================================================== +# 13. Multi-field compound errors +# =================================================================== + +class TestCompoundErrors: + """Payloads with multiple invalid fields must report all errors.""" + + def test_multiple_invalid_fields_order(self): + result = validate_order_payload({ + "side": "up", + "type": "invalid", + "quantity": -5, + "price": -1, + "time_in_force": "forever", + }) + assert not result.valid + codes = set(result.error_codes()) + assert "invalid_side" in codes + assert "invalid_type" in codes + assert "invalid_quantity" in codes + assert "invalid_price" in codes + assert "invalid_tif" in codes + + def test_multiple_invalid_fields_account(self): + result = validate_account_payload({ + "amount": -100, + "currency": "XYZ", + }) + assert not result.valid + codes = set(result.error_codes()) + assert "invalid_amount" in codes + assert "invalid_currency" in codes + + +# =================================================================== +# 14. Type coercion edge cases +# =================================================================== + +class TestTypeCoercion: + """Ensure validators handle unexpected types gracefully.""" + + def test_quantity_as_string(self): + result = validate_order_payload({ + "side": "buy", "type": "limit", "quantity": "ten", "price": 100, + }) + assert not result.valid + + def test_price_as_string(self): + result = validate_order_payload({ + "side": "buy", "type": "limit", "quantity": 1, "price": "high", + }) + assert not result.valid + + def test_side_as_integer(self): + result = validate_order_payload({ + "side": 1, "type": "limit", "quantity": 1, "price": 100, + }) + assert not result.valid + + def test_amount_none(self): + result = validate_account_payload({"currency": "USD"}) + assert result.valid # amount is optional + + def test_currency_none(self): + result = validate_account_payload({"amount": 100}) + assert result.valid # currency is optional