feat(ai): unify verified expense application workflow
This commit is contained in:
@@ -198,6 +198,21 @@ def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
|
||||
assert len(list(db.scalars(select(AIDecision)).all())) == 1
|
||||
|
||||
|
||||
def test_same_snapshot_with_different_issue_request_reuses_active_decision() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
first = issue_preview(client, request_id="issue-preview-same-snapshot-1")
|
||||
second = issue_preview(client, request_id="issue-preview-same-snapshot-2")
|
||||
|
||||
assert second["decision_id"] == first["decision_id"]
|
||||
with session_factory() as db:
|
||||
decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all())
|
||||
assert len(decisions) == 1
|
||||
assert decisions[0].status == "issued"
|
||||
|
||||
|
||||
def test_server_preview_decision_detects_server_side_field_edit() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
|
||||
407
server/tests/test_orchestrator_auth_endpoints.py
Normal file
407
server/tests/test_orchestrator_auth_endpoints.py
Normal file
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
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,
|
||||
get_optional_current_user,
|
||||
)
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.agent_conversation import AgentConversation
|
||||
from app.schemas.orchestrator import (
|
||||
OrchestratorRequest,
|
||||
OrchestratorResponse,
|
||||
OrchestratorTraceSummary,
|
||||
)
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_context() -> Generator[
|
||||
tuple[TestClient, FastAPI, sessionmaker[Session]],
|
||||
None,
|
||||
None,
|
||||
]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
try:
|
||||
yield client, app, session_factory
|
||||
finally:
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def authenticated_user() -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username="signed-in@example.com",
|
||||
name="登录用户",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="tenant-signed-in",
|
||||
department_name="财务共享部",
|
||||
cost_center="CC-SIGNED-IN",
|
||||
position="费用专员",
|
||||
grade="P5",
|
||||
employee_no="E-SIGNED-IN",
|
||||
manager_name="直属经理",
|
||||
employee_id="employee-signed-in",
|
||||
auth_session_id="session-signed-in",
|
||||
)
|
||||
|
||||
|
||||
def install_authenticated_user(app: FastAPI) -> CurrentUserContext:
|
||||
current_user = authenticated_user()
|
||||
app.dependency_overrides[get_current_user] = lambda: current_user
|
||||
app.dependency_overrides[get_optional_current_user] = lambda: current_user
|
||||
return current_user
|
||||
|
||||
|
||||
def install_admin_user(app: FastAPI) -> CurrentUserContext:
|
||||
current_user = authenticated_user()
|
||||
current_user.role_codes = ["admin"]
|
||||
current_user.is_admin = True
|
||||
app.dependency_overrides[get_current_user] = lambda: current_user
|
||||
app.dependency_overrides[get_optional_current_user] = lambda: current_user
|
||||
return current_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"])
|
||||
def test_orchestrator_run_requires_authentication_for_every_external_source(
|
||||
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
|
||||
source: str,
|
||||
) -> None:
|
||||
client, _, _ = http_context
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/orchestrator/run",
|
||||
json={
|
||||
"source": source,
|
||||
"user_id": "forged-user@example.com",
|
||||
"message": "帮我申请差旅费用",
|
||||
"context_json": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.headers["www-authenticate"] == "Bearer"
|
||||
assert response.json()["detail"] == "请先登录后再使用智能助手。"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["system_event", "schedule"])
|
||||
def test_non_admin_cannot_trigger_privileged_orchestrator_sources(
|
||||
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
|
||||
source: str,
|
||||
) -> None:
|
||||
client, app, _ = http_context
|
||||
install_authenticated_user(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/orchestrator/run",
|
||||
json={
|
||||
"source": source,
|
||||
"user_id": "forged-admin@example.com",
|
||||
"message": "运行全局风险扫描",
|
||||
"context_json": {
|
||||
"role_codes": ["admin"],
|
||||
"is_admin": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "只有平台管理员可以从外部触发调度或系统事件。"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["system_event", "schedule"])
|
||||
def test_admin_can_trigger_privileged_orchestrator_sources(
|
||||
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
source: str,
|
||||
) -> None:
|
||||
client, app, _ = http_context
|
||||
expected_user = install_admin_user(app)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(
|
||||
self: OrchestratorService,
|
||||
payload: OrchestratorRequest,
|
||||
*,
|
||||
current_user: CurrentUserContext | None = None,
|
||||
) -> OrchestratorResponse:
|
||||
del self
|
||||
captured["source"] = payload.source
|
||||
captured["current_user"] = current_user
|
||||
return OrchestratorResponse(
|
||||
run_id="run-admin-source",
|
||||
selected_agent="hermes",
|
||||
route_reason="test_admin_source",
|
||||
permission_level="read",
|
||||
status="succeeded",
|
||||
result={},
|
||||
requires_confirmation=False,
|
||||
trace_summary=OrchestratorTraceSummary(
|
||||
scenario="system",
|
||||
intent="risk_check",
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OrchestratorService, "run", fake_run)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/orchestrator/run",
|
||||
json={
|
||||
"source": source,
|
||||
"message": "运行全局风险扫描",
|
||||
"context_json": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert captured["source"] == source
|
||||
assert captured["current_user"] == expected_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"])
|
||||
def test_authenticated_payload_overrides_all_scheduler_identity_aliases(
|
||||
source: str,
|
||||
) -> None:
|
||||
current_user = authenticated_user()
|
||||
payload = OrchestratorRequest(
|
||||
source=source,
|
||||
user_id="forged-user@example.com",
|
||||
message="测试身份覆盖",
|
||||
context_json={
|
||||
"username": "forged-user@example.com",
|
||||
"user_id": "forged-user@example.com",
|
||||
"tenant_id": "tenant-forged",
|
||||
"role_codes": ["admin"],
|
||||
"is_admin": True,
|
||||
"requested_by_username": "forged-requester@example.com",
|
||||
"requested_by_name": "伪造请求人",
|
||||
"actor": "forged-actor@example.com",
|
||||
"actor_id": "forged-actor-id",
|
||||
"auth_session_id": "forged-session",
|
||||
},
|
||||
)
|
||||
|
||||
trusted = OrchestratorService._build_authenticated_user_payload(payload, current_user)
|
||||
|
||||
assert trusted.user_id == current_user.username
|
||||
assert trusted.context_json["tenant_id"] == current_user.tenant_id
|
||||
assert trusted.context_json["role_codes"] == current_user.role_codes
|
||||
assert trusted.context_json["is_admin"] is current_user.is_admin
|
||||
assert trusted.context_json["requested_by_username"] == current_user.username
|
||||
assert trusted.context_json["requested_by_name"] == current_user.name
|
||||
assert trusted.context_json["actor"] == current_user.username
|
||||
assert trusted.context_json["actor_id"] == current_user.username
|
||||
assert "auth_session_id" not in trusted.context_json
|
||||
|
||||
|
||||
def test_authenticated_run_passes_server_identity_to_orchestrator(
|
||||
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, app, _ = http_context
|
||||
expected_user = install_authenticated_user(app)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(
|
||||
self: OrchestratorService,
|
||||
payload: OrchestratorRequest,
|
||||
*,
|
||||
current_user: CurrentUserContext | None = None,
|
||||
) -> OrchestratorResponse:
|
||||
del self
|
||||
captured["payload"] = payload
|
||||
captured["current_user"] = current_user
|
||||
return OrchestratorResponse(
|
||||
run_id="run-auth-binding",
|
||||
conversation_id="conversation-auth-binding",
|
||||
selected_agent="user_agent",
|
||||
route_reason="test",
|
||||
permission_level="user",
|
||||
status="succeeded",
|
||||
result={},
|
||||
requires_confirmation=False,
|
||||
trace_summary=OrchestratorTraceSummary(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OrchestratorService, "run", fake_run)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/orchestrator/run",
|
||||
json={
|
||||
"source": "user_message",
|
||||
"user_id": "forged-user@example.com",
|
||||
"message": "保存申请草稿",
|
||||
"context_json": {
|
||||
"username": "forged-user@example.com",
|
||||
"tenant_id": "tenant-forged",
|
||||
"role_codes": ["admin"],
|
||||
"is_admin": True,
|
||||
"employee_no": "E-FORGED",
|
||||
"auth_session_id": "session-forged",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert captured["current_user"] == expected_user
|
||||
assert captured["payload"].user_id == "forged-user@example.com"
|
||||
assert captured["payload"].context_json["tenant_id"] == "tenant-forged"
|
||||
|
||||
|
||||
def test_latest_conversation_uses_authenticated_username(
|
||||
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
|
||||
) -> None:
|
||||
client, app, session_factory = http_context
|
||||
current_user = install_authenticated_user(app)
|
||||
with session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
AgentConversation(
|
||||
conversation_id="conversation-signed-in",
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
title="登录用户会话",
|
||||
state_json={
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"session_type": "expense",
|
||||
},
|
||||
),
|
||||
AgentConversation(
|
||||
conversation_id="conversation-same-user-other-tenant",
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
title="其他租户同名用户会话",
|
||||
state_json={
|
||||
"tenant_id": "tenant-other",
|
||||
"session_type": "expense",
|
||||
"application_preview_decision": {
|
||||
"decision_id": "decision-other-tenant",
|
||||
"application_preview": {
|
||||
"fields": {"reason": "其他租户敏感项目"}
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
AgentConversation(
|
||||
conversation_id="conversation-forged-user",
|
||||
user_id="forged-user@example.com",
|
||||
source="user_message",
|
||||
title="伪造用户会话",
|
||||
state_json={
|
||||
"tenant_id": "tenant-forged",
|
||||
"session_type": "expense",
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/orchestrator/conversations/latest",
|
||||
params={"user_id": "forged-user@example.com", "session_type": "expense"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["found"] is True
|
||||
assert payload["conversation"]["conversation_id"] == "conversation-signed-in"
|
||||
assert payload["conversation"]["user_id"] == current_user.username
|
||||
|
||||
|
||||
def test_delete_conversations_uses_authenticated_username(
|
||||
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
|
||||
) -> None:
|
||||
client, app, session_factory = http_context
|
||||
current_user = install_authenticated_user(app)
|
||||
with session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
AgentConversation(
|
||||
conversation_id="conversation-owner-single",
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
state_json={
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"session_type": "expense",
|
||||
},
|
||||
),
|
||||
AgentConversation(
|
||||
conversation_id="conversation-owner-bulk",
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
state_json={
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"session_type": "expense",
|
||||
},
|
||||
),
|
||||
AgentConversation(
|
||||
conversation_id="conversation-same-user-other-tenant",
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
state_json={
|
||||
"tenant_id": "tenant-other",
|
||||
"session_type": "expense",
|
||||
},
|
||||
),
|
||||
AgentConversation(
|
||||
conversation_id="conversation-attacker",
|
||||
user_id="forged-user@example.com",
|
||||
source="user_message",
|
||||
state_json={
|
||||
"tenant_id": "tenant-forged",
|
||||
"session_type": "expense",
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
single_response = client.delete(
|
||||
"/api/v1/orchestrator/conversations/conversation-owner-single",
|
||||
params={"user_id": "forged-user@example.com"},
|
||||
)
|
||||
bulk_response = client.delete(
|
||||
"/api/v1/orchestrator/conversations",
|
||||
params={"user_id": "forged-user@example.com", "session_type": "expense"},
|
||||
)
|
||||
|
||||
assert single_response.status_code == 200, single_response.text
|
||||
assert single_response.json()["deleted_count"] == 1
|
||||
assert bulk_response.status_code == 200, bulk_response.text
|
||||
assert bulk_response.json()["deleted_count"] == 1
|
||||
with session_factory() as db:
|
||||
remaining = list(db.scalars(select(AgentConversation)).all())
|
||||
assert {item.conversation_id for item in remaining} == {
|
||||
"conversation-attacker",
|
||||
"conversation-same-user-other-tenant",
|
||||
}
|
||||
@@ -4,13 +4,16 @@ from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
@@ -881,3 +884,135 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
assert "当前仅返回确认摘要" not in submitted.result["answer"]
|
||||
assert "申请单据已生成,并已进入审批流程" in submitted.result["answer"]
|
||||
assert submitted.result["draft_payload"]["status"] == "submitted"
|
||||
|
||||
|
||||
def test_authenticated_orchestrator_application_draft_consumes_server_preview_decision(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
def parse_application_for_run(self, request, run_id): # noqa: ANN001
|
||||
return OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
entities=[],
|
||||
permission=OntologyPermission(
|
||||
level="approval_required",
|
||||
allowed=True,
|
||||
reason="费用申请由可信预览工作流执行。",
|
||||
),
|
||||
confidence=0.99,
|
||||
missing_slots=[],
|
||||
ambiguity=[],
|
||||
clarification_required=False,
|
||||
clarification_question=None,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.ontology.SemanticOntologyService.parse_for_run",
|
||||
parse_application_for_run,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
current_user = CurrentUserContext(
|
||||
username="trusted-application@example.com",
|
||||
name="可信申请员工",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="tenant-orchestrator-preview",
|
||||
department_name="交付部",
|
||||
position="实施顾问",
|
||||
grade="P4",
|
||||
employee_no="E-ORCH-001",
|
||||
employee_id="employee-orchestrator-preview",
|
||||
manager_name="陈硕",
|
||||
auth_session_id="session-orchestrator-preview",
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"user_id": "forged@example.com",
|
||||
"tenant_id": "forged-tenant",
|
||||
"is_admin": True,
|
||||
"manager_name": "伪造审批人",
|
||||
}
|
||||
with session_factory() as db:
|
||||
service = OrchestratorService(db)
|
||||
first = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="forged@example.com",
|
||||
message=(
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
),
|
||||
context_json=context_json,
|
||||
),
|
||||
current_user=current_user,
|
||||
)
|
||||
preview = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="forged@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
),
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
decision_id = str(preview.result.get("decision_id") or "")
|
||||
assert preview.status == "blocked"
|
||||
assert preview.requires_confirmation is True
|
||||
assert decision_id
|
||||
assert preview.result["application_preview"]["decisionId"] == decision_id
|
||||
decision = db.get(AIApplicationPreviewDecision, decision_id)
|
||||
assert decision is not None
|
||||
assert decision.status == "issued"
|
||||
assert decision.actor_id == current_user.employee_id
|
||||
assert decision.tenant_id == current_user.tenant_id
|
||||
conversation = service.conversation_service.get_conversation(first.conversation_id or "")
|
||||
assert conversation is not None
|
||||
assert conversation.state_json["application_preview_decision"]["decision_id"] == decision_id
|
||||
saved = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="another-forged@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="保存草稿",
|
||||
context_json={
|
||||
**context_json,
|
||||
"application_preview_decision": {
|
||||
"decision_id": "forged-decision",
|
||||
"status": "issued",
|
||||
},
|
||||
},
|
||||
),
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert saved.status == "succeeded", saved.result
|
||||
assert saved.result["draft_payload"]["status"] == "draft"
|
||||
assert saved.result["draft_payload"]["draft_type"] == "expense_application"
|
||||
assert saved.result["draft_payload"]["claim_no"]
|
||||
assert saved.result["decision_id"]
|
||||
assert saved.result["decision_id"] != decision_id
|
||||
db.refresh(decision)
|
||||
assert decision.status == "consumed"
|
||||
assert decision.consumed_action == "save_draft"
|
||||
learning_decision = db.scalar(
|
||||
select(AIDecision).where(AIDecision.preview_decision_id == decision_id)
|
||||
)
|
||||
assert learning_decision is not None
|
||||
feedback = db.scalar(
|
||||
select(AIDecisionFeedback).where(
|
||||
AIDecisionFeedback.decision_id == learning_decision.id
|
||||
)
|
||||
)
|
||||
assert feedback is not None
|
||||
assert feedback.feedback_type == "accepted"
|
||||
|
||||
@@ -13,6 +13,8 @@ from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.agent_conversation import AgentConversation
|
||||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecisionFeedback
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
@@ -72,7 +74,11 @@ def auth_headers() -> dict[str, str]:
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-name": "Zhang San",
|
||||
"x-auth-employee-no": "E90001",
|
||||
"x-auth-employee-id": "steward-action-employee",
|
||||
"x-auth-session-id": "session-steward-action",
|
||||
"x-auth-tenant-id": "tenant-steward-action",
|
||||
"x-auth-role-codes": "user",
|
||||
"x-auth-department": "Delivery",
|
||||
"x-auth-position": "Engineer",
|
||||
"x-auth-grade": "P4",
|
||||
"x-auth-manager-name": "Leader",
|
||||
@@ -130,6 +136,33 @@ def claim_count(db: Session) -> int:
|
||||
return len(db.scalars(select(ExpenseClaim)).all())
|
||||
|
||||
|
||||
def issue_application_preview(
|
||||
client: TestClient,
|
||||
*,
|
||||
conversation_id: str,
|
||||
client_trace_id: str,
|
||||
task: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
response = client.post(
|
||||
"/api/v1/steward/actions/execute",
|
||||
headers=auth_headers(),
|
||||
json={
|
||||
"action_type": "build_application_preview",
|
||||
"message": (
|
||||
"2026-02-20 至 2026-02-23,去上海出差,"
|
||||
"辅助国网仿生产服务器部署,交通火车,申请金额3000元"
|
||||
),
|
||||
"conversation_id": conversation_id,
|
||||
"client_trace_id": client_trace_id,
|
||||
"task": task or base_application_task(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["status"] == "succeeded", payload
|
||||
return payload["result_payload"]
|
||||
|
||||
|
||||
def seed_approved_application(db: Session) -> None:
|
||||
application = ExpenseClaim(
|
||||
id="application-action-approved",
|
||||
@@ -249,16 +282,108 @@ def test_steward_action_executor_records_pending_interrupt_in_conversation_state
|
||||
assert checkpoint["actions"]["trace-submit-pending"]["status"] == "needs_confirmation"
|
||||
|
||||
|
||||
def test_steward_action_executor_builds_canonical_application_preview_decision() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
first = issue_application_preview(
|
||||
client,
|
||||
conversation_id="conv-action-preview",
|
||||
client_trace_id="trace-build-preview",
|
||||
)
|
||||
second = issue_application_preview(
|
||||
client,
|
||||
conversation_id="conv-action-preview",
|
||||
client_trace_id="trace-build-preview",
|
||||
)
|
||||
|
||||
assert first["decision_id"]
|
||||
assert first["decision_id"] == second["decision_id"]
|
||||
assert first["application_preview"]["decisionId"] == first["decision_id"]
|
||||
assert first["application_preview"]["fields"]["location"] == "上海市"
|
||||
assert second["idempotent_replay"] is True
|
||||
|
||||
|
||||
def test_steward_action_checkpoint_does_not_replay_across_tenants() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
shared_conversation_id = "conv-action-cross-tenant"
|
||||
shared_trace_id = "trace-build-cross-tenant"
|
||||
tenant_a_headers = {
|
||||
**auth_headers(),
|
||||
"x-auth-tenant-id": "tenant-steward-a",
|
||||
}
|
||||
tenant_b_headers = {
|
||||
**auth_headers(),
|
||||
"x-auth-tenant-id": "tenant-steward-b",
|
||||
}
|
||||
request_payload = {
|
||||
"action_type": "build_application_preview",
|
||||
"message": (
|
||||
"2026-02-20 至 2026-02-23,去上海出差,"
|
||||
"辅助国网仿生产服务器部署,交通火车,申请金额3000元"
|
||||
),
|
||||
"conversation_id": shared_conversation_id,
|
||||
"client_trace_id": shared_trace_id,
|
||||
"task": base_application_task(),
|
||||
}
|
||||
|
||||
tenant_a_response = client.post(
|
||||
"/api/v1/steward/actions/execute",
|
||||
headers=tenant_a_headers,
|
||||
json=request_payload,
|
||||
)
|
||||
tenant_b_response = client.post(
|
||||
"/api/v1/steward/actions/execute",
|
||||
headers=tenant_b_headers,
|
||||
json=request_payload,
|
||||
)
|
||||
|
||||
assert tenant_a_response.status_code == 200, tenant_a_response.text
|
||||
assert tenant_b_response.status_code == 200, tenant_b_response.text
|
||||
tenant_a_payload = tenant_a_response.json()
|
||||
tenant_b_payload = tenant_b_response.json()
|
||||
assert tenant_a_payload["status"] == "succeeded"
|
||||
assert tenant_b_payload["status"] == "succeeded"
|
||||
assert tenant_b_payload["result_payload"].get("idempotent_replay") is not True
|
||||
assert (
|
||||
tenant_a_payload["result_payload"]["decision_id"]
|
||||
!= tenant_b_payload["result_payload"]["decision_id"]
|
||||
)
|
||||
|
||||
with session_factory() as db:
|
||||
decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all())
|
||||
assert {decision.tenant_id for decision in decisions} == {
|
||||
"tenant-steward-a",
|
||||
"tenant-steward-b",
|
||||
}
|
||||
conversations = list(db.scalars(select(AgentConversation)).all())
|
||||
assert len(conversations) == 2
|
||||
assert {conversation.state_json.get("tenant_id") for conversation in conversations} == {
|
||||
"tenant-steward-a",
|
||||
"tenant-steward-b",
|
||||
}
|
||||
|
||||
|
||||
def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_duplicate_draft() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_application_preview(
|
||||
client,
|
||||
conversation_id="conv-action-draft",
|
||||
client_trace_id="trace-build-draft-preview",
|
||||
)
|
||||
request_payload = {
|
||||
"action_type": "save_application_draft",
|
||||
"message": "2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,保存草稿",
|
||||
"conversation_id": "conv-action-draft",
|
||||
"client_trace_id": "trace-save-draft",
|
||||
"decision_id": issued["decision_id"],
|
||||
"task": base_application_task("save_draft"),
|
||||
}
|
||||
first_response = client.post(
|
||||
@@ -325,12 +450,20 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_application_preview(
|
||||
client,
|
||||
conversation_id="conv-action-save",
|
||||
client_trace_id="trace-build-save-preview",
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1/steward/actions/execute",
|
||||
headers=auth_headers(),
|
||||
json={
|
||||
"action_type": "save_application_draft",
|
||||
"message": "2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,保存草稿",
|
||||
"conversation_id": "conv-action-save",
|
||||
"client_trace_id": "trace-save-application-action",
|
||||
"decision_id": issued["decision_id"],
|
||||
"task": base_application_task("save_draft"),
|
||||
},
|
||||
)
|
||||
@@ -342,22 +475,116 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N
|
||||
assert draft_payload["draft_type"] == "expense_application"
|
||||
assert draft_payload["status"] == "draft"
|
||||
assert draft_payload["claim_no"].startswith("A")
|
||||
assert payload["result_payload"]["decision_id"]
|
||||
assert payload["result_payload"]["decision_id"] != issued["decision_id"]
|
||||
with session_factory() as db:
|
||||
claim = db.scalars(select(ExpenseClaim)).one()
|
||||
assert claim.status == "draft"
|
||||
assert claim.reason == "辅助国网仿生产服务器部署"
|
||||
consumed_decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||||
assert consumed_decision is not None
|
||||
assert consumed_decision.status == "consumed"
|
||||
feedback = db.scalars(select(AIDecisionFeedback)).one()
|
||||
assert feedback.verification_status == "server_verified"
|
||||
event = db.scalars(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
).one()
|
||||
assert event.event_type == "claim_draft_created"
|
||||
assert event.actor_id == "zhangsan@example.com"
|
||||
assert event.correlation_id == "steward-action:save_application_draft:task_app_001"
|
||||
assert event.correlation_id == "application-preview-action:conv-action-save"
|
||||
link = db.scalars(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
|
||||
).one()
|
||||
assert link.relation_type == "application"
|
||||
|
||||
|
||||
def test_steward_action_executor_blocks_save_without_decision_and_stable_trace() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/steward/actions/execute",
|
||||
headers=auth_headers(),
|
||||
json={
|
||||
"action_type": "save_application_draft",
|
||||
"message": "保存申请草稿",
|
||||
"task": base_application_task("save_draft"),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "blocked"
|
||||
assert payload["blocked_reasons"] == ["missing_decision_id"]
|
||||
|
||||
issued = issue_application_preview(
|
||||
client,
|
||||
conversation_id="conv-action-missing-trace",
|
||||
client_trace_id="trace-build-missing-trace",
|
||||
)
|
||||
missing_trace_response = client.post(
|
||||
"/api/v1/steward/actions/execute",
|
||||
headers=auth_headers(),
|
||||
json={
|
||||
"action_type": "save_application_draft",
|
||||
"message": "保存申请草稿",
|
||||
"conversation_id": "conv-action-missing-trace",
|
||||
"decision_id": issued["decision_id"],
|
||||
"task": base_application_task("save_draft"),
|
||||
},
|
||||
)
|
||||
missing_trace_payload = missing_trace_response.json()
|
||||
assert missing_trace_payload["status"] == "blocked"
|
||||
assert missing_trace_payload["blocked_reasons"] == ["missing_client_trace_id"]
|
||||
with session_factory() as db:
|
||||
assert claim_count(db) == 0
|
||||
|
||||
|
||||
def test_steward_action_executor_submits_verified_application_after_confirmation_and_precheck() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_application_preview(
|
||||
client,
|
||||
conversation_id="conv-action-verified-submit",
|
||||
client_trace_id="trace-build-submit-preview",
|
||||
task=base_application_task("submit"),
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1/steward/actions/execute",
|
||||
headers=auth_headers(),
|
||||
json={
|
||||
"action_type": "submit_application",
|
||||
"message": (
|
||||
"2026-02-20 至 2026-02-23,去上海出差,"
|
||||
"辅助国网仿生产服务器部署,交通火车,直接提交"
|
||||
),
|
||||
"conversation_id": "conv-action-verified-submit",
|
||||
"client_trace_id": "trace-submit-verified-application",
|
||||
"decision_id": issued["decision_id"],
|
||||
"task": base_application_task("submit"),
|
||||
"confirmed": True,
|
||||
"context_json": {
|
||||
"precheck_result": {
|
||||
"status": "ok",
|
||||
"blocking": False,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["status"] == "succeeded"
|
||||
assert payload["result_payload"]["draft_payload"]["status"] == "submitted"
|
||||
assert payload["result_payload"]["decision_id"] is None
|
||||
with session_factory() as db:
|
||||
claim = db.scalars(select(ExpenseClaim)).one()
|
||||
assert claim.status == "submitted"
|
||||
|
||||
|
||||
def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
|
||||
Reference in New Issue
Block a user