"""Tests for source revision behavior in TranscriptionService.""" from uuid import uuid4 import pytest from transcription.db.models import Document from transcription.db.models import Job from transcription.db.models import JobSource from transcription.db.models import JobSourceStatus from transcription.db.models import JobStatus from transcription.db.models import Source from transcription.services.documents import DocumentService from transcription.services.jobs import JobService from transcription.services.transcription import SourceDeleteBlockedError from transcription.services.transcription import TranscriptionNotFoundError from transcription.services.transcription import TranscriptionService @pytest.mark.integration class TestTranscriptionServiceRevisionUpsert: """Verify page-level source revision semantics.""" @pytest.mark.asyncio async def test_upsert_revision_creates_new_revision(self, default_session_factory): documents = DocumentService(session_factory=default_session_factory) jobs = JobService(session_factory=default_session_factory) transcriptions = TranscriptionService(session_factory=default_session_factory) document = Document(id=uuid4(), name="revision-create") await documents.create_document(document=document) job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED) await jobs.create_job(job=job) source = Source( document_id=document.id, page_number=1, upload_name="source.jpg", filename="source.jpg", file_path="uploads/source.jpg", ) async with transcriptions._session_scope() as session: session.add(source) await session.flush() session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING)) await session.commit() await session.refresh(source) revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision") fetched = await transcriptions.read_revision_by_source(source.id) assert revision.id == source.id assert revision.revised_text == "User revision" assert fetched is not None assert fetched.id == source.id assert fetched.revised_text == "User revision" @pytest.mark.asyncio async def test_upsert_revision_updates_existing_single_revision(self, default_session_factory): documents = DocumentService(session_factory=default_session_factory) jobs = JobService(session_factory=default_session_factory) transcriptions = TranscriptionService(session_factory=default_session_factory) document = Document(id=uuid4(), name="revision-update") await documents.create_document(document=document) job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED) await jobs.create_job(job=job) source = Source( document_id=document.id, page_number=1, upload_name="source.jpg", filename="source.jpg", file_path="uploads/source.jpg", ) async with transcriptions._session_scope() as session: session.add(source) await session.flush() session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING)) await session.commit() await session.refresh(source) first = await transcriptions.upsert_revision_for_source(source_id=source.id, text="Revision v1") second = await transcriptions.upsert_revision_for_source(source_id=source.id, text="Revision v2") revisions = await transcriptions.list_revisions_by_job(job.id) assert first.id == second.id assert second.revised_text == "Revision v2" assert len(revisions) == 1 assert revisions[0].id == first.id assert revisions[0].revised_text == "Revision v2" @pytest.mark.asyncio async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory, tmp_path): documents = DocumentService(session_factory=default_session_factory) jobs = JobService(session_factory=default_session_factory) transcriptions = TranscriptionService(session_factory=default_session_factory) transcriptions.settings.upload_dir = tmp_path document = Document(id=uuid4(), name="delete-source-success") await documents.create_document(document=document) job = Job(document_id=document.id, status=JobStatus.QUEUED) await jobs.create_job(job=job) stored_path = tmp_path / "documents" / str(document.id) / "delete.jpg" stored_path.parent.mkdir(parents=True, exist_ok=True) stored_path.write_bytes(b"data") source = Source( document_id=document.id, page_number=1, upload_name="delete.jpg", filename="delete.jpg", file_path=str(stored_path), ) async with transcriptions._session_scope() as session: session.add(source) await session.flush() session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING)) await session.commit() await session.refresh(source) await transcriptions.delete_source_from_job_context(job_id=job.id, source_id=source.id) with pytest.raises(TranscriptionNotFoundError): await transcriptions.read_source(source.id) assert not stored_path.exists() @pytest.mark.asyncio async def test_delete_source_from_job_context_blocks_when_other_job_links_exist(self, default_session_factory): documents = DocumentService(session_factory=default_session_factory) jobs = JobService(session_factory=default_session_factory) transcriptions = TranscriptionService(session_factory=default_session_factory) document = Document(id=uuid4(), name="delete-source-blocked") await documents.create_document(document=document) job_one = Job(document_id=document.id, status=JobStatus.QUEUED) job_two = Job(document_id=document.id, status=JobStatus.QUEUED) await jobs.create_job(job=job_one) await jobs.create_job(job=job_two) source = Source( document_id=document.id, page_number=1, upload_name="shared.jpg", filename="shared.jpg", file_path="uploads/shared.jpg", ) async with transcriptions._session_scope() as session: session.add(source) await session.flush() session.add(JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING)) session.add(JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING)) await session.commit() await session.refresh(source) with pytest.raises(SourceDeleteBlockedError): await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id) @pytest.mark.asyncio async def test_delete_unlinked_source_succeeds(self, default_session_factory, tmp_path): documents = DocumentService(session_factory=default_session_factory) transcriptions = TranscriptionService(session_factory=default_session_factory) transcriptions.settings.upload_dir = tmp_path document = Document(id=uuid4(), name="delete-unlinked-source") await documents.create_document(document=document) stored_path = tmp_path / "documents" / str(document.id) / "orphan.jpg" stored_path.parent.mkdir(parents=True, exist_ok=True) stored_path.write_bytes(b"data") source = Source( document_id=document.id, page_number=1, upload_name="orphan.jpg", filename="orphan.jpg", file_path=str(stored_path), ) await transcriptions.create_source(source=source) await transcriptions.delete_unlinked_source(source_id=source.id) with pytest.raises(TranscriptionNotFoundError): await transcriptions.read_source(source.id) assert not stored_path.exists() @pytest.mark.asyncio async def test_delete_unlinked_source_blocks_when_linked(self, default_session_factory): documents = DocumentService(session_factory=default_session_factory) jobs = JobService(session_factory=default_session_factory) transcriptions = TranscriptionService(session_factory=default_session_factory) document = Document(id=uuid4(), name="delete-unlinked-blocked") await documents.create_document(document=document) job = Job(document_id=document.id, status=JobStatus.QUEUED) await jobs.create_job(job=job) source = Source( document_id=document.id, page_number=1, upload_name="linked.jpg", filename="linked.jpg", file_path="uploads/linked.jpg", ) async with transcriptions._session_scope() as session: session.add(source) await session.flush() session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING)) await session.commit() await session.refresh(source) with pytest.raises(SourceDeleteBlockedError): await transcriptions.delete_unlinked_source(source_id=source.id)