Files
AI-Trader/tests/unit/test_job_manager.py
Bill fb9583b374 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>
2025-10-31 11:47:10 -04:00

423 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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