579 lines
20 KiB
Python
579 lines
20 KiB
Python
"""
|
|
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
|