306 lines
10 KiB
Python
306 lines
10 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
from decimal import Decimal
|
||
|
|
from importlib.util import module_from_spec, spec_from_file_location
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import create_engine, select
|
||
|
|
from sqlalchemy.orm import Session, sessionmaker
|
||
|
|
from sqlalchemy.pool import StaticPool
|
||
|
|
|
||
|
|
from app.core.config import get_settings
|
||
|
|
from app.db.base import Base
|
||
|
|
from app.models.agent_run import AgentRun, AgentToolCall
|
||
|
|
from app.models.financial_record import ExpenseClaim
|
||
|
|
from app.models.tenant import Tenant
|
||
|
|
from app.models.tenant_finance_report import TenantFinanceReportRun
|
||
|
|
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
|
||
|
|
from app.services.digital_employee_finance_report_task import (
|
||
|
|
DigitalEmployeeFinanceReportTaskService,
|
||
|
|
)
|
||
|
|
from app.services.finance_report_tenant import TenantFinanceReportConfigService
|
||
|
|
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
|
||
|
|
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||
|
|
|
||
|
|
|
||
|
|
def _session_factory() -> sessionmaker[Session]:
|
||
|
|
engine = create_engine(
|
||
|
|
"sqlite+pysqlite:///:memory:",
|
||
|
|
connect_args={"check_same_thread": False},
|
||
|
|
poolclass=StaticPool,
|
||
|
|
)
|
||
|
|
Base.metadata.create_all(bind=engine)
|
||
|
|
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||
|
|
|
||
|
|
|
||
|
|
def _tenant(tenant_id: str) -> Tenant:
|
||
|
|
return Tenant(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
tenant_code=tenant_id,
|
||
|
|
name=f"{tenant_id} 公司",
|
||
|
|
status="active",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _claim(
|
||
|
|
*,
|
||
|
|
tenant_id: str,
|
||
|
|
claim_id: str,
|
||
|
|
claim_no: str,
|
||
|
|
amount: str,
|
||
|
|
now: datetime,
|
||
|
|
) -> ExpenseClaim:
|
||
|
|
return ExpenseClaim(
|
||
|
|
id=claim_id,
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
claim_no=claim_no,
|
||
|
|
employee_name=f"{tenant_id} 员工",
|
||
|
|
department_name=f"{tenant_id} 财务部",
|
||
|
|
expense_type="travel",
|
||
|
|
reason="客户拜访",
|
||
|
|
location="上海",
|
||
|
|
amount=Decimal(amount),
|
||
|
|
invoice_count=1,
|
||
|
|
occurred_at=now - timedelta(days=1),
|
||
|
|
submitted_at=now - timedelta(days=1),
|
||
|
|
status="submitted",
|
||
|
|
approval_stage="直属领导审批",
|
||
|
|
risk_flags_json=[],
|
||
|
|
hermes_risk_flag=False,
|
||
|
|
created_at=now - timedelta(days=1),
|
||
|
|
updated_at=now,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _digital_run(
|
||
|
|
*,
|
||
|
|
tenant_id: str,
|
||
|
|
run_id: str,
|
||
|
|
scanned_claim_count: int,
|
||
|
|
now: datetime,
|
||
|
|
) -> AgentRun:
|
||
|
|
return AgentRun(
|
||
|
|
run_id=run_id,
|
||
|
|
agent="hermes",
|
||
|
|
source="schedule",
|
||
|
|
user_id="digital_employee",
|
||
|
|
status="succeeded",
|
||
|
|
route_json={
|
||
|
|
"tenant_id": tenant_id,
|
||
|
|
"task_type": "global_risk_scan",
|
||
|
|
},
|
||
|
|
ontology_json={"tenant_id": tenant_id},
|
||
|
|
result_summary="风险扫描完成。",
|
||
|
|
started_at=now - timedelta(minutes=2),
|
||
|
|
finished_at=now - timedelta(minutes=1),
|
||
|
|
tool_calls=[
|
||
|
|
AgentToolCall(
|
||
|
|
run_id=run_id,
|
||
|
|
tool_type="rule_engine",
|
||
|
|
tool_name="digital_employee.financial_risk_graph.scan",
|
||
|
|
request_json={"tenant_id": tenant_id},
|
||
|
|
response_json={"scanned_claim_count": scanned_claim_count},
|
||
|
|
status="succeeded",
|
||
|
|
duration_ms=10,
|
||
|
|
created_at=now - timedelta(minutes=2),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_hermes_scanners_and_dashboard_do_not_mix_tenants() -> None:
|
||
|
|
factory = _session_factory()
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
with factory() as db:
|
||
|
|
db.add_all([_tenant("tenant-a"), _tenant("tenant-b")])
|
||
|
|
db.add_all(
|
||
|
|
[
|
||
|
|
_claim(
|
||
|
|
tenant_id="tenant-a",
|
||
|
|
claim_id="claim-a",
|
||
|
|
claim_no="RE-A-001",
|
||
|
|
amount="100.00",
|
||
|
|
now=now,
|
||
|
|
),
|
||
|
|
_claim(
|
||
|
|
tenant_id="tenant-b",
|
||
|
|
claim_id="claim-b",
|
||
|
|
claim_no="RE-B-SECRET",
|
||
|
|
amount="9000.00",
|
||
|
|
now=now,
|
||
|
|
),
|
||
|
|
_digital_run(
|
||
|
|
tenant_id="tenant-a",
|
||
|
|
run_id="run-a",
|
||
|
|
scanned_claim_count=1,
|
||
|
|
now=now,
|
||
|
|
),
|
||
|
|
_digital_run(
|
||
|
|
tenant_id="tenant-b",
|
||
|
|
run_id="run-b",
|
||
|
|
scanned_claim_count=99,
|
||
|
|
now=now,
|
||
|
|
),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
fetched = HermesRiskScannerService(db)._fetch_unscanned_claims(tenant_id="tenant-a")
|
||
|
|
assert [row.id for row in fetched] == ["claim-a"]
|
||
|
|
|
||
|
|
clues = HermesRiskClueCollectorService(db).collect_risk_clues(tenant_id="tenant-a")
|
||
|
|
assert clues["tenant_id"] == "tenant-a"
|
||
|
|
assert [row["claim_no"] for row in clues["facts"]] == ["RE-A-001"]
|
||
|
|
|
||
|
|
dashboard = DigitalEmployeeDashboardService(
|
||
|
|
db,
|
||
|
|
tenant_id="tenant-a",
|
||
|
|
).build_dashboard(days=7)
|
||
|
|
assert dashboard.totals["totalRuns"] == 1
|
||
|
|
assert dashboard.totals["riskObservations"] == 0
|
||
|
|
assert [row["runId"] for row in dashboard.recent_runs] == ["run-a"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_finance_report_recipients_data_path_and_idempotency_are_tenant_bound(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
tmp_path: Path,
|
||
|
|
) -> None:
|
||
|
|
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path))
|
||
|
|
get_settings.cache_clear()
|
||
|
|
factory = _session_factory()
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
start_date = (now - timedelta(days=3)).date()
|
||
|
|
end_date = now.date()
|
||
|
|
try:
|
||
|
|
with factory() as db:
|
||
|
|
db.add_all([_tenant("tenant-a"), _tenant("tenant-b")])
|
||
|
|
db.add_all(
|
||
|
|
[
|
||
|
|
_claim(
|
||
|
|
tenant_id="tenant-a",
|
||
|
|
claim_id="claim-a",
|
||
|
|
claim_no="RE-A-001",
|
||
|
|
amount="100.00",
|
||
|
|
now=now,
|
||
|
|
),
|
||
|
|
_claim(
|
||
|
|
tenant_id="tenant-b",
|
||
|
|
claim_id="claim-b",
|
||
|
|
claim_no="RE-B-001",
|
||
|
|
amount="9000.00",
|
||
|
|
now=now,
|
||
|
|
),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
config_service = TenantFinanceReportConfigService(db)
|
||
|
|
config_service.upsert(
|
||
|
|
tenant_id="tenant-a",
|
||
|
|
recipients=["finance-a@example.com"],
|
||
|
|
delivery_enabled=True,
|
||
|
|
updated_by="finance-a",
|
||
|
|
)
|
||
|
|
config_service.upsert(
|
||
|
|
tenant_id="tenant-b",
|
||
|
|
recipients=["finance-b@example.com"],
|
||
|
|
delivery_enabled=True,
|
||
|
|
updated_by="finance-b",
|
||
|
|
)
|
||
|
|
assert config_service.configured_recipients(tenant_id="tenant-a") == [
|
||
|
|
"finance-a@example.com"
|
||
|
|
]
|
||
|
|
assert (
|
||
|
|
config_service.configured_recipients(
|
||
|
|
tenant_id="tenant-a",
|
||
|
|
requested=["finance-b@example.com"],
|
||
|
|
)
|
||
|
|
== []
|
||
|
|
)
|
||
|
|
assert config_service.configured_recipients(tenant_id="missing") == []
|
||
|
|
|
||
|
|
task = DigitalEmployeeFinanceReportTaskService(db)
|
||
|
|
report_a = task.generate_report(
|
||
|
|
report_type="weekly",
|
||
|
|
start_date=start_date,
|
||
|
|
end_date=end_date,
|
||
|
|
tenant_id="tenant-a",
|
||
|
|
send_email=False,
|
||
|
|
)
|
||
|
|
replay_a = task.generate_report(
|
||
|
|
report_type="weekly",
|
||
|
|
start_date=start_date,
|
||
|
|
end_date=end_date,
|
||
|
|
tenant_id="tenant-a",
|
||
|
|
send_email=False,
|
||
|
|
)
|
||
|
|
report_b = task.generate_report(
|
||
|
|
report_type="weekly",
|
||
|
|
start_date=start_date,
|
||
|
|
end_date=end_date,
|
||
|
|
tenant_id="tenant-b",
|
||
|
|
send_email=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert report_a["summary"]["reimbursement_count"] == 1
|
||
|
|
assert report_a["summary"]["reimbursement_amount"] == 100.0
|
||
|
|
assert report_b["summary"]["reimbursement_count"] == 1
|
||
|
|
assert report_b["summary"]["reimbursement_amount"] == 9000.0
|
||
|
|
assert replay_a["idempotent_replay"] is True
|
||
|
|
assert report_a["pdf"]["storage_key"] != report_b["pdf"]["storage_key"]
|
||
|
|
assert "/tenants/" in report_a["pdf"]["storage_key"]
|
||
|
|
assert "tenant-a" not in report_a["pdf"]["storage_key"]
|
||
|
|
|
||
|
|
ledger_rows = list(db.scalars(select(TenantFinanceReportRun)).all())
|
||
|
|
assert len(ledger_rows) == 2
|
||
|
|
assert {row.tenant_id for row in ledger_rows} == {"tenant-a", "tenant-b"}
|
||
|
|
assert len({row.idempotency_key for row in ledger_rows}) == 2
|
||
|
|
|
||
|
|
runs = list(db.scalars(select(AgentRun).where(AgentRun.agent == "hermes")))
|
||
|
|
tenant_a_runs = [
|
||
|
|
row for row in runs if (row.route_json or {}).get("tenant_id") == "tenant-a"
|
||
|
|
]
|
||
|
|
assert len(tenant_a_runs) == 1
|
||
|
|
assert tenant_a_runs[0].ontology_json["tenant_id"] == "tenant-a"
|
||
|
|
finally:
|
||
|
|
get_settings.cache_clear()
|
||
|
|
|
||
|
|
|
||
|
|
class _UnsupportedDialectOperationGuard:
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.bind = type("Bind", (), {"dialect": type("Dialect", (), {"name": "sqlite"})()})()
|
||
|
|
self.mutation_calls: list[str] = []
|
||
|
|
|
||
|
|
def get_bind(self) -> Any:
|
||
|
|
return self.bind
|
||
|
|
|
||
|
|
def __getattr__(self, name: str) -> Any:
|
||
|
|
self.mutation_calls.append(name)
|
||
|
|
raise AssertionError(f"unsupported dialect attempted migration operation: {name}")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("direction", ["upgrade", "downgrade"])
|
||
|
|
def test_hermes_tenant_migration_rejects_unsupported_dialect_before_mutation(
|
||
|
|
direction: str,
|
||
|
|
) -> None:
|
||
|
|
migration_path = (
|
||
|
|
Path(__file__).resolve().parents[1]
|
||
|
|
/ "alembic"
|
||
|
|
/ "versions"
|
||
|
|
/ "20260717_0028_hermes_ontology_tenant_security.py"
|
||
|
|
)
|
||
|
|
spec = spec_from_file_location("migration_20260717_0028_test", migration_path)
|
||
|
|
assert spec is not None and spec.loader is not None
|
||
|
|
module = module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(module)
|
||
|
|
guard = _UnsupportedDialectOperationGuard()
|
||
|
|
module.op = guard
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError, match="only supports PostgreSQL"):
|
||
|
|
getattr(module, direction)()
|
||
|
|
|
||
|
|
assert guard.mutation_calls == []
|