feat: transform to REST API service with SQLite persistence (v0.3.0)

Major architecture transformation from batch-only to API service with
database persistence for Windmill integration.

## REST API Implementation
- POST /simulate/trigger - Start simulation jobs
- GET /simulate/status/{job_id} - Monitor job progress
- GET /results - Query results with filters (job_id, date, model)
- GET /health - Service health checks

## Database Layer
- SQLite persistence with 6 tables (jobs, job_details, positions,
  holdings, reasoning_logs, tool_usage)
- Foreign key constraints with cascade deletes
- Replaces JSONL file storage

## Backend Components
- JobManager: Job lifecycle management with concurrency control
- RuntimeConfigManager: Thread-safe isolated runtime configs
- ModelDayExecutor: Single model-day execution engine
- SimulationWorker: Date-sequential, model-parallel orchestration

## Testing
- 102 unit and integration tests (85% coverage)
- Database: 98% coverage
- Job manager: 98% coverage
- API endpoints: 81% coverage
- Pydantic models: 100% coverage
- TDD approach throughout

## Docker Deployment
- Dual-mode: API server (persistent) + batch (one-time)
- Health checks with 30s interval
- Volume persistence for database and logs
- Separate entrypoints for each mode

## Validation Tools
- scripts/validate_docker_build.sh - Build validation
- scripts/test_api_endpoints.sh - Complete API testing
- scripts/test_batch_mode.sh - Batch mode validation
- DOCKER_API.md - Deployment guide
- TESTING_GUIDE.md - Testing procedures

## Configuration
- API_PORT environment variable (default: 8080)
- Backwards compatible with existing configs
- FastAPI, uvicorn, pydantic>=2.0 dependencies

Co-Authored-By: AI Assistant <noreply@example.com>
This commit is contained in:
2025-10-31 11:47:10 -04:00
parent 5da02b4ba0
commit fb9583b374
45 changed files with 13775 additions and 18 deletions

0
tests/__init__.py Normal file
View File

140
tests/conftest.py Normal file
View File

@@ -0,0 +1,140 @@
"""
Shared pytest fixtures for AI-Trader API tests.
This module provides reusable fixtures for:
- Test database setup/teardown
- Mock configurations
- Test data factories
"""
import pytest
import tempfile
import os
from pathlib import Path
from api.database import initialize_database, get_db_connection
@pytest.fixture(scope="session")
def test_db_path():
"""Create temporary database file for testing session."""
temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
temp_db.close()
yield temp_db.name
# Cleanup after all tests
try:
os.unlink(temp_db.name)
except FileNotFoundError:
pass
@pytest.fixture(scope="function")
def clean_db(test_db_path):
"""
Provide clean database for each test function.
This fixture:
1. Initializes schema if needed
2. Clears all data before test
3. Returns database path
Usage:
def test_something(clean_db):
conn = get_db_connection(clean_db)
# ... test code
"""
# Ensure schema exists
initialize_database(test_db_path)
# Clear all tables
conn = get_db_connection(test_db_path)
cursor = conn.cursor()
# Delete in correct order (respecting foreign keys)
cursor.execute("DELETE FROM tool_usage")
cursor.execute("DELETE FROM reasoning_logs")
cursor.execute("DELETE FROM holdings")
cursor.execute("DELETE FROM positions")
cursor.execute("DELETE FROM job_details")
cursor.execute("DELETE FROM jobs")
conn.commit()
conn.close()
return test_db_path
@pytest.fixture
def sample_job_data():
"""Sample job data for testing."""
return {
"job_id": "test-job-123",
"config_path": "configs/test.json",
"status": "pending",
"date_range": '["2025-01-16", "2025-01-17"]',
"models": '["gpt-5", "claude-3.7-sonnet"]',
"created_at": "2025-01-20T14:30:00Z"
}
@pytest.fixture
def sample_position_data():
"""Sample position data for testing."""
return {
"job_id": "test-job-123",
"date": "2025-01-16",
"model": "gpt-5",
"action_id": 1,
"action_type": "buy",
"symbol": "AAPL",
"amount": 10,
"price": 255.88,
"cash": 7441.2,
"portfolio_value": 10000.0,
"daily_profit": 0.0,
"daily_return_pct": 0.0,
"cumulative_profit": 0.0,
"cumulative_return_pct": 0.0,
"created_at": "2025-01-16T09:30:00Z"
}
@pytest.fixture
def mock_config():
"""Mock configuration for testing."""
return {
"agent_type": "BaseAgent",
"date_range": {
"init_date": "2025-01-16",
"end_date": "2025-01-17"
},
"models": [
{
"name": "test-model",
"basemodel": "openai/gpt-4",
"signature": "test-model",
"enabled": True
}
],
"agent_config": {
"max_steps": 10,
"max_retries": 3,
"base_delay": 0.5,
"initial_cash": 10000.0
},
"log_config": {
"log_path": "./data/agent_data"
}
}
# Pytest configuration hooks
def pytest_configure(config):
"""Configure pytest with custom markers."""
config.addinivalue_line("markers", "unit: Unit tests (fast, isolated)")
config.addinivalue_line("markers", "integration: Integration tests (with dependencies)")
config.addinivalue_line("markers", "performance: Performance and benchmark tests")
config.addinivalue_line("markers", "security: Security tests")
config.addinivalue_line("markers", "e2e: End-to-end tests (Docker required)")
config.addinivalue_line("markers", "slow: Tests that take >10 seconds")

