# LuckySt Test Suite — 223 Tests

Run all tests:
```bash
cd backend && PYTHONPATH=$(pwd):$(dirname $(pwd)) python3 -m pytest tests/ -v --tb=short
```

Install test dependencies:
```bash
pip install -r requirements-test.txt
```

---

## Integration Tests (13) — `integration/test_api_endpoints.py`

Full HTTP request/response cycle through FastAPI routers with mocked DB, Redis, and auth.

| # | Test | Description |
|---|------|-------------|
| 1 | `TestRecentEvents::test_get_recent_events` | GET recent-events returns 200 with the user's event history |
| 2 | `TestInstanceListing::test_list_instances_empty` | GET instances returns empty list when no instances exist |
| 3 | `TestDeployValidation::test_deploy_missing_fields` | POST deploy with missing required fields returns 422 |
| 4 | `TestDeployValidation::test_deploy_invalid_platform` | POST deploy with unsupported platform ("binance") returns 422 |
| 5 | `TestControlActions::test_control_invalid_action` | POST control with invalid action ("explode") returns 422 |
| 6 | `TestControlActions::test_control_valid_pause` | POST toggle_pause on a running instance returns 200 |
| 7 | `TestDeleteInstance::test_delete_running_instance_rejected` | DELETE a running instance returns 400 (can only delete stopped) |
| 8 | `TestScannerEndpoints::test_list_scanners_empty` | GET scanner list returns empty array when none deployed |
| 9 | `TestScannerEndpoints::test_deploy_scanner_no_telegram` | POST scanner deploy without Telegram configured returns 400 |
| 10 | `TestSchedulerEndpoints::test_list_schedules_empty` | GET scheduler list returns empty when no jobs exist |
| 11 | `TestSchedulerEndpoints::test_create_schedule_no_markets` | POST schedule with empty markets array returns 400 |
| 12 | `TestSchedulerEndpoints::test_delete_nonexistent_schedule` | DELETE a non-existent schedule returns 404 |
| 13 | `TestLogout::test_logout_clears_credentials` | POST logout calls Redis delete to clear credentials |

---

## Unit Tests — `unit/test_schema.py` (75)

Validates all Pydantic models: DeployConfig (Kalshi/Turbine/Polymarket), spreads, bounds, positions, grid levels, strategies, price feeds, orderbook, instance status, user create/login, and input sanitization (XSS, SQL injection).

