Files
X-Financial/server/tests/test_expense_application_hierarchical_memory.py

506 lines
18 KiB
Python

from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import select
from app.api.deps import CurrentUserContext
from app.models.ai_memory import MemoryEntry
from app.models.organization import OrganizationUnit
from app.schemas.expense_application_memory import (
ExpenseApplicationOrganizationMemoryCreate,
ExpenseApplicationOrganizationMemoryRevoke,
ExpenseApplicationOrganizationMemoryUpdate,
)
from app.services.expense_application_memory import ExpenseApplicationMemoryService
from app.services.expense_application_memory_admin import (
ExpenseApplicationOrganizationMemoryService,
OrganizationMemoryConflictError,
OrganizationMemoryNotFoundError,
)
from app.services.organization_memory_locks import (
organization_memory_advisory_lock_id,
organization_memory_operation_locks,
)
from app.test_helpers.db import build_in_memory_session_factory
def _user(
*,
tenant_id: str = "tenant-hierarchy",
department_id: str = "department-delivery",
is_admin: bool = False,
) -> CurrentUserContext:
return CurrentUserContext(
username="memory-admin@example.com" if is_admin else "memory-user@example.com",
name="记忆管理员" if is_admin else "记忆员工",
role_codes=["admin"] if is_admin else ["user"],
is_admin=is_admin,
tenant_id=tenant_id,
employee_id="memory-admin" if is_admin else "memory-user",
employee_no="E-MEMORY",
department_id=department_id,
department_name="交付部",
)
def _active_organization_memory(
*,
tenant_id: str,
scope_type: str,
scope_id: str,
value: str,
generation: int,
activated_at: datetime | None = None,
expires_at: datetime | None = None,
) -> MemoryEntry:
now = datetime.now(UTC)
activated_at = activated_at or now
expires_at = expires_at or now + timedelta(days=180)
return MemoryEntry(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
origin_type="admin_managed",
managed_by="memory-admin",
managed_at=now,
management_reason="测试组织记忆",
policy_version="expense_application_transport_org_memory.v1",
scene="travel_application",
field_key="transport_mode",
generation=generation,
value_json={"value": value},
value_fingerprint=f"fingerprint-{scope_type}-{generation}-{value}",
status="active",
evidence_count=0,
approved_evidence_count=0,
confidence=Decimal("1.0000"),
last_evidence_at=now,
candidate_expires_at=expires_at,
activated_at=activated_at,
active_expires_at=expires_at,
created_at=activated_at,
updated_at=now,
)
def test_enterprise_precedes_department_and_explicit_input_precedes_all() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
db.add_all(
[
_active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="enterprise",
scope_id=current_user.tenant_id,
value="飞机",
generation=1,
),
_active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="department",
scope_id=current_user.department_id,
value="火车",
generation=1,
),
]
)
db.flush()
facts: dict[str, object] = {}
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
facts, current_user
)
assert facts == {"transport_mode": "飞机"}
assert applications[0].scope_type == "enterprise"
assert applications[0].priority == 300
assert applications[0].source == "enterprise_policy_memory"
assert applications[0].can_revoke is False
assert applications[0].conflicts[0].reason == "lower_priority_overridden"
assert applications[0].conflicts[0].scope_type == "department"
explicit_facts = {"transport_mode": "轮船"}
assert (
ExpenseApplicationMemoryService(db).apply_active_transport_memory(
explicit_facts, current_user
)
== []
)
assert explicit_facts == {"transport_mode": "轮船"}
def test_same_level_conflict_fails_closed_and_is_explainable() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
db.add_all(
[
_active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="enterprise",
scope_id=current_user.tenant_id,
value="飞机",
generation=1,
),
_active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="enterprise",
scope_id=current_user.tenant_id,
value="火车",
generation=2,
),
]
)
db.flush()
facts: dict[str, object] = {}
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
facts, current_user
)
assert facts == {}
assert len(applications) == 1
assert applications[0].status == "conflict"
assert applications[0].memory_id == ""
assert applications[0].scope_id == ""
assert applications[0].value == ""
assert len(applications[0].conflicts) == 2
assert {
conflict.reason for conflict in applications[0].conflicts
} == {"same_priority_conflict"}
for conflict in applications[0].model_dump()["conflicts"]:
assert "memory_id" not in conflict
assert "scope_id" not in conflict
assert "value" not in conflict
def test_expiry_and_time_decay_never_apply_cross_tenant_or_expire_early() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
now = datetime.now(UTC)
expired = _active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="department",
scope_id=current_user.department_id,
value="火车",
generation=1,
activated_at=now - timedelta(days=181),
expires_at=now - timedelta(days=1),
)
other_tenant = _active_organization_memory(
tenant_id="tenant-other",
scope_type="enterprise",
scope_id="tenant-other",
value="轮船",
generation=1,
)
decayed = _active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="enterprise",
scope_id=current_user.tenant_id,
value="飞机",
generation=1,
activated_at=now - timedelta(days=100),
expires_at=now + timedelta(days=100),
)
db.add_all([expired, other_tenant, decayed])
db.flush()
facts: dict[str, object] = {}
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
facts, current_user
)
db.refresh(expired)
assert expired.status == "expired"
assert facts == {"transport_mode": "飞机"}
assert 0.49 <= applications[0].effective_confidence <= 0.51
assert applications[0].memory_id == decayed.id
def test_organization_memory_lifecycle_is_audited_versioned_and_tenant_safe() -> None:
with build_in_memory_session_factory()() as db:
admin = _user(is_admin=True)
service = ExpenseApplicationOrganizationMemoryService(db)
created = service.create_organization_memory(
ExpenseApplicationOrganizationMemoryCreate(
scope_type="enterprise",
value="火车",
expires_in_days=90,
reason="统一优先使用高铁",
request_id="create-enterprise-memory-1",
),
admin,
)
assert created.scope_id == admin.tenant_id
assert created.generation == 1
assert created.origin_type == "admin_managed"
assert created.managed_by == admin.employee_id
assert created.management_reason == "统一优先使用高铁"
replayed_create = service.create_organization_memory(
ExpenseApplicationOrganizationMemoryCreate(
scope_type="enterprise",
value="火车",
expires_in_days=90,
reason="统一优先使用高铁",
request_id="create-enterprise-memory-1",
),
admin,
)
assert replayed_create.id == created.id
with pytest.raises(OrganizationMemoryConflictError, match="已有生效记忆"):
service.create_organization_memory(
ExpenseApplicationOrganizationMemoryCreate(
scope_type="enterprise",
value="火车",
expires_in_days=90,
reason="重复创建企业基线",
request_id="create-enterprise-memory-2",
),
admin,
)
updated = service.update_organization_memory(
created.id,
ExpenseApplicationOrganizationMemoryUpdate(
value="飞机",
expires_in_days=120,
expected_generation=1,
reason="适配跨区域差旅",
request_id="update-enterprise-memory-1",
),
admin,
)
assert updated.id != created.id
assert updated.generation == 2
old_entry = db.get(MemoryEntry, created.id)
assert old_entry is not None
assert old_entry.status == "suppressed"
assert old_entry.superseded_by_id == updated.id
replayed_update = service.update_organization_memory(
created.id,
ExpenseApplicationOrganizationMemoryUpdate(
value="飞机",
expires_in_days=120,
expected_generation=1,
reason="适配跨区域差旅",
request_id="update-enterprise-memory-1",
),
admin,
)
assert replayed_update.id == updated.id
with pytest.raises(OrganizationMemoryConflictError, match="刷新后重试"):
service.update_organization_memory(
created.id,
ExpenseApplicationOrganizationMemoryUpdate(
value="轮船",
expected_generation=1,
reason="使用旧记录地址更新",
request_id="update-enterprise-memory-old-id",
),
admin,
)
with pytest.raises(OrganizationMemoryConflictError, match="刷新后重试"):
service.update_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryUpdate(
expected_generation=1,
reason="使用陈旧版本更新",
request_id="update-enterprise-memory-stale",
),
admin,
)
with pytest.raises(OrganizationMemoryNotFoundError):
service.revoke_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=2,
reason="越权租户撤销",
request_id="revoke-other-tenant-memory",
),
_user(tenant_id="tenant-other", is_admin=True),
)
revoked = service.revoke_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=2,
reason="企业政策已调整",
request_id="revoke-enterprise-memory-1",
),
admin,
)
assert revoked.memory_id == updated.id
revoked_entry = db.get(MemoryEntry, updated.id)
assert revoked_entry is not None
assert revoked_entry.status == "revoked"
assert revoked_entry.value_json == {"value": "飞机"}
assert revoked_entry.revoked_reason == "企业政策已调整"
replayed_revoke = service.revoke_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=2,
reason="企业政策已调整",
request_id="revoke-enterprise-memory-1",
),
admin,
)
assert replayed_revoke == revoked
with pytest.raises(OrganizationMemoryConflictError, match="请求内容不一致"):
service.revoke_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=2,
reason="同一键却改变请求体",
request_id="revoke-enterprise-memory-1",
),
admin,
)
assert ExpenseApplicationMemoryService(db).revoke_current_user_memory(
updated.id, admin
) is None
def test_department_memory_requires_existing_department_and_admin() -> None:
with build_in_memory_session_factory()() as db:
department = OrganizationUnit(
id="department-delivery",
unit_code="DELIVERY",
name="交付部",
unit_type="department",
)
division = OrganizationUnit(
id="division-east",
unit_code="EAST",
name="华东大区",
unit_type="division",
)
db.add_all([department, division])
db.flush()
service = ExpenseApplicationOrganizationMemoryService(db)
payload = ExpenseApplicationOrganizationMemoryCreate(
scope_type="department",
scope_id=department.id,
value="火车",
reason="部门短途差旅基线",
request_id="create-department-memory-1",
)
with pytest.raises(PermissionError, match="平台管理员"):
service.create_organization_memory(payload, _user())
with pytest.raises(ValueError, match="department 类型"):
service.create_organization_memory(
payload.model_copy(update={"scope_id": division.id}),
_user(is_admin=True),
)
created = service.create_organization_memory(payload, _user(is_admin=True))
assert created.scope_id == department.id
assert created.scope_label == "部门规则(交付部)"
memories = service.list_organization_memories(_user(is_admin=True))
assert [item.id for item in memories.items] == [created.id]
assert db.scalar(
select(MemoryEntry).where(MemoryEntry.id == created.id)
) is not None
def test_expired_organization_memory_revoke_returns_conflict_and_marks_expired() -> None:
with build_in_memory_session_factory()() as db:
admin = _user(is_admin=True)
now = datetime.now(UTC)
entry = _active_organization_memory(
tenant_id=admin.tenant_id,
scope_type="enterprise",
scope_id=admin.tenant_id,
value="火车",
generation=1,
activated_at=now - timedelta(days=100),
expires_at=now - timedelta(days=1),
)
db.add(entry)
db.commit()
with pytest.raises(OrganizationMemoryConflictError, match="已过期"):
ExpenseApplicationOrganizationMemoryService(db).revoke_organization_memory(
entry.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=1,
reason="撤销已过期规则",
request_id="revoke-expired-memory-1",
),
admin,
)
db.refresh(entry)
assert entry.status == "expired"
assert entry.expired_at is not None
def test_postgres_organization_mutations_use_signed_transaction_advisory_locks() -> None:
executed: list[object] = []
class _Dialect:
name = "postgresql"
class _Bind:
dialect = _Dialect()
class _RecordingSession:
@staticmethod
def get_bind() -> _Bind:
return _Bind()
@staticmethod
def execute(statement: object) -> None:
executed.append(statement)
session = _RecordingSession()
with organization_memory_operation_locks( # type: ignore[arg-type]
session,
"scope-lock",
"request-lock",
):
assert len(executed) == 2
assert all("pg_advisory_xact_lock" in str(statement) for statement in executed)
lock_id = organization_memory_advisory_lock_id("scope-lock")
assert -(2**63) <= lock_id < 2**63
assert lock_id == organization_memory_advisory_lock_id("scope-lock")
def test_new_generation_rejects_unexpected_active_set_before_suppression() -> None:
with build_in_memory_session_factory()() as db:
admin = _user(is_admin=True)
active = _active_organization_memory(
tenant_id=admin.tenant_id,
scope_type="enterprise",
scope_id=admin.tenant_id,
value="火车",
generation=1,
)
db.add(active)
db.commit()
now = datetime.now(UTC)
with pytest.raises(OrganizationMemoryConflictError, match="生效版本已变化"):
ExpenseApplicationOrganizationMemoryService(db)._new_active_generation(
entry_id=str(uuid.uuid4()),
tenant_id=admin.tenant_id,
scope_type="enterprise",
scope_id=admin.tenant_id,
value="飞机",
expires_at=now + timedelta(days=180),
reason="模拟并发创建",
request_id="unexpected-active-generation",
request_fingerprint="hmac-sha256:" + "0" * 64,
expected_active_ids=set(),
current_user=admin,
now=now,
)
db.refresh(active)
assert active.status == "active"
assert active.superseded_by_id is None