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 == []