| # | Test | Description |
|---|------|-------------|
| 1 | `TestSanitizeString::test_clean_string` | Clean alphanumeric string passes sanitization |
| 2 | `TestSanitizeString::test_strips_whitespace` | Whitespace is trimmed from input strings |
| 3 | `TestSanitizeString::test_empty_string` | Empty string passes sanitization |
| 4 | `TestSanitizeString::test_rejects_script_tag` | `<script>` tags are rejected as XSS |
| 5 | `TestSanitizeString::test_rejects_sql_injection` | SQL injection patterns (DROP TABLE) are rejected |
| 6 | `TestSanitizeString::test_rejects_javascript_uri` | `javascript:` URIs are rejected |
| 7 | `TestSanitizeString::test_rejects_union_select` | UNION SELECT injection patterns are rejected |
| 8 | `TestDeployConfigKalshi::test_valid_single_market` | Valid single-market Kalshi config passes validation |
| 9 | `TestDeployConfigKalshi::test_valid_two_market` | Valid two-market Kalshi config passes validation |
| 10 | `TestDeployConfigKalshi::test_rejects_invalid_platform` | Non-existent platform is rejected |
| 11 | `TestDeployConfigKalshi::test_rejects_missing_api_key` | Kalshi config without API key is rejected |
| 12 | `TestDeployConfigKalshi::test_rejects_invalid_uuid_api_key` | Non-UUID API key is rejected |
| 13 | `TestDeployConfigKalshi::test_rejects_missing_rsa_key` | Kalshi config without RSA key is rejected |
| 14 | `TestDeployConfigKalshi::test_rejects_short_rsa_key` | RSA key that's too short is rejected |
| 15 | `TestDeployConfigKalshi::test_rejects_rsa_key_without_pem_markers` | RSA key missing PEM headers is rejected |
| 16 | `TestDeployConfigKalshi::test_rejects_invalid_market_ticker` | Single-segment ticker ("INVALID") is rejected |
| 17 | `TestDeployConfigKalshi::test_rejects_empty_market_ticker` | Empty string ticker is rejected |
| 18 | `TestDeployConfigKalshi::test_rejects_duplicate_markets` | Duplicate market tickers are rejected |
| 19 | `TestDeployConfigKalshi::test_market_count_mismatch` | num_markets=2 with only 1 market is rejected |
| 20 | `TestDeployConfigKalshi::test_rejects_xss_in_market` | XSS payload in market ticker is rejected |
| 21 | `TestDeployConfigSpreads::test_min_spread_equals_max_rejected` | min_spread == max_spread is rejected |
| 22 | `TestDeployConfigSpreads::test_min_spread_greater_than_max_rejected` | min_spread > max_spread is rejected |
| 23 | `TestDeployConfigSpreads::test_spread_range_too_wide` | Spread range > 20 is rejected |
| 24 | `TestDeployConfigSpreads::test_valid_m1_bounds` | Valid [1,7,93,99] bounds pass validation |
| 25 | `TestDeployConfigSpreads::test_invalid_m1_bounds_overlap` | Overlapping inner/outer bounds are rejected |
| 26 | `TestDeployConfigSpreads::test_invalid_m1_bounds_first_range` | First pair where low > high is rejected |
| 27 | `TestDeployConfigSpreads::test_invalid_m1_bounds_second_range` | Second pair where low > high is rejected |
| 28 | `TestDeployConfigSpreads::test_m1_bounds_out_of_range` | Bounds outside 0-100 are rejected |
| 29 | `TestDeployConfigPositions::test_position_increment_too_high` | Position increment > 500 is rejected |
| 30 | `TestDeployConfigPositions::test_max_position_too_high` | Max position > 10000 is rejected |
| 31 | `TestDeployConfigPositions::test_valid_grid_levels` | Valid grid level structure passes |
| 32 | `TestDeployConfigPositions::test_grid_levels_without_grid_mode` | Grid levels without grid_mode enabled are rejected |
| 33 | `TestDeployConfigPositions::test_grid_level_invalid_structure` | Grid level missing required keys is rejected |
| 34 | `TestDeployConfigPositions::test_contract_increment_limits` | Contract increment outside 1-100 is rejected |
| 35 | `TestDeployConfigStrategy::test_valid_trade_strategies` | All valid strategy names (join_jump, cdf, etc.) pass |
| 36 | `TestDeployConfigStrategy::test_invalid_trade_strategy` | Unknown strategy name is rejected |
| 37 | `TestDeployConfigStrategy::test_valid_price_feeds` | All valid price feed names pass |
| 38 | `TestDeployConfigStrategy::test_invalid_price_feed` | Unknown price feed is rejected |
| 39 | `TestDeployConfigStrategy::test_rolling_avg_window_range` | Rolling avg window outside valid range is rejected |
| 40 | `TestDeployConfigStrategy::test_rolling_avg_spread_range` | Rolling avg spread outside valid range is rejected |
| 41 | `TestDeployConfigTwoMarket::test_both_side_required_for_2market` | 2-market config without both_side is rejected |
| 42 | `TestDeployConfigTwoMarket::test_both_side_invalid_value` | both_side with invalid value is rejected |
| 43 | `TestDeployConfigTwoMarket::test_valid_market_priority` | Valid market_priority values pass |
| 44 | `TestDeployConfigTwoMarket::test_invalid_market_priority` | Invalid market_priority value is rejected |
| 45 | `TestDeployConfigTurbine::test_valid_turbine` | Valid Turbine deploy config passes |
| 46 | `TestDeployConfigTurbine::test_turbine_missing_key` | Turbine config without private key is rejected |
| 47 | `TestDeployConfigTurbine::test_turbine_invalid_key_format` | Turbine key without 0x prefix is rejected |
| 48 | `TestDeployConfigTurbine::test_turbine_missing_asset` | Turbine config without asset is rejected |
| 49 | `TestDeployConfigPolymarket::test_valid_polymarket` | Valid Polymarket deploy config passes |
| 50 | `TestDeployConfigPolymarket::test_polymarket_missing_key` | Polymarket config without private key is rejected |
| 51 | `TestDeployConfigPolymarket::test_polymarket_missing_market_type` | Polymarket config without market_type is rejected |
| 52 | `TestInstanceControlRequest::test_valid_actions` | All valid control actions pass validation |
| 53 | `TestInstanceControlRequest::test_invalid_action` | Invalid control action is rejected |
| 54 | `TestOrderbookLevel::test_valid_level` | Valid orderbook level (price + size) passes |
| 55 | `TestOrderbookLevel::test_price_out_of_range` | Price outside 0-100 is rejected |
| 56 | `TestOrderbookLevel::test_negative_size` | Negative order size is rejected |
| 57 | `TestOrderbookLevel::test_size_too_large` | Order size > 100000 is rejected |
| 58 | `TestMarketOrderbook::test_valid_orderbook` | Valid orderbook with ticker/side/levels passes |
| 59 | `TestMarketOrderbook::test_invalid_side` | Orderbook side other than yes/no is rejected |
| 60 | `TestMarketOrderbook::test_side_normalized` | Orderbook side is lowercased automatically |
| 61 | `TestInstanceStatusUpdate::test_valid_status_update` | Valid status update passes |
| 62 | `TestInstanceStatusUpdate::test_invalid_status` | Invalid status value is rejected |
| 63 | `TestInstanceStatusUpdate::test_position_limit` | Position outside -10000 to 10000 is rejected |
| 64 | `TestUserCreate::test_valid_user` | Valid user registration data passes |
| 65 | `TestUserCreate::test_username_too_short` | Username < 3 chars is rejected |
| 66 | `TestUserCreate::test_username_special_chars_rejected` | Special characters in username are rejected |
| 67 | `TestUserCreate::test_password_no_uppercase` | Password without uppercase letter is rejected |
| 68 | `TestUserCreate::test_password_no_lowercase` | Password without lowercase letter is rejected |
| 69 | `TestUserCreate::test_password_no_digit` | Password without digit is rejected |
| 70 | `TestUserCreate::test_password_no_special_char` | Password without special character is rejected |
| 71 | `TestUserCreate::test_password_too_short` | Password < 8 chars is rejected |
| 72 | `TestUserCreate::test_invalid_email` | Invalid email format is rejected |
| 73 | `TestUserCreate::test_xss_in_username` | XSS in username is rejected |
| 74 | `TestUserLogin::test_valid_login` | Valid login data passes |
| 75 | `TestUserLogin::test_xss_in_login_username` | XSS in login username is rejected |

