from __future__ import annotations from collections.abc import Generator from datetime import UTC, datetime from decimal import Decimal import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.api.deps import CurrentUserContext, get_current_user, get_db from app.db.base import Base from app.main import create_app from app.models.agent_run import AgentRun from app.models.employee import Employee from app.models.financial_record import ( AccountsPayableRecord, AccountsReceivableRecord, ExpenseClaim, ) from app.models.organization import OrganizationUnit from app.models.tenant import Tenant from app.schemas.ontology import OntologyParseRequest from app.schemas.orchestrator import OrchestratorRequest from app.services.ontology import SemanticOntologyService from app.services.orchestrator import OrchestratorService 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 _employee( *, tenant_id: str, employee_id: str, employee_no: str, name: str, email: str, manager_id: str | None = None, ) -> Employee: return Employee( id=employee_id, tenant_id=tenant_id, employee_no=employee_no, name=name, email=email, manager_id=manager_id, ) def _claim( *, tenant_id: str, claim_id: str, claim_no: str, employee_id: str, employee_name: str, department_id: str, department_name: str, project_code: str, ) -> ExpenseClaim: now = datetime.now(UTC) return ExpenseClaim( id=claim_id, tenant_id=tenant_id, claim_no=claim_no, employee_id=employee_id, employee_name=employee_name, department_id=department_id, department_name=department_name, project_code=project_code, expense_type="travel", reason="客户拜访", location="上海", amount=Decimal("100.00"), invoice_count=1, occurred_at=now, submitted_at=now, status="submitted", approval_stage="直属领导审批", risk_flags_json=[], ) def test_ontology_reference_catalog_never_reads_another_tenant() -> None: factory = _session_factory() today = datetime.now(UTC).date() with factory() as db: db.add_all([_tenant("tenant-a"), _tenant("tenant-b")]) db.add_all( [ OrganizationUnit( id="dept-a", tenant_id="tenant-a", unit_code="A-FIN", name="甲方财务部", ), OrganizationUnit( id="dept-b", tenant_id="tenant-b", unit_code="B-FIN", name="乙方机密部门", ), _employee( tenant_id="tenant-a", employee_id="employee-a", employee_no="A001", name="甲方员工", email="employee-a@example.com", ), _employee( tenant_id="tenant-b", employee_id="employee-b", employee_no="B001", name="乙方机密员工", email="employee-b@example.com", ), ] ) db.add_all( [ _claim( tenant_id="tenant-a", claim_id="claim-a", claim_no="RE-A-001", employee_id="employee-a", employee_name="甲方员工", department_id="dept-a", department_name="甲方财务部", project_code="PROJECT-A", ), _claim( tenant_id="tenant-b", claim_id="claim-b", claim_no="RE-B-001", employee_id="employee-b", employee_name="乙方机密员工", department_id="dept-b", department_name="乙方机密部门", project_code="PROJECT-B-SECRET", ), AccountsReceivableRecord( tenant_id="tenant-a", receivable_no="AR-A-001", customer_id="customer-a", customer_name="甲方客户", amount_receivable=Decimal("100"), amount_received=Decimal("0"), amount_outstanding=Decimal("100"), posting_date=today, due_date=today, status="open", ), AccountsReceivableRecord( tenant_id="tenant-b", receivable_no="AR-B-001", customer_id="customer-b", customer_name="乙方机密客户", amount_receivable=Decimal("200"), amount_received=Decimal("0"), amount_outstanding=Decimal("200"), posting_date=today, due_date=today, status="open", ), AccountsPayableRecord( tenant_id="tenant-a", payable_no="AP-A-001", vendor_id="vendor-a", vendor_name="甲方供应商", amount_payable=Decimal("100"), amount_paid=Decimal("0"), amount_outstanding=Decimal("100"), posting_date=today, due_date=today, status="open", ), AccountsPayableRecord( tenant_id="tenant-b", payable_no="AP-B-001", vendor_id="vendor-b", vendor_name="乙方机密供应商", amount_payable=Decimal("200"), amount_paid=Decimal("0"), amount_outstanding=Decimal("200"), posting_date=today, due_date=today, status="open", ), ] ) db.commit() catalog = SemanticOntologyService(db)._load_reference_catalog(tenant_id="tenant-a") assert catalog.employees == ["甲方员工"] assert catalog.departments == ["甲方财务部"] assert catalog.customers == ["甲方客户"] assert catalog.vendors == ["甲方供应商"] assert catalog.projects == ["PROJECT-A"] all_values = [ *catalog.employees, *catalog.departments, *catalog.customers, *catalog.vendors, *catalog.projects, ] assert all("机密" not in value for value in all_values) def test_ontology_creates_tenant_run_before_model_and_persists_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: factory = _session_factory() with factory() as db: db.add(_tenant("tenant-a")) db.commit() service = SemanticOntologyService(db) observed: dict[str, str] = {} def fake_model_parse(**kwargs): context = kwargs["operation_context"] run = db.scalar(select(AgentRun).where(AgentRun.run_id == context.run_id)) assert run is not None assert run.status == "running" assert run.route_json["tenant_id"] == "tenant-a" assert run.route_json["phase"] == "pending_model_analysis" assert context.tenant_id == "tenant-a" observed["run_id"] = context.run_id return None, [], None monkeypatch.setattr(service, "_parse_with_model", fake_model_parse) result = service.parse( OntologyParseRequest(query="查询本月报销金额", user_id="employee-a"), tenant_id="tenant-a", ) assert result.run_id == observed["run_id"] succeeded = db.scalar(select(AgentRun).where(AgentRun.run_id == result.run_id)) assert succeeded is not None assert succeeded.route_json["tenant_id"] == "tenant-a" assert succeeded.ontology_json["tenant_id"] == "tenant-a" with pytest.raises(ValueError, match="仅支持财务业务"): service.parse( OntologyParseRequest(query="今天天气怎么样", user_id="employee-a"), tenant_id="tenant-a", ) failed = db.scalars( select(AgentRun).where(AgentRun.status == "failed").order_by(AgentRun.started_at.desc()) ).first() assert failed is not None assert failed.route_json["tenant_id"] == "tenant-a" assert failed.route_json["phase"] == "failed" def test_employee_profile_api_hides_cross_tenant_and_enforces_manager_scope() -> None: factory = _session_factory() with factory() as db: db.add_all([_tenant("tenant-a"), _tenant("tenant-b")]) db.add_all( [ _employee( tenant_id="tenant-a", employee_id="manager-a", employee_no="A-MGR", name="甲方经理", email="manager-a@example.com", ), _employee( tenant_id="tenant-a", employee_id="employee-a", employee_no="A001", name="甲方员工", email="employee-a@example.com", manager_id="manager-a", ), _employee( tenant_id="tenant-b", employee_id="employee-b", employee_no="B001", name="乙方员工", email="employee-b@example.com", ), ] ) db.add( _claim( tenant_id="tenant-b", claim_id="claim-b", claim_no="RE-B-001", employee_id="employee-b", employee_name="乙方员工", department_id="dept-b", department_name="乙方部门", project_code="PROJECT-B", ) ) db.commit() current = { "user": CurrentUserContext( username="manager-a@example.com", name="甲方经理", role_codes=["manager"], is_admin=False, tenant_id="tenant-a", employee_id="manager-a", ) } app = create_app() def override_db() -> Generator[Session, None, None]: db = factory() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_db app.dependency_overrides[get_current_user] = lambda: current["user"] client = TestClient(app) assert client.get("/api/v1/employee-profiles/employee-a/latest").status_code == 200 assert client.get("/api/v1/employee-profiles/employee-b/latest").status_code == 404 assert ( client.get( "/api/v1/employee-profiles/employee-a/latest", params={"claim_id": "claim-b"}, ).status_code == 404 ) current["user"] = CurrentUserContext( username="employee-a@example.com", name="甲方员工", role_codes=[], is_admin=False, tenant_id="tenant-a", employee_id="employee-a", ) assert client.get("/api/v1/employee-profiles/manager-a/latest").status_code == 404 assert client.get("/api/v1/employee-profiles/employee-a/latest").status_code == 200 def test_orchestrator_rejects_untrusted_or_mismatched_tenant_before_run() -> None: factory = _session_factory() payload = OrchestratorRequest( source="user_message", user_id="employee-a@example.com", message="查询本月报销金额", context_json={"tenant_id": "forged-tenant"}, ) with factory() as db: db.add(_tenant("tenant-a")) db.add( Tenant( tenant_id="tenant-suspended", tenant_code="tenant-suspended", name="已停用公司", status="suspended", ) ) db.commit() service = OrchestratorService(db) with pytest.raises(ValueError, match="缺少可信租户上下文"): service.run(payload) with pytest.raises(ValueError, match="不存在或未启用"): service.run(payload, trusted_tenant_id="unknown-tenant") with pytest.raises(ValueError, match="不存在或未启用"): service.run(payload, trusted_tenant_id="tenant-suspended") current_user = CurrentUserContext( username="employee-a@example.com", name="甲方员工", role_codes=[], is_admin=False, tenant_id="tenant-a", employee_id="employee-a", ) with pytest.raises(ValueError, match="租户不一致"): service.run( payload, current_user=current_user, trusted_tenant_id="tenant-b", ) assert db.scalar(select(AgentRun.id)) is None