Spaces:
Running
Running
File size: 7,493 Bytes
6741fc6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | """The review queue: what reaches the front of it, and who gets to clear a flag.
Both regressions here only appear once the queue has more than a page of work or
more than one reviewer — which is to say, only in the conditions the queue exists
for. Severity was applied after `LIMIT`, so "most severe first" stopped being true
past page one; and a resolve that lost a race still wrote itself into the
append-only log as though it had won.
"""
from __future__ import annotations
import asyncio
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
import httpx
import pytest
from sqlalchemy import func, select
from app.core.db import transaction
from app.main import create_app
from app.models.enums import (
AnomalySeverity,
AnomalyStatus,
AnomalyType,
AuditEvent,
DocumentStatus,
)
from app.models.tables import Anomaly, AuditLog, Document
pytestmark = pytest.mark.integration
@pytest.fixture
async def api(clean_db: None) -> AsyncIterator[httpx.AsyncClient]:
"""The HTTP surface, without running the application lifespan.
Deliberately not wrapped in `LifespanManager`. Shutdown calls
`dispose_engine()`, which disposes the *process-wide* engine that the
session-scoped `engine` fixture also owns — survivable for one module, but a
second module doing it leaves the pool disposed underneath tests that are
still using it, and the suite's zero-warning policy turns the resulting
unclosed socket into a failure. Nothing these tests touch needs the lifespan:
the schema comes from the fixtures, and `app.state.limiter` is wired in
`create_app`.
"""
app = create_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://ledgerlens.test",
timeout=30.0,
) as client:
yield client
async def _document(suffix: str) -> uuid.UUID:
document_id = uuid.uuid4()
async with transaction() as session:
session.add(
Document(
id=document_id,
file_hash=suffix.rjust(64, "0"),
filename=f"{suffix}.pdf",
media_type="application/pdf",
size_bytes=1024,
status=str(DocumentStatus.DONE),
)
)
return document_id
async def _anomaly(
document_id: uuid.UUID, severity: AnomalySeverity, *, age_days: int, fingerprint: str
) -> uuid.UUID:
anomaly_id = uuid.uuid4()
async with transaction() as session:
session.add(
Anomaly(
id=anomaly_id,
document_id=document_id,
anomaly_type=str(AnomalyType.ROUND_NUMBER),
severity=str(severity),
reason=f"{severity} finding",
evidence={},
fingerprint=fingerprint,
status=str(AnomalyStatus.OPEN),
created_at=datetime.now(UTC) - timedelta(days=age_days),
)
)
return anomaly_id
# --- F-5: severity ordering survives pagination ----------------------------
async def test_the_most_severe_flag_leads_the_queue_even_when_it_is_the_oldest(
api: httpx.AsyncClient,
) -> None:
"""The failure the Python sort hid: order by date, page, *then* sort.
With the HIGH finding older than every LOW one, a page-sized LIMIT taken in
date order contains no HIGH row at all — so re-sorting the page could never
surface it, and the queue's own description became false at page one.
"""
document_id = await _document("a1")
await _anomaly(document_id, AnomalySeverity.HIGH, age_days=90, fingerprint="old-high")
for day in range(4):
await _anomaly(
document_id, AnomalySeverity.LOW, age_days=day, fingerprint=f"recent-low-{day}"
)
first_page = (await api.get("/v1/anomalies", params={"limit": 1})).json()
assert [item["severity"] for item in first_page] == [str(AnomalySeverity.HIGH)]
async def test_the_whole_queue_is_ordered_by_severity_then_recency(
api: httpx.AsyncClient,
) -> None:
document_id = await _document("a2")
await _anomaly(document_id, AnomalySeverity.LOW, age_days=1, fingerprint="low")
await _anomaly(document_id, AnomalySeverity.HIGH, age_days=2, fingerprint="high")
await _anomaly(document_id, AnomalySeverity.MEDIUM, age_days=3, fingerprint="medium")
severities = [item["severity"] for item in (await api.get("/v1/anomalies")).json()]
assert severities == [
str(AnomalySeverity.HIGH),
str(AnomalySeverity.MEDIUM),
str(AnomalySeverity.LOW),
]
async def test_paging_never_repeats_or_skips_a_flag(api: httpx.AsyncClient) -> None:
"""A stable total order is what makes LIMIT/OFFSET paging coherent at all."""
document_id = await _document("a3")
for index, severity in enumerate(
[AnomalySeverity.LOW, AnomalySeverity.HIGH, AnomalySeverity.MEDIUM] * 2
):
await _anomaly(document_id, severity, age_days=index, fingerprint=f"f{index}")
seen: list[str] = []
for offset in range(0, 6, 2):
page = (await api.get("/v1/anomalies", params={"limit": 2, "offset": offset})).json()
seen.extend(item["id"] for item in page)
assert len(seen) == len(set(seen)) == 6
# --- F-6: a resolve that loses the race writes nothing ---------------------
async def test_only_one_of_two_simultaneous_reviewers_wins(api: httpx.AsyncClient) -> None:
"""Both read OPEN; Postgres lets one UPDATE through and the other matches zero.
The loser must not append ANOMALY_RESOLVED naming its own decision — the
trigger would make that permanent — and must not return 200 carrying the
winner's outcome as though it were the caller's own.
"""
document_id = await _document("a4")
anomaly_id = await _anomaly(
document_id, AnomalySeverity.HIGH, age_days=0, fingerprint="contested"
)
approve, reject = await asyncio.gather(
api.post(f"/v1/anomalies/{anomaly_id}/resolve", json={"action": "approve"}),
api.post(f"/v1/anomalies/{anomaly_id}/resolve", json={"action": "reject"}),
)
codes = sorted([approve.status_code, reject.status_code])
assert codes == [200, 409], f"expected exactly one winner, got {codes}"
loser = approve if approve.status_code == 409 else reject
assert loser.json()["error"]["code"] == "invalid_state_transition"
async with transaction() as session:
resolutions = (
await session.execute(
select(func.count())
.select_from(AuditLog)
.where(
AuditLog.document_id == document_id,
AuditLog.event == str(AuditEvent.ANOMALY_RESOLVED),
)
)
).scalar_one()
assert resolutions == 1, "the losing reviewer must not appear in the audit trail"
async def test_resolving_an_already_reviewed_flag_is_refused(api: httpx.AsyncClient) -> None:
document_id = await _document("a5")
anomaly_id = await _anomaly(
document_id, AnomalySeverity.MEDIUM, age_days=0, fingerprint="settled"
)
assert (
await api.post(f"/v1/anomalies/{anomaly_id}/resolve", json={"action": "approve"})
).status_code == 200
second = await api.post(f"/v1/anomalies/{anomaly_id}/resolve", json={"action": "reject"})
assert second.status_code == 409
assert second.json()["error"]["code"] == "invalid_state_transition"
|