""" 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 import pytest_asyncio from bson import ObjectId from mongomock_motor import AsyncMongoMockClient 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_asyncio.fixture async def in_memory_database(): """Create an in-memory database for testing.""" client = AsyncMongoMockClient() return client.test_database @pytest_asyncio.fixture async def job_service(in_memory_database): """Create JobService with in-memory repositories.""" service = await 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.""" @pytest.mark.asyncio async 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 = await 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 = await 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 @pytest.mark.asyncio async def test_i_can_create_job_without_task_id( self, job_service, sample_document_id ): """Test creating job without task ID.""" # Execute result = await 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.""" @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) # Execute result = await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, "pending-task") processing_job = await job_service.create_job(ObjectId(), "processing-task") await job_service.mark_job_as_started(processing_job.id) completed_job = await job_service.create_job(ObjectId(), "completed-task") await job_service.mark_job_as_started(completed_job.id) await job_service.mark_job_as_completed(completed_job.id) # Execute - get pending jobs pending_results = await 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 = await 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 = await 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.""" @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) assert created_job.status == ProcessingStatus.PENDING # Execute result = await 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 = await job_service.get_job_by_id(created_job.id) assert updated_job.status == ProcessingStatus.PROCESSING @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) await job_service.mark_job_as_started(created_job.id) # Try to start it again with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) await job_service.mark_job_as_started(created_job.id) await job_service.mark_job_as_completed(created_job.id) # Try to start it again with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) await job_service.mark_job_as_started(created_job.id) await job_service.mark_job_as_failed(created_job.id, "Test error") # Try to start it again with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) started_job = await job_service.mark_job_as_started(created_job.id) # Execute result = await 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 = await job_service.get_job_by_id(created_job.id) assert updated_job.status == ProcessingStatus.COMPLETED @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) # Try to complete it directly with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) await job_service.mark_job_as_started(created_job.id) await job_service.mark_job_as_completed(created_job.id) # Try to complete it again with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) await job_service.mark_job_as_started(created_job.id) await job_service.mark_job_as_failed(created_job.id, "Test error") # Try to complete it with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) started_job = await job_service.mark_job_as_started(created_job.id) error_message = "Processing failed due to invalid file format" # Execute result = await 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 = await job_service.get_job_by_id(created_job.id) assert updated_job.status == ProcessingStatus.FAILED assert updated_job.error_message == error_message @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) await job_service.mark_job_as_started(created_job.id) # Execute without error message result = await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) # Try to fail it directly with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) await job_service.mark_job_as_started(created_job.id) await job_service.mark_job_as_completed(created_job.id) # Try to fail it with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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 @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) await job_service.mark_job_as_started(created_job.id) await job_service.mark_job_as_failed(created_job.id, "First error") # Try to fail it again with pytest.raises(InvalidStatusTransitionError) as exc_info: await 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.""" @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) # Verify job exists job_before_delete = await job_service.get_job_by_id(created_job.id) assert job_before_delete is not None # Execute deletion result = await job_service.delete_job(created_job.id) # Verify deletion assert result is True # Verify job no longer exists deleted_job = await job_service.get_job_by_id(created_job.id) assert deleted_job is None @pytest.mark.asyncio async def test_i_cannot_delete_nonexistent_job( self, job_service ): """Test deleting a nonexistent job returns False.""" # Execute deletion with random ObjectId result = await job_service.delete_job(ObjectId()) # Verify assert result is False class TestStatusTransitionValidation: """Tests for status transition validation across different scenarios.""" @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) assert job.status == ProcessingStatus.PENDING # Start job (PENDING → PROCESSING) started_job = await job_service.mark_job_as_started(job.id) assert started_job.status == ProcessingStatus.PROCESSING # Complete job (PROCESSING → COMPLETED) completed_job = await job_service.mark_job_as_completed(job.id) assert completed_job.status == ProcessingStatus.COMPLETED @pytest.mark.asyncio async 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 = await job_service.create_job(sample_document_id, sample_task_id) assert job.status == ProcessingStatus.PENDING # Start job (PENDING → PROCESSING) started_job = await job_service.mark_job_as_started(job.id) assert started_job.status == ProcessingStatus.PROCESSING # Fail job (PROCESSING → FAILED) failed_job = await job_service.mark_job_as_failed(job.id, "Test failure") assert failed_job.status == ProcessingStatus.FAILED assert failed_job.error_message == "Test failure" class TestEdgeCases: """Tests for edge cases and error conditions.""" # # @pytest.mark.asyncio # async def test_multiple_jobs_for_same_file( # self, # job_service, # sample_document_id # ): # """Test handling multiple jobs for the same file.""" # # Create multiple jobs for same file # job1 = await job_service.create_job(sample_document_id, "task-1") # job2 = await job_service.create_job(sample_document_id, "task-2") # job3 = await job_service.create_job(sample_document_id, "task-3") # # # Verify all jobs exist and are independent # jobs_for_file = await job_service.get_jobs_by_file_id(sample_document_id) # assert len(jobs_for_file) == 3 # # job_ids = [job.id for job in jobs_for_file] # assert job1.id in job_ids # assert job2.id in job_ids # assert job3.id in job_ids # # # Verify status transitions work independently # await job_service.mark_job_as_started(job1.id) # await job_service.mark_job_as_completed(job1.id) # # # Other jobs should still be pending # updated_job2 = await job_service.get_job_by_id(job2.id) # updated_job3 = await job_service.get_job_by_id(job3.id) # # assert updated_job2.status == ProcessingStatus.PENDING # assert updated_job3.status == ProcessingStatus.PENDING @pytest.mark.asyncio async def test_job_operations_with_empty_database( self, job_service ): """Test job operations when database is empty.""" # Try to get nonexistent job result = await job_service.get_job_by_id(ObjectId()) assert result is None # Try to get jobs by status when none exist pending_jobs = await job_service.get_jobs_by_status(ProcessingStatus.PENDING) assert pending_jobs == [] # Try to delete nonexistent job delete_result = await job_service.delete_job(ObjectId()) assert delete_result is False