From 898f89926d6db961eed253372bb84637b5afd819 Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Mon, 20 Apr 2026 02:34:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20beta=20API=20integration=20test=20suite?= =?UTF-8?q?=20=E2=80=94=2085=20new=20tests=20across=206=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends integration test coverage from 108 to 193 tests for the beta gate. New test modules: - test_query_api_extended.py (33 tests): documents, evidence, macro/competitive, ops/admin, agents, analytics - test_registry_write_paths.py (16 tests): write paths, validation, duplicates, competitor/exposure CRUD - test_risk_approval_lifecycle.py (8 tests): evaluation edge cases, full approval lifecycle - test_trading_extended.py (12 tests): config round-trips, decision filtering, override validation - test_cross_service_roundtrip.py (4 tests): cross-service data consistency - test_error_handling.py (12 tests): 404s, 422s, empty states, health checks Seed script extended with watchlists, approvals, lockouts, notifications, ingestion runs, saved queries, and daily risk snapshots. --- .kiro/specs/beta-api-test-suite/.config.kiro | 1 + .kiro/specs/beta-api-test-suite/design.md | 216 ++++++++++ .../specs/beta-api-test-suite/requirements.md | 206 +++++++++ .kiro/specs/beta-api-test-suite/tasks.md | 124 ++++++ tests/integration/conftest.py | 14 + tests/integration/seed_sandbox.py | 199 +++++++++ .../test_cross_service_roundtrip.py | 159 +++++++ tests/integration/test_error_handling.py | 170 ++++++++ tests/integration/test_query_api_extended.py | 407 ++++++++++++++++++ .../integration/test_registry_write_paths.py | 253 +++++++++++ .../test_risk_approval_lifecycle.py | 136 ++++++ tests/integration/test_trading_extended.py | 244 +++++++++++ 12 files changed, 2129 insertions(+) create mode 100644 .kiro/specs/beta-api-test-suite/.config.kiro create mode 100644 .kiro/specs/beta-api-test-suite/design.md create mode 100644 .kiro/specs/beta-api-test-suite/requirements.md create mode 100644 .kiro/specs/beta-api-test-suite/tasks.md create mode 100644 tests/integration/test_cross_service_roundtrip.py create mode 100644 tests/integration/test_error_handling.py create mode 100644 tests/integration/test_query_api_extended.py create mode 100644 tests/integration/test_registry_write_paths.py create mode 100644 tests/integration/test_risk_approval_lifecycle.py create mode 100644 tests/integration/test_trading_extended.py diff --git a/.kiro/specs/beta-api-test-suite/.config.kiro b/.kiro/specs/beta-api-test-suite/.config.kiro new file mode 100644 index 0000000..b6e3ad3 --- /dev/null +++ b/.kiro/specs/beta-api-test-suite/.config.kiro @@ -0,0 +1 @@ +{"specId": "e433350c-baf0-4f4f-a30e-3724f6654090", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/beta-api-test-suite/design.md b/.kiro/specs/beta-api-test-suite/design.md new file mode 100644 index 0000000..c797c87 --- /dev/null +++ b/.kiro/specs/beta-api-test-suite/design.md @@ -0,0 +1,216 @@ +# Design Document: Beta API Test Suite + +## Overview + +This design describes a comprehensive integration test suite that validates every HTTP endpoint across the four Stonks Oracle API services (query-api, symbol-registry, risk-engine, trading-engine) that can be tested without external broker or news API dependencies. The suite extends the existing ~56 integration tests to achieve full endpoint coverage, serving as the beta gate that blocks promotion to paper-trading if any test fails. + +The test suite operates against an ephemeral Kubernetes sandbox with seeded PostgreSQL data, using deterministic UUIDs for exact equality assertions. It validates read paths, write paths, round-trips, edge cases, error handling, pagination/filtering, and empty-state behavior. + +## Architecture + +```mermaid +graph TD + subgraph "K8s Sandbox Namespace" + PG[(PostgreSQL)] + Redis[(Redis)] + MinIO[(MinIO)] + QA[query-api :8000] + SR[symbol-registry :8000] + RE[risk-engine :8000] + TE[trading-engine :8000] + end + + subgraph "Test Runner Job" + SEED[seed_sandbox.py] + CONF[conftest.py] + T1[test_query_api.py] + T2[test_registry_api.py] + T3[test_risk_api.py] + T4[test_trading_api.py] + T5[test_signal_flow.py] + T6[test_frontend_data_deps.py] + T7[test_query_api_extended.py] + T8[test_registry_write_paths.py] + T9[test_risk_approval_lifecycle.py] + T10[test_trading_extended.py] + T11[test_cross_service_roundtrip.py] + T12[test_error_handling.py] + end + + SEED -->|INSERT| PG + CONF -->|httpx.AsyncClient| QA + CONF -->|httpx.AsyncClient| SR + CONF -->|httpx.AsyncClient| RE + CONF -->|httpx.AsyncClient| TE + QA --> PG + SR --> PG + RE --> PG + TE --> PG + TE --> Redis +``` + +The architecture preserves the existing test infrastructure: +- **Runner Job**: K8s Job (`infra/inttest/runner.yaml`) executes pytest in the sandbox namespace +- **Seed Script**: `tests/integration/seed_sandbox.py` populates deterministic data before tests run +- **Fixtures**: `tests/integration/conftest.py` provides `ProfiledAsyncClient` wrappers per service +- **New test files** extend coverage without modifying existing passing tests + +## Components and Interfaces + +### 1. Extended Seed Script (`seed_sandbox.py`) + +New seed functions added to populate tables required by untested endpoints: + +| Table | New Records | Purpose | +|-------|-------------|---------| +| `watchlists` | 2 watchlists | Watchlist CRUD tests | +| `watchlist_members` | 3 members | Watchlist membership tests | +| `operator_approvals` | 2 (pending + approved) | Approval lifecycle tests | +| `symbol_lockouts` | 2 (active + expired) | Lockout CRUD tests | +| `notifications` | 1 delivered | Notification history tests | +| `ingestion_runs` | 2 (completed + failed) | Ingestion summary tests | +| `saved_queries` | 1 | Saved query CRUD tests | +| `daily_risk_snapshots` | 1 | Risk snapshot tests | + +All new records use deterministic UUIDs exported as named constants. + +### 2. New Test Modules + +| Module | Service | Coverage | +|--------|---------|----------| +| `test_query_api_extended.py` | query-api | Documents filtering, evidence drill-down, trend projections, macro/competitive endpoints, ops dashboard, agents CRUD, analytics | +| `test_registry_write_paths.py` | symbol-registry | Alias/source/watchlist/competitor/exposure write paths, validation errors, duplicates | +| `test_risk_approval_lifecycle.py` | risk-engine | Full approval lifecycle, evaluation edge cases, expiry | +| `test_trading_extended.py` | trading-engine | Config round-trips, decision filtering, notification CRUD, override validation | +| `test_cross_service_roundtrip.py` | cross-service | Write via one service, read via another | +| `test_error_handling.py` | all services | 404s, 422s, empty lists, structured JSON errors | + +### 3. Conftest Extensions + +New fixtures added to `conftest.py`: +- Export new seed IDs (watchlists, approvals, lockouts, notifications, ingestion runs, saved queries) +- No changes to existing client fixtures + +## Data Models + +### New Seed ID Constants + +```python +# Watchlists +WATCHLIST_01 = UUID("00000000-0000-4000-ac00-000000000001") +WATCHLIST_02 = UUID("00000000-0000-4000-ac00-000000000002") + +# Operator Approvals +APPROVAL_PENDING = UUID("00000000-0000-4000-ad00-000000000001") +APPROVAL_APPROVED = UUID("00000000-0000-4000-ad00-000000000002") + +# Symbol Lockouts +LOCKOUT_ACTIVE = UUID("00000000-0000-4000-ae00-000000000001") +LOCKOUT_EXPIRED = UUID("00000000-0000-4000-ae00-000000000002") + +# Notifications +NOTIFICATION_01 = UUID("00000000-0000-4000-af00-000000000001") + +# Ingestion Runs +INGESTION_RUN_01 = UUID("00000000-0000-4000-b300-000000000001") +INGESTION_RUN_02 = UUID("00000000-0000-4000-b300-000000000002") + +# Saved Queries +SAVED_QUERY_01 = UUID("00000000-0000-4000-b400-000000000001") + +# Daily Risk Snapshot +RISK_SNAPSHOT_01 = UUID("00000000-0000-4000-b500-000000000001") +``` + +### Seed ID Export Dictionary Extension + +```python +SEED_WATCHLIST_IDS = { + "WL_01": str(WATCHLIST_01), + "WL_02": str(WATCHLIST_02), +} +SEED_APPROVAL_IDS = { + "PENDING": str(APPROVAL_PENDING), + "APPROVED": str(APPROVAL_APPROVED), +} +SEED_LOCKOUT_IDS = { + "ACTIVE": str(LOCKOUT_ACTIVE), + "EXPIRED": str(LOCKOUT_EXPIRED), +} +SEED_NOTIFICATION_IDS = {"NOTIF_01": str(NOTIFICATION_01)} +SEED_INGESTION_RUN_IDS = { + "RUN_01": str(INGESTION_RUN_01), + "RUN_02": str(INGESTION_RUN_02), +} +SEED_SAVED_QUERY_IDS = {"SQ_01": str(SAVED_QUERY_01)} +``` + +## Error Handling + +The test suite validates error handling across all services: + +1. **404 Not Found**: All detail endpoints return JSON `{"detail": "..."}` for non-existent UUIDs +2. **422 Validation Error**: POST/PUT endpoints return structured validation errors for invalid bodies +3. **409 Conflict**: Duplicate creation attempts return conflict errors +4. **400 Bad Request**: Business logic violations (self-referencing competitors, etc.) +5. **503 Service Unavailable**: Graceful degradation when Redis is unavailable (trading engine override) +6. **Empty Lists**: List endpoints return `[]` with HTTP 200, never 404 + +Each error response is verified to be valid JSON (not HTML stack traces). + +## Testing Strategy + +### Approach: Integration Testing with Seeded Data + +**Why PBT does not apply**: This is an integration test suite that validates HTTP API contracts against live services with a seeded database. The tests verify: +- Correct HTTP status codes +- Response schema conformance +- Data consistency across services +- Error handling behavior + +These are integration tests with concrete, deterministic assertions — not pure functions with universal properties. The input space is fixed (seeded data), and behavior depends on external service state (PostgreSQL, Redis). Property-based testing would require mocking the entire service layer, defeating the purpose of integration testing. + +### Test Organization + +**Existing tests (preserved as-is)**: +- `test_query_api.py` — 17 tests covering core query API reads +- `test_registry_api.py` — 8 tests covering registry CRUD +- `test_risk_api.py` — 4 tests covering risk evaluation and approvals +- `test_trading_api.py` — 12 tests covering trading engine endpoints +- `test_signal_flow.py` — 11 tests covering cross-service contracts +- `test_frontend_data_deps.py` — 21 tests covering frontend page dependencies + +**New tests (added by this spec)**: +- `test_query_api_extended.py` — ~25 tests for uncovered query API endpoints +- `test_registry_write_paths.py` — ~15 tests for registry write paths and edge cases +- `test_risk_approval_lifecycle.py` — ~10 tests for approval workflow and evaluation edge cases +- `test_trading_extended.py` — ~12 tests for trading config round-trips, filtering, notifications +- `test_cross_service_roundtrip.py` — ~6 tests for cross-service data consistency +- `test_error_handling.py` — ~10 tests for structured error responses + +**Total target**: ~130+ integration tests (existing ~56 + ~75 new) + +### Test Execution + +- Framework: pytest + pytest-asyncio +- HTTP client: httpx.AsyncClient wrapped in ProfiledAsyncClient +- Timeout: 30s per request, 600s total Job deadline +- Parallelism: Sequential within each file (shared state from writes), files can run in parallel +- Idempotency: Seed script uses `ON CONFLICT DO NOTHING` for re-runnability + +### Naming Convention + +Test classes follow the pattern `Test{Service}{Feature}` with descriptive method names: +```python +class TestQueryAPIDocumentFiltering: + async def test_filter_documents_by_ticker(self, query_client, seed_ids): ... + async def test_filter_documents_by_doc_type(self, query_client, seed_ids): ... +``` + +### Assertions + +- Status code assertions first (`assert resp.status_code == 200`) +- Schema assertions verify required fields exist +- Value assertions use deterministic seed data for exact equality +- Numeric assertions use approximate comparison where appropriate (portfolio math) +- List assertions verify minimum counts from seed data diff --git a/.kiro/specs/beta-api-test-suite/requirements.md b/.kiro/specs/beta-api-test-suite/requirements.md new file mode 100644 index 0000000..e81e5f7 --- /dev/null +++ b/.kiro/specs/beta-api-test-suite/requirements.md @@ -0,0 +1,206 @@ +# Requirements Document + +## Introduction + +Comprehensive API integration test suite for the Stonks Oracle beta gate. The suite validates every HTTP endpoint across all four API services (query-api, symbol-registry, risk-engine, trading-engine) that can be tested without external broker or news API dependencies. Tests run against a seeded PostgreSQL database with deterministic data, exercising read paths, write paths, edge cases, empty-state behavior, error handling, pagination/filtering, and round-trip fidelity. The suite extends the existing ~41 integration tests to provide full endpoint coverage suitable for blocking promotion from beta to paper-trading. + +## Glossary + +- **Test_Suite**: The collection of pytest-asyncio integration test modules under `tests/integration/` that validate API behavior against live sandbox services +- **Seed_Script**: The `tests/integration/seed_sandbox.py` module that populates the database with deterministic UUIDs, timestamps, and relationships for reproducible assertions +- **Query_API**: The FastAPI service at `services/api/app.py` exposing ~50 read/write endpoints for analytics, evidence drill-down, admin controls, macro/competitive layers, agent management, and operational dashboards +- **Symbol_Registry**: The FastAPI service at `services/symbol_registry/app.py` exposing CRUD endpoints for companies, aliases, watchlists, sources, competitor relationships, and exposure profiles +- **Risk_Engine**: The FastAPI service at `services/risk/app.py` exposing order risk evaluation, approval workflow, and approval expiry endpoints +- **Trading_Engine**: The FastAPI service at `services/trading/app.py` exposing engine control, configuration, decisions, metrics, notifications, backtest, and override order endpoints +- **Beta_Gate**: The promotion pipeline (`infra/inttest/promote.sh`) that deploys to beta namespace, seeds data, runs the Test_Suite, and promotes to paper-trading only if all tests pass +- **Sandbox**: The ephemeral Kubernetes namespace with PostgreSQL, Redis, and MinIO where integration tests execute +- **Deterministic_UUID**: A hardcoded UUID in the Seed_Script that enables exact equality assertions in tests +- **Round_Trip**: A test pattern where data is written via a POST/PUT endpoint and then read back via a GET endpoint to verify fidelity + +## Requirements + +### Requirement 1: Expanded Seed Data for Full Coverage + +**User Story:** As a test engineer, I want the seed script to populate all database tables with enough variety to exercise every API code path, so that tests can validate filtering, pagination, edge cases, and empty-state behavior. + +#### Acceptance Criteria + +1. THE Seed_Script SHALL insert at least 2 watchlists with at least 3 watchlist members across the watchlists +2. THE Seed_Script SHALL insert at least 2 operator approval records: one with status "pending" and one with status "approved" +3. THE Seed_Script SHALL insert at least 2 symbol lockout records: one expired and one still active +4. THE Seed_Script SHALL insert at least 1 notification record with delivery_status "delivered" +5. THE Seed_Script SHALL insert at least 2 ingestion run records with different statuses ("completed" and "failed") +6. THE Seed_Script SHALL insert at least 1 saved query record +7. THE Seed_Script SHALL insert at least 1 daily risk snapshot record +8. THE Seed_Script SHALL export all new Deterministic_UUIDs as named constants and include them in the SEED lookup dictionaries for test assertion use +9. WHEN the Seed_Script is run against an already-seeded database, THE Seed_Script SHALL be idempotent and not raise errors due to duplicate key violations + +### Requirement 2: Query API — Document and Evidence Endpoints + +**User Story:** As a test engineer, I want comprehensive tests for the Query API document timeline, evidence drill-down, and trend projection endpoints, so that schema drift between services is caught before promotion. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/documents` with `ticker` filter parameter, THE Query_API SHALL return only documents mentioning that ticker +2. WHEN a GET request is made to `/api/documents` with `doc_type` filter parameter, THE Query_API SHALL return only documents of that type +3. WHEN a GET request is made to `/api/documents/{id}` for a seeded document, THE Query_API SHALL return the document with `intelligence`, `company_mentions`, and `title` fields populated +4. WHEN a GET request is made to `/api/documents/{id}` with a non-existent UUID, THE Query_API SHALL return HTTP 404 +5. WHEN a GET request is made to `/api/recommendations/{id}/evidence` for a seeded recommendation, THE Query_API SHALL return an evidence drill-down with document and intelligence references +6. WHEN a GET request is made to `/api/trends/{id}/evidence` for a seeded trend, THE Query_API SHALL return an evidence drill-down with supporting and opposing document references +7. WHEN a GET request is made to `/api/trends/{id}/projection` for a seeded trend with a projection, THE Query_API SHALL return the trend projection with `projected_direction`, `projected_strength`, `projected_confidence`, and `macro_contribution_pct` fields + +### Requirement 3: Query API — Macro and Competitive Layer Endpoints + +**User Story:** As a test engineer, I want tests for the macro event, competitive signal, and pattern endpoints, so that the three-layer signal aggregation engine is validated end-to-end. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/macro/status`, THE Query_API SHALL return the macro layer toggle status with `enabled` field +2. WHEN a GET request is made to `/api/macro/events`, THE Query_API SHALL return at least 2 seeded global events with `event_types`, `severity`, `affected_regions`, and `summary` fields +3. WHEN a GET request is made to `/api/macro/events/{id}` for a seeded event, THE Query_API SHALL return the event detail with `macro_impacts` containing per-company impact records +4. WHEN a GET request is made to `/api/macro/impacts/{ticker}` for "AAPL", THE Query_API SHALL return at least 1 macro impact record with `macro_impact_score`, `impact_direction`, and `contributing_factors` +5. WHEN a GET request is made to `/api/competitive/status`, THE Query_API SHALL return the competitive layer toggle status with `enabled` field +6. WHEN a GET request is made to `/api/competitive/signals/{ticker}` for "AAPL", THE Query_API SHALL return competitive signal records with `source_ticker`, `target_ticker`, `signal_direction`, and `signal_strength` fields +7. WHEN a GET request is made to `/api/competitive/patterns/{ticker}` for "AAPL", THE Query_API SHALL return historical pattern data (may be empty if no patterns computed) + +### Requirement 4: Query API — Operational and Admin Endpoints + +**User Story:** As a test engineer, I want tests for the operational dashboard, source management, trading config, approval workflow, and lockout endpoints, so that admin functionality is validated before promotion. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/ops/pipeline/health`, THE Query_API SHALL return pipeline health with `document_stages`, `parsing`, `extraction`, `aggregation`, and `queue_depths` fields +2. WHEN a GET request is made to `/api/ops/ingestion/summary`, THE Query_API SHALL return ingestion statistics with `total_runs` and `by_source_type` fields +3. WHEN a GET request is made to `/api/ops/sources/coverage-gaps`, THE Query_API SHALL return gap analysis with `missing_source_types` and `stale_sources` lists +4. WHEN a PUT request is made to `/api/sources/{id}/toggle?active=false` for a seeded source, THE Query_API SHALL return HTTP 200 and the source active status SHALL be updated +5. WHEN a GET request is made to `/api/trading/config`, THE Query_API SHALL return the risk configuration with `trading_mode` and `config` fields +6. WHEN a GET request is made to `/api/approvals/pending`, THE Query_API SHALL return a list containing the seeded pending approval record +7. WHEN a GET request is made to `/api/lockouts/active`, THE Query_API SHALL return a list containing the seeded active lockout record +8. WHEN a POST request is made to `/api/lockouts` with a valid ticker and expiry, THE Query_API SHALL create a lockout and return HTTP 200 with the lockout details +9. WHEN a DELETE request is made to `/api/lockouts/{id}` for the created lockout, THE Query_API SHALL remove the lockout and return HTTP 200 + +### Requirement 5: Query API — Agent and Variant Management Endpoints + +**User Story:** As a test engineer, I want tests for the AI agent CRUD, variant lifecycle, and performance endpoints, so that the agent management system is validated. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/agents`, THE Query_API SHALL return at least 3 seeded agents with `id`, `name`, `slug`, `model_name`, and `active` fields +2. WHEN a GET request is made to `/api/agents/{id}` for a seeded agent, THE Query_API SHALL return the agent detail with `system_prompt`, `temperature`, and `max_tokens` fields +3. WHEN a POST request is made to `/api/agents` with a valid agent body, THE Query_API SHALL create the agent and return HTTP 201 with the new agent including a generated `slug` +4. WHEN a PUT request is made to `/api/agents/{id}` with updated fields, THE Query_API SHALL update the agent and return the modified record +5. WHEN a GET request is made to `/api/agents/{id}/variants` for a seeded agent, THE Query_API SHALL return at least 1 variant with `variant_name`, `model_name`, and `is_active` fields +6. WHEN a POST request is made to `/api/agents/{id}/variants` with a valid variant body, THE Query_API SHALL create the variant and return HTTP 201 +7. WHEN a POST request is made to `/api/agents/{id}/variants/{vid}/activate`, THE Query_API SHALL set the variant as active and deactivate any previously active variant for that agent +8. WHEN a GET request is made to `/api/agents/{id}/performance` for a seeded agent, THE Query_API SHALL return performance metrics derived from the agent performance log +9. WHEN a GET request is made to `/api/agents/{id}/variants/{vid}/performance` for a seeded variant, THE Query_API SHALL return variant-level performance metrics + +### Requirement 6: Symbol Registry — Write Path and Edge Case Endpoints + +**User Story:** As a test engineer, I want tests for the Symbol Registry write paths (create, update, delete) and edge cases (duplicates, not-found, validation), so that data integrity is validated. + +#### Acceptance Criteria + +1. WHEN a POST request is made to `/companies` with a duplicate ticker and exchange, THE Symbol_Registry SHALL return HTTP 409 +2. WHEN a GET request is made to `/companies/{id}` with a non-existent UUID, THE Symbol_Registry SHALL return HTTP 404 +3. WHEN a POST request is made to `/companies/{id}/aliases` with a valid alias, THE Symbol_Registry SHALL create the alias and return HTTP 201 with `id`, `alias`, and `alias_type` +4. WHEN a POST request is made to `/companies/{id}/sources` with a valid source body, THE Symbol_Registry SHALL create the source and return HTTP 201 with `id`, `source_type`, and `source_name` +5. WHEN a POST request is made to `/companies/{id}/sources` with an invalid `source_type`, THE Symbol_Registry SHALL return HTTP 422 +6. WHEN a POST request is made to `/watchlists` with a valid name, THE Symbol_Registry SHALL create the watchlist and return HTTP 201 +7. WHEN a POST request is made to `/watchlists/{id}/members/{company_id}` for a seeded company, THE Symbol_Registry SHALL add the member and return HTTP 201 +8. WHEN a GET request is made to `/watchlists/{id}/members` after adding members, THE Symbol_Registry SHALL return the member companies with `ticker` and `legal_name` fields +9. WHEN a POST request is made to `/watchlists` with a duplicate name, THE Symbol_Registry SHALL return HTTP 409 + +### Requirement 7: Symbol Registry — Competitor and Exposure Write Paths + +**User Story:** As a test engineer, I want tests for competitor relationship CRUD and exposure profile upsert, so that the competitive intelligence data layer is validated. + +#### Acceptance Criteria + +1. WHEN a POST request is made to `/companies/{id}/competitors` with a valid competitor body, THE Symbol_Registry SHALL create the relationship and return HTTP 201 with `relationship_type`, `strength`, and `bidirectional` fields +2. WHEN a POST request is made to `/companies/{id}/competitors` where company_id equals company_b_id, THE Symbol_Registry SHALL return HTTP 400 with a self-referencing error +3. WHEN a PUT request is made to `/companies/{id}/competitors/{rel_id}` with updated strength, THE Symbol_Registry SHALL update the relationship and return the modified record +4. WHEN a DELETE request is made to `/companies/{id}/competitors/{rel_id}`, THE Symbol_Registry SHALL soft-delete the relationship (set active=false) and return HTTP 200 +5. WHEN a PUT request is made to `/companies/{id}/exposure` with a valid exposure profile, THE Symbol_Registry SHALL create or update the profile and return the new version with incremented `version` number +6. WHEN a GET request is made to `/companies/{id}/exposure/history` for a company with multiple profile versions, THE Symbol_Registry SHALL return all versions ordered by version descending +7. WHEN a GET request is made to `/companies/{id}/exposure` for a company with no exposure profile, THE Symbol_Registry SHALL return HTTP 404 + +### Requirement 8: Risk Engine — Evaluation Edge Cases and Approval Workflow + +**User Story:** As a test engineer, I want tests for risk evaluation edge cases, the full approval lifecycle, and approval expiry, so that the risk gate is validated. + +#### Acceptance Criteria + +1. WHEN a POST request is made to `/evaluate` with a minimal order (only ticker), THE Risk_Engine SHALL return a valid evaluation with `evaluation_id`, `eligible`, and `rejection_reasons` +2. WHEN a POST request is made to `/evaluate` with an order exceeding the absolute position cap, THE Risk_Engine SHALL return `eligible: false` with at least one rejection reason +3. WHEN a POST request is made to `/evaluate` with a custom `config` overriding risk parameters, THE Risk_Engine SHALL use the provided config for evaluation +4. WHEN a GET request is made to `/approvals/pending`, THE Risk_Engine SHALL return a list of pending approval records from the seeded data +5. WHEN a GET request is made to `/approvals/{id}` for a seeded pending approval, THE Risk_Engine SHALL return the approval detail with `ticker`, `side`, `quantity`, `status`, and `expires_at` fields +6. WHEN a POST request is made to `/approvals/{id}/review` with `approved: true`, THE Risk_Engine SHALL update the approval status to "approved" and return the new status +7. WHEN a POST request is made to `/approvals/{id}/review` for a non-existent approval, THE Risk_Engine SHALL return HTTP 404 +8. WHEN a POST request is made to `/approvals/expire`, THE Risk_Engine SHALL expire stale pending approvals and return the count of expired records + +### Requirement 9: Trading Engine — Configuration, Metrics, and Notification Endpoints + +**User Story:** As a test engineer, I want tests for trading engine configuration round-trips, metrics consistency, notification config CRUD, and notification history, so that the autonomous trading control plane is validated. + +#### Acceptance Criteria + +1. WHEN a PUT request is made to `/api/trading/config` with `risk_tier: "aggressive"` followed by a GET to `/api/trading/status`, THE Trading_Engine SHALL reflect the updated risk tier in the status response (Round_Trip) +2. WHEN a POST request is made to `/api/trading/pause` followed by a GET to `/api/trading/status`, THE Trading_Engine SHALL report `paused: true` in the status response +3. WHEN a POST request is made to `/api/trading/resume` followed by a GET to `/api/trading/status`, THE Trading_Engine SHALL report `paused: false` in the status response +4. WHEN a GET request is made to `/api/trading/metrics`, THE Trading_Engine SHALL return all numeric portfolio metrics where `total_portfolio_value` approximately equals `active_pool + reserve_pool + unrealized_pnl` +5. WHEN a GET request is made to `/api/trading/metrics/history`, THE Trading_Engine SHALL return a list of portfolio snapshots with `portfolio_value` and `snapshot_date` fields +6. WHEN a PUT request is made to `/api/trading/notifications/config` with `sms_enabled: true`, THE Trading_Engine SHALL update the notification config and a subsequent GET SHALL reflect the change (Round_Trip) +7. WHEN a GET request is made to `/api/trading/notifications/history`, THE Trading_Engine SHALL return a list of notification records (at least 1 from seed data) +8. WHEN a GET request is made to `/api/trading/decisions` with `ticker` filter, THE Trading_Engine SHALL return only decisions for that ticker + +### Requirement 10: Trading Engine — Decision Filtering, Backtest, and Override Validation + +**User Story:** As a test engineer, I want tests for decision audit trail filtering, backtest submission, and override order validation, so that the trading engine's write paths and input validation are verified. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/trading/decisions` with `limit=1`, THE Trading_Engine SHALL return at most 1 decision record +2. WHEN a GET request is made to `/api/trading/decisions` with `decision=act`, THE Trading_Engine SHALL return only decisions with `decision: "act"` +3. WHEN a POST request is made to `/api/trading/override/order` with an invalid ticker (lowercase or special characters), THE Trading_Engine SHALL return HTTP 422 +4. WHEN a POST request is made to `/api/trading/override/order` with quantity of 0 or negative, THE Trading_Engine SHALL return HTTP 422 +5. WHEN a POST request is made to `/api/trading/override/order` with a valid market order, THE Trading_Engine SHALL return HTTP 202 with `job_id`, `status: "queued"`, `ticker`, `side`, and `quantity` fields, or a structured error if Redis is unavailable +6. IF the Trading_Engine returns HTTP 503 for an override order due to Redis unavailability, THEN THE Trading_Engine SHALL return a JSON error body (not an unhandled exception) + +### Requirement 11: Query API — Analytics, Saved Queries, and Schema Introspection + +**User Story:** As a test engineer, I want tests for the analytics query engine, saved query CRUD, and schema introspection endpoints, so that the data exploration features are validated. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/analytics/schema`, THE Query_API SHALL return the Trino schema information (or a structured error if Trino is unavailable) +2. WHEN a GET request is made to `/api/pg/schema`, THE Query_API SHALL return the PostgreSQL schema with table and column information +3. WHEN a GET request is made to `/api/saved-queries`, THE Query_API SHALL return a list of saved queries (at least 1 from seed data or migrations) +4. WHEN a POST request is made to `/api/saved-queries` with a valid query body, THE Query_API SHALL create the saved query and return the record +5. WHEN a DELETE request is made to `/api/saved-queries/{id}` for the created query, THE Query_API SHALL delete the query and return HTTP 200 +6. WHEN a POST request is made to `/api/pg/query` with a valid read-only SQL query, THE Query_API SHALL execute the query and return results + +### Requirement 12: Cross-Service Round-Trip and Contract Validation + +**User Story:** As a test engineer, I want round-trip tests that write data through one service and read it through another, so that cross-service data consistency is validated. + +#### Acceptance Criteria + +1. WHEN a company is created via POST to Symbol_Registry `/companies` and then queried via GET to Query_API `/api/companies`, THE company SHALL appear in the Query_API response with matching `ticker` and `legal_name` +2. WHEN an exposure profile is created via PUT to Symbol_Registry `/companies/{id}/exposure` and then queried via GET to Symbol_Registry `/companies/{id}/exposure`, THE profile SHALL match the submitted data with `geographic_revenue_mix`, `supply_chain_regions`, and `market_position_tier` fields preserved +3. WHEN a competitor relationship is created via POST to Symbol_Registry `/companies/{id}/competitors` and then queried from both sides, THE relationship SHALL be visible from both company_a and company_b perspectives +4. WHEN a risk evaluation is performed via POST to Risk_Engine `/evaluate` and then the same ticker's recommendation is queried via Query_API `/api/recommendations`, THE recommendation SHALL include a `risk_evaluation` field with matching evaluation structure + +### Requirement 13: Error Handling and Empty-State Behavior + +**User Story:** As a test engineer, I want tests that verify all endpoints return structured JSON errors (not HTML or stack traces) and handle empty-state gracefully, so that the frontend never receives unexpected response formats. + +#### Acceptance Criteria + +1. WHEN a GET request is made to any list endpoint with no matching data, THE service SHALL return HTTP 200 with an empty list (not HTTP 404 or an error) +2. WHEN a GET request is made to any detail endpoint with a non-existent UUID, THE service SHALL return HTTP 404 with a JSON body containing an error message +3. WHEN a POST request is made with an invalid JSON body (missing required fields), THE service SHALL return HTTP 422 with a JSON body containing validation error details +4. WHEN a GET request is made to `/api/trends/{id}/projection` for a trend with no projection, THE Query_API SHALL return HTTP 404 with a JSON error (not HTTP 500) +5. WHEN a GET request is made to `/api/macro/impacts/{ticker}` for a ticker with no macro impacts, THE Query_API SHALL return HTTP 200 with an empty list +6. THE Test_Suite SHALL verify that all health endpoints (`/health`) across all four services return `{"status": "ok"}` with HTTP 200 diff --git a/.kiro/specs/beta-api-test-suite/tasks.md b/.kiro/specs/beta-api-test-suite/tasks.md new file mode 100644 index 0000000..e49aa5f --- /dev/null +++ b/.kiro/specs/beta-api-test-suite/tasks.md @@ -0,0 +1,124 @@ +# Tasks + +## Task 1: Extend Seed Script with New Test Data + +- [x] 1.1 Add deterministic UUID constants for watchlists, approvals, lockouts, notifications, ingestion runs, saved queries, and daily risk snapshots +- [x] 1.2 Add `_seed_watchlists` function to insert 2 watchlists with 3 watchlist members +- [x] 1.3 Add `_seed_operator_approvals` function to insert 1 pending and 1 approved approval record +- [x] 1.4 Add `_seed_symbol_lockouts` function to insert 1 active and 1 expired lockout +- [x] 1.5 Add `_seed_notifications` function to insert 1 delivered notification record +- [x] 1.6 Add `_seed_ingestion_runs` function to insert 2 ingestion runs (completed + failed) +- [x] 1.7 Add `_seed_saved_queries` function to insert 1 saved query record +- [x] 1.8 Add `_seed_daily_risk_snapshots` function to insert 1 daily risk snapshot +- [x] 1.9 Export new seed ID lookup dictionaries (SEED_WATCHLIST_IDS, SEED_APPROVAL_IDS, SEED_LOCKOUT_IDS, etc.) +- [x] 1.10 Call all new seed functions from the main `seed()` function +- [x] 1.11 Update conftest.py to import and expose new seed IDs in the `seed_ids` fixture + +## Task 2: Query API Extended Tests — Documents and Evidence + +- [x] 2.1 Create `tests/integration/test_query_api_extended.py` with test class for document filtering (by ticker, by doc_type) +- [x] 2.2 Add test for document detail with non-existent UUID returning 404 +- [x] 2.3 Add test for recommendation evidence drill-down endpoint (`/api/recommendations/{id}/evidence`) +- [x] 2.4 Add test for trend evidence drill-down endpoint (`/api/trends/{id}/evidence`) +- [x] 2.5 Add test for trend projection endpoint (`/api/trends/{id}/projection`) +- [x] 2.6 Add test for trend projection with no projection returning appropriate response + +## Task 3: Query API Extended Tests — Macro and Competitive Layer + +- [x] 3.1 Add test for macro status endpoint (`/api/admin/macro/status`) +- [x] 3.2 Add test for macro events list endpoint (`/api/macro/events`) verifying seeded events +- [x] 3.3 Add test for macro event detail endpoint (`/api/macro/events/{id}`) with impacts +- [x] 3.4 Add test for macro impacts by ticker endpoint (`/api/macro/impacts/AAPL`) +- [x] 3.5 Add test for competitive status endpoint (`/api/admin/competitive/status`) +- [x] 3.6 Add test for competitive signals endpoint (`/api/patterns/{ticker}/competitive-signals`) +- [x] 3.7 Add test for patterns endpoint (`/api/patterns/{ticker}`) + +## Task 4: Query API Extended Tests — Operational and Admin Endpoints + +- [x] 4.1 Add test for pipeline health endpoint verifying all required fields +- [x] 4.2 Add test for ingestion summary endpoint verifying `total_runs` and `by_source_type` +- [x] 4.3 Add test for coverage gaps endpoint verifying `missing_source_types` and `stale_sources` +- [x] 4.4 Add test for source toggle endpoint (`PUT /api/admin/sources/{id}/toggle`) +- [x] 4.5 Add test for trading config endpoint (`GET /api/admin/trading/config`) +- [x] 4.6 Add test for pending approvals endpoint (`GET /api/admin/trading/approvals`) +- [x] 4.7 Add test for active lockouts endpoint (`GET /api/admin/trading/lockouts`) +- [x] 4.8 Add test for lockout create and delete lifecycle (`POST` then `DELETE /api/admin/trading/lockouts`) + +## Task 5: Query API Extended Tests — Agents and Analytics + +- [x] 5.1 Add test for agent detail endpoint (`GET /api/agents/{id}`) verifying `system_prompt`, `temperature`, `max_tokens` +- [x] 5.2 Add test for agent create endpoint (`POST /api/agents`) verifying 201 and slug generation +- [x] 5.3 Add test for agent update endpoint (`PUT /api/agents/{id}`) +- [x] 5.4 Add test for variant create endpoint (`POST /api/agents/{id}/variants`) verifying 201 +- [x] 5.5 Add test for variant activate endpoint (`POST /api/agents/{id}/variants/{vid}/activate`) +- [x] 5.6 Add test for agent performance endpoint (`GET /api/agents/{id}/performance`) +- [x] 5.7 Add test for variant performance endpoint (`GET /api/agents/{id}/variants/{vid}/performance`) +- [x] 5.8 Add test for PostgreSQL schema endpoint (`GET /api/analytics/pg-schema`) +- [x] 5.9 Add test for saved queries list endpoint (`GET /api/analytics/saved-queries`) +- [x] 5.10 Add test for saved query create and delete lifecycle +- [x] 5.11 Add test for pg-query endpoint with a valid read-only SQL query + +## Task 6: Symbol Registry Write Path and Edge Case Tests + +- [x] 6.1 Create `tests/integration/test_registry_write_paths.py` with test for duplicate company creation returning 409 +- [x] 6.2 Add test for company not found returning 404 +- [x] 6.3 Add test for alias creation (`POST /companies/{id}/aliases`) returning 201 +- [x] 6.4 Add test for source creation (`POST /companies/{id}/sources`) returning 201 +- [x] 6.5 Add test for source creation with invalid source_type returning 422 +- [x] 6.6 Add test for watchlist creation (`POST /watchlists`) returning 201 +- [x] 6.7 Add test for watchlist member addition (`POST /watchlists/{id}/members/{company_id}`) returning 201 +- [x] 6.8 Add test for watchlist members list (`GET /watchlists/{id}/members`) with ticker and legal_name +- [x] 6.9 Add test for duplicate watchlist creation returning 409 + +## Task 7: Symbol Registry — Competitor and Exposure Write Paths + +- [x] 7.1 Add test for competitor creation (`POST /companies/{id}/competitors`) returning 201 +- [x] 7.2 Add test for self-referencing competitor returning 400 +- [x] 7.3 Add test for competitor update (`PUT /companies/{id}/competitors/{rel_id}`) +- [x] 7.4 Add test for competitor soft-delete (`DELETE /companies/{id}/competitors/{rel_id}`) returning 200 +- [x] 7.5 Add test for exposure profile upsert (`PUT /companies/{id}/exposure`) with version increment +- [x] 7.6 Add test for exposure history endpoint (`GET /companies/{id}/exposure/history`) +- [x] 7.7 Add test for exposure profile not found returning 404 + +## Task 8: Risk Engine — Evaluation Edge Cases and Approval Lifecycle + +- [x] 8.1 Create `tests/integration/test_risk_approval_lifecycle.py` with test for minimal order evaluation +- [x] 8.2 Add test for order exceeding position cap returning `eligible: false` +- [x] 8.3 Add test for evaluation with custom config override +- [x] 8.4 Add test for pending approvals list from seeded data +- [x] 8.5 Add test for approval detail endpoint (`GET /approvals/{id}`) with seeded pending approval +- [x] 8.6 Add test for approval review (`POST /approvals/{id}/review` with `approved: true`) +- [x] 8.7 Add test for review of non-existent approval returning 404 +- [x] 8.8 Add test for approval expiry endpoint (`POST /approvals/expire`) + +## Task 9: Trading Engine — Configuration Round-Trips and Extended Tests + +- [x] 9.1 Create `tests/integration/test_trading_extended.py` with config round-trip test (PUT config → GET status) +- [x] 9.2 Add test for pause/resume round-trip (POST pause → GET status → POST resume → GET status) +- [x] 9.3 Add test for metrics consistency (`total_portfolio_value ≈ active_pool + reserve_pool + unrealized_pnl`) +- [x] 9.4 Add test for metrics history returning portfolio snapshots +- [x] 9.5 Add test for notification config update round-trip (PUT → GET) +- [x] 9.6 Add test for notification history endpoint +- [x] 9.7 Add test for decisions filtering by ticker +- [x] 9.8 Add test for decisions filtering by limit +- [x] 9.9 Add test for decisions filtering by decision type +- [x] 9.10 Add test for override order with invalid ticker returning 422 +- [x] 9.11 Add test for override order with zero/negative quantity returning 422 +- [x] 9.12 Add test for valid override order returning 202 or structured error + +## Task 10: Cross-Service Round-Trip Tests + +- [x] 10.1 Create `tests/integration/test_cross_service_roundtrip.py` with test creating company via registry then reading via query API +- [x] 10.2 Add test for exposure profile round-trip (PUT via registry → GET via registry) +- [x] 10.3 Add test for competitor relationship bidirectional visibility +- [x] 10.4 Add test for risk evaluation schema matching what query API returns for recommendations + +## Task 11: Error Handling and Empty-State Tests + +- [x] 11.1 Create `tests/integration/test_error_handling.py` with test for empty list endpoints returning 200 with `[]` +- [x] 11.2 Add test for non-existent UUID detail endpoints returning 404 with JSON body +- [x] 11.3 Add test for invalid JSON body returning 422 with validation details +- [x] 11.4 Add test for trend projection with no projection returning appropriate response (not 500) +- [x] 11.5 Add test for macro impacts with no data returning 200 with empty impacts list +- [x] 11.6 Add test verifying all four service health endpoints return `{"status": "ok"}` +- [x] 11.7 Add test for override order structured error when Redis unavailable (503 with JSON, not unhandled exception) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3ad81c2..a025781 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -21,18 +21,25 @@ import pytest_asyncio from tests.integration.profiler import EndpointProfiler from tests.integration.seed_sandbox import ( SEED_AGENT_IDS, + SEED_APPROVAL_IDS, SEED_BROKER_ACCOUNT_ID, SEED_COMPANY_IDS, SEED_DOCUMENT_IDS, SEED_GLOBAL_EVENT_IDS, + SEED_INGESTION_RUN_IDS, + SEED_LOCKOUT_IDS, + SEED_NOTIFICATION_IDS, SEED_ORDER_IDS, SEED_PORTFOLIO_SNAPSHOT_ID, SEED_POSITION_IDS, SEED_RECOMMENDATION_IDS, SEED_RISK_CONFIG_ID, + SEED_RISK_SNAPSHOT_IDS, + SEED_SAVED_QUERY_IDS, SEED_TRADING_DECISION_ID, SEED_TREND_IDS, SEED_VARIANT_IDS, + SEED_WATCHLIST_IDS, ) # --------------------------------------------------------------------------- @@ -185,4 +192,11 @@ def seed_ids() -> dict: "trading_decision_id": SEED_TRADING_DECISION_ID, "portfolio_snapshot_id": SEED_PORTFOLIO_SNAPSHOT_ID, "risk_config_id": SEED_RISK_CONFIG_ID, + "watchlists": SEED_WATCHLIST_IDS, + "approvals": SEED_APPROVAL_IDS, + "lockouts": SEED_LOCKOUT_IDS, + "notifications": SEED_NOTIFICATION_IDS, + "ingestion_runs": SEED_INGESTION_RUN_IDS, + "saved_queries": SEED_SAVED_QUERY_IDS, + "risk_snapshots": SEED_RISK_SNAPSHOT_IDS, } diff --git a/tests/integration/seed_sandbox.py b/tests/integration/seed_sandbox.py index db0ff03..5363d00 100644 --- a/tests/integration/seed_sandbox.py +++ b/tests/integration/seed_sandbox.py @@ -194,6 +194,36 @@ AUDIT_01 = UUID("00000000-0000-4000-ab00-000000000001") AUDIT_02 = UUID("00000000-0000-4000-ab00-000000000002") AUDIT_03 = UUID("00000000-0000-4000-ab00-000000000003") +# Watchlists +WATCHLIST_01 = UUID("00000000-0000-4000-ac00-000000000001") +WATCHLIST_02 = UUID("00000000-0000-4000-ac00-000000000002") + +# Watchlist members +WL_MEMBER_01 = UUID("00000000-0000-4000-ac10-000000000001") +WL_MEMBER_02 = UUID("00000000-0000-4000-ac10-000000000002") +WL_MEMBER_03 = UUID("00000000-0000-4000-ac10-000000000003") + +# Operator Approvals +APPROVAL_PENDING = UUID("00000000-0000-4000-ad00-000000000001") +APPROVAL_APPROVED = UUID("00000000-0000-4000-ad00-000000000002") + +# Symbol Lockouts +LOCKOUT_ACTIVE = UUID("00000000-0000-4000-ae00-000000000001") +LOCKOUT_EXPIRED = UUID("00000000-0000-4000-ae00-000000000002") + +# Notifications +NOTIFICATION_01 = UUID("00000000-0000-4000-af00-000000000001") + +# Ingestion Runs +INGESTION_RUN_01 = UUID("00000000-0000-4000-b300-000000000001") +INGESTION_RUN_02 = UUID("00000000-0000-4000-b300-000000000002") + +# Saved Queries +SAVED_QUERY_01 = UUID("00000000-0000-4000-b400-000000000001") + +# Daily Risk Snapshot +RISK_SNAPSHOT_01 = UUID("00000000-0000-4000-b500-000000000001") + # ── Exported lookup dicts for test imports ──────────────────── SEED_COMPANY_IDS = { @@ -259,6 +289,26 @@ SEED_TRADING_DECISION_ID = str(TRADING_DECISION_01) SEED_PORTFOLIO_SNAPSHOT_ID = str(PORTFOLIO_SNAP_01) SEED_RISK_CONFIG_ID = str(RISK_CONFIG_01) +SEED_WATCHLIST_IDS = { + "WL_01": str(WATCHLIST_01), + "WL_02": str(WATCHLIST_02), +} +SEED_APPROVAL_IDS = { + "PENDING": str(APPROVAL_PENDING), + "APPROVED": str(APPROVAL_APPROVED), +} +SEED_LOCKOUT_IDS = { + "ACTIVE": str(LOCKOUT_ACTIVE), + "EXPIRED": str(LOCKOUT_EXPIRED), +} +SEED_NOTIFICATION_IDS = {"NOTIF_01": str(NOTIFICATION_01)} +SEED_INGESTION_RUN_IDS = { + "RUN_01": str(INGESTION_RUN_01), + "RUN_02": str(INGESTION_RUN_02), +} +SEED_SAVED_QUERY_IDS = {"SQ_01": str(SAVED_QUERY_01)} +SEED_RISK_SNAPSHOT_IDS = {"SNAP_01": str(RISK_SNAPSHOT_01)} + # ── Seed function ───────────────────────────────────────────── @@ -303,6 +353,13 @@ async def seed() -> None: await _seed_agent_performance_log(conn) await _seed_risk_configs(conn) await _seed_audit_events(conn) + await _seed_watchlists(conn) + await _seed_operator_approvals(conn) + await _seed_symbol_lockouts(conn) + await _seed_notifications(conn) + await _seed_ingestion_runs(conn) + await _seed_saved_queries(conn) + await _seed_daily_risk_snapshots(conn) finally: await conn.close() @@ -990,6 +1047,148 @@ async def _seed_audit_events(conn: asyncpg.Connection) -> None: ) +# ── Watchlists ──────────────────────────────────────────────── + + +async def _seed_watchlists(conn: asyncpg.Connection) -> None: + watchlists = [ + (WATCHLIST_01, "Tech Leaders", "Top technology companies", True, BASE_TS, BASE_TS), + (WATCHLIST_02, "Value Picks", "Undervalued large caps", True, BASE_TS, BASE_TS), + ] + await conn.executemany( + """INSERT INTO watchlists (id, name, description, active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING""", + watchlists, + ) + members = [ + (WL_MEMBER_01, WATCHLIST_01, COMPANY_AAPL, BASE_TS), + (WL_MEMBER_02, WATCHLIST_01, COMPANY_MSFT, BASE_TS), + (WL_MEMBER_03, WATCHLIST_02, COMPANY_JPM, BASE_TS), + ] + await conn.executemany( + """INSERT INTO watchlist_members (id, watchlist_id, company_id, added_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING""", + members, + ) + + +# ── Operator Approvals ──────────────────────────────────────── + + +async def _seed_operator_approvals(conn: asyncpg.Connection) -> None: + approvals = [ + (APPROVAL_PENDING, json.dumps({"ticker": "AAPL", "side": "buy", "qty": 5}), + REC_01, "AAPL", "buy", 5, 927.50, "pending", RISK_EVAL_01, + "system", None, None, + BASE_TS + timedelta(hours=24), # expires in 24h + BASE_TS, None, BASE_TS, BASE_TS), + (APPROVAL_APPROVED, json.dumps({"ticker": "MSFT", "side": "buy", "qty": 3}), + REC_02, "MSFT", "buy", 3, 1230.00, "approved", None, + "system", "test-operator", "Approved for paper trading", + BASE_TS + timedelta(hours=24), + BASE_TS - timedelta(hours=2), BASE_TS - timedelta(hours=1), + BASE_TS, BASE_TS), + ] + await conn.executemany( + """INSERT INTO operator_approvals + (id, order_job, recommendation_id, ticker, side, quantity, estimated_value, + status, risk_evaluation_id, requested_by, reviewed_by, review_note, + expires_at, requested_at, reviewed_at, created_at, updated_at) + VALUES ($1, $2::jsonb, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ON CONFLICT DO NOTHING""", + approvals, + ) + + +# ── Symbol Lockouts ─────────────────────────────────────────── + + +async def _seed_symbol_lockouts(conn: asyncpg.Connection) -> None: + lockouts = [ + (LOCKOUT_ACTIVE, "AAPL", "news_shock", "Earnings volatility cooldown", + BASE_TS + timedelta(days=7), BASE_TS), + (LOCKOUT_EXPIRED, "XOM", "cooldown", "Post-trade cooldown period", + BASE_TS - timedelta(days=1), BASE_TS - timedelta(days=3)), + ] + await conn.executemany( + """INSERT INTO symbol_lockouts (id, ticker, lockout_type, reason, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING""", + lockouts, + ) + + +# ── Notifications ───────────────────────────────────────────── + + +async def _seed_notifications(conn: asyncpg.Connection) -> None: + await conn.execute( + """INSERT INTO notifications + (id, channel, event_type, message, delivery_status, retry_count, error_message, created_at, delivered_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT DO NOTHING""", + NOTIFICATION_01, "email", "order.filled", + "Order filled: AAPL buy 10 shares at $185.50", + "delivered", 0, None, BASE_TS, BASE_TS + timedelta(seconds=5), + ) + + +# ── Ingestion Runs ──────────────────────────────────────────── + + +async def _seed_ingestion_runs(conn: asyncpg.Connection) -> None: + runs = [ + (INGESTION_RUN_01, SOURCE_AAPL, COMPANY_AAPL, "news", "completed", + BASE_TS - timedelta(hours=2), BASE_TS - timedelta(hours=1, minutes=55), + 15, 8, None, 0, None), + (INGESTION_RUN_02, SOURCE_JPM, COMPANY_JPM, "filing", "failed", + BASE_TS - timedelta(hours=1), None, + 0, 0, "Connection timeout to SEC EDGAR", 2, + BASE_TS + timedelta(hours=1)), + ] + await conn.executemany( + """INSERT INTO ingestion_runs + (id, source_id, company_id, source_type, status, started_at, completed_at, + items_fetched, items_new, error_message, retry_count, next_retry_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT DO NOTHING""", + runs, + ) + + +# ── Saved Queries ───────────────────────────────────────────── + + +async def _seed_saved_queries(conn: asyncpg.Connection) -> None: + await conn.execute( + """INSERT INTO saved_queries (id, name, description, sql_text, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT DO NOTHING""", + SAVED_QUERY_01, "Top Recommendations", + "Shows highest confidence recommendations", + "SELECT ticker, action, confidence FROM recommendations ORDER BY confidence DESC LIMIT 10", + "operator", BASE_TS, BASE_TS, + ) + + +# ── Daily Risk Snapshots ────────────────────────────────────── + + +async def _seed_daily_risk_snapshots(conn: asyncpg.Connection) -> None: + await conn.execute( + """INSERT INTO daily_risk_snapshots + (id, account_id, snapshot_date, portfolio_value, daily_pnl, + daily_trade_count, positions_by_sector, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9) + ON CONFLICT DO NOTHING""", + RISK_SNAPSHOT_01, "PAPER-001", BASE_DATE, 12500.00, 150.25, + 4, json.dumps({"Technology": 0.45, "Financial Services": 0.30, "Energy": 0.25}), + BASE_TS, BASE_TS, + ) + + # ── Entry point ─────────────────────────────────────────────── if __name__ == "__main__": diff --git a/tests/integration/test_cross_service_roundtrip.py b/tests/integration/test_cross_service_roundtrip.py new file mode 100644 index 0000000..9a93f27 --- /dev/null +++ b/tests/integration/test_cross_service_roundtrip.py @@ -0,0 +1,159 @@ +"""Integration tests for cross-service data consistency. + +These tests validate round-trip behavior: writing data via one service +and reading it back via another (or the same service on a different path). +They catch data propagation issues and schema drift between services. +""" + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Cross-Service: Company creation via Registry → read via Query API +# --------------------------------------------------------------------------- + + +class TestCrossServiceCompanyRoundTrip: + """Write company via registry, verify visibility in query API.""" + + async def test_create_company_via_registry_read_via_query( + self, registry_client, query_client, + ): + """Create company via registry, read via query API.""" + payload = { + "ticker": "XRND", + "legal_name": "Cross Round Trip Corp", + "exchange": "NYSE", + "sector": "Technology", + "industry": "Software", + "market_cap_bucket": "small", + } + create_resp = await registry_client.post("/companies", json=payload) + assert create_resp.status_code == 201 + + # Read via query API + query_resp = await query_client.get("/api/companies") + assert query_resp.status_code == 200 + tickers = {c["ticker"] for c in query_resp.json()} + assert "XRND" in tickers + + +# --------------------------------------------------------------------------- +# Cross-Service: Exposure profile round-trip via Registry +# --------------------------------------------------------------------------- + + +class TestCrossServiceExposureRoundTrip: + """PUT exposure via registry, GET via registry on a different path.""" + + async def test_exposure_round_trip(self, registry_client, seed_ids): + """PUT exposure via registry → GET via registry.""" + company_id = seed_ids["companies"]["MSFT"] + payload = { + "geographic_revenue_mix": { + "North America": 0.55, + "Europe": 0.25, + "Asia": 0.20, + }, + "supply_chain_regions": ["North America", "Europe"], + "key_input_commodities": [], + "regulatory_jurisdictions": ["US", "EU"], + "market_position_tier": "global_leader", + "export_dependency_pct": 0.35, + "source": "manual", + "confidence": 0.85, + } + put_resp = await registry_client.put( + f"/companies/{company_id}/exposure", json=payload, + ) + assert put_resp.status_code == 200 + + # Read back + get_resp = await registry_client.get( + f"/companies/{company_id}/exposure", + ) + assert get_resp.status_code == 200 + data = get_resp.json() + assert data["geographic_revenue_mix"]["North America"] == 0.55 + assert data["market_position_tier"] == "global_leader" + assert "supply_chain_regions" in data + + +# --------------------------------------------------------------------------- +# Cross-Service: Competitor relationship bidirectional visibility +# --------------------------------------------------------------------------- + + +class TestCrossServiceCompetitorBidirectional: + """Competitor relationships visible from both sides.""" + + async def test_competitor_bidirectional_visibility( + self, registry_client, seed_ids, + ): + """Competitor visible from both sides.""" + aapl_id = seed_ids["companies"]["AAPL"] + msft_id = seed_ids["companies"]["MSFT"] + + # Check from AAPL side + resp_a = await registry_client.get( + f"/companies/{aapl_id}/competitors", + ) + assert resp_a.status_code == 200 + a_partners = set() + for rel in resp_a.json(): + if rel["company_a_id"] == aapl_id: + a_partners.add(rel["company_b_id"]) + else: + a_partners.add(rel["company_a_id"]) + assert msft_id in a_partners + + # Check from MSFT side + resp_b = await registry_client.get( + f"/companies/{msft_id}/competitors", + ) + assert resp_b.status_code == 200 + b_partners = set() + for rel in resp_b.json(): + if rel["company_a_id"] == msft_id: + b_partners.add(rel["company_b_id"]) + else: + b_partners.add(rel["company_a_id"]) + assert aapl_id in b_partners + + +# --------------------------------------------------------------------------- +# Cross-Service: Risk evaluation schema matches query API recommendations +# --------------------------------------------------------------------------- + + +class TestCrossServiceRiskEvaluationSchema: + """Risk evaluation schema matches what query API returns for recommendations.""" + + async def test_risk_evaluation_matches_recommendation( + self, risk_client, query_client, seed_ids, + ): + """Risk evaluation schema matches what query API returns for recommendations.""" + # Evaluate via risk engine + eval_resp = await risk_client.post("/evaluate", json={ + "order": { + "ticker": "AAPL", + "action": "buy", + "quantity": 10, + "estimated_value": 1855.00, + "confidence": 0.85, + }, + }) + assert eval_resp.status_code == 200 + eval_data = eval_resp.json() + assert "evaluation_id" in eval_data + assert "eligible" in eval_data + assert "checks" in eval_data + + # Query recommendation with risk evaluation + rec_id = seed_ids["recommendations"]["REC_01"] + rec_resp = await query_client.get(f"/api/recommendations/{rec_id}") + assert rec_resp.status_code == 200 + rec_data = rec_resp.json() + assert "risk_evaluation" in rec_data diff --git a/tests/integration/test_error_handling.py b/tests/integration/test_error_handling.py new file mode 100644 index 0000000..671d383 --- /dev/null +++ b/tests/integration/test_error_handling.py @@ -0,0 +1,170 @@ +"""Integration tests for error handling and empty-state behavior across all services. + +Validates that all endpoints return structured JSON errors (not HTML or stack +traces) and handle empty-state gracefully, so the frontend never receives +unexpected response formats. +""" + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Empty list endpoints +# --------------------------------------------------------------------------- + + +class TestEmptyListEndpoints: + """List endpoints with no matching data return 200 with [].""" + + async def test_documents_empty_filter(self, query_client): + """GET /api/documents?ticker=ZZZZ — no matching docs returns 200 with list.""" + resp = await query_client.get("/api/documents", params={"ticker": "ZZZZ"}) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 0 + + +# --------------------------------------------------------------------------- +# Not-found detail endpoints +# --------------------------------------------------------------------------- + + +class TestNotFoundEndpoints: + """Detail endpoints with non-existent UUID return 404 with JSON body.""" + + async def test_query_api_document_not_found(self, query_client): + """GET /api/documents/{id} — 404 with JSON body.""" + fake_id = "00000000-0000-4000-ffff-000000000099" + resp = await query_client.get(f"/api/documents/{fake_id}") + assert resp.status_code == 404 + data = resp.json() + assert isinstance(data, dict) + + async def test_registry_company_not_found(self, registry_client): + """GET /companies/{id} — 404 with JSON body.""" + fake_id = "00000000-0000-4000-ffff-000000000099" + resp = await registry_client.get(f"/companies/{fake_id}") + assert resp.status_code == 404 + data = resp.json() + assert isinstance(data, dict) + + async def test_risk_approval_not_found(self, risk_client): + """GET /approvals/{id} — 404 with JSON body.""" + fake_id = "00000000-0000-4000-ffff-000000000099" + resp = await risk_client.get(f"/approvals/{fake_id}") + assert resp.status_code == 404 + data = resp.json() + assert isinstance(data, dict) + + +# --------------------------------------------------------------------------- +# Validation errors +# --------------------------------------------------------------------------- + + +class TestValidationErrors: + """Invalid JSON body returns 422 with validation details.""" + + async def test_invalid_company_body(self, registry_client): + """POST /companies — missing required fields returns 422.""" + resp = await registry_client.post("/companies", json={}) + assert resp.status_code == 422 + data = resp.json() + assert isinstance(data, dict) + assert "detail" in data + + +# --------------------------------------------------------------------------- +# Trend projection edge case +# --------------------------------------------------------------------------- + + +class TestTrendProjectionNoProjection: + """Trend projection with no projection returns appropriate response (not 500).""" + + async def test_trend_projection_nonexistent(self, query_client): + """GET /api/trends/{id}/projection — non-existent trend returns 404, not 500.""" + fake_id = "00000000-0000-4000-ffff-000000000099" + resp = await query_client.get(f"/api/trends/{fake_id}/projection") + assert resp.status_code != 500 + assert resp.status_code in (200, 404) + + +# --------------------------------------------------------------------------- +# Macro impacts empty state +# --------------------------------------------------------------------------- + + +class TestMacroImpactsEmpty: + """Macro impacts with no data returns 200 with empty impacts list.""" + + async def test_macro_impacts_no_data(self, query_client): + """GET /api/macro/impacts/ZZZZ — no impacts returns 200 with empty list.""" + resp = await query_client.get("/api/macro/impacts/ZZZZ") + assert resp.status_code == 200 + data = resp.json() + assert "impacts" in data + assert isinstance(data["impacts"], list) + assert len(data["impacts"]) == 0 + + +# --------------------------------------------------------------------------- +# Health endpoints across all services +# --------------------------------------------------------------------------- + + +class TestAllServiceHealthEndpoints: + """All four service health endpoints return {"status": "ok"}.""" + + async def test_query_api_health(self, query_client): + """GET /health — query API.""" + resp = await query_client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + async def test_registry_health(self, registry_client): + """GET /health — symbol registry.""" + resp = await registry_client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + async def test_risk_health(self, risk_client): + """GET /health — risk engine.""" + resp = await risk_client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + async def test_trading_health(self, trading_client): + """GET /health — trading engine.""" + resp = await trading_client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +# --------------------------------------------------------------------------- +# Override order structured error +# --------------------------------------------------------------------------- + + +class TestOverrideStructuredError: + """Override order returns structured JSON error when Redis unavailable.""" + + async def test_override_structured_error(self, trading_client): + """POST /api/trading/override/order — structured error (not unhandled exception).""" + payload = { + "ticker": "AAPL", + "side": "buy", + "quantity": 1.0, + "order_type": "market", + } + resp = await trading_client.post("/api/trading/override/order", json=payload) + # Accept 202 (success) or structured error + assert resp.status_code in (200, 202, 400, 422, 503) + data = resp.json() + assert isinstance(data, dict) + # If 503, verify it's JSON not an unhandled exception + if resp.status_code == 503: + assert "detail" in data or "error" in data or "message" in data diff --git a/tests/integration/test_query_api_extended.py b/tests/integration/test_query_api_extended.py new file mode 100644 index 0000000..0ab8345 --- /dev/null +++ b/tests/integration/test_query_api_extended.py @@ -0,0 +1,407 @@ +"""Extended integration tests for the Query API — documents, evidence, macro, +competitive, operational, admin, agents, and analytics endpoints. + +Validates endpoints not covered by test_query_api.py against the live sandbox +with deterministic seed data. Uses the ``query_client`` and ``seed_ids`` +fixtures from conftest.py. +""" + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Task 2: Documents and Evidence +# --------------------------------------------------------------------------- + + +class TestQueryAPIDocumentFiltering: + """Endpoints: /api/documents with ticker and document_type filters.""" + + async def test_filter_documents_by_ticker(self, query_client, seed_ids): + """GET /api/documents?ticker=AAPL — only AAPL documents.""" + resp = await query_client.get("/api/documents", params={"ticker": "AAPL"}) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 + # All returned docs should have an id field + for doc in data: + assert "id" in doc + + async def test_filter_documents_by_doc_type(self, query_client, seed_ids): + """GET /api/documents?document_type=filing — only filing documents.""" + resp = await query_client.get( + "/api/documents", params={"document_type": "filing"} + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 + for doc in data: + assert doc["document_type"] == "filing" + + +class TestQueryAPIDocumentDetail: + """Endpoint: /api/documents/{id} — 404 for non-existent UUID.""" + + async def test_document_not_found(self, query_client): + """GET /api/documents/{id} — 404 for non-existent UUID.""" + fake_id = "00000000-0000-4000-ffff-000000000099" + resp = await query_client.get(f"/api/documents/{fake_id}") + assert resp.status_code == 404 + + +class TestQueryAPIRecommendationEvidence: + """Endpoint: /api/recommendations/{id}/evidence — evidence drill-down.""" + + async def test_recommendation_evidence_drilldown(self, query_client, seed_ids): + """GET /api/recommendations/{id}/evidence — evidence drill-down.""" + rec_id = seed_ids["recommendations"]["REC_01"] + resp = await query_client.get(f"/api/recommendations/{rec_id}/evidence") + assert resp.status_code == 200 + data = resp.json() + assert "recommendation" in data + assert "evidence" in data + + +class TestQueryAPITrendEvidence: + """Endpoint: /api/trends/{id}/evidence — trend evidence drill-down.""" + + async def test_trend_evidence_drilldown(self, query_client, seed_ids): + """GET /api/trends/{id}/evidence — trend evidence drill-down.""" + trend_id = seed_ids["trends"]["TREND_01"] + resp = await query_client.get(f"/api/trends/{trend_id}/evidence") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, dict) + assert "trend" in data or "id" in data + + +class TestQueryAPITrendProjection: + """Endpoint: /api/trends/{id}/projection — trend projection.""" + + async def test_trend_projection(self, query_client, seed_ids): + """GET /api/trends/{id}/projection — trend with projection.""" + trend_id = seed_ids["trends"]["TREND_01"] + resp = await query_client.get(f"/api/trends/{trend_id}/projection") + assert resp.status_code == 200 + data = resp.json() + assert "projected_direction" in data + assert "projected_strength" in data + assert "projected_confidence" in data + assert "macro_contribution_pct" in data + + async def test_trend_projection_not_found(self, query_client): + """GET /api/trends/{id}/projection — no projection returns 404 for non-existent trend.""" + fake_id = "00000000-0000-4000-ffff-000000000099" + resp = await query_client.get(f"/api/trends/{fake_id}/projection") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Task 3: Macro and Competitive Layer +# --------------------------------------------------------------------------- + + +class TestQueryAPIMacro: + """Endpoints: /api/admin/macro/status, /api/macro/events, /api/macro/impacts.""" + + async def test_macro_status(self, query_client): + """GET /api/admin/macro/status — macro layer toggle status.""" + resp = await query_client.get("/api/admin/macro/status") + assert resp.status_code == 200 + data = resp.json() + assert "macro_enabled" in data + + async def test_list_macro_events(self, query_client, seed_ids): + """GET /api/macro/events — at least 2 seeded events.""" + resp = await query_client.get("/api/macro/events") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) >= 2 + for evt in data: + assert "id" in evt + assert "severity" in evt + assert "summary" in evt + + async def test_get_macro_event_detail(self, query_client, seed_ids): + """GET /api/macro/events/{id} — event detail with impacts.""" + event_id = seed_ids["global_events"]["EVT_01"] + resp = await query_client.get(f"/api/macro/events/{event_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == event_id + assert "impacts" in data + + async def test_macro_impacts_by_ticker(self, query_client): + """GET /api/macro/impacts/AAPL — at least 1 impact record.""" + resp = await query_client.get("/api/macro/impacts/AAPL") + assert resp.status_code == 200 + data = resp.json() + assert "impacts" in data + assert isinstance(data["impacts"], list) + assert len(data["impacts"]) >= 1 + for impact in data["impacts"]: + assert "macro_impact_score" in impact + assert "impact_direction" in impact + + +class TestQueryAPICompetitive: + """Endpoints: /api/admin/competitive/status, /api/patterns/{ticker}/competitive-signals, /api/patterns/{ticker}.""" + + async def test_competitive_status(self, query_client): + """GET /api/admin/competitive/status — competitive layer toggle.""" + resp = await query_client.get("/api/admin/competitive/status") + assert resp.status_code == 200 + data = resp.json() + assert "competitive_enabled" in data + + async def test_competitive_signals(self, query_client): + """GET /api/patterns/AAPL/competitive-signals — competitive signals.""" + resp = await query_client.get("/api/patterns/AAPL/competitive-signals") + assert resp.status_code == 200 + data = resp.json() + assert "competitive_signals" in data + assert isinstance(data["competitive_signals"], list) + + async def test_patterns_for_ticker(self, query_client): + """GET /api/patterns/AAPL — patterns data.""" + resp = await query_client.get("/api/patterns/AAPL") + assert resp.status_code == 200 + data = resp.json() + assert "ticker" in data + assert "patterns" in data + + +# --------------------------------------------------------------------------- +# Task 4: Operational and Admin Endpoints +# --------------------------------------------------------------------------- + + +class TestQueryAPIOpsExtended: + """Endpoints: /api/ops/pipeline/health, /api/ops/ingestion/summary, /api/ops/sources/coverage-gaps.""" + + async def test_pipeline_health_fields(self, query_client): + """GET /api/ops/pipeline/health — verify all required fields.""" + resp = await query_client.get("/api/ops/pipeline/health") + assert resp.status_code == 200 + data = resp.json() + assert "document_stages" in data + assert "parsing" in data + assert "extraction" in data + assert "aggregation" in data + assert "queue_depths" in data + + async def test_ingestion_summary_fields(self, query_client): + """GET /api/ops/ingestion/summary — verify total_runs and by_source_type.""" + resp = await query_client.get("/api/ops/ingestion/summary") + assert resp.status_code == 200 + data = resp.json() + assert "total_runs" in data + assert "by_source_type" in data + + async def test_coverage_gaps_fields(self, query_client): + """GET /api/ops/sources/coverage-gaps — verify fields.""" + resp = await query_client.get("/api/ops/sources/coverage-gaps") + assert resp.status_code == 200 + data = resp.json() + assert "missing_source_types" in data + assert "stale_sources" in data + + +class TestQueryAPISourceToggle: + """Endpoint: PUT /api/admin/sources/{id}/toggle.""" + + async def test_toggle_source(self, query_client): + """PUT /api/admin/sources/{id}/toggle?active=false — toggle source off.""" + source_id = "00000000-0000-4000-b000-000000000001" # SOURCE_AAPL + resp = await query_client.put( + f"/api/admin/sources/{source_id}/toggle", params={"active": "false"} + ) + assert resp.status_code == 200 + # Toggle back on + resp2 = await query_client.put( + f"/api/admin/sources/{source_id}/toggle", params={"active": "true"} + ) + assert resp2.status_code == 200 + + +class TestQueryAPITradingAdmin: + """Endpoints: /api/admin/trading/config, approvals, lockouts.""" + + async def test_trading_config(self, query_client): + """GET /api/admin/trading/config — trading config.""" + resp = await query_client.get("/api/admin/trading/config") + assert resp.status_code == 200 + data = resp.json() + assert "trading_mode" in data or "config" in data or "name" in data + + async def test_pending_approvals(self, query_client, seed_ids): + """GET /api/admin/trading/approvals — pending approvals list.""" + resp = await query_client.get("/api/admin/trading/approvals") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + # Should have at least the seeded pending approval + assert len(data) >= 1 + + async def test_active_lockouts(self, query_client, seed_ids): + """GET /api/admin/trading/lockouts — active lockouts list.""" + resp = await query_client.get("/api/admin/trading/lockouts") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) >= 1 + + async def test_lockout_create_and_delete(self, query_client): + """POST then DELETE /api/admin/trading/lockouts — lifecycle.""" + # Create + body = { + "ticker": "TSLA", + "lockout_type": "manual", + "reason": "Integration test lockout", + "duration_minutes": 1440, + } + resp = await query_client.post("/api/admin/trading/lockouts", json=body) + assert resp.status_code == 200 + data = resp.json() + lockout_id = data["id"] + # Delete + resp2 = await query_client.delete( + f"/api/admin/trading/lockouts/{lockout_id}" + ) + assert resp2.status_code == 200 + + +# --------------------------------------------------------------------------- +# Task 5: Agents and Analytics +# --------------------------------------------------------------------------- + + +class TestQueryAPIAgentsExtended: + """Endpoints: /api/agents CRUD, variants, performance.""" + + async def test_agent_detail(self, query_client, seed_ids): + """GET /api/agents/{id} — agent detail with system_prompt, temperature, max_tokens.""" + agent_id = seed_ids["agents"]["extractor"] + resp = await query_client.get(f"/api/agents/{agent_id}") + assert resp.status_code == 200 + data = resp.json() + assert "system_prompt" in data + assert "temperature" in data + assert "max_tokens" in data + + async def test_create_agent(self, query_client): + """POST /api/agents — create agent with slug generation.""" + body = { + "name": "Integration Test Agent", + "purpose": "Testing agent creation", + "model_provider": "ollama", + "model_name": "qwen3.5:9b", + "system_prompt": "You are a test agent.", + "prompt_version": "test-v1", + "schema_version": "1.0.0", + } + resp = await query_client.post("/api/agents", json=body) + assert resp.status_code == 201 + data = resp.json() + assert "id" in data + assert "slug" in data + assert data["name"] == "Integration Test Agent" + + async def test_update_agent(self, query_client, seed_ids): + """PUT /api/agents/{id} — update agent.""" + agent_id = seed_ids["agents"]["thesis"] + body = {"purpose": "Updated purpose for integration test"} + resp = await query_client.put(f"/api/agents/{agent_id}", json=body) + assert resp.status_code == 200 + data = resp.json() + assert data["purpose"] == "Updated purpose for integration test" + + async def test_create_variant(self, query_client, seed_ids): + """POST /api/agents/{id}/variants — create variant.""" + agent_id = seed_ids["agents"]["extractor"] + body = { + "variant_name": "Test Variant", + "description": "Integration test variant", + "model_provider": "ollama", + "model_name": "qwen3.5:9b", + "system_prompt": "Test variant prompt", + "prompt_version": "test-variant-v1", + } + resp = await query_client.post(f"/api/agents/{agent_id}/variants", json=body) + assert resp.status_code == 201 + data = resp.json() + assert "id" in data + assert data["variant_name"] == "Test Variant" + + async def test_activate_variant(self, query_client, seed_ids): + """POST /api/agents/{id}/variants/{vid}/activate — activate variant.""" + agent_id = seed_ids["agents"]["extractor"] + variant_id = seed_ids["variants"]["extractor"] + resp = await query_client.post( + f"/api/agents/{agent_id}/variants/{variant_id}/activate" + ) + assert resp.status_code == 200 + + async def test_agent_performance(self, query_client, seed_ids): + """GET /api/agents/{id}/performance — agent performance metrics.""" + agent_id = seed_ids["agents"]["extractor"] + resp = await query_client.get(f"/api/agents/{agent_id}/performance") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, dict) + + async def test_variant_performance(self, query_client, seed_ids): + """GET /api/agents/{id}/variants/{vid}/performance — variant performance.""" + agent_id = seed_ids["agents"]["extractor"] + variant_id = seed_ids["variants"]["extractor"] + resp = await query_client.get( + f"/api/agents/{agent_id}/variants/{variant_id}/performance" + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, dict) + + +class TestQueryAPIAnalytics: + """Endpoints: /api/analytics/pg-schema, saved-queries, pg-query.""" + + async def test_pg_schema(self, query_client): + """GET /api/analytics/pg-schema — PG schema info.""" + resp = await query_client.get("/api/analytics/pg-schema") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, (list, dict)) + + async def test_list_saved_queries(self, query_client, seed_ids): + """GET /api/analytics/saved-queries — at least 1 from seed.""" + resp = await query_client.get("/api/analytics/saved-queries") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) >= 1 + + async def test_saved_query_create_and_delete(self, query_client): + """POST then DELETE /api/analytics/saved-queries — lifecycle.""" + body = { + "name": "IntTest Query", + "description": "Integration test saved query", + "sql_text": "SELECT 1 AS test", + } + resp = await query_client.post("/api/analytics/saved-queries", json=body) + assert resp.status_code == 201 + data = resp.json() + query_id = data["id"] + # Delete + resp2 = await query_client.delete(f"/api/analytics/saved-queries/{query_id}") + assert resp2.status_code == 200 + + async def test_pg_query(self, query_client): + """POST /api/analytics/pg-query — valid read-only SQL.""" + body = {"sql": "SELECT ticker, legal_name FROM companies LIMIT 5"} + resp = await query_client.post("/api/analytics/pg-query", json=body) + assert resp.status_code == 200 + data = resp.json() + assert "columns" in data or "rows" in data or isinstance(data, list) diff --git a/tests/integration/test_registry_write_paths.py b/tests/integration/test_registry_write_paths.py new file mode 100644 index 0000000..8f4283d --- /dev/null +++ b/tests/integration/test_registry_write_paths.py @@ -0,0 +1,253 @@ +"""Integration tests for Symbol Registry write paths and edge cases. + +Covers duplicate detection, alias/source creation, watchlist CRUD, +competitor relationship lifecycle, and exposure profile management. + +Uses the ``registry_client`` and ``seed_ids`` fixtures from conftest.py. +""" + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Task 6: Write Paths and Edge Cases +# --------------------------------------------------------------------------- + + +class TestRegistryDuplicateCompany: + """POST /companies — duplicate ticker+exchange returns 409.""" + + async def test_duplicate_company_returns_409(self, registry_client, seed_ids): + """POST /companies — duplicate ticker+exchange returns 409.""" + payload = { + "ticker": "AAPL", + "legal_name": "Apple Inc Duplicate", + "exchange": "NASDAQ", + "sector": "Technology", + "industry": "Consumer Electronics", + "market_cap_bucket": "mega", + } + resp = await registry_client.post("/companies", json=payload) + assert resp.status_code == 409 + + +class TestRegistryCompanyNotFound: + """GET /companies/{id} — 404 for non-existent UUID.""" + + async def test_company_not_found(self, registry_client): + """GET /companies/{id} — 404 for non-existent UUID.""" + fake_id = "00000000-0000-4000-ffff-000000000099" + resp = await registry_client.get(f"/companies/{fake_id}") + assert resp.status_code == 404 + + +class TestRegistryAliasCreate: + """POST /companies/{id}/aliases — create alias returns 201.""" + + async def test_create_alias(self, registry_client, seed_ids): + """POST /companies/{id}/aliases — create alias returns 201.""" + company_id = seed_ids["companies"]["AAPL"] + payload = {"alias": "Apple Computer", "alias_type": "historical"} + resp = await registry_client.post(f"/companies/{company_id}/aliases", json=payload) + assert resp.status_code == 201 + data = resp.json() + assert "id" in data + assert data["alias"] == "Apple Computer" + assert data["alias_type"] == "historical" + + +class TestRegistrySourceCreate: + """POST /companies/{id}/sources — create and validate sources.""" + + async def test_create_source(self, registry_client, seed_ids): + """POST /companies/{id}/sources — create source returns 201.""" + company_id = seed_ids["companies"]["AAPL"] + payload = { + "source_type": "news_api", + "source_name": "Test News Source", + "config": {}, + "credibility_score": 0.7, + } + resp = await registry_client.post(f"/companies/{company_id}/sources", json=payload) + assert resp.status_code == 201 + data = resp.json() + assert "id" in data + assert data["source_type"] == "news_api" + assert data["source_name"] == "Test News Source" + + async def test_create_source_invalid_type(self, registry_client, seed_ids): + """POST /companies/{id}/sources — invalid source_type returns 422.""" + company_id = seed_ids["companies"]["AAPL"] + payload = { + "source_type": "invalid_type", + "source_name": "Bad Source", + } + resp = await registry_client.post(f"/companies/{company_id}/sources", json=payload) + assert resp.status_code == 422 + + +class TestRegistryWatchlistCRUD: + """Watchlist creation, member management, and duplicate detection.""" + + async def test_create_watchlist(self, registry_client): + """POST /watchlists — create watchlist returns 201.""" + payload = {"name": "IntTest Watchlist", "description": "Integration test"} + resp = await registry_client.post("/watchlists", json=payload) + assert resp.status_code == 201 + data = resp.json() + assert "id" in data + assert data["name"] == "IntTest Watchlist" + + async def test_add_watchlist_member(self, registry_client, seed_ids): + """POST /watchlists/{id}/members/{company_id} — add member returns 201.""" + wl_id = seed_ids["watchlists"]["WL_01"] + company_id = seed_ids["companies"]["XOM"] + resp = await registry_client.post(f"/watchlists/{wl_id}/members/{company_id}") + assert resp.status_code == 201 + + async def test_list_watchlist_members(self, registry_client, seed_ids): + """GET /watchlists/{id}/members — members with ticker and legal_name.""" + wl_id = seed_ids["watchlists"]["WL_01"] + resp = await registry_client.get(f"/watchlists/{wl_id}/members") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) >= 2 # AAPL and MSFT from seed + for member in data: + assert "ticker" in member + assert "legal_name" in member + + async def test_duplicate_watchlist_returns_409(self, registry_client, seed_ids): + """POST /watchlists — duplicate name returns 409.""" + payload = {"name": "Tech Leaders"} # Already seeded + resp = await registry_client.post("/watchlists", json=payload) + assert resp.status_code == 409 + + +# --------------------------------------------------------------------------- +# Task 7: Competitor and Exposure Write Paths +# --------------------------------------------------------------------------- + + +class TestRegistryCompetitorCRUD: + """Competitor relationship creation, update, and soft-delete.""" + + async def test_create_competitor(self, registry_client, seed_ids): + """POST /companies/{id}/competitors — create returns 201.""" + company_id = seed_ids["companies"]["XOM"] + competitor_id = seed_ids["companies"]["JNJ"] + payload = { + "company_b_id": competitor_id, + "relationship_type": "same_sector", + "strength": 0.4, + "bidirectional": True, + "source": "manual", + } + resp = await registry_client.post(f"/companies/{company_id}/competitors", json=payload) + assert resp.status_code == 201 + data = resp.json() + assert "id" in data + assert data["relationship_type"] == "same_sector" + assert data["strength"] == 0.4 + assert data["bidirectional"] is True + + async def test_self_referencing_competitor_returns_400(self, registry_client, seed_ids): + """POST /companies/{id}/competitors — self-reference returns 400.""" + company_id = seed_ids["companies"]["AAPL"] + payload = { + "company_b_id": company_id, + "relationship_type": "direct_rival", + "strength": 0.5, + } + resp = await registry_client.post(f"/companies/{company_id}/competitors", json=payload) + assert resp.status_code == 400 + + async def test_update_competitor(self, registry_client, seed_ids): + """PUT /companies/{id}/competitors/{rel_id} — update strength.""" + company_id = seed_ids["companies"]["AAPL"] + competitor_id = seed_ids["companies"]["JNJ"] + # Create a new relationship to update + create_payload = { + "company_b_id": competitor_id, + "relationship_type": "overlapping_products", + "strength": 0.3, + } + create_resp = await registry_client.post( + f"/companies/{company_id}/competitors", json=create_payload + ) + assert create_resp.status_code == 201 + rel_id = create_resp.json()["id"] + # Update + update_payload = { + "company_b_id": competitor_id, + "relationship_type": "overlapping_products", + "strength": 0.7, + "bidirectional": True, + "source": "manual", + } + resp = await registry_client.put( + f"/companies/{company_id}/competitors/{rel_id}", json=update_payload + ) + assert resp.status_code == 200 + data = resp.json() + assert data["strength"] == 0.7 + + async def test_soft_delete_competitor(self, registry_client, seed_ids): + """DELETE /companies/{id}/competitors/{rel_id} — soft-delete returns 200.""" + company_id = seed_ids["companies"]["AAPL"] + competitor_id = seed_ids["companies"]["XOM"] + # Create a relationship to delete + create_payload = { + "company_b_id": competitor_id, + "relationship_type": "supply_chain_adjacent", + "strength": 0.2, + } + create_resp = await registry_client.post( + f"/companies/{company_id}/competitors", json=create_payload + ) + assert create_resp.status_code == 201 + rel_id = create_resp.json()["id"] + # Delete + resp = await registry_client.delete(f"/companies/{company_id}/competitors/{rel_id}") + assert resp.status_code == 200 + + +class TestRegistryExposureCRUD: + """Exposure profile upsert, history, and not-found handling.""" + + async def test_upsert_exposure_profile(self, registry_client, seed_ids): + """PUT /companies/{id}/exposure — upsert with version increment.""" + company_id = seed_ids["companies"]["MSFT"] # MSFT has no exposure profile + payload = { + "geographic_revenue_mix": {"North America": 0.50, "Europe": 0.30, "Asia": 0.20}, + "supply_chain_regions": ["North America", "Europe"], + "key_input_commodities": [], + "regulatory_jurisdictions": ["US", "EU"], + "market_position_tier": "global_leader", + "export_dependency_pct": 0.40, + "source": "manual", + "confidence": 0.9, + } + resp = await registry_client.put(f"/companies/{company_id}/exposure", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["version"] >= 1 + assert data["market_position_tier"] == "global_leader" + assert data["geographic_revenue_mix"]["North America"] == 0.50 + + async def test_exposure_history(self, registry_client, seed_ids): + """GET /companies/{id}/exposure/history — all versions.""" + company_id = seed_ids["companies"]["AAPL"] # AAPL has a seeded profile + resp = await registry_client.get(f"/companies/{company_id}/exposure/history") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) >= 1 + + async def test_exposure_not_found(self, registry_client, seed_ids): + """GET /companies/{id}/exposure — 404 for company with no profile.""" + company_id = seed_ids["companies"]["JNJ"] # JNJ has no exposure profile + resp = await registry_client.get(f"/companies/{company_id}/exposure") + assert resp.status_code == 404 diff --git a/tests/integration/test_risk_approval_lifecycle.py b/tests/integration/test_risk_approval_lifecycle.py new file mode 100644 index 0000000..b6fad22 --- /dev/null +++ b/tests/integration/test_risk_approval_lifecycle.py @@ -0,0 +1,136 @@ +"""Integration tests for Risk Engine — evaluation edge cases and approval lifecycle. + +Validates evaluation with minimal/extreme orders, custom config overrides, +and the full approval lifecycle (list → detail → review → expire) against +the live sandbox with deterministic seed data. + +Uses the ``risk_client`` and ``seed_ids`` fixtures from conftest.py. +""" + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# 1 Evaluation Edge Cases +# --------------------------------------------------------------------------- + + +class TestRiskEvaluationEdgeCases: + """Edge-case scenarios for POST /evaluate.""" + + async def test_minimal_order_evaluation(self, risk_client): + """POST /evaluate — minimal order with only ticker.""" + payload = {"order": {"ticker": "AAPL"}} + resp = await risk_client.post("/evaluate", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert "evaluation_id" in data + assert "eligible" in data + assert "rejection_reasons" in data + + async def test_order_exceeding_position_cap(self, risk_client): + """POST /evaluate — order exceeding position cap returns eligible: false.""" + payload = { + "order": { + "ticker": "AAPL", + "action": "buy", + "quantity": 100000, + "estimated_value": 18550000.00, + "confidence": 0.5, + "sector": "Technology", + }, + } + resp = await risk_client.post("/evaluate", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["eligible"] is False + assert len(data["rejection_reasons"]) >= 1 + + async def test_evaluation_with_custom_config(self, risk_client): + """POST /evaluate — custom config override.""" + payload = { + "order": { + "ticker": "MSFT", + "action": "buy", + "quantity": 5, + "estimated_value": 2050.00, + "confidence": 0.8, + }, + "config": { + "max_portfolio_heat": 0.50, + "max_single_position_pct": 0.10, + "max_sector_concentration": 0.50, + "daily_loss_limit_pct": 0.05, + }, + } + resp = await risk_client.post("/evaluate", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert "evaluation_id" in data + assert "eligible" in data + + +# --------------------------------------------------------------------------- +# 2 Approval Lifecycle +# --------------------------------------------------------------------------- + + +class TestRiskApprovalLifecycle: + """Full approval lifecycle: list → detail → review → expire.""" + + async def test_pending_approvals_list(self, risk_client, seed_ids): + """GET /approvals/pending — list pending approvals from seed.""" + resp = await risk_client.get("/approvals/pending") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) >= 1 + + async def test_approval_detail(self, risk_client, seed_ids): + """GET /approvals/{id} — seeded pending approval detail.""" + approval_id = seed_ids["approvals"]["PENDING"] + resp = await risk_client.get(f"/approvals/{approval_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["ticker"] == "AAPL" + assert data["side"] == "buy" + assert "status" in data + assert "expires_at" in data + + async def test_approval_review(self, risk_client, seed_ids): + """POST /approvals/{id}/review — approve the seeded pending approval.""" + approval_id = seed_ids["approvals"]["PENDING"] + payload = { + "approved": True, + "reviewed_by": "test-operator", + "review_note": "Integration test approval", + } + resp = await risk_client.post( + f"/approvals/{approval_id}/review", json=payload, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "approved" + + async def test_review_nonexistent_approval(self, risk_client): + """POST /approvals/{id}/review — 404 for non-existent approval.""" + fake_id = "00000000-0000-4000-ffff-000000000099" + payload = { + "approved": True, + "reviewed_by": "test-operator", + "review_note": "Should fail", + } + resp = await risk_client.post( + f"/approvals/{fake_id}/review", json=payload, + ) + assert resp.status_code == 404 + + async def test_approval_expiry(self, risk_client): + """POST /approvals/expire — expire stale approvals.""" + resp = await risk_client.post("/approvals/expire") + assert resp.status_code == 200 + data = resp.json() + assert "expired" in data + assert isinstance(data["expired"], int) diff --git a/tests/integration/test_trading_extended.py b/tests/integration/test_trading_extended.py new file mode 100644 index 0000000..7c67d14 --- /dev/null +++ b/tests/integration/test_trading_extended.py @@ -0,0 +1,244 @@ +"""Integration tests for Trading Engine — extended coverage. + +Validates configuration round-trips, pause/resume lifecycle, metrics +consistency, notification config, decision filtering, and override +order validation against the live sandbox. + +Uses the ``trading_client`` fixture from conftest.py. +""" + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# 1 Config Round-Trip +# --------------------------------------------------------------------------- + + +class TestTradingConfigRoundTrip: + """PUT config → GET status — verify risk_tier reflected.""" + + async def test_config_round_trip(self, trading_client): + """PUT config → GET status — verify risk_tier reflected.""" + # Set to aggressive + resp = await trading_client.put( + "/api/trading/config", json={"risk_tier": "aggressive"} + ) + assert resp.status_code == 200 + # Verify in status + status_resp = await trading_client.get("/api/trading/status") + assert status_resp.status_code == 200 + assert status_resp.json()["risk_tier"] == "aggressive" + # Restore to moderate + await trading_client.put( + "/api/trading/config", json={"risk_tier": "moderate"} + ) + + +# --------------------------------------------------------------------------- +# 2 Pause/Resume Round-Trip +# --------------------------------------------------------------------------- + + +class TestTradingPauseResumeRoundTrip: + """POST pause → GET status → POST resume → GET status.""" + + async def test_pause_resume_round_trip(self, trading_client): + """POST pause → GET status → POST resume → GET status.""" + # Pause + resp = await trading_client.post("/api/trading/pause") + assert resp.status_code == 200 + # Verify paused + status_resp = await trading_client.get("/api/trading/status") + assert status_resp.status_code == 200 + assert status_resp.json()["paused"] is True + # Resume + resp2 = await trading_client.post("/api/trading/resume") + assert resp2.status_code == 200 + # Verify resumed + status_resp2 = await trading_client.get("/api/trading/status") + assert status_resp2.status_code == 200 + assert status_resp2.json()["paused"] is False + + +# --------------------------------------------------------------------------- +# 3 Metrics Consistency +# --------------------------------------------------------------------------- + + +class TestTradingMetricsConsistency: + """GET /api/trading/metrics — total ≈ active + reserve + unrealized.""" + + async def test_metrics_consistency(self, trading_client): + """GET /api/trading/metrics — total ≈ active + reserve + unrealized.""" + resp = await trading_client.get("/api/trading/metrics") + assert resp.status_code == 200 + data = resp.json() + total = data["total_portfolio_value"] + active = data["active_pool"] + reserve = data["reserve_pool"] + unrealized = data["unrealized_pnl"] + # Allow tolerance for rounding + assert abs(total - (active + reserve + unrealized)) < 1.0 + + +# --------------------------------------------------------------------------- +# 4 Metrics History +# --------------------------------------------------------------------------- + + +class TestTradingMetricsHistoryExtended: + """GET /api/trading/metrics/history — returns portfolio snapshots.""" + + async def test_metrics_history_snapshots(self, trading_client): + """GET /api/trading/metrics/history — returns portfolio snapshots.""" + resp = await trading_client.get("/api/trading/metrics/history") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + if len(data) >= 1: + snap = data[0] + assert "portfolio_value" in snap + assert "snapshot_date" in snap + + +# --------------------------------------------------------------------------- +# 5 Notification Config Round-Trip +# --------------------------------------------------------------------------- + + +class TestTradingNotificationRoundTrip: + """PUT → GET notification config round-trip.""" + + async def test_notification_config_round_trip(self, trading_client): + """PUT → GET notification config round-trip.""" + # Update phone number (which drives sms_enabled) + resp = await trading_client.put( + "/api/trading/notifications/config", + json={"phone_number": "+15551234567"}, + ) + assert resp.status_code == 200 + # Verify GET returns config structure + get_resp = await trading_client.get("/api/trading/notifications/config") + assert get_resp.status_code == 200 + data = get_resp.json() + assert "sms_enabled" in data + assert "email_enabled" in data + assert isinstance(data["sms_enabled"], bool) + assert isinstance(data["email_enabled"], bool) + + +# --------------------------------------------------------------------------- +# 6 Notification History +# --------------------------------------------------------------------------- + + +class TestTradingNotificationHistory: + """GET /api/trading/notifications/history — returns list.""" + + async def test_notification_history(self, trading_client): + """GET /api/trading/notifications/history — returns list.""" + resp = await trading_client.get("/api/trading/notifications/history") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + +# --------------------------------------------------------------------------- +# 7 Decision Filtering +# --------------------------------------------------------------------------- + + +class TestTradingDecisionFiltering: + """GET /api/trading/decisions with various filters.""" + + async def test_decisions_filter_by_ticker(self, trading_client): + """GET /api/trading/decisions?ticker=AAPL — only AAPL decisions.""" + resp = await trading_client.get( + "/api/trading/decisions", params={"ticker": "AAPL"} + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + for d in data: + assert d["ticker"] == "AAPL" + + async def test_decisions_filter_by_limit(self, trading_client): + """GET /api/trading/decisions?limit=1 — at most 1 decision.""" + resp = await trading_client.get( + "/api/trading/decisions", params={"limit": "1"} + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) <= 1 + + async def test_decisions_filter_by_decision_type(self, trading_client): + """GET /api/trading/decisions?decision=execute — only execute decisions.""" + resp = await trading_client.get( + "/api/trading/decisions", params={"decision": "execute"} + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + for d in data: + assert d["decision"] == "execute" + + +# --------------------------------------------------------------------------- +# 8 Override Order Validation +# --------------------------------------------------------------------------- + + +class TestTradingOverrideValidation: + """POST /api/trading/override/order — validation edge cases.""" + + async def test_override_invalid_ticker(self, trading_client): + """POST /api/trading/override/order — invalid ticker returns 422.""" + payload = { + "ticker": "AAPL123", # contains digits — should be rejected + "side": "buy", + "quantity": 1.0, + "order_type": "market", + } + resp = await trading_client.post( + "/api/trading/override/order", json=payload + ) + assert resp.status_code == 422 + + async def test_override_zero_quantity(self, trading_client): + """POST /api/trading/override/order — zero quantity returns 422.""" + payload = { + "ticker": "AAPL", + "side": "buy", + "quantity": 0, + "order_type": "market", + } + resp = await trading_client.post( + "/api/trading/override/order", json=payload + ) + assert resp.status_code == 422 + + async def test_override_valid_order(self, trading_client): + """POST /api/trading/override/order — valid order returns 202 or structured error.""" + payload = { + "ticker": "AAPL", + "side": "buy", + "quantity": 1.0, + "order_type": "market", + } + resp = await trading_client.post( + "/api/trading/override/order", json=payload + ) + assert resp.status_code in (200, 202, 400, 422, 503) + data = resp.json() + if resp.status_code == 202: + assert "job_id" in data + assert data["status"] == "queued" + assert data["ticker"] == "AAPL" + assert data["side"] == "buy" + assert data["quantity"] == 1.0 + else: + assert isinstance(data, dict)