---

## Unit Tests — `unit/test_crypto.py` (18)

Tests Fernet encryption (CryptoService for session credentials) and AES-256-GCM encryption (report_crypto for on-chain reports).

| # | Test | Description |
|---|------|-------------|
| 1 | `TestCryptoService::test_encrypt_decrypt_roundtrip` | Fernet encrypt then decrypt returns original text |
| 2 | `TestCryptoService::test_different_plaintexts_different_ciphertexts` | Different inputs produce different ciphertext |
| 3 | `TestCryptoService::test_encrypt_empty_string` | Empty string encrypts/decrypts correctly |
| 4 | `TestCryptoService::test_encrypt_long_rsa_key` | Full RSA private key encrypts/decrypts correctly |
| 5 | `TestCryptoService::test_legacy_private_key_roundtrip` | Legacy private key field encrypt/decrypt works |
| 6 | `TestCryptoService::test_missing_master_key_raises` | Missing MASTER_KEY env var raises error |
| 7 | `TestCryptoService::test_wrong_key_cannot_decrypt` | Decrypting with wrong key fails |
| 8 | `TestReportCrypto::test_encrypt_decrypt_roundtrip` | AES-256-GCM encrypt then decrypt returns original |
| 9 | `TestReportCrypto::test_different_keys_per_encryption` | Each encryption generates a unique key |
| 10 | `TestReportCrypto::test_wrong_key_fails` | AES decryption with wrong key raises error |
| 11 | `TestReportCrypto::test_wrong_nonce_fails` | AES decryption with wrong nonce raises error |
| 12 | `TestReportCrypto::test_chain_bytes_serialization` | Nonce + ciphertext serialize to bytes correctly |
| 13 | `TestReportCrypto::test_parse_chain_bytes` | Parse chain bytes back into nonce + ciphertext |
| 14 | `TestReportCrypto::test_encrypt_large_report` | Large (10KB) report encrypts/decrypts correctly |
| 15 | `TestReportCrypto::test_encrypt_unicode` | Unicode text encrypts/decrypts correctly |
| 16 | `TestReportKeyManager::test_store_and_retrieve` | Store encryption key in Redis and retrieve it |
| 17 | `TestReportKeyManager::test_list_reports_excludes_key` | Report listing does not expose encryption keys |
| 18 | `TestReportKeyManager::test_non_pledged_denied` | Non-pledged agents cannot store report keys |
| 19 | `TestReportKeyManager::test_missing_report_returns_none` | Retrieving non-existent report returns None |