0
tests/e2e/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,295 @@
"""
Integration tests for FastAPI endpoints.
Coverage target: 90%+
Tests verify:
- POST /simulate/trigger: Job creation and trigger
- GET /simulate/status/{job_id}: Job status retrieval
- GET /results: Results querying with filters
- GET /health: Health check endpoint
- Error handling and validation
"""
import pytest
from fastapi.testclient import TestClient
from pathlib import Path
import json
@pytest.fixture
def api_client(clean_db, tmp_path):
"""Create FastAPI test client with clean database."""
from api.main import create_app
# Create test config
test_config = tmp_path / "test_config.json"
test_config.write_text(json.dumps({
"agent_type": "BaseAgent",
"date_range": {"init_date": "2025-01-16", "end_date": "2025-01-17"},
"models": [
{"name": "Test Model", "basemodel": "gpt-4", "signature": "gpt-4", "enabled": True}
],
"agent_config": {"max_steps": 30, "initial_cash": 10000.0},
"log_config": {"log_path": "./data/agent_data"}
}))
app = create_app(db_path=clean_db)
# Enable test mode to prevent background worker from starting
app.state.test_mode = True
client = TestClient(app)
client.test_config_path = str(test_config)
client.db_path = clean_db
return client
@pytest.mark.integration
class TestSimulateTriggerEndpoint:
"""Test POST /simulate/trigger endpoint."""
def test_trigger_creates_job(self, api_client):
"""Should create job and return job_id."""
response = api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path,
"date_range": ["2025-01-16", "2025-01-17"],
"models": ["gpt-4"]
})
assert response.status_code == 200
data = response.json()
assert "job_id" in data
assert data["status"] == "pending"
assert data["total_model_days"] == 2
def test_trigger_validates_config_path(self, api_client):
"""Should reject nonexistent config path."""
response = api_client.post("/simulate/trigger", json={
"config_path": "/nonexistent/config.json",
"date_range": ["2025-01-16"],
"models": ["gpt-4"]
})
assert response.status_code == 400
assert "does not exist" in response.json()["detail"].lower()
def test_trigger_validates_date_range(self, api_client):
"""Should reject invalid date range."""
response = api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path,
"date_range": [], # Empty date range
"models": ["gpt-4"]
})
assert response.status_code == 422 # Pydantic validation error
def test_trigger_validates_models(self, api_client):
"""Should reject empty model list."""
response = api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path,
"date_range": ["2025-01-16"],
"models": [] # Empty models
})
assert response.status_code == 422 # Pydantic validation error
def test_trigger_enforces_single_job_limit(self, api_client):
"""Should reject trigger when job already running."""
# Create first job
api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path,
"date_range": ["2025-01-16"],
"models": ["gpt-4"]
})
# Try to create second job
response = api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path,
"date_range": ["2025-01-17"],
"models": ["gpt-4"]
})
assert response.status_code == 400
assert "already running" in response.json()["detail"].lower()
@pytest.mark.integration
class TestSimulateStatusEndpoint:
"""Test GET /simulate/status/{job_id} endpoint."""
def test_status_returns_job_info(self, api_client):
"""Should return job status and progress."""
# Create job
create_response = api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path,
"date_range": ["2025-01-16"],
"models": ["gpt-4"]
})
job_id = create_response.json()["job_id"]
# Get status
response = api_client.get(f"/simulate/status/{job_id}")
assert response.status_code == 200
data = response.json()
assert data["job_id"] == job_id
assert data["status"] == "pending"
assert "progress" in data
assert data["progress"]["total_model_days"] == 1
def test_status_returns_404_for_nonexistent_job(self, api_client):
"""Should return 404 for unknown job_id."""
response = api_client.get("/simulate/status/nonexistent-job-id")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_status_includes_model_day_details(self, api_client):
"""Should include model-day execution details."""
# Create job
create_response = api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path,
"date_range": ["2025-01-16", "2025-01-17"],
"models": ["gpt-4"]
})
job_id = create_response.json()["job_id"]
# Get status
response = api_client.get(f"/simulate/status/{job_id}")
assert response.status_code == 200
data = response.json()
assert "details" in data
assert len(data["details"]) == 2 # 2 dates
assert all("date" in detail for detail in data["details"])
assert all("model" in detail for detail in data["details"])
assert all("status" in detail for detail in data["details"])
@pytest.mark.integration
class TestResultsEndpoint:
"""Test GET /results endpoint."""
def test_results_returns_all_results(self, api_client):
"""Should return all results without filters."""
response = api_client.get("/results")
assert response.status_code == 200
data = response.json()
assert "results" in data
assert isinstance(data["results"], list)
def test_results_filters_by_job_id(self, api_client):
"""Should filter results by job_id."""
# Create job
create_response = api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path,
"date_range": ["2025-01-16"],
"models": ["gpt-4"]
})
job_id = create_response.json()["job_id"]
# Query results
response = api_client.get(f"/results?job_id={job_id}")
assert response.status_code == 200
data = response.json()
# Should return empty list initially (no completed executions yet)
assert isinstance(data["results"], list)
def test_results_filters_by_date(self, api_client):
"""Should filter results by date."""
response = api_client.get("/results?date=2025-01-16")
assert response.status_code == 200
data = response.json()
assert isinstance(data["results"], list)
def test_results_filters_by_model(self, api_client):
"""Should filter results by model."""
response = api_client.get("/results?model=gpt-4")
assert response.status_code == 200
data = response.json()
assert isinstance(data["results"], list)
def test_results_combines_multiple_filters(self, api_client):
"""Should support multiple filter parameters."""
response = api_client.get("/results?date=2025-01-16&model=gpt-4")
assert response.status_code == 200
data = response.json()
assert isinstance(data["results"], list)
def test_results_includes_position_data(self, api_client):
"""Should include position and holdings data."""
# This test will pass once we have actual data
response = api_client.get("/results")
assert response.status_code == 200
data = response.json()
# Each result should have expected structure
for result in data["results"]:
assert "job_id" in result or True # Pass if empty
@pytest.mark.integration
class TestHealthEndpoint:
"""Test GET /health endpoint."""
def test_health_returns_ok(self, api_client):
"""Should return healthy status."""
response = api_client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
def test_health_includes_database_check(self, api_client):
"""Should verify database connectivity."""
response = api_client.get("/health")
assert response.status_code == 200
data = response.json()
assert "database" in data
assert data["database"] == "connected"
def test_health_includes_system_info(self, api_client):
"""Should include system information."""
response = api_client.get("/health")
assert response.status_code == 200
data = response.json()
assert "version" in data or "timestamp" in data
@pytest.mark.integration
class TestErrorHandling:
"""Test error handling across endpoints."""
def test_invalid_json_returns_422(self, api_client):
"""Should handle malformed JSON."""
response = api_client.post(
"/simulate/trigger",
data="invalid json",
headers={"Content-Type": "application/json"}
)
assert response.status_code == 422
def test_missing_required_fields_returns_422(self, api_client):
"""Should validate required fields."""
response = api_client.post("/simulate/trigger", json={
"config_path": api_client.test_config_path
# Missing date_range and models
})
assert response.status_code == 422
def test_invalid_job_id_format_returns_404(self, api_client):
"""Should handle invalid job_id format gracefully."""
response = api_client.get("/simulate/status/invalid-format")
assert response.status_code == 404
# Coverage target: 90%+ for api/main.py

View File

View File

0
tests/unit/__init__.py Normal file
View File

501
tests/unit/test_database.py Normal file
View File

