Initial commit: InternalAuditInterprise
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""集成测试 fixture:连接本地 PostgreSQL 16,按事务隔离并回滚。
|
||||
|
||||
需要可连接的数据库(DATABASE_URL)。无法连接时跳过整组集成测试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.db import get_engine
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def db_available() -> bool:
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return True
|
||||
except OperationalError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(db_available):
|
||||
if not db_available:
|
||||
pytest.skip("数据库不可用,跳过集成测试")
|
||||
engine = get_engine()
|
||||
connection = engine.connect()
|
||||
trans = connection.begin()
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
sess = Session(bind=connection)
|
||||
try:
|
||||
yield sess
|
||||
finally:
|
||||
sess.close()
|
||||
if trans.is_active:
|
||||
trans.rollback()
|
||||
connection.close()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""双时态集成测试(需 PostgreSQL)。
|
||||
|
||||
验证 R3:按历史业务时点回放属性值,以及双时态排他约束防止有效期重叠。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.datahub import bitemporal_repo as btr
|
||||
from app.datahub.graph_repo import upsert_entity
|
||||
from app.datahub.ontology import EntityType
|
||||
|
||||
|
||||
def test_bitemporal_replay(session):
|
||||
"""不同业务时点回放出不同的属性值。"""
|
||||
cust = upsert_entity(session, EntityType.CUSTOMER, "CUST_BT", "丁公司")
|
||||
session.flush()
|
||||
|
||||
t1 = dt.datetime(2025, 1, 1, tzinfo=dt.UTC)
|
||||
t2 = dt.datetime(2025, 6, 1, tzinfo=dt.UTC)
|
||||
|
||||
btr.record_fact(session, cust.id, "credit_level", {"v": "A"}, valid_from=t1, valid_to=t2)
|
||||
btr.record_fact(session, cust.id, "credit_level", {"v": "C"}, valid_from=t2)
|
||||
session.flush()
|
||||
|
||||
early = btr.as_of(session, cust.id, "credit_level", dt.datetime(2025, 3, 1, tzinfo=dt.UTC))
|
||||
late = btr.as_of(session, cust.id, "credit_level", dt.datetime(2025, 9, 1, tzinfo=dt.UTC))
|
||||
assert early is not None and early.attr_value["v"] == "A"
|
||||
assert late is not None and late.attr_value["v"] == "C"
|
||||
|
||||
|
||||
def test_bitemporal_exclusion_constraint(session):
|
||||
"""同一实体同一属性的业务有效期重叠应被排他约束拒绝。"""
|
||||
cust = upsert_entity(session, EntityType.CUSTOMER, "CUST_EX", "戊公司")
|
||||
session.flush()
|
||||
|
||||
t1 = dt.datetime(2025, 1, 1, tzinfo=dt.UTC)
|
||||
t3 = dt.datetime(2025, 12, 1, tzinfo=dt.UTC)
|
||||
t2 = dt.datetime(2025, 6, 1, tzinfo=dt.UTC)
|
||||
|
||||
btr.record_fact(session, cust.id, "status", {"v": "active"}, valid_from=t1, valid_to=t3)
|
||||
session.flush()
|
||||
# 与上一条 [t1,t3) 重叠:record_fact 内部 flush 时即触发排他约束
|
||||
with pytest.raises(IntegrityError):
|
||||
btr.record_fact(session, cust.id, "status", {"v": "frozen"}, valid_from=t2, valid_to=None)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""线索闭环 + 系统自审计集成测试(需 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)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""数据中台穿透 API 集成测试(需 PostgreSQL)。
|
||||
|
||||
通过 TestClient 调用 /datahub/penetrate,验证统一穿透查询服务端到端可用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.datahub.graph_repo import add_relationship, upsert_entity
|
||||
from app.datahub.ontology import EntityType, RelationshipType
|
||||
from app.db import get_session
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(session):
|
||||
# 用集成测试的事务化 session 覆盖应用依赖,保证测试数据回滚
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
try:
|
||||
yield TestClient(app)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_session, None)
|
||||
|
||||
|
||||
def test_penetrate_endpoint_detects_related(client, session):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
controller = upsert_entity(session, EntityType.LEGAL_PERSON, f"CTRL-{suffix}", "实控人")
|
||||
cust = upsert_entity(session, EntityType.CUSTOMER, f"CUST-{suffix}", "政企客户")
|
||||
rep = upsert_entity(session, EntityType.LEGAL_PERSON, f"REP-{suffix}", "法人")
|
||||
add_relationship(session, RelationshipType.LEGAL_REP_OF, rep, cust)
|
||||
add_relationship(session, RelationshipType.RELATED_TO, rep, controller)
|
||||
session.flush()
|
||||
|
||||
resp = client.post(
|
||||
"/datahub/penetrate",
|
||||
json={"start_entity_id": str(controller.id), "max_depth": 3},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
related_ids = {r["entity"]["id"] for r in body["related"]}
|
||||
assert str(cust.id) in related_ids
|
||||
assert body["related_count"] >= 2
|
||||
|
||||
|
||||
def test_penetrate_unknown_entity_404(client):
|
||||
resp = client.post(
|
||||
"/datahub/penetrate",
|
||||
json={"start_entity_id": str(uuid.uuid4()), "max_depth": 2},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_entity_endpoint(client, session):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
e = upsert_entity(session, EntityType.SUPPLIER, f"SUP-{suffix}", "供应商甲")
|
||||
session.flush()
|
||||
resp = client.get(f"/datahub/entities/{e.id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["business_key"] == f"SUP-{suffix}"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""知识图谱穿透集成测试(需 PostgreSQL)。
|
||||
|
||||
验证 R2 关键能力:通过关系边的多跳穿透识别"疑似同一实控人",
|
||||
以及本体约束对非法关系的拒绝。对应场景一(政企拆单+隐性实控人,R8)的图谱基础。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.datahub.graph_repo import (
|
||||
OntologyViolationError,
|
||||
add_relationship,
|
||||
find_related_entities,
|
||||
upsert_entity,
|
||||
)
|
||||
from app.datahub.ontology import EntityType, RelationshipType
|
||||
|
||||
|
||||
def test_upsert_entity_is_idempotent(session):
|
||||
e1 = upsert_entity(session, EntityType.CUSTOMER, "CUST-001", "客户甲")
|
||||
e2 = upsert_entity(session, EntityType.CUSTOMER, "CUST-001", "客户甲")
|
||||
assert e1.id == e2.id
|
||||
|
||||
|
||||
def test_ontology_violation_rejected(session):
|
||||
contract = upsert_entity(session, EntityType.CONTRACT, "C-1")
|
||||
customer = upsert_entity(session, EntityType.CUSTOMER, "CUST-2")
|
||||
# 合同 —签约→ 客户 方向非法
|
||||
with pytest.raises(OntologyViolationError):
|
||||
add_relationship(session, RelationshipType.SIGNED, contract, customer)
|
||||
|
||||
|
||||
def test_detect_shared_controller_across_customers(session):
|
||||
"""模拟"8 个客户疑似同一实控人":多个客户经法人关联到同一实控自然人。
|
||||
|
||||
构图:每个客户 <-法定代表人- 各自法人;各法人 -关联-> 同一实控人。
|
||||
从实控人出发,应能穿透到全部客户。
|
||||
"""
|
||||
controller = upsert_entity(session, EntityType.LEGAL_PERSON, "PER-CTRL", "实控人")
|
||||
|
||||
customers = []
|
||||
for i in range(8):
|
||||
cust = upsert_entity(session, EntityType.CUSTOMER, f"CUST-{i}", f"政企客户{i}")
|
||||
rep = upsert_entity(session, EntityType.LEGAL_PERSON, f"PER-{i}", f"法人{i}")
|
||||
# 法人 —法定代表人→ 客户
|
||||
add_relationship(session, RelationshipType.LEGAL_REP_OF, rep, cust)
|
||||
# 法人 —关联(亲属/实控)→ 实控人
|
||||
add_relationship(session, RelationshipType.RELATED_TO, rep, controller)
|
||||
customers.append(cust)
|
||||
session.flush()
|
||||
|
||||
related = find_related_entities(session, controller.id, max_depth=3)
|
||||
related_ids = {rid for rid, _ in related}
|
||||
|
||||
# 从实控人 3 跳内应能穿透到全部 8 个客户
|
||||
for cust in customers:
|
||||
assert cust.id in related_ids, f"未穿透到 {cust.business_key}"
|
||||
|
||||
|
||||
def test_traversal_respects_max_depth(session):
|
||||
a = upsert_entity(session, EntityType.LEGAL_PERSON, "A")
|
||||
b = upsert_entity(session, EntityType.LEGAL_PERSON, "B")
|
||||
c = upsert_entity(session, EntityType.CUSTOMER, "C")
|
||||
add_relationship(session, RelationshipType.RELATED_TO, a, b)
|
||||
add_relationship(session, RelationshipType.LEGAL_REP_OF, b, c)
|
||||
session.flush()
|
||||
|
||||
# depth=1:从 A 只能到 B,到不了 C
|
||||
ids_d1 = {rid for rid, _ in find_related_entities(session, a.id, max_depth=1)}
|
||||
assert b.id in ids_d1
|
||||
assert c.id not in ids_d1
|
||||
|
||||
# depth=2:能到 C
|
||||
ids_d2 = {rid for rid, _ in find_related_entities(session, a.id, max_depth=2)}
|
||||
assert c.id in ids_d2
|
||||
Reference in New Issue
Block a user