---

## Unit Tests — `unit/test_service.py` (18)

Tests TerminalService: credential storage/retrieval, instance lifecycle (stop/pause/fire/cancel/end), Redis command dispatch, and response formatting.

| # | Test | Description |
|---|------|-------------|
| 1 | `TestCredentialManagement::test_store_kalshi_credentials` | Stores Kalshi API key + RSA key in Redis |
| 2 | `TestCredentialManagement::test_store_turbine_credentials` | Stores Turbine private key in Redis |
| 3 | `TestCredentialManagement::test_store_polymarket_credentials` | Stores Polymarket private key in Redis |
| 4 | `TestCredentialManagement::test_get_credentials_expired` | Returns None when Redis credentials have expired |
| 5 | `TestCredentialManagement::test_clear_credentials` | Clears all platform credentials from Redis |
| 6 | `TestInstanceManagement::test_get_instance_not_found` | Getting non-existent instance raises NotFoundError |
| 7 | `TestInstanceManagement::test_get_instance_found` | Getting existing instance returns it |
| 8 | `TestInstanceManagement::test_stop_already_stopped_raises` | Stopping an already-stopped instance raises error |
| 9 | `TestInstanceManagement::test_stop_sends_redis_command` | Stop pushes stop command to Redis queue |
| 10 | `TestInstanceManagement::test_force_stop_sends_command` | Force stop pushes force_stop to Redis queue |
| 11 | `TestInstanceManagement::test_toggle_pause_sends_command` | Toggle pause pushes command to Redis queue |
| 12 | `TestInstanceManagement::test_single_fire_sends_command` | Single fire pushes command to Redis queue |
| 13 | `TestInstanceManagement::test_cancel_orders_sends_command` | Cancel orders pushes command to Redis queue |
| 14 | `TestInstanceManagement::test_toggle_fair_value_sends_command` | Fair value toggle pushes command to Redis queue |
| 15 | `TestInstanceManagement::test_toggle_jump_sends_command` | Jump toggle pushes command to Redis queue |
| 16 | `TestInstanceManagement::test_end_instance_sets_dead` | End instance sets status to DEAD in DB |
| 17 | `TestFormatResponse::test_format_positive_pnl` | Positive PnL formatted correctly in response |
| 18 | `TestFormatResponse::test_format_negative_pnl` | Negative PnL formatted correctly in response |
| 19 | `TestFormatResponse::test_get_instance_status_from_redis` | Instance status fetched from Redis correctly |

---

## Unit Tests — `unit/test_mm_core.py` (22)

