"""Repository tests for the manual-review queue query. Backs issue 017: queue rows are documents in ``manual_review`` joined with the latest prescreen result, filterable and paginated. These tests hit the Docker Compose Postgres stack and roll back after each test. """ from __future__ import annotations import datetime as dt import uuid from decimal import Decimal import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from contract_check.core.db.repositories import ReviewQueueRepository pytestmark = [pytest.mark.unit, pytest.mark.usefixtures("db_session")] @pytest.fixture(autouse=True) async def _clean_queue_before_each(db_session: AsyncSession) -> None: """Give every queue test a clean manual-review slice inside the savepoint.""" await _clean_manual_review(db_session) async def _clean_manual_review(session: AsyncSession) -> None: """Remove leftover manual-review rows from earlier integration runs. Runs inside the per-test nested transaction; the enclosing savepoint rolls back the cleanup at the end of the test, so the shared dev database keeps whatever was there before. """ await session.execute( text( "DELETE FROM credit_events WHERE document_id IN " "(SELECT id FROM documents WHERE status = 'manual_review')" ) ) await session.execute(text("DELETE FROM documents WHERE status = 'manual_review'")) async def _seed_user(session: AsyncSession, *, telegram_id: int = 0) -> uuid.UUID: if telegram_id == 0: telegram_id = int.from_bytes(uuid.uuid4().bytes[:4], "big", signed=True) result = await session.execute( text("INSERT INTO users (telegram_id, credits_left) VALUES (:t, 5) RETURNING id"), {"t": telegram_id}, ) return result.scalar_one() _TEST_EPOCH = dt.datetime(2030, 1, 1, tzinfo=dt.UTC) async def _seed_manual_review_doc( session: AsyncSession, *, user_id: uuid.UUID, created_at: dt.datetime | None = None, confidence: Decimal | float = Decimal("0.5"), total_amount: Decimal | float | None = Decimal("100000.00"), ) -> tuple[uuid.UUID, uuid.UUID]: document_id = uuid.uuid4() correlation_id = uuid.uuid4() if created_at is None: created_at = _TEST_EPOCH await session.execute( text( "INSERT INTO documents " "(id, user_id, s3_key, extracted_s3_key, filename, mime, bytes, status, stage) " "VALUES (:id, :uid, :s3, :ext, 'contract.pdf', 'application/pdf', 1000, " " 'manual_review', 'manual_review')" ), { "id": document_id, "uid": user_id, "s3": f"users/{user_id}/docs/{document_id}.pdf", "ext": f"users/{user_id}/docs/{document_id}.txt", }, ) await session.execute( text( "INSERT INTO prescreen_results " "(id, document_id, correlation_id, contract_type, party_a, party_b, " " total_amount, currency, start_date, end_date, has_penalty_clause, " " has_termination_clause, has_arbitration, confidence_score, routing_decision, " " prescreened_at, auto_summary, auto_findings, extractor_version) " "VALUES (:id, :d, :cid, 'supply', 'ООО Продавец', 'ООО Покупатель', " " :ta, 'RUB', '2025-01-01', '2025-12-31', TRUE, TRUE, FALSE, " " :cs, 'manual_review', now(), 'summary', '[{\"note\":\"ok\"}]', 'heuristic-v2')" ), { "id": uuid.uuid4(), "d": document_id, "cid": correlation_id, "ta": total_amount, "cs": confidence, }, ) # created_at is server-default now(); override it for ordering tests. if created_at: await session.execute( text("UPDATE documents SET created_at = :ca WHERE id = :id"), {"ca": created_at, "id": document_id}, ) await session.commit() return document_id, correlation_id async def test_list_returns_manual_review_documents(db_session: AsyncSession) -> None: repo = ReviewQueueRepository(db_session) user_id = await _seed_user(db_session) doc_id, _ = await _seed_manual_review_doc(db_session, user_id=user_id) rows, total = await repo.list_queue( date_from=dt.date(2030, 1, 1), date_to=dt.date(2030, 12, 31), limit=10, offset=0, ) assert total == 1 assert len(rows) == 1 assert rows[0].document_id == doc_id assert rows[0].party_a == "ООО Продавец" assert rows[0].confidence_score == Decimal("0.5") async def test_list_orders_newest_first(db_session: AsyncSession) -> None: repo = ReviewQueueRepository(db_session) user_id = await _seed_user(db_session) older = await _seed_manual_review_doc( db_session, user_id=user_id, created_at=dt.datetime(2030, 1, 1, tzinfo=dt.UTC) ) newer = await _seed_manual_review_doc( db_session, user_id=user_id, created_at=dt.datetime(2030, 6, 1, tzinfo=dt.UTC) ) rows, _ = await repo.list_queue( date_from=dt.date(2030, 1, 1), date_to=dt.date(2030, 12, 31), limit=10, offset=0, ) assert [r.document_id for r in rows] == [newer[0], older[0]] async def test_list_ignores_non_manual_review(db_session: AsyncSession) -> None: repo = ReviewQueueRepository(db_session) user_id = await _seed_user(db_session) manual = await _seed_manual_review_doc(db_session, user_id=user_id) ignored_id = uuid.uuid4() await db_session.execute( text( "INSERT INTO documents " "(id, user_id, s3_key, filename, mime, bytes, status) " "VALUES (:id, :uid, :s3, 'x.pdf', 'application/pdf', 1, 'analyzing')" ), {"id": ignored_id, "uid": user_id, "s3": f"users/{user_id}/docs/{ignored_id}.pdf"}, ) await db_session.execute( text( "INSERT INTO prescreen_results " "(id, document_id, correlation_id, routing_decision, prescreened_at) " "VALUES (:id, :d, :cid, 'manual_review', now())" ), {"id": uuid.uuid4(), "d": ignored_id, "cid": uuid.uuid4()}, ) await db_session.commit() rows, total = await repo.list_queue( date_from=dt.date(2030, 1, 1), date_to=dt.date(2030, 12, 31), limit=10, offset=0, ) assert total == 1 assert rows[0].document_id == manual[0] async def test_list_pagination(db_session: AsyncSession) -> None: repo = ReviewQueueRepository(db_session) user_id = await _seed_user(db_session) for i in range(3): await _seed_manual_review_doc( db_session, user_id=user_id, created_at=dt.datetime(2030, 1, 1 + i, tzinfo=dt.UTC), ) rows, total = await repo.list_queue( date_from=dt.date(2030, 1, 1), date_to=dt.date(2030, 12, 31), limit=2, offset=0, ) assert total == 3 assert len(rows) == 2 rows2, _ = await repo.list_queue( date_from=dt.date(2030, 1, 1), date_to=dt.date(2030, 12, 31), limit=2, offset=2, ) assert len(rows2) == 1 async def test_list_filters_by_date(db_session: AsyncSession) -> None: repo = ReviewQueueRepository(db_session) user_id = await _seed_user(db_session) inside = await _seed_manual_review_doc( db_session, user_id=user_id, created_at=dt.datetime(2030, 3, 15, tzinfo=dt.UTC) ) await _seed_manual_review_doc( db_session, user_id=user_id, created_at=dt.datetime(2030, 6, 1, tzinfo=dt.UTC) ) rows, total = await repo.list_queue( date_from=dt.date(2030, 3, 1), date_to=dt.date(2030, 3, 31), limit=10, offset=0 ) assert total == 1 assert rows[0].document_id == inside[0] async def test_list_filters_by_confidence(db_session: AsyncSession) -> None: repo = ReviewQueueRepository(db_session) user_id = await _seed_user(db_session) low = await _seed_manual_review_doc(db_session, user_id=user_id, confidence=Decimal("0.3")) high = await _seed_manual_review_doc(db_session, user_id=user_id, confidence=Decimal("0.9")) rows, _ = await repo.list_queue( date_from=dt.date(2030, 1, 1), date_to=dt.date(2030, 12, 31), conf_min=Decimal("0.5"), conf_max=Decimal("1.0"), limit=10, offset=0, ) assert len(rows) == 1 assert rows[0].document_id == high[0] rows, _ = await repo.list_queue( date_from=dt.date(2030, 1, 1), date_to=dt.date(2030, 12, 31), conf_min=Decimal("0.1"), conf_max=Decimal("0.5"), limit=10, offset=0, ) assert len(rows) == 1 assert rows[0].document_id == low[0] async def test_get_item_returns_detail_fields(db_session: AsyncSession) -> None: repo = ReviewQueueRepository(db_session) user_id = await _seed_user(db_session) doc_id, correlation_id = await _seed_manual_review_doc( db_session, user_id=user_id, total_amount=Decimal("250000.00") ) item = await repo.get_item(doc_id) assert item is not None assert item.document_id == doc_id assert item.correlation_id == correlation_id assert item.user_id == user_id assert item.total_amount == Decimal("250000.00") assert item.extracted_s3_key == f"users/{user_id}/docs/{doc_id}.txt" async def test_get_item_returns_none_for_missing(db_session: AsyncSession) -> None: repo = ReviewQueueRepository(db_session) assert await repo.get_item(uuid.uuid4()) is None