DealDocumentScreening/tests/unit/test_review_repository.py
febux befa5c897f M4: admin manual-review queue and operator actions (#017, #018)
- ReviewQueueRepository: filtered/paginated queue query + item loader.
- Admin panel: /admin/review list/detail with extracted-text preview.
- Operator actions: send-to-analysis, complete, reject with Document Slot
  compensation and row-level status guard.
- /code-review cleanups applied; actions lock via DocumentRepository.

Closes issues 017, 018.
2026-09-05 23:47:45 +03:00

276 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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