Tests BaseMarketMaker: state management, fair value updates, rolling window, and all sync/async Redis command processing (pause, resume, single_fire, fair_value, stop, force_stop, cancel_orders).

| # | Test | Description |
|---|------|-------------|
| 1 | `TestBaseMarketMakerState::test_initial_state` | New BaseMarketMaker has correct default state |
| 2 | `TestBaseMarketMakerState::test_toggle_jump` | Jump mode toggles on and off |
| 3 | `TestBaseMarketMakerState::test_update_fair_value` | Fair value updates stored price correctly |
| 4 | `TestBaseMarketMakerState::test_update_fair_value_with_none` | None fair value is handled gracefully |
| 5 | `TestBaseMarketMakerState::test_fair_value_rolling_window` | Rolling window caps at max history size |
| 6 | `TestSyncRedisCommands::test_toggle_pause_command` | Pause command sets paused state |
| 7 | `TestSyncRedisCommands::test_resume_from_pause` | Second pause command resumes |
| 8 | `TestSyncRedisCommands::test_single_fire_when_paused` | Single fire works when paused |
| 9 | `TestSyncRedisCommands::test_single_fire_when_running_ignored` | Single fire ignored when already running |
| 10 | `TestSyncRedisCommands::test_toggle_fair_value_on` | Fair value command enables fair value mode |
| 11 | `TestSyncRedisCommands::test_toggle_fair_value_off_clears_history` | Disabling fair value clears price history |
| 12 | `TestSyncRedisCommands::test_stop_command` | Stop command sets should_stop flag |
| 13 | `TestSyncRedisCommands::test_force_stop_command` | Force stop sets force_stop flag |
| 14 | `TestSyncRedisCommands::test_no_commands_noop` | Empty command queue is a no-op |
| 15 | `TestSyncRedisCommands::test_no_redis_client_noop` | No Redis client is handled gracefully |
| 16 | `TestSyncRedisCommands::test_no_instance_id_noop` | No instance ID is handled gracefully |
| 17 | `TestSyncRedisCommands::test_multiple_commands_processed` | Multiple queued commands all processed |
| 18 | `TestSyncRedisCommands::test_malformed_command_handled` | Malformed JSON command doesn't crash |
| 19 | `TestAsyncRedisCommands::test_async_toggle_pause` | Async pause command sets paused state |
| 20 | `TestAsyncRedisCommands::test_async_stop` | Async stop command sets should_stop flag |
| 21 | `TestAsyncRedisCommands::test_async_cancel_orders` | Async cancel command sets cancel flag |
| 22 | `TestAsyncRedisCommands::test_async_force_stop_cancels_and_stops` | Async force stop sets both cancel and stop flags |

---

## Unit Tests — `unit/test_scanner_scheduler.py` (19)

Tests scanner deploy/list/stop endpoints and scheduler create/list/delete endpoints via direct function calls with mocked Redis.

