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>
438 lines
15 KiB
Python
438 lines
15 KiB
Python
"""
|
|
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
|