Files
X-Financial/server/tests/test_linked_reimbursement_draft_jobs.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

440 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()