| # | Test | Description |
|---|------|-------------|
| 1 | `TestScannerDeploy::test_deploy_kalshi_scanner` | Valid Kalshi scanner deploy succeeds |
| 2 | `TestScannerDeploy::test_deploy_turbine_scanner` | Valid Turbine scanner deploy succeeds |
| 3 | `TestScannerDeploy::test_deploy_invalid_platform` | Invalid platform returns 400 |
| 4 | `TestScannerDeploy::test_deploy_no_telegram` | Deploy without Telegram configured returns 400 |
| 5 | `TestScannerDeploy::test_deploy_kalshi_invalid_prefix` | Kalshi scanner with too-short prefix returns 400 |
| 6 | `TestScannerDeploy::test_deploy_turbine_invalid_wallet` | Turbine scanner with invalid wallet address returns 400 |
| 7 | `TestScannerDeploy::test_deploy_turbine_no_wallets` | Turbine scanner with no wallets returns 400 |
| 8 | `TestScannerListStop::test_list_empty` | Scanner list returns empty when none deployed |
| 9 | `TestScannerListStop::test_list_after_deploy` | Scanner list shows deployed scanner |
| 10 | `TestScannerListStop::test_stop_scanner` | Stop scanner removes it from active set |
| 11 | `TestScannerListStop::test_stop_nonexistent_scanner` | Stop non-existent scanner returns 404 |
| 12 | `TestSchedulerCreate::test_create_single_market_schedule` | Create schedule with 1 market succeeds |
| 13 | `TestSchedulerCreate::test_create_two_market_schedule` | Create schedule with 2 markets succeeds |
| 14 | `TestSchedulerCreate::test_create_no_markets` | Create schedule with no markets returns 400 |
| 15 | `TestSchedulerCreate::test_create_too_many_markets` | Create schedule with 3+ markets returns 400 |
| 16 | `TestSchedulerCreate::test_create_invalid_ticker` | Create schedule with invalid ticker returns 400 |
| 17 | `TestSchedulerCreate::test_create_invalid_dates` | Create schedule with invalid date format returns 400 |
| 18 | `TestSchedulerCreate::test_create_end_before_start` | End time before start time returns 400 |
| 19 | `TestSchedulerCreate::test_create_window_exceeds_24h` | Trading window > 24 hours returns 400 |

---

## Unit Tests — `unit/test_syndicate.py` (22)

Tests on-chain infrastructure: ERC-8021 builder codes, Base JSON-RPC calls, write_report_to_chain, session summaries, and game scheduler.

| # | Test | Description |
|---|------|-------------|
| 1 | `TestERC8021::test_build_suffix_default` | Default "luckyst" entity produces 24-byte suffix |
| 2 | `TestERC8021::test_build_suffix_custom_entity` | Custom entity name encodes correctly in suffix |
| 3 | `TestERC8021::test_luckyst_suffix_constant` | LUCKYST_BUILDER_SUFFIX constant is 24 bytes |
| 4 | `TestChainWriterRPC::test_rpc_call` | JSON-RPC call returns expected result |
| 5 | `TestChainWriterRPC::test_send_raw_tx_error` | send_raw_tx raises RuntimeError on RPC error |
| 6 | `TestWriteReport::test_write_report_success` | Successful report write returns tx hash and block |
| 7 | `TestWriteReport::test_write_report_reverted` | Reverted transaction returns success=False |
| 8 | `TestWriteReport::test_write_report_exception` | Network exception returns success=False with error |
| 9 | `TestFetchCalldata::test_fetch_strips_suffix` | Fetched calldata strips 24-byte builder suffix |
| 10 | `TestFetchCalldata::test_fetch_no_tx` | Non-existent transaction returns None |
| 11 | `TestSessionSummary::test_session_summary_to_json` | SessionSummary serializes to valid JSON |
| 12 | `TestSessionSummary::test_session_summary_to_text` | SessionSummary produces readable text report |
| 13 | `TestSessionSummary::test_template_narrative` | Template narrative includes duration and cycle count |
| 14 | `TestSessionSummary::test_generate_ai_narrative_no_key` | AI narrative falls back to template without API key |
| 15 | `TestSessionSummary::test_build_session_summary` | build_session_summary fetches data and builds summary |
| 16 | `TestSessionSummary::test_fetch_session_data_handles_errors` | fetch_session_data returns empty data on API error |
| 17 | `TestGameScheduler::test_game_entry_dataclass` | GameEntry dataclass has correct defaults |
| 18 | `TestGameScheduler::test_schedule_commitment_serialization` | ScheduleCommitment roundtrips through JSON |
| 19 | `TestGameScheduler::test_create_schedule_redis_only` | create_schedule stores games in Redis sorted set |
| 20 | `TestGameScheduler::test_check_and_deploy` | check_and_deploy calls deploy function at game time |
| 21 | `TestGameScheduler::test_check_and_stop` | check_and_stop calls stop function for ended games |
| 22 | `TestGameScheduler::test_list_pending_games` | list_pending_games returns pending game metadata |

---

## Unit Tests — `unit/test_user_service.py` (12)

