Files
office_translator/tests/test_progress_tracking.py
Sepehr Ramezani 26bd096a06 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>
2026-04-25 15:01:47 +02:00

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