""" Unit tests for JobService using in-memory MongoDB. Tests the business logic operations with real MongoDB operations using mongomock for better integration testing. """ import pytest from bson import ObjectId from mongomock.mongo_client import MongoClient from app.exceptions.job_exceptions import InvalidStatusTransitionError from app.models.job import ProcessingStatus from app.models.types import PyObjectId from app.services.job_service import JobService @pytest.fixture def in_memory_database(): """Create an in-memory database for testing.""" client = MongoClient() return client.test_database @pytest.fixture def job_service(in_memory_database): """Create JobService with in-memory repositories.""" service = JobService(in_memory_database).initialize() return service @pytest.fixture def sample_document_id(): """Sample file ObjectId.""" return PyObjectId() @pytest.fixture def sample_task_id(): """Sample Celery task UUID.""" return "550e8400-e29b-41d4-a716-446655440000" class TestCreateJob: """Tests for create_job method.""" def test_i_can_create_job_with_task_id( self, job_service, sample_document_id, sample_task_id ): """Test creating job with task ID.""" # Execute result = job_service.create_job(sample_document_id, sample_task_id) # Verify job creation assert result is not None assert result.document_id == sample_document_id assert result.task_id == sample_task_id assert result.status == ProcessingStatus.PENDING assert result.created_at is not None assert result.started_at is None assert result.error_message is None # Verify job exists in database job_in_db = job_service.get_job_by_id(result.id) assert job_in_db is not None assert job_in_db.id == result.id assert job_in_db.document_id == sample_document_id assert job_in_db.task_id == sample_task_id assert job_in_db.status == ProcessingStatus.PENDING def test_i_can_create_job_without_task_id( self, job_service, sample_document_id ): """Test creating job without task ID.""" # Execute result = job_service.create_job(sample_document_id) # Verify job creation assert result is not None assert result.document_id == sample_document_id assert result.task_id is None assert result.status == ProcessingStatus.PENDING assert result.created_at is not None assert result.started_at is None assert result.error_message is None class TestGetJobMethods: """Tests for job retrieval methods.""" def test_i_can_get_job_by_id( self, job_service, sample_document_id, sample_task_id ): """Test retrieving job by ID.""" # Create a job first created_job = job_service.create_job(sample_document_id, sample_task_id) # Execute result = job_service.get_job_by_id(created_job.id) # Verify assert result is not None assert result.id == created_job.id assert result.document_id == created_job.document_id assert result.task_id == created_job.task_id assert result.status == created_job.status def test_i_can_get_jobs_by_status( self, job_service, sample_document_id ): """Test retrieving jobs by status.""" # Create jobs with different statuses pending_job = job_service.create_job(sample_document_id, "pending-task") processing_job = job_service.create_job(ObjectId(), "processing-task") job_service.mark_job_as_started(processing_job.id) completed_job = job_service.create_job(ObjectId(), "completed-task") job_service.mark_job_as_started(completed_job.id) job_service.mark_job_as_completed(completed_job.id) # Execute - get pending jobs pending_results = job_service.get_jobs_by_status(ProcessingStatus.PENDING) # Verify assert len(pending_results) == 1 assert pending_results[0].id == pending_job.id assert pending_results[0].status == ProcessingStatus.PENDING # Execute - get processing jobs processing_results = job_service.get_jobs_by_status(ProcessingStatus.PROCESSING) assert len(processing_results) == 1 assert processing_results[0].status == ProcessingStatus.PROCESSING # Execute - get completed jobs completed_results = job_service.get_jobs_by_status(ProcessingStatus.COMPLETED) assert len(completed_results) == 1 assert completed_results[0].status == ProcessingStatus.COMPLETED class TestUpdateStatus: """Tests for mark_job_as_started method.""" def test_i_can_mark_pending_job_as_started( self, job_service, sample_document_id, sample_task_id ): """Test marking pending job as started (PENDING → PROCESSING).""" # Create a pending job created_job = job_service.create_job(sample_document_id, sample_task_id) assert created_job.status == ProcessingStatus.PENDING # Execute result = job_service.mark_job_as_started(created_job.id) # Verify status transition assert result is not None assert result.id == created_job.id assert result.status == ProcessingStatus.PROCESSING # Verify in database updated_job = job_service.get_job_by_id(created_job.id) assert updated_job.status == ProcessingStatus.PROCESSING def test_i_cannot_mark_processing_job_as_started( self, job_service, sample_document_id, sample_task_id ): """Test that processing job cannot be marked as started.""" # Create and start a job created_job = job_service.create_job(sample_document_id, sample_task_id) job_service.mark_job_as_started(created_job.id) # Try to start it again with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_started(created_job.id) # Verify exception details assert exc_info.value.current_status == ProcessingStatus.PROCESSING assert exc_info.value.target_status == ProcessingStatus.PROCESSING def test_i_cannot_mark_completed_job_as_started( self, job_service, sample_document_id, sample_task_id ): """Test that completed job cannot be marked as started.""" # Create, start, and complete a job created_job = job_service.create_job(sample_document_id, sample_task_id) job_service.mark_job_as_started(created_job.id) job_service.mark_job_as_completed(created_job.id) # Try to start it again with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_started(created_job.id) # Verify exception details assert exc_info.value.current_status == ProcessingStatus.COMPLETED assert exc_info.value.target_status == ProcessingStatus.PROCESSING def test_i_cannot_mark_failed_job_as_started( self, job_service, sample_document_id, sample_task_id ): """Test that failed job cannot be marked as started.""" # Create, start, and fail a job created_job = job_service.create_job(sample_document_id, sample_task_id) job_service.mark_job_as_started(created_job.id) job_service.mark_job_as_failed(created_job.id, "Test error") # Try to start it again with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_started(created_job.id) # Verify exception details assert exc_info.value.current_status == ProcessingStatus.FAILED assert exc_info.value.target_status == ProcessingStatus.PROCESSING def test_i_can_mark_processing_job_as_completed( self, job_service, sample_document_id, sample_task_id ): """Test marking processing job as completed (PROCESSING → COMPLETED).""" # Create and start a job created_job = job_service.create_job(sample_document_id, sample_task_id) started_job = job_service.mark_job_as_started(created_job.id) # Execute result = job_service.mark_job_as_completed(created_job.id) # Verify status transition assert result is not None assert result.id == created_job.id assert result.status == ProcessingStatus.COMPLETED # Verify in database updated_job = job_service.get_job_by_id(created_job.id) assert updated_job.status == ProcessingStatus.COMPLETED def test_i_cannot_mark_pending_job_as_completed( self, job_service, sample_document_id, sample_task_id ): """Test that pending job cannot be marked as completed.""" # Create a pending job created_job = job_service.create_job(sample_document_id, sample_task_id) # Try to complete it directly with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_completed(created_job.id) # Verify exception details assert exc_info.value.current_status == ProcessingStatus.PENDING assert exc_info.value.target_status == ProcessingStatus.COMPLETED def test_i_cannot_mark_completed_job_as_completed( self, job_service, sample_document_id, sample_task_id ): """Test that completed job cannot be marked as completed again.""" # Create, start, and complete a job created_job = job_service.create_job(sample_document_id, sample_task_id) job_service.mark_job_as_started(created_job.id) job_service.mark_job_as_completed(created_job.id) # Try to complete it again with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_completed(created_job.id) # Verify exception details assert exc_info.value.current_status == ProcessingStatus.COMPLETED assert exc_info.value.target_status == ProcessingStatus.COMPLETED def test_i_cannot_mark_failed_job_as_completed( self, job_service, sample_document_id, sample_task_id ): """Test that failed job cannot be marked as completed.""" # Create, start, and fail a job created_job = job_service.create_job(sample_document_id, sample_task_id) job_service.mark_job_as_started(created_job.id) job_service.mark_job_as_failed(created_job.id, "Test error") # Try to complete it with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_completed(created_job.id) # Verify exception details assert exc_info.value.current_status == ProcessingStatus.FAILED assert exc_info.value.target_status == ProcessingStatus.COMPLETED def test_i_can_mark_processing_job_as_failed_with_error_message( self, job_service, sample_document_id, sample_task_id ): """Test marking processing job as failed with error message.""" # Create and start a job created_job = job_service.create_job(sample_document_id, sample_task_id) started_job = job_service.mark_job_as_started(created_job.id) error_message = "Processing failed due to invalid file format" # Execute result = job_service.mark_job_as_failed(created_job.id, error_message) # Verify status transition assert result is not None assert result.id == created_job.id assert result.status == ProcessingStatus.FAILED assert result.error_message == error_message # Verify in database updated_job = job_service.get_job_by_id(created_job.id) assert updated_job.status == ProcessingStatus.FAILED assert updated_job.error_message == error_message def test_i_can_mark_processing_job_as_failed_without_error_message( self, job_service, sample_document_id, sample_task_id ): """Test marking processing job as failed without error message.""" # Create and start a job created_job = job_service.create_job(sample_document_id, sample_task_id) job_service.mark_job_as_started(created_job.id) # Execute without error message result = job_service.mark_job_as_failed(created_job.id) # Verify status transition assert result is not None assert result.status == ProcessingStatus.FAILED assert result.error_message is None def test_i_cannot_mark_pending_job_as_failed( self, job_service, sample_document_id, sample_task_id ): """Test that pending job cannot be marked as failed.""" # Create a pending job created_job = job_service.create_job(sample_document_id, sample_task_id) # Try to fail it directly with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_failed(created_job.id, "Test error") # Verify exception details assert exc_info.value.current_status == ProcessingStatus.PENDING assert exc_info.value.target_status == ProcessingStatus.FAILED def test_i_cannot_mark_completed_job_as_failed( self, job_service, sample_document_id, sample_task_id ): """Test that completed job cannot be marked as failed.""" # Create, start, and complete a job created_job = job_service.create_job(sample_document_id, sample_task_id) job_service.mark_job_as_started(created_job.id) job_service.mark_job_as_completed(created_job.id) # Try to fail it with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_failed(created_job.id, "Test error") # Verify exception details assert exc_info.value.current_status == ProcessingStatus.COMPLETED assert exc_info.value.target_status == ProcessingStatus.FAILED def test_i_cannot_mark_failed_job_as_failed( self, job_service, sample_document_id, sample_task_id ): """Test that failed job cannot be marked as failed again.""" # Create, start, and fail a job created_job = job_service.create_job(sample_document_id, sample_task_id) job_service.mark_job_as_started(created_job.id) job_service.mark_job_as_failed(created_job.id, "First error") # Try to fail it again with pytest.raises(InvalidStatusTransitionError) as exc_info: job_service.mark_job_as_failed(created_job.id, "Second error") # Verify exception details assert exc_info.value.current_status == ProcessingStatus.FAILED assert exc_info.value.target_status == ProcessingStatus.FAILED class TestDeleteJob: """Tests for delete_job method.""" def test_i_can_delete_existing_job( self, job_service, sample_document_id, sample_task_id ): """Test deleting an existing job.""" # Create a job created_job = job_service.create_job(sample_document_id, sample_task_id) # Verify job exists job_before_delete = job_service.get_job_by_id(created_job.id) assert job_before_delete is not None # Execute deletion result = job_service.delete_job(created_job.id) # Verify deletion assert result is True # Verify job no longer exists deleted_job = job_service.get_job_by_id(created_job.id) assert deleted_job is None def test_i_cannot_delete_nonexistent_job( self, job_service ): """Test deleting a nonexistent job returns False.""" # Execute deletion with random ObjectId result = job_service.delete_job(ObjectId()) # Verify assert result is False class TestStatusTransitionValidation: """Tests for status transition validation across different scenarios.""" def test_valid_job_lifecycle_flow( self, job_service, sample_document_id, sample_task_id ): """Test complete valid job lifecycle: PENDING → PROCESSING → COMPLETED.""" # Create job (PENDING) job = job_service.create_job(sample_document_id, sample_task_id) assert job.status == ProcessingStatus.PENDING # Start job (PENDING → PROCESSING) started_job = job_service.mark_job_as_started(job.id) assert started_job.status == ProcessingStatus.PROCESSING # Complete job (PROCESSING → COMPLETED) completed_job = job_service.mark_job_as_completed(job.id) assert completed_job.status == ProcessingStatus.COMPLETED def test_valid_job_failure_flow( self, job_service, sample_document_id, sample_task_id ): """Test valid job failure: PENDING → PROCESSING → FAILED.""" # Create job (PENDING) job = job_service.create_job(sample_document_id, sample_task_id) assert job.status == ProcessingStatus.PENDING # Start job (PENDING → PROCESSING) started_job = job_service.mark_job_as_started(job.id) assert started_job.status == ProcessingStatus.PROCESSING # Fail job (PROCESSING → FAILED) failed_job = job_service.mark_job_as_failed(job.id, "Test failure") assert failed_job.status == ProcessingStatus.FAILED assert failed_job.error_message == "Test failure" def test_job_operations_with_empty_database( self, job_service ): """Test job operations when database is empty.""" # Try to get nonexistent job result = job_service.get_job_by_id(ObjectId()) assert result is None # Try to get jobs by status when none exist pending_jobs = job_service.get_jobs_by_status(ProcessingStatus.PENDING) assert pending_jobs == [] # Try to delete nonexistent job delete_result = job_service.delete_job(ObjectId()) assert delete_result is False