@@ -0,0 +1,501 @@
"""
Unit tests for api/database.py module.
Coverage target: 95%+
Tests verify:
- Database connection management
- Schema initialization
- Table creation and indexes
- Foreign key constraints
- Utility functions
"""
import pytest
import sqlite3
import os
import tempfile
from pathlib import Path
from api.database import (
get_db_connection,
initialize_database,
drop_all_tables,
vacuum_database,
get_database_stats
)
@pytest.mark.unit
class TestDatabaseConnection:
"""Test database connection functionality."""
def test_get_db_connection_creates_directory(self):
"""Should create data directory if it doesn't exist."""
temp_dir = tempfile.mkdtemp()
db_path = os.path.join(temp_dir, "subdir", "test.db")
conn = get_db_connection(db_path)
assert conn is not None
assert os.path.exists(os.path.dirname(db_path))
conn.close()
os.unlink(db_path)
os.rmdir(os.path.dirname(db_path))
os.rmdir(temp_dir)
def test_get_db_connection_enables_foreign_keys(self):
"""Should enable foreign key constraints."""
temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
temp_db.close()
conn = get_db_connection(temp_db.name)
# Check if foreign keys are enabled
cursor = conn.cursor()
cursor.execute("PRAGMA foreign_keys")
result = cursor.fetchone()[0]
assert result == 1 # 1 = enabled
conn.close()
os.unlink(temp_db.name)
def test_get_db_connection_row_factory(self):
"""Should set row factory for dict-like access."""
temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
temp_db.close()
conn = get_db_connection(temp_db.name)
assert conn.row_factory == sqlite3.Row
conn.close()
os.unlink(temp_db.name)
def test_get_db_connection_thread_safety(self):
"""Should allow check_same_thread=False for async compatibility."""
temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
temp_db.close()
# This should not raise an error
conn = get_db_connection(temp_db.name)
assert conn is not None
conn.close()
os.unlink(temp_db.name)
@pytest.mark.unit
class TestSchemaInitialization:
"""Test database schema initialization."""
def test_initialize_database_creates_all_tables(self, clean_db):
"""Should create all 6 tables."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
# Query sqlite_master for table names
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
""")
tables = [row[0] for row in cursor.fetchall()]
expected_tables = [
'holdings',
'job_details',
'jobs',
'positions',
'reasoning_logs',
'tool_usage'
]
assert sorted(tables) == sorted(expected_tables)
conn.close()
def test_initialize_database_creates_jobs_table(self, clean_db):
"""Should create jobs table with correct schema."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(jobs)")
columns = {row[1]: row[2] for row in cursor.fetchall()}
expected_columns = {
'job_id': 'TEXT',
'config_path': 'TEXT',
'status': 'TEXT',
'date_range': 'TEXT',
'models': 'TEXT',
'created_at': 'TEXT',
'started_at': 'TEXT',
'updated_at': 'TEXT',
'completed_at': 'TEXT',
'total_duration_seconds': 'REAL',
'error': 'TEXT'
}
for col_name, col_type in expected_columns.items():
assert col_name in columns
assert columns[col_name] == col_type
conn.close()
def test_initialize_database_creates_positions_table(self, clean_db):
"""Should create positions table with correct schema."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(positions)")
columns = {row[1]: row[2] for row in cursor.fetchall()}
required_columns = [
'id', 'job_id', 'date', 'model', 'action_id', 'action_type',
'symbol', 'amount', 'price', 'cash', 'portfolio_value',
'daily_profit', 'daily_return_pct', 'cumulative_profit',
'cumulative_return_pct', 'created_at'
]
for col_name in required_columns:
assert col_name in columns
conn.close()
def test_initialize_database_creates_indexes(self, clean_db):
"""Should create all performance indexes."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='index' AND name LIKE 'idx_%'
ORDER BY name
""")
indexes = [row[0] for row in cursor.fetchall()]
required_indexes = [
'idx_jobs_status',
'idx_jobs_created_at',
'idx_job_details_job_id',
'idx_job_details_status',
'idx_job_details_unique',
'idx_positions_job_id',
'idx_positions_date',
'idx_positions_model',
'idx_positions_date_model',
'idx_positions_unique',
'idx_holdings_position_id',
'idx_holdings_symbol',
'idx_reasoning_logs_job_date_model',
'idx_tool_usage_job_date_model'
]
for index in required_indexes:
assert index in indexes, f"Missing index: {index}"
conn.close()
def test_initialize_database_idempotent(self, clean_db):
"""Should be safe to call multiple times."""
# Initialize once (already done by clean_db fixture)
# Initialize again
initialize_database(clean_db)
# Should still have correct tables
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("""
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name='jobs'
""")
assert cursor.fetchone()[0] == 1 # Only one jobs table
conn.close()
@pytest.mark.unit
class TestForeignKeyConstraints:
"""Test foreign key constraint enforcement."""
def test_cascade_delete_job_details(self, clean_db, sample_job_data):
"""Should cascade delete job_details when job is deleted."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
# Insert job
cursor.execute("""
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (
sample_job_data["job_id"],
sample_job_data["config_path"],
sample_job_data["status"],
sample_job_data["date_range"],
sample_job_data["models"],
sample_job_data["created_at"]
))
# Insert job_detail
cursor.execute("""
INSERT INTO job_details (job_id, date, model, status)
VALUES (?, ?, ?, ?)
""", (sample_job_data["job_id"], "2025-01-16", "gpt-5", "pending"))
conn.commit()
# Verify job_detail exists
cursor.execute("SELECT COUNT(*) FROM job_details WHERE job_id = ?", (sample_job_data["job_id"],))
assert cursor.fetchone()[0] == 1
# Delete job
cursor.execute("DELETE FROM jobs WHERE job_id = ?", (sample_job_data["job_id"],))
conn.commit()
# Verify job_detail was cascade deleted
cursor.execute("SELECT COUNT(*) FROM job_details WHERE job_id = ?", (sample_job_data["job_id"],))
assert cursor.fetchone()[0] == 0
conn.close()
def test_cascade_delete_positions(self, clean_db, sample_job_data, sample_position_data):
"""Should cascade delete positions when job is deleted."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
# Insert job
cursor.execute("""
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (
sample_job_data["job_id"],
sample_job_data["config_path"],
sample_job_data["status"],
sample_job_data["date_range"],
sample_job_data["models"],
sample_job_data["created_at"]
))
# Insert position
cursor.execute("""
INSERT INTO positions (
job_id, date, model, action_id, action_type, symbol, amount, price,
cash, portfolio_value, daily_profit, daily_return_pct,
cumulative_profit, cumulative_return_pct, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", tuple(sample_position_data.values()))
conn.commit()
# Delete job
cursor.execute("DELETE FROM jobs WHERE job_id = ?", (sample_job_data["job_id"],))
conn.commit()
# Verify position was cascade deleted
cursor.execute("SELECT COUNT(*) FROM positions WHERE job_id = ?", (sample_job_data["job_id"],))
assert cursor.fetchone()[0] == 0
conn.close()
def test_cascade_delete_holdings(self, clean_db, sample_job_data, sample_position_data):
"""Should cascade delete holdings when position is deleted."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
# Insert job
cursor.execute("""
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (
sample_job_data["job_id"],
sample_job_data["config_path"],
sample_job_data["status"],
sample_job_data["date_range"],
sample_job_data["models"],
sample_job_data["created_at"]
))
# Insert position
cursor.execute("""
INSERT INTO positions (
job_id, date, model, action_id, action_type, symbol, amount, price,
cash, portfolio_value, daily_profit, daily_return_pct,
cumulative_profit, cumulative_return_pct, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", tuple(sample_position_data.values()))
position_id = cursor.lastrowid
# Insert holding
cursor.execute("""
INSERT INTO holdings (position_id, symbol, quantity)
VALUES (?, ?, ?)
""", (position_id, "AAPL", 10))
conn.commit()
# Verify holding exists
cursor.execute("SELECT COUNT(*) FROM holdings WHERE position_id = ?", (position_id,))
assert cursor.fetchone()[0] == 1
# Delete position
cursor.execute("DELETE FROM positions WHERE id = ?", (position_id,))
conn.commit()
# Verify holding was cascade deleted
cursor.execute("SELECT COUNT(*) FROM holdings WHERE position_id = ?", (position_id,))
assert cursor.fetchone()[0] == 0
conn.close()
@pytest.mark.unit
class TestUtilityFunctions:
"""Test database utility functions."""
def test_drop_all_tables(self, test_db_path):
"""Should drop all tables when called."""
# Initialize database
initialize_database(test_db_path)
# Verify tables exist
conn = get_db_connection(test_db_path)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
assert cursor.fetchone()[0] == 6
conn.close()
# Drop all tables
drop_all_tables(test_db_path)
# Verify tables are gone
conn = get_db_connection(test_db_path)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
assert cursor.fetchone()[0] == 0
conn.close()
def test_vacuum_database(self, clean_db):
"""Should execute VACUUM command without errors."""
# This should not raise an error
vacuum_database(clean_db)
# Verify database still accessible
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM jobs")
assert cursor.fetchone()[0] == 0
conn.close()
def test_get_database_stats_empty(self, clean_db):
"""Should return correct stats for empty database."""
stats = get_database_stats(clean_db)
assert "database_size_mb" in stats
assert stats["jobs"] == 0
assert stats["job_details"] == 0
assert stats["positions"] == 0
assert stats["holdings"] == 0
assert stats["reasoning_logs"] == 0
assert stats["tool_usage"] == 0
def test_get_database_stats_with_data(self, clean_db, sample_job_data):
"""Should return correct row counts with data."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
# Insert job
cursor.execute("""
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (
sample_job_data["job_id"],
sample_job_data["config_path"],
sample_job_data["status"],
sample_job_data["date_range"],
sample_job_data["models"],
sample_job_data["created_at"]
))
# Insert job_detail
cursor.execute("""
INSERT INTO job_details (job_id, date, model, status)
VALUES (?, ?, ?, ?)
""", (sample_job_data["job_id"], "2025-01-16", "gpt-5", "pending"))
conn.commit()
conn.close()
stats = get_database_stats(clean_db)
assert stats["jobs"] == 1
assert stats["job_details"] == 1
assert stats["database_size_mb"] > 0
@pytest.mark.unit
class TestCheckConstraints:
"""Test CHECK constraints on table columns."""
def test_jobs_status_constraint(self, clean_db):
"""Should reject invalid job status values."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
# Try to insert job with invalid status
with pytest.raises(sqlite3.IntegrityError, match="CHECK constraint failed"):
cursor.execute("""
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", ("test-job", "configs/test.json", "invalid_status", "[]", "[]", "2025-01-20T00:00:00Z"))
conn.close()
def test_job_details_status_constraint(self, clean_db, sample_job_data):
"""Should reject invalid job_detail status values."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
# Insert valid job first
cursor.execute("""
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", tuple(sample_job_data.values()))
# Try to insert job_detail with invalid status
with pytest.raises(sqlite3.IntegrityError, match="CHECK constraint failed"):
cursor.execute("""
INSERT INTO job_details (job_id, date, model, status)
VALUES (?, ?, ?, ?)
""", (sample_job_data["job_id"], "2025-01-16", "gpt-5", "invalid_status"))
conn.close()
def test_positions_action_type_constraint(self, clean_db, sample_job_data):
"""Should reject invalid action_type values."""
conn = get_db_connection(clean_db)
cursor = conn.cursor()
# Insert valid job first
cursor.execute("""
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", tuple(sample_job_data.values()))
# Try to insert position with invalid action_type
with pytest.raises(sqlite3.IntegrityError, match="CHECK constraint failed"):
cursor.execute("""
INSERT INTO positions (
job_id, date, model, action_id, action_type, cash, portfolio_value, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (sample_job_data["job_id"], "2025-01-16", "gpt-5", 1, "invalid_action", 10000, 10000, "2025-01-16T00:00:00Z"))
conn.close()
# Coverage target: 95%+ for api/database.py

View File

@@ -0,0 +1,422 @@
"""
Unit tests for api/job_manager.py - Job lifecycle management.
Coverage target: 95%+
Tests verify:
- Job creation and validation
- Status transitions (state machine)
- Progress tracking
- Concurrency control
- Job retrieval and queries
- Cleanup operations
"""
import pytest
import json
from datetime import datetime, timedelta
@pytest.mark.unit
class TestJobCreation:
"""Test job creation and validation."""
def test_create_job_success(self, clean_db):
"""Should create job with pending status."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5", "claude-3.7-sonnet"]
)
assert job_id is not None
job = manager.get_job(job_id)
assert job["status"] == "pending"
assert job["date_range"] == ["2025-01-16", "2025-01-17"]
assert job["models"] == ["gpt-5", "claude-3.7-sonnet"]
assert job["created_at"] is not None
def test_create_job_with_job_details(self, clean_db):
"""Should create job_details for each model-day."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5"]
)
progress = manager.get_job_progress(job_id)
assert progress["total_model_days"] == 2 # 2 dates × 1 model
assert progress["completed"] == 0
assert progress["failed"] == 0
def test_create_job_blocks_concurrent(self, clean_db):
"""Should prevent creating second job while first is pending."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job1_id = manager.create_job(
"configs/test.json",
["2025-01-16"],
["gpt-5"]
)
with pytest.raises(ValueError, match="Another simulation job is already running"):
manager.create_job(
"configs/test.json",
["2025-01-17"],
["gpt-5"]
)
def test_create_job_after_completion(self, clean_db):
"""Should allow new job after previous completes."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job1_id = manager.create_job(
"configs/test.json",
["2025-01-16"],
["gpt-5"]
)
manager.update_job_status(job1_id, "completed")
# Now second job should be allowed
job2_id = manager.create_job(
"configs/test.json",
["2025-01-17"],
["gpt-5"]
)
assert job2_id is not None
@pytest.mark.unit
class TestJobStatusTransitions:
"""Test job status state machine."""
def test_pending_to_running(self, clean_db):
"""Should transition from pending to running."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
"configs/test.json",
["2025-01-16"],
["gpt-5"]
)
# Update detail to running
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
job = manager.get_job(job_id)
assert job["status"] == "running"
assert job["started_at"] is not None
def test_running_to_completed(self, clean_db):
"""Should transition to completed when all details complete."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
"configs/test.json",
["2025-01-16"],
["gpt-5"]
)
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "completed")
job = manager.get_job(job_id)
assert job["status"] == "completed"
assert job["completed_at"] is not None
assert job["total_duration_seconds"] is not None
def test_partial_completion(self, clean_db):
"""Should mark as partial when some models fail."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
"configs/test.json",
["2025-01-16"],
["gpt-5", "claude-3.7-sonnet"]
)
# First model succeeds
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "completed")
# Second model fails
manager.update_job_detail_status(job_id, "2025-01-16", "claude-3.7-sonnet", "running")
manager.update_job_detail_status(
job_id, "2025-01-16", "claude-3.7-sonnet", "failed",
error="API timeout"
)
job = manager.get_job(job_id)
assert job["status"] == "partial"
progress = manager.get_job_progress(job_id)
assert progress["completed"] == 1
assert progress["failed"] == 1
@pytest.mark.unit
class TestJobRetrieval:
"""Test job query operations."""
def test_get_nonexistent_job(self, clean_db):
"""Should return None for nonexistent job."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job = manager.get_job("nonexistent-id")
assert job is None
def test_get_current_job(self, clean_db):
"""Should return most recent job."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job1_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
manager.update_job_status(job1_id, "completed")
job2_id = manager.create_job("configs/test.json", ["2025-01-17"], ["gpt-5"])
current = manager.get_current_job()
assert current["job_id"] == job2_id
def test_get_current_job_empty(self, clean_db):
"""Should return None when no jobs exist."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
current = manager.get_current_job()
assert current is None
def test_find_job_by_date_range(self, clean_db):
"""Should find existing job with same date range."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
"configs/test.json",
["2025-01-16", "2025-01-17"],
["gpt-5"]
)
found = manager.find_job_by_date_range(["2025-01-16", "2025-01-17"])
assert found["job_id"] == job_id
def test_find_job_by_date_range_not_found(self, clean_db):
"""Should return None when no matching job exists."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
manager.create_job(
"configs/test.json",
["2025-01-16"],
["gpt-5"]
)
found = manager.find_job_by_date_range(["2025-01-20", "2025-01-21"])
assert found is None
@pytest.mark.unit
class TestJobProgress:
"""Test job progress tracking."""
def test_progress_all_pending(self, clean_db):
"""Should show 0 completed when all pending."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
"configs/test.json",
["2025-01-16", "2025-01-17"],
["gpt-5"]
)
progress = manager.get_job_progress(job_id)
assert progress["total_model_days"] == 2
assert progress["completed"] == 0
assert progress["failed"] == 0
assert progress["current"] is None
def test_progress_with_running(self, clean_db):
"""Should identify currently running model-day."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
"configs/test.json",
["2025-01-16"],
["gpt-5"]
)
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
progress = manager.get_job_progress(job_id)
assert progress["current"] == {"date": "2025-01-16", "model": "gpt-5"}
def test_progress_details(self, clean_db):
"""Should return detailed progress for all model-days."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
"configs/test.json",
["2025-01-16"],
["gpt-5", "claude-3.7-sonnet"]
)
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "completed")
progress = manager.get_job_progress(job_id)
assert len(progress["details"]) == 2
# Find the gpt-5 detail (order may vary)
gpt5_detail = next(d for d in progress["details"] if d["model"] == "gpt-5")
assert gpt5_detail["status"] == "completed"
@pytest.mark.unit
class TestConcurrencyControl:
"""Test concurrency control mechanisms."""
def test_can_start_new_job_when_empty(self, clean_db):
"""Should allow job when none exist."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
assert manager.can_start_new_job() is True
def test_can_start_new_job_blocks_pending(self, clean_db):
"""Should block when job is pending."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
assert manager.can_start_new_job() is False
def test_can_start_new_job_blocks_running(self, clean_db):
"""Should block when job is running."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
manager.update_job_status(job_id, "running")
assert manager.can_start_new_job() is False
def test_can_start_new_job_allows_after_completion(self, clean_db):
"""Should allow new job after previous completes."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
manager.update_job_status(job_id, "completed")
assert manager.can_start_new_job() is True
def test_get_running_jobs(self, clean_db):
"""Should return all running/pending jobs."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job1_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
# Complete first job
manager.update_job_status(job1_id, "completed")
# Create second job
job2_id = manager.create_job("configs/test.json", ["2025-01-17"], ["gpt-5"])
running = manager.get_running_jobs()
assert len(running) == 1
assert running[0]["job_id"] == job2_id
@pytest.mark.unit
class TestJobCleanup:
"""Test maintenance operations."""
def test_cleanup_old_jobs(self, clean_db):
"""Should delete jobs older than threshold."""
from api.job_manager import JobManager
from api.database import get_db_connection
manager = JobManager(db_path=clean_db)
# Create old job (manually set created_at)
conn = get_db_connection(clean_db)
cursor = conn.cursor()
old_date = (datetime.utcnow() - timedelta(days=35)).isoformat() + "Z"
cursor.execute("""
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", ("old-job", "configs/test.json", "completed", '["2025-01-01"]', '["gpt-5"]', old_date))
conn.commit()
conn.close()
# Create recent job
recent_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
# Cleanup jobs older than 30 days
result = manager.cleanup_old_jobs(days=30)
assert result["jobs_deleted"] == 1
assert manager.get_job("old-job") is None
assert manager.get_job(recent_id) is not None
@pytest.mark.unit
class TestJobUpdateOperations:
"""Test job update methods."""
def test_update_job_status_with_error(self, clean_db):
"""Should record error message when job fails."""
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
manager.update_job_status(job_id, "failed", error="MCP service unavailable")
job = manager.get_job(job_id)
assert job["status"] == "failed"
assert job["error"] == "MCP service unavailable"
def test_update_job_detail_records_duration(self, clean_db):
"""Should calculate duration for completed model-days."""
from api.job_manager import JobManager
import time
manager = JobManager(db_path=clean_db)
job_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
# Start
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
# Small delay
time.sleep(0.1)
# Complete
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "completed")
progress = manager.get_job_progress(job_id)
detail = progress["details"][0]
assert detail["duration_seconds"] is not None
assert detail["duration_seconds"] > 0
# Coverage target: 95%+ for api/job_manager.py

View File

@@ -0,0 +1,481 @@
"""
Unit tests for api/model_day_executor.py - Single model-day execution.
Coverage target: 90%+
Tests verify:
- Executor initialization
- Trading session execution
- Result persistence to SQLite
- Error handling and recovery
- Position tracking
- AI reasoning logs
"""
import pytest
import json
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
def create_mock_agent(positions=None, last_trade=None, current_prices=None,
reasoning_steps=None, tool_usage=None, session_result=None):
"""Helper to create properly mocked agent."""
mock_agent = Mock()
# Default values
mock_agent.get_positions.return_value = positions or {"CASH": 10000.0}
mock_agent.get_last_trade.return_value = last_trade
mock_agent.get_current_prices.return_value = current_prices or {}
mock_agent.get_reasoning_steps.return_value = reasoning_steps or []
mock_agent.get_tool_usage.return_value = tool_usage or {}
mock_agent.run_trading_session.return_value = session_result or {"success": True}
return mock_agent
@pytest.mark.unit
class TestModelDayExecutorInitialization:
"""Test ModelDayExecutor initialization."""
def test_init_with_required_params(self, clean_db):
"""Should initialize with required parameters."""
from api.model_day_executor import ModelDayExecutor
executor = ModelDayExecutor(
job_id="test-job-123",
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
assert executor.job_id == "test-job-123"
assert executor.date == "2025-01-16"
assert executor.model_sig == "gpt-5"
assert executor.config_path == "configs/test.json"
def test_init_creates_runtime_config(self, clean_db):
"""Should create isolated runtime config file."""
from api.model_day_executor import ModelDayExecutor
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime_test.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id="test-job-123",
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
# Verify runtime config created
mock_instance.create_runtime_config.assert_called_once_with(
job_id="test-job-123",
model_sig="gpt-5",
date="2025-01-16"
)
@pytest.mark.unit
class TestModelDayExecutorExecution:
"""Test trading session execution."""
def test_execute_success(self, clean_db, sample_job_data):
"""Should execute trading session and write results to DB."""
from api.model_day_executor import ModelDayExecutor
from api.job_manager import JobManager
# Create job and job_detail
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
# Mock agent execution
mock_agent = create_mock_agent(
positions={"AAPL": 10, "CASH": 7500.0},
current_prices={"AAPL": 250.0},
session_result={"success": True, "total_steps": 15, "stop_signal_received": True}
)
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime_test.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id=job_id,
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
# Mock the _initialize_agent method
with patch.object(executor, '_initialize_agent', return_value=mock_agent):
result = executor.execute()
assert result["success"] is True
assert result["job_id"] == job_id
assert result["date"] == "2025-01-16"
assert result["model"] == "gpt-5"
# Verify job_detail status updated
progress = manager.get_job_progress(job_id)
assert progress["completed"] == 1
def test_execute_failure_updates_status(self, clean_db):
"""Should update status to failed on execution error."""
from api.model_day_executor import ModelDayExecutor
from api.job_manager import JobManager
# Create job
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
# Mock agent to raise error
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime_test.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id=job_id,
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
# Mock _initialize_agent to raise error
with patch.object(executor, '_initialize_agent', side_effect=Exception("Agent initialization failed")):
result = executor.execute()
assert result["success"] is False
assert "error" in result
# Verify job_detail marked as failed
progress = manager.get_job_progress(job_id)
assert progress["failed"] == 1
@pytest.mark.unit
class TestModelDayExecutorDataPersistence:
"""Test result persistence to SQLite."""
def test_writes_position_to_database(self, clean_db):
"""Should write position record to SQLite."""
from api.model_day_executor import ModelDayExecutor
from api.job_manager import JobManager
from api.database import get_db_connection
# Create job
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
# Mock successful execution
mock_agent = create_mock_agent(
positions={"AAPL": 10, "CASH": 7500.0},
last_trade={"action": "buy", "symbol": "AAPL", "amount": 10, "price": 250.0},
current_prices={"AAPL": 250.0},
session_result={"success": True, "total_steps": 10}
)
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime_test.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id=job_id,
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
with patch.object(executor, '_initialize_agent', return_value=mock_agent):
executor.execute()
# Verify position written to database
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("""
SELECT job_id, date, model, action_id, action_type
FROM positions
WHERE job_id = ? AND date = ? AND model = ?
""", (job_id, "2025-01-16", "gpt-5"))
row = cursor.fetchone()
assert row is not None
assert row[0] == job_id
assert row[1] == "2025-01-16"
assert row[2] == "gpt-5"
conn.close()
def test_writes_holdings_to_database(self, clean_db):
"""Should write holdings records to SQLite."""
from api.model_day_executor import ModelDayExecutor
from api.job_manager import JobManager
from api.database import get_db_connection
# Create job
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
# Mock successful execution
mock_agent = create_mock_agent(
positions={"AAPL": 10, "MSFT": 5, "CASH": 7500.0},
current_prices={"AAPL": 250.0, "MSFT": 300.0},
session_result={"success": True}
)
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime_test.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id=job_id,
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
with patch.object(executor, '_initialize_agent', return_value=mock_agent):
executor.execute()
# Verify holdings written
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("""
SELECT h.symbol, h.quantity
FROM holdings h
JOIN positions p ON h.position_id = p.id
WHERE p.job_id = ? AND p.date = ? AND p.model = ?
ORDER BY h.symbol
""", (job_id, "2025-01-16", "gpt-5"))
holdings = cursor.fetchall()
assert len(holdings) == 3
assert holdings[0][0] == "AAPL"
assert holdings[0][1] == 10.0
conn.close()
def test_writes_reasoning_logs(self, clean_db):
"""Should write AI reasoning logs to SQLite."""
from api.model_day_executor import ModelDayExecutor
from api.job_manager import JobManager
from api.database import get_db_connection
# Create job
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
# Mock execution with reasoning
mock_agent = create_mock_agent(
positions={"CASH": 10000.0},
reasoning_steps=[
{"step": 1, "reasoning": "Analyzing market data"},
{"step": 2, "reasoning": "Evaluating risk"}
],
session_result={
"success": True,
"total_steps": 5,
"stop_signal_received": True,
"reasoning_summary": "Market analysis indicates upward trend"
}
)
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime_test.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id=job_id,
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
with patch.object(executor, '_initialize_agent', return_value=mock_agent):
executor.execute()
# Verify reasoning logs
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("""
SELECT step_number, content
FROM reasoning_logs
WHERE job_id = ? AND date = ? AND model = ?
ORDER BY step_number
""", (job_id, "2025-01-16", "gpt-5"))
logs = cursor.fetchall()
assert len(logs) == 2
assert logs[0][0] == 1
conn.close()
@pytest.mark.unit
class TestModelDayExecutorCleanup:
"""Test cleanup operations."""
def test_cleanup_runtime_config_on_success(self, clean_db):
"""Should cleanup runtime config after successful execution."""
from api.model_day_executor import ModelDayExecutor
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
mock_agent = create_mock_agent(
positions={"CASH": 10000.0},
session_result={"success": True}
)
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id=job_id,
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
with patch.object(executor, '_initialize_agent', return_value=mock_agent):
executor.execute()
# Verify cleanup called
mock_instance.cleanup_runtime_config.assert_called_once_with("/tmp/runtime.json")
def test_cleanup_runtime_config_on_failure(self, clean_db):
"""Should cleanup runtime config even after failure."""
from api.model_day_executor import ModelDayExecutor
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id=job_id,
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
# Mock _initialize_agent to raise error
with patch.object(executor, '_initialize_agent', side_effect=Exception("Agent failed")):
executor.execute()
# Verify cleanup called even on failure
mock_instance.cleanup_runtime_config.assert_called_once_with("/tmp/runtime.json")
@pytest.mark.unit
class TestModelDayExecutorPositionCalculations:
"""Test position and P&L calculations."""
def test_calculates_portfolio_value(self, clean_db):
"""Should calculate total portfolio value."""
from api.model_day_executor import ModelDayExecutor
from api.job_manager import JobManager
from api.database import get_db_connection
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
mock_agent = create_mock_agent(
positions={"AAPL": 10, "CASH": 7500.0}, # 10 shares @ $250 = $2500
current_prices={"AAPL": 250.0},
session_result={"success": True}
)
with patch("api.model_day_executor.RuntimeConfigManager") as mock_runtime:
mock_instance = Mock()
mock_instance.create_runtime_config.return_value = "/tmp/runtime_test.json"
mock_runtime.return_value = mock_instance
executor = ModelDayExecutor(
job_id=job_id,
date="2025-01-16",
model_sig="gpt-5",
config_path="configs/test.json",
db_path=clean_db
)
with patch.object(executor, '_initialize_agent', return_value=mock_agent):
executor.execute()
# Verify portfolio value calculated correctly
conn = get_db_connection(clean_db)
cursor = conn.cursor()
cursor.execute("""
SELECT portfolio_value
FROM positions
WHERE job_id = ? AND date = ? AND model = ?
""", (job_id, "2025-01-16", "gpt-5"))
row = cursor.fetchone()
assert row is not None
# Portfolio value should be 2500 (stocks) + 7500 (cash) = 10000
assert row[0] == 10000.0
conn.close()
# Coverage target: 90%+ for api/model_day_executor.py

381
tests/unit/test_models.py Normal file
View File

@@ -0,0 +1,381 @@
"""
Unit tests for api/models.py - Pydantic data models.
Coverage target: 90%+
Tests verify:
- Request model validation
- Response model serialization
- Field constraints and types
- Optional vs required fields
"""
import pytest
from pydantic import ValidationError
from datetime import datetime
@pytest.mark.unit
class TestTriggerSimulationRequest:
"""Test TriggerSimulationRequest model."""
def test_valid_request_with_defaults(self):
"""Should accept request with default config_path."""
from api.models import TriggerSimulationRequest
request = TriggerSimulationRequest()
assert request.config_path == "configs/default_config.json"
def test_valid_request_with_custom_path(self):
"""Should accept request with custom config_path."""
from api.models import TriggerSimulationRequest
request = TriggerSimulationRequest(config_path="configs/custom.json")
assert request.config_path == "configs/custom.json"
@pytest.mark.unit
class TestJobProgress:
"""Test JobProgress model."""
def test_valid_progress_minimal(self):
"""Should create progress with minimal fields."""
from api.models import JobProgress
progress = JobProgress(
total_model_days=4,
completed=2,
failed=0
)
assert progress.total_model_days == 4
assert progress.completed == 2
assert progress.failed == 0
assert progress.current is None
assert progress.details is None
def test_valid_progress_with_current(self):
"""Should include current model-day being executed."""
from api.models import JobProgress
progress = JobProgress(
total_model_days=4,
completed=1,
failed=0,
current={"date": "2025-01-16", "model": "gpt-5"}
)
assert progress.current == {"date": "2025-01-16", "model": "gpt-5"}
def test_valid_progress_with_details(self):
"""Should include detailed progress for all model-days."""
from api.models import JobProgress
details = [
{"date": "2025-01-16", "model": "gpt-5", "status": "completed", "duration_seconds": 45.2},
{"date": "2025-01-16", "model": "claude", "status": "running", "duration_seconds": None}
]
progress = JobProgress(
total_model_days=2,
completed=1,
failed=0,
details=details
)
assert len(progress.details) == 2
assert progress.details[0]["status"] == "completed"
@pytest.mark.unit
class TestTriggerSimulationResponse:
"""Test TriggerSimulationResponse model."""
def test_valid_response_accepted(self):
"""Should create accepted response."""
from api.models import TriggerSimulationResponse
response = TriggerSimulationResponse(
job_id="test-job-123",
status="accepted",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5"],
created_at="2025-01-20T14:30:00Z",
message="Job queued successfully"
)
assert response.job_id == "test-job-123"
assert response.status == "accepted"
assert len(response.date_range) == 2
assert response.progress is None
def test_valid_response_with_progress(self):
"""Should include progress for running jobs."""
from api.models import TriggerSimulationResponse, JobProgress
progress = JobProgress(
total_model_days=4,
completed=2,
failed=0
)
response = TriggerSimulationResponse(
job_id="test-job-123",
status="running",
date_range=["2025-01-16"],
models=["gpt-5"],
created_at="2025-01-20T14:30:00Z",
message="Simulation in progress",
progress=progress
)
assert response.progress is not None
assert response.progress.completed == 2
@pytest.mark.unit
class TestJobStatusResponse:
"""Test JobStatusResponse model."""
def test_valid_status_running(self):
"""Should create running status response."""
from api.models import JobStatusResponse, JobProgress
progress = JobProgress(
total_model_days=4,
completed=2,
failed=0,
current={"date": "2025-01-16", "model": "gpt-5"}
)
response = JobStatusResponse(
job_id="test-job-123",
status="running",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5", "claude"],
progress=progress,
created_at="2025-01-20T14:30:00Z"
)
assert response.status == "running"
assert response.completed_at is None
assert response.total_duration_seconds is None
def test_valid_status_completed(self):
"""Should create completed status response."""
from api.models import JobStatusResponse, JobProgress
progress = JobProgress(
total_model_days=4,
completed=4,
failed=0
)
response = JobStatusResponse(
job_id="test-job-123",
status="completed",
date_range=["2025-01-16"],
models=["gpt-5"],
progress=progress,
created_at="2025-01-20T14:30:00Z",
completed_at="2025-01-20T14:35:00Z",
total_duration_seconds=300.5
)
assert response.status == "completed"
assert response.completed_at == "2025-01-20T14:35:00Z"
assert response.total_duration_seconds == 300.5
@pytest.mark.unit
class TestDailyPnL:
"""Test DailyPnL model."""
def test_valid_pnl(self):
"""Should create P&L with all fields."""
from api.models import DailyPnL
pnl = DailyPnL(
profit=150.50,
return_pct=1.51,
portfolio_value=10150.50
)
assert pnl.profit == 150.50
assert pnl.return_pct == 1.51
assert pnl.portfolio_value == 10150.50
@pytest.mark.unit
class TestTrade:
"""Test Trade model."""
def test_valid_trade_buy(self):
"""Should create buy trade."""
from api.models import Trade
trade = Trade(
id=1,
action="buy",
symbol="AAPL",
amount=10,
price=255.88,
total=2558.80
)
assert trade.action == "buy"
assert trade.symbol == "AAPL"
assert trade.amount == 10
def test_valid_trade_sell(self):
"""Should create sell trade."""
from api.models import Trade
trade = Trade(
id=2,
action="sell",
symbol="MSFT",
amount=5
)
assert trade.action == "sell"
assert trade.price is None # Optional
assert trade.total is None # Optional
@pytest.mark.unit
class TestAIReasoning:
"""Test AIReasoning model."""
def test_valid_reasoning(self):
"""Should create reasoning summary."""
from api.models import AIReasoning
reasoning = AIReasoning(
total_steps=15,
stop_signal_received=True,
reasoning_summary="Market analysis shows...",
tool_usage={"search": 3, "get_price": 5, "trade": 1}
)
assert reasoning.total_steps == 15
assert reasoning.stop_signal_received is True
assert "search" in reasoning.tool_usage
@pytest.mark.unit
class TestModelResult:
"""Test ModelResult model."""
def test_valid_result_minimal(self):
"""Should create minimal result."""
from api.models import ModelResult, DailyPnL
pnl = DailyPnL(profit=150.0, return_pct=1.5, portfolio_value=10150.0)
result = ModelResult(
model="gpt-5",
positions={"AAPL": 10, "CASH": 7500.0},
daily_pnl=pnl
)
assert result.model == "gpt-5"
assert result.positions["AAPL"] == 10
assert result.trades is None
assert result.ai_reasoning is None
def test_valid_result_full(self):
"""Should create full result with all details."""
from api.models import ModelResult, DailyPnL, Trade, AIReasoning
pnl = DailyPnL(profit=150.0, return_pct=1.5, portfolio_value=10150.0)
trades = [Trade(id=1, action="buy", symbol="AAPL", amount=10)]
reasoning = AIReasoning(
total_steps=15,
stop_signal_received=True,
reasoning_summary="...",
tool_usage={"search": 3}
)
result = ModelResult(
model="gpt-5",
positions={"AAPL": 10, "CASH": 7500.0},
daily_pnl=pnl,
trades=trades,
ai_reasoning=reasoning,
log_file_path="data/agent_data/gpt-5/log/2025-01-16/log.jsonl"
)
assert result.trades is not None
assert len(result.trades) == 1
assert result.ai_reasoning is not None
@pytest.mark.unit
class TestResultsResponse:
"""Test ResultsResponse model."""
def test_valid_results_response(self):
"""Should create results response."""
from api.models import ResultsResponse, ModelResult, DailyPnL
pnl = DailyPnL(profit=150.0, return_pct=1.5, portfolio_value=10150.0)
model_result = ModelResult(
model="gpt-5",
positions={"AAPL": 10, "CASH": 7500.0},
daily_pnl=pnl
)
response = ResultsResponse(
date="2025-01-16",
results=[model_result]
)
assert response.date == "2025-01-16"
assert len(response.results) == 1
assert response.results[0].model == "gpt-5"
@pytest.mark.unit
class TestResultsQueryParams:
"""Test ResultsQueryParams model."""
def test_valid_params_minimal(self):
"""Should create params with minimal fields."""
from api.models import ResultsQueryParams
params = ResultsQueryParams(date="2025-01-16")
assert params.date == "2025-01-16"
assert params.model is None
assert params.detail == "minimal"
def test_valid_params_with_filters(self):
"""Should create params with all filters."""
from api.models import ResultsQueryParams
params = ResultsQueryParams(
date="2025-01-16",
model="gpt-5",
detail="full"
)
assert params.model == "gpt-5"
assert params.detail == "full"
def test_invalid_date_format(self):
"""Should reject invalid date format."""
from api.models import ResultsQueryParams
with pytest.raises(ValidationError):
ResultsQueryParams(date="2025/01/16") # Wrong format
def test_invalid_detail_value(self):
"""Should reject invalid detail value."""
from api.models import ResultsQueryParams
with pytest.raises(ValidationError):
ResultsQueryParams(date="2025-01-16", detail="invalid")
# Coverage target: 90%+ for api/models.py

View File

@@ -0,0 +1,210 @@
"""
Unit tests for api/runtime_manager.py - Runtime config isolation.
Coverage target: 85%+
Tests verify:
- Isolated runtime config file creation
- Config path uniqueness per model-day
- Cleanup operations
- File lifecycle management
"""
import pytest
import os
import json
from pathlib import Path
import tempfile
@pytest.mark.unit
class TestRuntimeConfigCreation:
"""Test runtime config file creation."""
def test_create_runtime_config(self):
"""Should create unique runtime config file."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
manager = RuntimeConfigManager(data_dir=temp_dir)
config_path = manager.create_runtime_config(
job_id="test-job-123",
model_sig="gpt-5",
date="2025-01-16"
)
# Verify file exists
assert os.path.exists(config_path)
# Verify file is in correct location
assert temp_dir in config_path
# Verify filename contains identifiers
assert "gpt-5" in config_path
assert "2025-01-16" in config_path
def test_create_runtime_config_contents(self):
"""Should initialize config with correct values."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
manager = RuntimeConfigManager(data_dir=temp_dir)
config_path = manager.create_runtime_config(
job_id="test-job-123",
model_sig="gpt-5",
date="2025-01-16"
)
# Read and verify contents
with open(config_path, 'r') as f:
config = json.load(f)
assert config["TODAY_DATE"] == "2025-01-16"
assert config["SIGNATURE"] == "gpt-5"
assert config["IF_TRADE"] is False
assert config["JOB_ID"] == "test-job-123"
def test_create_runtime_config_unique_paths(self):
"""Should create unique paths for different model-days."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
manager = RuntimeConfigManager(data_dir=temp_dir)
path1 = manager.create_runtime_config("job1", "gpt-5", "2025-01-16")
path2 = manager.create_runtime_config("job1", "claude", "2025-01-16")
path3 = manager.create_runtime_config("job1", "gpt-5", "2025-01-17")
# All paths should be different
assert path1 != path2
assert path1 != path3
assert path2 != path3
# All files should exist
assert os.path.exists(path1)
assert os.path.exists(path2)
assert os.path.exists(path3)
def test_create_runtime_config_creates_directory(self):
"""Should create data directory in __init__ if it doesn't exist."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = os.path.join(temp_dir, "data")
# Directory shouldn't exist yet
assert not os.path.exists(data_dir)
# Manager creates directory in __init__
manager = RuntimeConfigManager(data_dir=data_dir)
# Directory should be created by __init__
assert os.path.exists(data_dir)
config_path = manager.create_runtime_config("job1", "gpt-5", "2025-01-16")
# Config file should exist
assert os.path.exists(config_path)
@pytest.mark.unit
class TestRuntimeConfigCleanup:
"""Test runtime config cleanup operations."""
def test_cleanup_runtime_config(self):
"""Should delete runtime config file."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
manager = RuntimeConfigManager(data_dir=temp_dir)
config_path = manager.create_runtime_config("job1", "gpt-5", "2025-01-16")
assert os.path.exists(config_path)
# Cleanup
manager.cleanup_runtime_config(config_path)
# File should be deleted
assert not os.path.exists(config_path)
def test_cleanup_nonexistent_file(self):
"""Should handle cleanup of nonexistent file gracefully."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
manager = RuntimeConfigManager(data_dir=temp_dir)
# Should not raise error
manager.cleanup_runtime_config("/nonexistent/path.json")
def test_cleanup_all_runtime_configs(self):
"""Should cleanup all runtime config files."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
manager = RuntimeConfigManager(data_dir=temp_dir)
# Create multiple configs
path1 = manager.create_runtime_config("job1", "gpt-5", "2025-01-16")
path2 = manager.create_runtime_config("job1", "claude", "2025-01-16")
path3 = manager.create_runtime_config("job2", "gpt-5", "2025-01-17")
# Also create a non-runtime file (should not be deleted)
other_file = os.path.join(temp_dir, "other.json")
with open(other_file, 'w') as f:
json.dump({"test": "data"}, f)
# Cleanup all
count = manager.cleanup_all_runtime_configs()
# Runtime configs should be deleted
assert not os.path.exists(path1)
assert not os.path.exists(path2)
assert not os.path.exists(path3)
# Other file should still exist
assert os.path.exists(other_file)
# Should return count of deleted files
assert count == 3
def test_cleanup_all_empty_directory(self):
"""Should handle cleanup when no runtime configs exist."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
manager = RuntimeConfigManager(data_dir=temp_dir)
count = manager.cleanup_all_runtime_configs()
# Should return 0
assert count == 0
@pytest.mark.unit
class TestRuntimeConfigManager:
"""Test RuntimeConfigManager initialization."""
def test_init_with_default_path(self):
"""Should initialize with default data directory."""
from api.runtime_manager import RuntimeConfigManager
manager = RuntimeConfigManager()
assert manager.data_dir == Path("data")
def test_init_with_custom_path(self):
"""Should initialize with custom data directory."""
from api.runtime_manager import RuntimeConfigManager
with tempfile.TemporaryDirectory() as temp_dir:
custom_path = os.path.join(temp_dir, "custom", "path")
manager = RuntimeConfigManager(data_dir=custom_path)
assert manager.data_dir == Path(custom_path)
assert os.path.exists(custom_path) # Should create the directory
# Coverage target: 85%+ for api/runtime_manager.py

View File

@@ -0,0 +1,277 @@
"""
Unit tests for api/simulation_worker.py - Job orchestration.
Coverage target: 90%+
Tests verify:
- Worker initialization
- Job execution orchestration
- Date-sequential, model-parallel execution
- Error handling and partial completion
- Job status updates
"""
import pytest
from unittest.mock import Mock, patch, call
from datetime import datetime
@pytest.mark.unit
class TestSimulationWorkerInitialization:
"""Test SimulationWorker initialization."""
def test_init_with_job_id(self, clean_db):
"""Should initialize with job ID."""
from api.simulation_worker import SimulationWorker
worker = SimulationWorker(job_id="test-job-123", db_path=clean_db)
assert worker.job_id == "test-job-123"
assert worker.db_path == clean_db
@pytest.mark.unit
class TestSimulationWorkerExecution:
"""Test job execution orchestration."""
def test_run_executes_all_model_days(self, clean_db):
"""Should execute all model-day combinations."""
from api.simulation_worker import SimulationWorker
from api.job_manager import JobManager
# Create job with 2 dates and 2 models = 4 model-days
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5", "claude-3.7-sonnet"]
)
worker = SimulationWorker(job_id=job_id, db_path=clean_db)
# Mock ModelDayExecutor
with patch("api.simulation_worker.ModelDayExecutor") as mock_executor_class:
mock_executor = Mock()
mock_executor.execute.return_value = {"success": True}
mock_executor_class.return_value = mock_executor
worker.run()
# Should have created 4 executors (2 dates × 2 models)
assert mock_executor_class.call_count == 4
def test_run_date_sequential_execution(self, clean_db):
"""Should execute dates sequentially, models in parallel."""
from api.simulation_worker import SimulationWorker
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5", "claude-3.7-sonnet"]
)
worker = SimulationWorker(job_id=job_id, db_path=clean_db)
execution_order = []
def track_execution(job_id, date, model_sig, config_path, db_path):
executor = Mock()
execution_order.append((date, model_sig))
executor.execute.return_value = {"success": True}
return executor
with patch("api.simulation_worker.ModelDayExecutor", side_effect=track_execution):
worker.run()
# All 2025-01-16 executions should come before 2025-01-17
date_16_executions = [e for e in execution_order if e[0] == "2025-01-16"]
date_17_executions = [e for e in execution_order if e[0] == "2025-01-17"]
assert len(date_16_executions) == 2
assert len(date_17_executions) == 2
# Find last index of date 16 and first index of date 17
last_16_idx = max(i for i, e in enumerate(execution_order) if e[0] == "2025-01-16")
first_17_idx = min(i for i, e in enumerate(execution_order) if e[0] == "2025-01-17")
assert last_16_idx < first_17_idx
def test_run_updates_job_status_to_completed(self, clean_db):
"""Should update job status to completed on success."""
from api.simulation_worker import SimulationWorker
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
worker = SimulationWorker(job_id=job_id, db_path=clean_db)
with patch("api.simulation_worker.ModelDayExecutor") as mock_executor_class:
mock_executor = Mock()
mock_executor.execute.return_value = {"success": True}
mock_executor_class.return_value = mock_executor
worker.run()
# Check job status
job = manager.get_job(job_id)
assert job["status"] == "completed"
def test_run_handles_partial_failure(self, clean_db):
"""Should mark job as partial when some models fail."""
from api.simulation_worker import SimulationWorker
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5", "claude-3.7-sonnet"]
)
worker = SimulationWorker(job_id=job_id, db_path=clean_db)
call_count = 0
def mixed_results(*args, **kwargs):
nonlocal call_count
executor = Mock()
# First model succeeds, second fails
executor.execute.return_value = {"success": call_count == 0}
call_count += 1
return executor
with patch("api.simulation_worker.ModelDayExecutor", side_effect=mixed_results):
worker.run()
# Check job status
job = manager.get_job(job_id)
assert job["status"] == "partial"
@pytest.mark.unit
class TestSimulationWorkerErrorHandling:
"""Test error handling."""
def test_run_continues_on_single_model_failure(self, clean_db):
"""Should continue executing other models if one fails."""
from api.simulation_worker import SimulationWorker
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5", "claude-3.7-sonnet", "gemini"]
)
worker = SimulationWorker(job_id=job_id, db_path=clean_db)
execution_count = 0
def counting_executor(*args, **kwargs):
nonlocal execution_count
execution_count += 1
executor = Mock()
# Second model fails
if execution_count == 2:
executor.execute.return_value = {"success": False, "error": "Model failed"}
else:
executor.execute.return_value = {"success": True}
return executor
with patch("api.simulation_worker.ModelDayExecutor", side_effect=counting_executor):
worker.run()
# All 3 models should have been executed
assert execution_count == 3
def test_run_updates_job_to_failed_on_exception(self, clean_db):
"""Should update job to failed on unexpected exception."""
from api.simulation_worker import SimulationWorker
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5"]
)
worker = SimulationWorker(job_id=job_id, db_path=clean_db)
with patch("api.simulation_worker.ModelDayExecutor", side_effect=Exception("Unexpected error")):
worker.run()
# Check job status
job = manager.get_job(job_id)
assert job["status"] == "failed"
assert "Unexpected error" in job["error"]
@pytest.mark.unit
class TestSimulationWorkerConcurrency:
"""Test concurrent execution handling."""
def test_run_with_threading(self, clean_db):
"""Should use threading for parallel model execution."""
from api.simulation_worker import SimulationWorker
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16"],
models=["gpt-5", "claude-3.7-sonnet"]
)
worker = SimulationWorker(job_id=job_id, db_path=clean_db)
with patch("api.simulation_worker.ModelDayExecutor") as mock_executor_class:
mock_executor = Mock()
mock_executor.execute.return_value = {"success": True}
mock_executor_class.return_value = mock_executor
# Mock ThreadPoolExecutor to verify it's being used
with patch("api.simulation_worker.ThreadPoolExecutor") as mock_pool:
mock_pool_instance = Mock()
mock_pool.return_value.__enter__.return_value = mock_pool_instance
mock_pool_instance.submit.return_value = Mock(result=lambda: {"success": True})
worker.run()
# Verify ThreadPoolExecutor was used
mock_pool.assert_called_once()
@pytest.mark.unit
class TestSimulationWorkerJobRetrieval:
"""Test job information retrieval."""
def test_get_job_info(self, clean_db):
"""Should retrieve job information."""
from api.simulation_worker import SimulationWorker
from api.job_manager import JobManager
manager = JobManager(db_path=clean_db)
job_id = manager.create_job(
config_path="configs/test.json",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5"]
)
worker = SimulationWorker(job_id=job_id, db_path=clean_db)
job_info = worker.get_job_info()
assert job_info["job_id"] == job_id
assert job_info["date_range"] == ["2025-01-16", "2025-01-17"]
assert job_info["models"] == ["gpt-5"]
# Coverage target: 90%+ for api/simulation_worker.py