"""线索闭环 + 系统自审计集成测试(需 PostgreSQL)。 覆盖 R7/R17/R18/R19:线索生成与分级、状态流转、底稿、审计哈希链、线索不可删。 """ from __future__ import annotations import pytest from sqlalchemy import text from sqlalchemy.exc import InternalError, ProgrammingError from app.audit import service as audit from app.clues import service as clue_svc from app.clues.models import ClueStatus, ConfidenceTier def _new_clue(session, score=0.9): return clue_svc.create_clue( session, title="疑似政企拆单", risk_domain="收入", scenario_code="R8", score=score, rationale="8 个客户金额集中在审批阈值边缘,且法人关联同一实控人", evidence={"contracts": 8, "threshold": 1000000}, amount_involved=4800000, actor="system", ) def test_score_to_confidence_tier(): assert clue_svc.score_to_tier(0.9) == ConfidenceTier.HIGH assert clue_svc.score_to_tier(0.6) == ConfidenceTier.MEDIUM assert clue_svc.score_to_tier(0.2) == ConfidenceTier.LOW def test_clue_full_lifecycle(session): clue = _new_clue(session) assert clue.confidence == ConfidenceTier.HIGH assert clue.status == ClueStatus.NEW clue_svc.assign(session, clue, assignee="auditor_zhang", actor="manager_li") assert clue.status == ClueStatus.ASSIGNED assert clue.assignee == "auditor_zhang" paper = clue_svc.adjudicate(session, clue, confirmed=True, actor="auditor_zhang", note="属实,移交") assert clue.status == ClueStatus.CONFIRMED assert clue.feedback == "confirmed" assert paper.conclusion == "confirmed" assert paper.snapshot["score"] == 0.9 # 继续闭环:确认 -> 移交 -> 销项 clue_svc.transition(session, clue, ClueStatus.TRANSFERRED, actor="manager_li") clue_svc.transition(session, clue, ClueStatus.CLOSED, actor="manager_li") assert clue.status == ClueStatus.CLOSED def test_illegal_transition_rejected(session): clue = _new_clue(session) with pytest.raises(clue_svc.IllegalTransitionError): # NEW 不能直接到 CLOSED clue_svc.transition(session, clue, ClueStatus.CLOSED, actor="x") def test_audit_hash_chain_integrity(session): _new_clue(session) clue = _new_clue(session) clue_svc.assign(session, clue, "auditor_zhang", "manager_li") ok, broken = audit.verify_chain(session) assert ok is True assert broken is None def test_clue_cannot_be_deleted(session): """R19:数据库触发器禁止物理删除线索。""" clue = _new_clue(session) session.flush() with pytest.raises((InternalError, ProgrammingError)): session.execute(text("DELETE FROM clue WHERE id = :i"), {"i": clue.id}) session.flush() def test_list_clues_filters(session): _new_clue(session, score=0.9) _new_clue(session, score=0.3) highs = clue_svc.list_clues(session, confidence=ConfidenceTier.HIGH) assert all(c.confidence == ConfidenceTier.HIGH for c in highs)