feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
437
tests/test_progress_tracking.py
Normal file
437
tests/test_progress_tracking.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
Unit tests for progress tracking functionality (Story 2.11).
|
||||
|
||||
Tests cover:
|
||||
- ProgressTracker class
|
||||
- Job data model extensions
|
||||
- Status endpoint with progress fields
|
||||
- Status transitions (queued -> processing -> completed/failed)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
|
||||
class TestProgressTracker:
|
||||
"""Tests for ProgressTracker class."""
|
||||
|
||||
def test_progress_tracker_update_sets_percent_and_step(self):
|
||||
"""Test that update() sets progress_percent and current_step correctly."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {}
|
||||
tracker = ProgressTracker("job_123", storage)
|
||||
storage["job_123"] = {"id": "job_123"}
|
||||
|
||||
tracker.update(50, "Translating sheet 2/4")
|
||||
|
||||
assert storage["job_123"]["progress_percent"] == 50
|
||||
assert storage["job_123"]["current_step"] == "Translating sheet 2/4"
|
||||
|
||||
def test_progress_tracker_update_clamps_percent_to_0_100(self):
|
||||
"""Test that update() clamps progress_percent between 0 and 100."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {}
|
||||
tracker = ProgressTracker("job_123", storage)
|
||||
storage["job_123"] = {"id": "job_123"}
|
||||
|
||||
tracker.update(-10, "Testing negative")
|
||||
assert storage["job_123"]["progress_percent"] == 0
|
||||
|
||||
tracker.update(150, "Testing overflow")
|
||||
assert storage["job_123"]["progress_percent"] == 100
|
||||
|
||||
def test_progress_tracker_update_item_calculates_percent(self):
|
||||
"""Test that update_item() calculates percent from current/total."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {}
|
||||
tracker = ProgressTracker("job_123", storage)
|
||||
storage["job_123"] = {"id": "job_123"}
|
||||
|
||||
tracker.update_item(3, 10, "Translating slide")
|
||||
|
||||
assert storage["job_123"]["progress_percent"] == 30
|
||||
assert storage["job_123"]["current_step"] == "Translating slide 3/10"
|
||||
|
||||
def test_progress_tracker_update_item_handles_zero_total(self):
|
||||
"""Test that update_item() handles zero total gracefully."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {}
|
||||
tracker = ProgressTracker("job_123", storage)
|
||||
storage["job_123"] = {"id": "job_123"}
|
||||
|
||||
tracker.update_item(0, 0, "Processing")
|
||||
|
||||
assert storage["job_123"]["progress_percent"] == 0
|
||||
assert storage["job_123"]["current_step"] == "Processing 0/0"
|
||||
|
||||
def test_progress_tracker_sets_total_and_processed_items(self):
|
||||
"""Test that update_item() sets total_items and processed_items."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {}
|
||||
tracker = ProgressTracker("job_123", storage)
|
||||
storage["job_123"] = {"id": "job_123"}
|
||||
|
||||
tracker.update_item(5, 20, "Processing")
|
||||
|
||||
assert storage["job_123"]["processed_items"] == 5
|
||||
assert storage["job_123"]["total_items"] == 20
|
||||
|
||||
def test_progress_tracker_no_op_for_missing_job(self):
|
||||
"""Test that update() does nothing if job doesn't exist."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {}
|
||||
tracker = ProgressTracker("job_nonexistent", storage)
|
||||
|
||||
tracker.update(50, "Should not crash")
|
||||
|
||||
assert "job_nonexistent" not in storage
|
||||
|
||||
|
||||
class TestJobDataModelExtensions:
|
||||
"""Tests for extended job data model fields - using mocked storage."""
|
||||
|
||||
def test_job_creation_includes_progress_fields(self):
|
||||
"""Test that new jobs include all progress-related fields."""
|
||||
storage = {
|
||||
"job_id": {
|
||||
"id": "tr_test_123",
|
||||
"status": "queued",
|
||||
"progress_percent": 0,
|
||||
"current_step": "Initializing",
|
||||
"total_items": 0,
|
||||
"processed_items": 0,
|
||||
"error_message": None,
|
||||
}
|
||||
}
|
||||
|
||||
job = storage["job_id"]
|
||||
assert job["progress_percent"] == 0
|
||||
assert job["current_step"] == "Initializing"
|
||||
assert job["total_items"] == 0
|
||||
assert job["processed_items"] == 0
|
||||
assert job["error_message"] is None
|
||||
|
||||
def test_job_status_transitions_from_queued_to_processing(self):
|
||||
"""Test job status transitions from queued to processing."""
|
||||
storage = {
|
||||
"job_id": {
|
||||
"id": "tr_test_456",
|
||||
"status": "queued",
|
||||
"progress_percent": 0,
|
||||
}
|
||||
}
|
||||
|
||||
storage["job_id"]["status"] = "processing"
|
||||
storage["job_id"]["progress_percent"] = 10
|
||||
|
||||
assert storage["job_id"]["status"] == "processing"
|
||||
assert storage["job_id"]["progress_percent"] == 10
|
||||
|
||||
def test_job_failed_status_includes_error_message(self):
|
||||
"""Test that failed jobs include error_message."""
|
||||
storage = {
|
||||
"job_id": {
|
||||
"id": "tr_test_789",
|
||||
"status": "failed",
|
||||
"progress_percent": 30,
|
||||
"current_step": "Error during translation",
|
||||
"error_message": "Provider unavailable: timeout after 30s",
|
||||
}
|
||||
}
|
||||
|
||||
job = storage["job_id"]
|
||||
assert job["status"] == "failed"
|
||||
assert job["error_message"] == "Provider unavailable: timeout after 30s"
|
||||
|
||||
|
||||
class TestTranslationStatusEndpoint:
|
||||
"""Tests for GET /api/v1/translations/{job_id} endpoint - using mocks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_endpoint_returns_progress_fields(self):
|
||||
"""Test that status endpoint returns all progress fields."""
|
||||
mock_jobs = {
|
||||
"tr_status_test_1": {
|
||||
"id": "tr_status_test_1",
|
||||
"status": "processing",
|
||||
"progress_percent": 45,
|
||||
"current_step": "Translating sheet 2/5",
|
||||
"file_name": "report.xlsx",
|
||||
"source_lang": "en",
|
||||
"target_lang": "fr",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"total_items": 5,
|
||||
"processed_items": 2,
|
||||
}
|
||||
}
|
||||
|
||||
job = mock_jobs["tr_status_test_1"]
|
||||
|
||||
response_data = {
|
||||
"id": job["id"],
|
||||
"status": job["status"],
|
||||
"progress_percent": job.get("progress_percent", 0),
|
||||
"current_step": job.get("current_step", "Unknown"),
|
||||
}
|
||||
|
||||
assert response_data["id"] == "tr_status_test_1"
|
||||
assert response_data["status"] == "processing"
|
||||
assert response_data["progress_percent"] == 45
|
||||
assert response_data["current_step"] == "Translating sheet 2/5"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_endpoint_returns_404_for_nonexistent_job(self):
|
||||
"""Test that status endpoint returns 404 for non-existent job."""
|
||||
mock_jobs = {}
|
||||
|
||||
job = mock_jobs.get("tr_nonexistent")
|
||||
assert job is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_endpoint_includes_error_message_for_failed(self):
|
||||
"""Test that failed status includes error_message field."""
|
||||
mock_jobs = {
|
||||
"tr_failed_test_1": {
|
||||
"id": "tr_failed_test_1",
|
||||
"status": "failed",
|
||||
"progress_percent": 30,
|
||||
"current_step": "Error during translation",
|
||||
"error_message": "Provider unavailable: timeout",
|
||||
"file_name": "report.xlsx",
|
||||
"source_lang": "en",
|
||||
"target_lang": "fr",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"failed_at": "2024-01-15T10:30:15Z",
|
||||
}
|
||||
}
|
||||
|
||||
job = mock_jobs["tr_failed_test_1"]
|
||||
response_data = {
|
||||
"status": job["status"],
|
||||
"error_message": job.get("error_message"),
|
||||
}
|
||||
|
||||
assert response_data["status"] == "failed"
|
||||
assert response_data["error_message"] == "Provider unavailable: timeout"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_endpoint_returns_completed_at_for_completed_jobs(self):
|
||||
"""Test that completed jobs include completed_at timestamp."""
|
||||
mock_jobs = {
|
||||
"tr_completed_test_1": {
|
||||
"id": "tr_completed_test_1",
|
||||
"status": "completed",
|
||||
"progress_percent": 100,
|
||||
"current_step": "Translation complete",
|
||||
"file_name": "report.xlsx",
|
||||
"source_lang": "en",
|
||||
"target_lang": "fr",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"completed_at": "2024-01-15T10:30:45Z",
|
||||
}
|
||||
}
|
||||
|
||||
job = mock_jobs["tr_completed_test_1"]
|
||||
assert job["status"] == "completed"
|
||||
assert "completed_at" in job
|
||||
|
||||
|
||||
class TestLLMProgressDetails:
|
||||
"""Tests for LLM mode progress details."""
|
||||
|
||||
def test_progress_shows_slide_format(self):
|
||||
"""Test that progress shows 'Translating slide X/Y' format for PPTX."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {}
|
||||
tracker = ProgressTracker("job_pptx", storage)
|
||||
storage["job_pptx"] = {"id": "job_pptx"}
|
||||
|
||||
tracker.update_item(3, 10, "Translating slide")
|
||||
|
||||
assert "Translating slide 3/10" == storage["job_pptx"]["current_step"]
|
||||
|
||||
def test_progress_shows_sheet_format(self):
|
||||
"""Test that progress shows 'Translating sheet X/Y' format for Excel."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {}
|
||||
tracker = ProgressTracker("job_excel", storage)
|
||||
storage["job_excel"] = {"id": "job_excel"}
|
||||
|
||||
tracker.update_item(2, 5, "Translating sheet")
|
||||
|
||||
assert "Translating sheet 2/5" == storage["job_excel"]["current_step"]
|
||||
|
||||
|
||||
class TestStatusTransitions:
|
||||
"""Tests for job status transitions."""
|
||||
|
||||
def test_status_queued_to_processing(self):
|
||||
"""Test transition from queued to processing."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {"job_1": {"id": "job_1", "status": "queued", "progress_percent": 0}}
|
||||
tracker = ProgressTracker("job_1", storage)
|
||||
|
||||
storage["job_1"]["status"] = "processing"
|
||||
tracker.update(10, "Starting translation")
|
||||
|
||||
assert storage["job_1"]["status"] == "processing"
|
||||
assert storage["job_1"]["progress_percent"] == 10
|
||||
|
||||
def test_status_processing_to_completed(self):
|
||||
"""Test transition from processing to completed."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
|
||||
storage = {
|
||||
"job_2": {"id": "job_2", "status": "processing", "progress_percent": 90}
|
||||
}
|
||||
tracker = ProgressTracker("job_2", storage)
|
||||
|
||||
storage["job_2"]["status"] = "completed"
|
||||
tracker.update(100, "Translation complete")
|
||||
|
||||
assert storage["job_2"]["status"] == "completed"
|
||||
assert storage["job_2"]["progress_percent"] == 100
|
||||
|
||||
def test_status_processing_to_failed(self):
|
||||
"""Test transition from processing to failed with error message."""
|
||||
storage = {
|
||||
"job_3": {
|
||||
"id": "job_3",
|
||||
"status": "processing",
|
||||
"progress_percent": 30,
|
||||
}
|
||||
}
|
||||
|
||||
storage["job_3"]["status"] = "failed"
|
||||
storage["job_3"]["error_message"] = "Provider timeout"
|
||||
|
||||
assert storage["job_3"]["status"] == "failed"
|
||||
assert storage["job_3"]["error_message"] == "Provider timeout"
|
||||
|
||||
def test_progress_tracker_throttle_is_thread_safe(self):
|
||||
"""Test that throttling check happens inside the lock to prevent race conditions."""
|
||||
from services.progress_tracker import ProgressTracker
|
||||
import threading
|
||||
|
||||
storage = {
|
||||
"job_race": {"id": "job_race", "progress_percent": 0, "current_step": ""}
|
||||
}
|
||||
tracker = ProgressTracker("job_race", storage)
|
||||
|
||||
results = []
|
||||
|
||||
def update_many():
|
||||
for i in range(10):
|
||||
tracker.update(i * 10, f"Step {i}")
|
||||
results.append(storage["job_race"]["progress_percent"])
|
||||
|
||||
threads = [threading.Thread(target=update_many) for _ in range(3)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert storage["job_race"]["progress_percent"] >= 0
|
||||
assert storage["job_race"]["progress_percent"] <= 100
|
||||
|
||||
|
||||
class TestEstimatedRemainingSeconds:
|
||||
"""Tests for estimated_remaining_seconds calculation."""
|
||||
|
||||
def test_estimated_remaining_calculated_for_processing_jobs(self):
|
||||
"""Test that estimated_remaining_seconds is calculated for processing jobs."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
created_at = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
|
||||
mock_jobs = {
|
||||
"tr_est_test": {
|
||||
"id": "tr_est_test",
|
||||
"status": "processing",
|
||||
"progress_percent": 50,
|
||||
"current_step": "Translating",
|
||||
"created_at": created_at,
|
||||
}
|
||||
}
|
||||
|
||||
job = mock_jobs["tr_est_test"]
|
||||
progress_percent = job.get("progress_percent", 0)
|
||||
estimated_remaining = None
|
||||
if progress_percent > 0:
|
||||
created_at_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
elapsed = (datetime.now(timezone.utc) - created_at_dt).total_seconds()
|
||||
total_estimated = elapsed / (progress_percent / 100)
|
||||
estimated_remaining = max(1, int(total_estimated - elapsed))
|
||||
|
||||
assert estimated_remaining is not None
|
||||
assert estimated_remaining >= 1
|
||||
|
||||
def test_estimated_remaining_none_for_completed_jobs(self):
|
||||
"""Test that estimated_remaining_seconds is None for completed jobs."""
|
||||
mock_jobs = {
|
||||
"tr_completed": {
|
||||
"id": "tr_completed",
|
||||
"status": "completed",
|
||||
"progress_percent": 100,
|
||||
}
|
||||
}
|
||||
|
||||
job = mock_jobs["tr_completed"]
|
||||
estimated_remaining = None
|
||||
if job["status"] == "processing" and job.get("progress_percent", 0) > 0:
|
||||
estimated_remaining = 0
|
||||
|
||||
assert estimated_remaining is None
|
||||
|
||||
|
||||
class TestJobCleanup:
|
||||
"""Tests for job cleanup mechanism."""
|
||||
|
||||
def test_cleanup_removes_old_completed_jobs(self):
|
||||
"""Test that completed jobs older than TTL are cleaned up."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import time as time_module
|
||||
|
||||
old_completed_at = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
||||
mock_jobs = {
|
||||
"tr_old_completed": {
|
||||
"id": "tr_old_completed",
|
||||
"status": "completed",
|
||||
"completed_at": old_completed_at,
|
||||
},
|
||||
"tr_recent_completed": {
|
||||
"id": "tr_recent_completed",
|
||||
"status": "completed",
|
||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
ttl_seconds = 3600
|
||||
expired = []
|
||||
current_time = time_module.time()
|
||||
for job_id, job in mock_jobs.items():
|
||||
if job.get("status") in ("completed", "failed"):
|
||||
completed_at = job.get("completed_at") or job.get("failed_at")
|
||||
if completed_at:
|
||||
try:
|
||||
completed_ts = datetime.fromisoformat(
|
||||
completed_at.replace("Z", "+00:00")
|
||||
).timestamp()
|
||||
if current_time - completed_ts > ttl_seconds:
|
||||
expired.append(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert "tr_old_completed" in expired
|
||||
assert "tr_recent_completed" not in expired
|
||||
Reference in New Issue
Block a user