feat: beta API integration test suite — 85 new tests across 6 modules

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.
This commit is contained in:
Celes Renata
2026-04-20 02:34:19 +00:00
parent 8f67d326c9
commit 898f89926d
12 changed files with 2129 additions and 0 deletions
@@ -0,0 +1 @@
{"specId": "e433350c-baf0-4f4f-a30e-3724f6654090", "workflowType": "requirements-first", "specType": "feature"}
+216
View File
@@ -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
@@ -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
+124
View File
@@ -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)
+14
View File
@@ -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,
}
+199
View File
@@ -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__":
@@ -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
+170
View File
@@ -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
@@ -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)
@@ -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
@@ -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)
+244
View File
@@ -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)