- 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.
276 lines
9.4 KiB
Python
276 lines
9.4 KiB
Python
"""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
|