Tests password hashing (bcrypt), JWT token creation/verification, authentication flow (login, lockout after 3 failures), and user creation.

| # | Test | Description |
|---|------|-------------|
| 1 | `TestPasswordHashing::test_hash_and_verify` | Hash then verify returns True for correct password |
| 2 | `TestPasswordHashing::test_wrong_password_fails` | Wrong password fails verification |
| 3 | `TestPasswordHashing::test_different_hashes_for_same_password` | Same password produces different hashes (salt) |
| 4 | `TestJWTTokens::test_create_access_token` | JWT token is created as non-empty string |
| 5 | `TestJWTTokens::test_token_contains_claims` | JWT token contains correct sub and exp claims |
| 6 | `TestAuthentication::test_successful_login` | Correct credentials return user + token |
| 7 | `TestAuthentication::test_login_resets_failed_attempts` | Successful login resets failed attempt counter |
| 8 | `TestAuthentication::test_wrong_password_increments_attempts` | Wrong password increments failed_login_attempts |
| 9 | `TestAuthentication::test_account_locks_after_3_attempts` | Account locks after 3 failed login attempts |
| 10 | `TestAuthentication::test_locked_account_rejected` | Locked account is rejected even with correct password |
| 11 | `TestAuthentication::test_nonexistent_user` | Login with non-existent username raises error |
| 12 | `TestCreateUser::test_create_user_success` | New user is created and added to DB |
| 13 | `TestCreateUser::test_duplicate_username_rejected` | Duplicate username raises error |

---

## Unit Tests — `unit/test_rate_limiter.py` (9)

Tests sliding window rate limiter, per-endpoint rate limiter, and user identification from request.

| # | Test | Description |
|---|------|-------------|
| 1 | `TestRateLimiter::test_allows_within_limit` | Requests within limit are allowed |
| 2 | `TestRateLimiter::test_blocks_over_limit` | Requests over limit are blocked |
| 3 | `TestRateLimiter::test_different_users_independent` | Different users have independent rate limits |
| 4 | `TestRateLimiter::test_info_fields` | Rate limit info contains limit, remaining, and reset |
| 5 | `TestEndpointRateLimiter::test_allows_within_limit` | Per-endpoint limiter allows within limit |
| 6 | `TestEndpointRateLimiter::test_blocks_over_limit` | Per-endpoint limiter raises 429 over limit |
| 7 | `TestGetUserIdentifier::test_authenticated_user` | Returns "user_42" for authenticated user with id=42 |
| 8 | `TestGetUserIdentifier::test_anonymous_user_cloudflare` | Returns CF-Connecting-IP for anonymous users |
| 9 | `TestGetUserIdentifier::test_anonymous_user_no_cf` | Returns "ip_unknown" when no IP header present |

---

## Unit Tests — `unit/test_exceptions.py` (7)

Tests all custom exception types have correct HTTP status codes and messages.

| # | Test | Description |
|---|------|-------------|
| 1 | `TestExceptions::test_not_found_error` | NotFoundError has status code 404 |
| 2 | `TestExceptions::test_unauthorized_error` | UnauthorizedError has status code 401 |
| 3 | `TestExceptions::test_forbidden_error` | ForbiddenError has status code 403 |
| 4 | `TestExceptions::test_bad_request_error` | BadRequestError has status code 400 |
| 5 | `TestExceptions::test_rate_limit_error` | RateLimitError has status code 429 |
| 6 | `TestExceptions::test_kalshi_api_error` | KalshiAPIError has status code 502 |
| 7 | `TestExceptions::test_base_exception` | AppException base class works with custom status code |

---

## CI/CD

GitHub Actions workflow at `.github/workflows/test.yml` runs on push to `main`/`ls*` branches and PRs to `main`:

1. **Unit Tests** — `pytest tests/unit/ -v --tb=short -m "not slow"`
2. **Integration Tests** — `pytest tests/integration/ -v --tb=short`
3. **Coverage Report** — `pytest tests/ --cov=app --cov=../agentic/syndicate`
