from __future__ import annotations from collections.abc import Generator from datetime import UTC, datetime from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.api.deps import get_db from app.api.v1.endpoints import linked_reimbursement_draft_jobs as draft_jobs_endpoint from app.main import create_app from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary from app.services.expense_cases import ExpenseCaseService from app.services.linked_reimbursement_draft_jobs import ( _build_direct_context_json, _find_application_claim, clear_linked_reimbursement_draft_jobs_for_tests, ) from app.services.orchestrator import OrchestratorService from app.test_helpers.db import build_in_memory_session_factory def seed_employee_and_application(db: Session) -> None: employee = Employee( id="emp-linked-draft-fast", tenant_id="tenant-a", employee_no="E10001", name="张三", email="zhangsan@example.com", position="实施顾问", grade="P5", ) application = ExpenseClaim( id="application-linked-draft-fast", tenant_id="tenant-a", claim_no="AP-202606-FAST", employee_id=employee.id, employee_name=employee.name, department_id="dept-delivery", department_name="交付部", project_code=None, expense_type="travel_application", reason="支撑国网仿生产服务器部署", location="上海", amount=3000, currency="CNY", invoice_count=0, occurred_at=datetime(2026, 2, 20, tzinfo=UTC), submitted_at=None, status="approved", approval_stage="已完成", risk_flags_json=[], ) db.add_all([employee, application]) db.commit() def build_client(monkeypatch) -> tuple[TestClient, object]: session_factory = build_in_memory_session_factory() app = create_app() install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_db monkeypatch.setattr(draft_jobs_endpoint, "get_session_factory", lambda: session_factory) return TestClient(app), session_factory def test_linked_reimbursement_draft_job_runs_after_conversation_leaves(monkeypatch) -> None: clear_linked_reimbursement_draft_jobs_for_tests() captured_messages = [] captured_users = [] def fake_run(self, payload, *, current_user=None): captured_messages.append(payload.message) captured_users.append(current_user) return OrchestratorResponse( run_id="run-linked-draft-job", conversation_id=None, selected_agent="user_agent", route_reason="测试后台生成报销草稿。", permission_level="draft_write", status="succeeded", result={ "message": "报销草稿已生成。", "draft_payload": { "claim_id": "draft-linked-1", "claim_no": "RE-202606-009", "status": "draft", "expense_type": "travel", }, }, requires_confirmation=False, trace_summary=OrchestratorTraceSummary( scenario="expense", intent="draft", tool_count=1, failed_tool_count=0, selected_capability_codes=[], degraded=False, ), ) monkeypatch.setattr(OrchestratorService, "run", fake_run) try: client, _session_factory = build_client(monkeypatch) headers = { "x-auth-username": "zhangsan@example.com", "x-auth-name": "Zhang San", "x-auth-employee-no": "E10001", "x-auth-role-codes": "user", } response = client.post( "/api/v1/reimbursements/linked-reimbursement-draft-jobs", headers=headers, json={ "message": "我要报销\n用户选择报销场景:差旅费\n关联申请单:AP-202606-001", "conversation_id": "inline-test", "context_json": { "review_action": "save_draft", "expense_scene_selection": { "expense_type": "travel", "expense_type_label": "差旅费", "application_claim_no": "AP-202606-001", }, "review_form_values": { "application_claim_no": "AP-202606-001", }, }, }, ) assert response.status_code == 202 job_id = response.json()["job_id"] status_response = client.get( f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}", headers=headers, ) assert status_response.status_code == 200 payload = status_response.json() assert payload["status"] == "succeeded" assert payload["draft_payload"]["claim_no"] == "RE-202606-009" assert payload["run_id"] == "run-linked-draft-job" assert captured_messages == [ "我要报销\n用户选择报销场景:差旅费\n关联申请单:AP-202606-001" ] assert len(captured_users) == 1 assert captured_users[0] is not None assert captured_users[0].username == "zhangsan@example.com" assert captured_users[0].tenant_id == "default" finally: clear_linked_reimbursement_draft_jobs_for_tests() def test_linked_job_overrides_forged_tenant_and_rejects_same_owner_cross_tenant( monkeypatch, ) -> None: clear_linked_reimbursement_draft_jobs_for_tests() captured_contexts = [] captured_users = [] def fake_run(self, payload, *, current_user=None): captured_contexts.append(dict(payload.context_json or {})) captured_users.append(current_user) return OrchestratorResponse( run_id="run-linked-tenant-guard", conversation_id=None, selected_agent="user_agent", route_reason="验证租户边界。", permission_level="draft_write", status="succeeded", result={ "message": "报销草稿已生成。", "draft_payload": { "claim_id": "tenant-guard-draft", "claim_no": "RE-TENANT-GUARD", "status": "draft", "expense_type": "travel", }, }, requires_confirmation=False, trace_summary=OrchestratorTraceSummary( scenario="expense", intent="draft", tool_count=1, failed_tool_count=0, selected_capability_codes=[], degraded=False, ), ) monkeypatch.setattr(OrchestratorService, "run", fake_run) try: client, _session_factory = build_client(monkeypatch) tenant_a_headers = { "x-auth-username": "same-owner@example.com", "x-auth-name": "Same Owner", "x-auth-role-codes": "user", "x-auth-tenant-id": "tenant-a", } response = client.post( "/api/v1/reimbursements/linked-reimbursement-draft-jobs", headers=tenant_a_headers, json={ "message": "创建普通报销草稿", "context_json": { "tenant_id": "tenant-forged", "tenantId": "tenant-forged-camel", }, }, ) assert response.status_code == 202 assert captured_contexts == [ { "tenant_id": "tenant-a", "entry_source": "workbench-ai", "session_type": "expense", } ] assert len(captured_users) == 1 assert captured_users[0] is not None assert captured_users[0].tenant_id == "tenant-a" assert captured_users[0].tenant_id != "default" job_id = response.json()["job_id"] cross_tenant_response = client.get( f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}", headers={**tenant_a_headers, "x-auth-tenant-id": "tenant-b"}, ) assert cross_tenant_response.status_code == 404 finally: clear_linked_reimbursement_draft_jobs_for_tests() def test_linked_application_lookup_and_context_are_tenant_scoped() -> None: session_factory = build_in_memory_session_factory() with session_factory() as db: seed_employee_and_application(db) application = db.get(ExpenseClaim, "application-linked-draft-fast") assert application is not None ExpenseCaseService(db).ensure_case_for_claim( application, tenant_id="tenant-a", ) db.commit() assert ( _find_application_claim( db, claim_no="AP-202606-FAST", tenant_id="tenant-b", ) is None ) resolved = _find_application_claim( db, claim_no="AP-202606-FAST", tenant_id="tenant-a", ) assert resolved is not None context = _build_direct_context_json( db, { "tenant_id": "tenant-forged", "tenantId": "tenant-forged-camel", "review_form_values": { "application_claim_no": "AP-202606-FAST", }, }, tenant_id="tenant-a", ) assert context["tenant_id"] == "tenant-a" assert "tenantId" not in context assert context["review_form_values"]["application_claim_id"] == application.id def test_linked_reimbursement_draft_job_uses_direct_save_path(monkeypatch) -> None: clear_linked_reimbursement_draft_jobs_for_tests() def fail_if_orchestrator_runs(self, payload): raise AssertionError("linked draft job should not run full orchestrator") monkeypatch.setattr(OrchestratorService, "run", fail_if_orchestrator_runs) try: client, session_factory = build_client(monkeypatch) with session_factory() as db: seed_employee_and_application(db) headers = { "x-auth-username": "zhangsan@example.com", "x-auth-name": "Zhang San", "x-auth-employee-no": "E10001", "x-auth-role-codes": "user", "x-auth-tenant-id": "tenant-a", } response = client.post( "/api/v1/reimbursements/linked-reimbursement-draft-jobs", headers=headers, json={ "message": "我要报销\n用户选择报销场景:差旅费\n关联申请单:AP-202606-FAST", "conversation_id": "inline-fast-test", "context_json": { "name": "张三", "review_action": "save_draft", "expense_scene_selection": { "expense_type": "travel", "expense_type_label": "差旅费", "application_claim_id": "application-linked-draft-fast", "application_claim_no": "AP-202606-FAST", }, "review_form_values": { "expense_type": "差旅费", "reason": "支撑国网仿生产服务器部署", "location": "上海", "time_range": "2026-02-20 至 2026-02-23", "application_claim_id": "application-linked-draft-fast", "application_claim_no": "AP-202606-FAST", "application_reason": "支撑国网仿生产服务器部署", "application_location": "上海", "application_amount": "3000", "application_amount_label": "¥3,000", "application_business_time": "2026-02-20 至 2026-02-23", }, }, }, ) assert response.status_code == 202 job_id = response.json()["job_id"] status_response = client.get( f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}", headers=headers, ) assert status_response.status_code == 200 payload = status_response.json() assert payload["status"] == "succeeded" assert payload["draft_payload"]["claim_no"] assert payload["draft_payload"]["claim_id"] assert payload["run_id"].startswith("linked-reimbursement-draft-") with session_factory() as db: draft = db.get(ExpenseClaim, payload["draft_payload"]["claim_id"]) assert draft is not None assert draft.status == "draft" assert draft.expense_type == "travel" assert draft.reason == "支撑国网仿生产服务器部署" assert draft.items == [] finally: clear_linked_reimbursement_draft_jobs_for_tests() def test_linked_reimbursement_draft_job_uses_direct_save_path_with_application_no_only( monkeypatch, ) -> None: clear_linked_reimbursement_draft_jobs_for_tests() def fail_if_orchestrator_runs(self, payload): raise AssertionError( "linked draft job should resolve application no without full orchestrator" ) monkeypatch.setattr(OrchestratorService, "run", fail_if_orchestrator_runs) try: client, session_factory = build_client(monkeypatch) with session_factory() as db: seed_employee_and_application(db) headers = { "x-auth-username": "zhangsan@example.com", "x-auth-name": "Zhang San", "x-auth-employee-no": "E10001", "x-auth-role-codes": "user", "x-auth-tenant-id": "tenant-a", } response = client.post( "/api/v1/reimbursements/linked-reimbursement-draft-jobs", headers=headers, json={ "message": "我要报销\n用户选择报销场景:差旅费\n关联申请单:AP-202606-FAST", "conversation_id": "inline-fast-no-id-test", "context_json": { "name": "张三", "review_action": "save_draft", "expense_scene_selection": { "expense_type": "travel", "expense_type_label": "差旅费", "application_claim_no": "AP-202606-FAST", }, "review_form_values": { "expense_type": "差旅费", "reason": "支撑国网仿生产服务器部署", "location": "上海", "time_range": "2026-02-20 至 2026-02-23", "application_claim_no": "AP-202606-FAST", "application_reason": "支撑国网仿生产服务器部署", "application_location": "上海", "application_amount": "3000", }, }, }, ) assert response.status_code == 202 job_id = response.json()["job_id"] status_response = client.get( f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}", headers=headers, ) assert status_response.status_code == 200 payload = status_response.json() assert payload["status"] == "succeeded" assert payload["draft_payload"]["claim_no"] assert payload["draft_payload"]["claim_id"] with session_factory() as db: draft = db.get(ExpenseClaim, payload["draft_payload"]["claim_id"]) assert draft is not None link_flag = next( flag for flag in draft.risk_flags_json if flag.get("source") == "application_link" ) assert link_flag["application_claim_no"] == "AP-202606-FAST" assert link_flag["application_claim_id"] == "application-linked-draft-fast" finally: clear_linked_reimbursement_draft_jobs_for_tests()