Files
lserp_cs_6.0/插件库/astrbot_plugin_lserp/tests/test_tools.py
T
2026-08-14 14:28:28 +08:00

1037 lines
35 KiB
Python

from __future__ import annotations
import json
import sys
import types
import unittest
from pathlib import Path
from typing import Any, Generic, TypeVar
def _install_astrbot_contract_stubs() -> None:
"""Load the plugin tool without requiring a full AstrBot server in unit tests."""
if "astrbot.api" in sys.modules:
return
context_type = TypeVar("context_type")
class FunctionTool(Generic[context_type]):
pass
class ContextWrapper(Generic[context_type]):
pass
astrbot = types.ModuleType("astrbot")
api = types.ModuleType("astrbot.api")
api.FunctionTool = FunctionTool
core = types.ModuleType("astrbot.core")
agent = types.ModuleType("astrbot.core.agent")
run_context = types.ModuleType("astrbot.core.agent.run_context")
run_context.ContextWrapper = ContextWrapper
tool = types.ModuleType("astrbot.core.agent.tool")
tool.ToolExecResult = Any
astr_context = types.ModuleType("astrbot.core.astr_agent_context")
astr_context.AstrAgentContext = object
sys.modules.update(
{
"astrbot": astrbot,
"astrbot.api": api,
"astrbot.core": core,
"astrbot.core.agent": agent,
"astrbot.core.agent.run_context": run_context,
"astrbot.core.agent.tool": tool,
"astrbot.core.astr_agent_context": astr_context,
}
)
_install_astrbot_contract_stubs()
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from astrbot_plugin_lserp.tools import ( # noqa: E402
ErpCapabilitiesTool,
ErpContextTool,
ErpPlanCommandTool,
)
from astrbot_plugin_lserp.session_auth import ( # noqa: E402
bridge_client_session_id,
compute_session_scope_token,
)
DEFAULT_CONTEXT = {
"userId": "user-7",
"userName": "测试用户",
"accountBook": "lserp_test",
"subSystemId": "PURCHASE",
"databaseScopeFingerprint": "a" * 64,
"subSystemName": "采购管理",
"isAdministrator": False,
"activeModule": None,
"openModuleCount": 0,
"openModulesTruncated": False,
"openModules": [],
}
BOUND_SCOPE_TOKEN = compute_session_scope_token(
DEFAULT_CONTEXT["databaseScopeFingerprint"],
DEFAULT_CONTEXT["userId"],
DEFAULT_CONTEXT["userName"],
DEFAULT_CONTEXT["accountBook"],
DEFAULT_CONTEXT["subSystemId"],
DEFAULT_CONTEXT["isAdministrator"],
)
BOUND_SESSION_ID = (
"lserp-pet-p4321-s1786400000-"
f"c{BOUND_SCOPE_TOKEN}-"
"0123456789abcdef0123456789abcdef"
)
BOUND_EVENT_SESSION_ID = "webchat!api-user!" + BOUND_SESSION_ID
from astrbot_plugin_lserp.attachment_extract import ( # noqa: E402
AttachmentProvenanceError,
PREPROCESS_CONTRACT_CSV,
PREPROCESS_CONTRACT_IMAGE,
PREPROCESS_CONTRACT_PDF,
)
from astrbot_plugin_lserp.attachment_provenance import ( # noqa: E402
VerifiedAttachmentBundle,
)
from astrbot_plugin_lserp.vision import ( # noqa: E402
validate_business_vision_content,
)
def _proof(version: str) -> str:
return (
f"{version}.638905536000000000."
+ "a" * 32
+ "."
+ "b" * 64
+ "."
+ "c" * 43
)
def _vision_content() -> str:
return validate_business_vision_content(
json.dumps(
{
"schema_version": "1.0",
"document_type": "purchase_invoice",
"invoice_number": "INV-1",
"invoice_date": "2026-08-12",
"supplier_name": "供应商甲",
"supplier_tax_id": "91510100TEST",
"currency": "CNY",
"total_without_tax": "200.00",
"tax_amount": "26.00",
"total_with_tax": "226.00",
"source_order_numbers": ["PO-001"],
"lines": [{
"line_id": "untrusted",
"item_code": "MAT-01",
"item_name": "测试物料",
"specification": "10mm",
"unit": "件",
"source_order_hint": "PO-001",
"quantity": "2",
"unit_price": "100.00",
"tax_rate": "0.13",
"tax_amount": "26.00",
"line_total": "200.00",
}],
"uncertain_fields": [],
},
ensure_ascii=False,
)
)
def _purchase_csv_payload() -> str:
filename = "invoice.csv"
source = {
"kind": "file",
"filename": filename,
"sha256": "c" * 64,
"sizeBytes": 2048,
}
rows = [
["发票号码", "INV-CSV-1"],
["发票日期", "2026-08-12"],
["供应商名称", "CSV供应商"],
["币种", "CNY"],
["发票不含税金额", "200.00"],
["发票税额", "26.00"],
["价税合计", "226.00"],
["物料名称", "采购订单号", "数量", "单价", "税率", "税额", "金额"],
["CSV物料", "PO-CSV-1", "2", "100.00", "13%", "26.00", "200.00"],
]
return "UNTRUSTED_BUSINESS_ATTACHMENT_JSON=" + json.dumps(
{
"kind": "file",
"filename": filename,
"sourceDocument": source,
"content": json.dumps(
{"rows": rows},
ensure_ascii=False,
separators=(",", ":"),
),
},
ensure_ascii=False,
separators=(",", ":"),
)
def _purchase_pdf_payload() -> str:
filename = "invoice.pdf"
source = {
"kind": "file",
"filename": filename,
"sha256": "e" * 64,
"sizeBytes": 4096,
}
content = {
"schemaVersion": "1.0",
"pipeline": "pdfium_minimax_pages_v1",
"pageCount": 1,
"pages": [
{
"pageNumber": 1,
"widthPixels": 1200,
"heightPixels": 1700,
"pngSha256": "f" * 64,
"pngSizeBytes": 4096,
"visionDocument": json.loads(_vision_content()),
}
],
}
return "UNTRUSTED_BUSINESS_ATTACHMENT_JSON=" + json.dumps(
{
"kind": "file",
"filename": filename,
"sourceDocument": source,
"content": json.dumps(
content,
ensure_ascii=False,
separators=(",", ":"),
),
},
ensure_ascii=False,
separators=(",", ":"),
)
class _Event:
def __init__(
self,
session_id: str = BOUND_EVENT_SESSION_ID,
platform_name: str = "webchat",
) -> None:
self.session_id = session_id
self.platform_name = platform_name
def get_session_id(self) -> str:
return self.session_id
def get_platform_name(self) -> str:
return self.platform_name
class _Context:
def __init__(self, event: _Event | None = None) -> None:
self.context = types.SimpleNamespace(event=event or _Event())
class _Bridge:
def __init__(
self,
responses: list[dict[str, Any]],
context_responses: list[dict[str, Any]] | None = None,
) -> None:
self.responses = list(responses)
self.context_responses = list(context_responses or [])
self.calls: list[
tuple[
str,
dict[str, Any],
str,
int | None,
int | None,
str | None,
str | None,
]
] = []
self.scope_calls: list[
tuple[
str,
dict[str, Any],
str,
int | None,
int | None,
str | None,
str | None,
]
] = []
async def call(
self,
method: str,
payload: dict[str, Any] | None = None,
*,
client_session_id: str,
target_process_id: int | None = None,
target_started_at_unix_seconds: int | None = None,
correlation_id: str | None = None,
session_scope_token: str | None = None,
) -> dict[str, Any]:
call = (
method,
payload or {},
client_session_id,
target_process_id,
target_started_at_unix_seconds,
correlation_id,
session_scope_token,
)
if method == "context.get":
self.scope_calls.append(call)
if self.context_responses:
return self.context_responses.pop(0)
return json.loads(json.dumps(DEFAULT_CONTEXT, ensure_ascii=False))
self.calls.append(call)
return self.responses.pop(0)
def _resolution_plan(
command: str,
resolved_command: str,
resolved_input: dict[str, Any],
) -> dict[str, Any]:
return {
"plan": {
"commandName": command,
"risk": "draft",
"valid": True,
"executionAllowed": False,
"data": {
"requiresFollowupPlan": True,
"resolvedCommand": resolved_command,
"resolvedInput": resolved_input,
},
}
}
class ErpPlanCommandToolTests(unittest.IsolatedAsyncioTestCase):
async def test_purchase_source_documents_are_injected_by_trusted_tool(self) -> None:
receipt = {
"kind": "image",
"filename": "invoice.png",
"sha256": "a" * 64,
"sizeBytes": 1024,
"extractionSha256": "b" * 64,
"preprocessContract": PREPROCESS_CONTRACT_IMAGE,
}
bundle = VerifiedAttachmentBundle(
((
"image",
"invoice.png",
"a" * 64,
1024,
"b" * 64,
PREPROCESS_CONTRACT_IMAGE,
),),
(_vision_content(),),
(),
)
async def receipts(context: object) -> VerifiedAttachmentBundle:
self.assertIsNotNone(context)
return bundle
bridge = _Bridge(
[{
"plan": {
"commandName": "purchase.invoice.resolve",
"risk": "draft",
"valid": False,
"executionAllowed": False,
"data": {"issues": []},
}
}]
)
model_input = {
"supplierReference": "模型伪造供应商",
"currencyReference": "USD",
"invoiceNumber": "FORGED",
"invoiceDate": "2026-01-01",
"totalWithoutTax": 200,
"taxAmount": 26,
"totalWithTax": 226,
"lines": [{
"lineId": "forged-line",
"materialReference": "模型伪造物料",
"quantity": 2,
"unitPrice": 100,
"taxRate": 0.13,
"taxAmount": 26,
"lineAmount": 200,
}],
"sourceDocuments": [{"sha256": "forged"}],
}
result = json.loads(
await ErpPlanCommandTool(
bridge=bridge,
attachment_receipts=receipts,
).call(_Context(), "purchase.invoice.resolve", model_input)
)
self.assertTrue(result["ok"])
trusted = bridge.calls[0][1]["input"]
self.assertEqual([receipt], trusted["sourceDocuments"])
self.assertEqual("供应商甲", trusted["supplierReference"])
self.assertEqual("91510100TEST", trusted["supplierTaxId"])
self.assertEqual("CNY", trusted["currencyReference"])
self.assertEqual("INV-1", trusted["invoiceNumber"])
self.assertEqual("2026-08-12", trusted["invoiceDate"])
self.assertEqual("ocr-line-001", trusted["lines"][0]["lineId"])
self.assertEqual("测试物料", trusted["lines"][0]["materialReference"])
self.assertEqual("PO-001", trusted["lines"][0]["sourceOrderHint"])
self.assertEqual("forged", model_input["sourceDocuments"][0]["sha256"])
self.assertEqual("模型伪造物料", model_input["lines"][0]["materialReference"])
mismatch_input = dict(model_input)
mismatch_input["totalWithTax"] = 999
mismatch_bridge = _Bridge([])
mismatch = json.loads(
await ErpPlanCommandTool(
bridge=mismatch_bridge,
attachment_receipts=receipts,
).call(_Context(), "purchase.invoice.resolve", mismatch_input)
)
self.assertFalse(mismatch["ok"])
self.assertEqual(
"purchase_vision_input_mismatch",
mismatch["error"]["code"],
)
self.assertEqual([], mismatch_bridge.calls)
async def test_purchase_provenance_failure_never_reaches_bridge(self) -> None:
async def receipts(context: object) -> VerifiedAttachmentBundle:
raise AttachmentProvenanceError("too_many_business_attachments")
bridge = _Bridge([])
result = json.loads(
await ErpPlanCommandTool(
bridge=bridge,
attachment_receipts=receipts,
).call(
_Context(),
"purchase.invoice.resolve",
{"supplierReference": "供应商甲"},
)
)
self.assertFalse(result["ok"])
self.assertEqual("too_many_business_attachments", result["error"]["code"])
self.assertEqual([], bridge.calls)
async def test_purchase_csv_fields_are_bound_before_bridge(self) -> None:
payload = _purchase_csv_payload()
receipt = (
"file",
"invoice.csv",
"c" * 64,
2048,
"d" * 64,
PREPROCESS_CONTRACT_CSV,
)
async def receipts(context: object) -> VerifiedAttachmentBundle:
self.assertIsNotNone(context)
return VerifiedAttachmentBundle((receipt,), (), (payload,))
bridge = _Bridge([{
"plan": {
"commandName": "purchase.invoice.resolve",
"risk": "draft",
"valid": False,
"executionAllowed": False,
"data": {"issues": []},
}
}])
model_input = {
"supplierReference": "模型伪造供应商",
"currencyReference": "USD",
"invoiceNumber": "FORGED",
"invoiceDate": "2026-01-01",
"totalWithoutTax": 200,
"taxAmount": 26,
"totalWithTax": 226,
"lines": [{
"lineId": "model-line",
"materialReference": "模型伪造物料",
"quantity": 2,
"unitPrice": 100,
"taxRate": 0.13,
"taxAmount": 26,
"lineAmount": 200,
}],
}
result = json.loads(
await ErpPlanCommandTool(
bridge=bridge,
attachment_receipts=receipts,
).call(_Context(), "purchase.invoice.resolve", model_input)
)
self.assertTrue(result["ok"])
trusted = bridge.calls[0][1]["input"]
self.assertEqual("CSV供应商", trusted["supplierReference"])
self.assertEqual("INV-CSV-1", trusted["invoiceNumber"])
self.assertEqual("CSV物料", trusted["lines"][0]["materialReference"])
self.assertEqual("PO-CSV-1", trusted["lines"][0]["sourceOrderHint"])
self.assertEqual("d" * 64, trusted["sourceDocuments"][0]["extractionSha256"])
self.assertEqual(
PREPROCESS_CONTRACT_CSV,
trusted["sourceDocuments"][0]["preprocessContract"],
)
async def test_purchase_pdf_fields_are_bound_before_bridge(self) -> None:
payload = _purchase_pdf_payload()
receipt = (
"file",
"invoice.pdf",
"e" * 64,
4096,
"a" * 64,
PREPROCESS_CONTRACT_PDF,
)
async def receipts(context: object) -> VerifiedAttachmentBundle:
self.assertIsNotNone(context)
return VerifiedAttachmentBundle((receipt,), (), (payload,))
bridge = _Bridge([{
"plan": {
"commandName": "purchase.invoice.resolve",
"risk": "draft",
"valid": False,
"executionAllowed": False,
"data": {"issues": []},
}
}])
result = json.loads(
await ErpPlanCommandTool(
bridge=bridge,
attachment_receipts=receipts,
).call(
_Context(),
"purchase.invoice.resolve",
{
"supplierReference": "模型供应商",
"currencyReference": "USD",
"invoiceNumber": "FORGED",
"invoiceDate": "2026-01-01",
"totalWithoutTax": 200,
"taxAmount": 26,
"totalWithTax": 226,
"lines": [{
"lineId": "model-line",
"materialReference": "模型物料",
"quantity": 2,
"unitPrice": 100,
"taxRate": 0.13,
"taxAmount": 26,
"lineAmount": 200,
}],
},
)
)
self.assertTrue(result["ok"])
trusted = bridge.calls[0][1]["input"]
self.assertEqual("供应商甲", trusted["supplierReference"])
self.assertEqual("INV-1", trusted["invoiceNumber"])
self.assertEqual("测试物料", trusted["lines"][0]["materialReference"])
self.assertEqual("e" * 64, trusted["sourceDocuments"][0]["sha256"])
self.assertEqual(
"a" * 64,
trusted["sourceDocuments"][0]["extractionSha256"],
)
self.assertEqual(
PREPROCESS_CONTRACT_PDF,
trusted["sourceDocuments"][0]["preprocessContract"],
)
async def test_purchase_auto_follow_uses_exact_server_input(self) -> None:
resolved_input = {
"resolutionProof": _proof("rp1"),
"supplierCode": "SUP-1",
"lines": [{"materialCode": "MAT-1"}],
}
final = {
"plan": {
"planId": "plan-purchase-0123456789",
"commandName": "purchase.invoice.create",
"risk": "write",
"valid": True,
"executionAllowed": True,
}
}
bridge = _Bridge(
[
_resolution_plan(
"purchase.invoice.resolve",
"purchase.invoice.create",
resolved_input,
),
final,
]
)
tool = ErpPlanCommandTool(bridge=bridge)
result = json.loads(
await tool.call(
_Context(),
"purchase.invoice.resolve",
{"supplierText": "供应商甲"},
)
)
self.assertTrue(result["ok"])
self.assertEqual("purchase.invoice.resolve", result["data"]["autoFollowedFrom"])
self.assertEqual(2, len(bridge.calls))
self.assertEqual(
bridge_client_session_id(BOUND_SESSION_ID),
bridge.calls[0][2],
)
self.assertEqual(bridge.calls[0][2], bridge.calls[1][2])
self.assertRegex(bridge.calls[0][5] or "", r"^[a-f0-9]{32}$")
self.assertEqual(bridge.calls[0][5], bridge.calls[1][5])
self.assertEqual("purchase.invoice.create", bridge.calls[1][1]["command"])
self.assertEqual(resolved_input, bridge.calls[1][1]["input"])
self.assertIsNot(resolved_input, bridge.calls[1][1]["input"])
async def test_leave_auto_follow_is_fixed_to_create(self) -> None:
resolved_input = {
"resolutionProof": _proof("lrp1"),
"employeeId": "EMP-1",
"leaveTypeCode": "PERSONAL",
"startLocal": "2026-08-12T13:00:00",
"endLocal": "2026-08-12T17:00:00",
"requestedHours": 4,
"reason": "就医复查",
"submitAfterSave": False,
}
bridge = _Bridge(
[
_resolution_plan(
"hr.leave.resolve", "hr.leave.create", resolved_input
),
{
"plan": {
"planId": "plan-leave-0123456789",
"commandName": "hr.leave.create",
"risk": "write",
"valid": True,
"executionAllowed": True,
}
},
]
)
result = json.loads(
await ErpPlanCommandTool(bridge=bridge).call(
_Context(), "hr.leave.resolve", {"dateExpression": "明天下午"}
)
)
self.assertTrue(result["ok"])
self.assertEqual("hr.leave.create", bridge.calls[1][1]["command"])
self.assertEqual(resolved_input, bridge.calls[1][1]["input"])
async def test_dynamic_lookup_auto_follow_uses_fixed_write_when_published(self) -> None:
resolved_input = {
"moduleCode": "DYNAMIC-BILL",
"contractFingerprint": "d" * 64,
"masterValues": [
{"parameterId": "m1234567890abcdef", "value": "SUP-001"}
],
"detailRows": [],
"lookupResolutionProof": _proof("mlp1"),
}
bridge = _Bridge(
[
_resolution_plan(
"module.record.resolve-create",
"module.record.create",
resolved_input,
),
{
"plan": {
"planId": "plan-module-0123456789",
"commandName": "module.record.create",
"risk": "write",
"valid": True,
"executionAllowed": True,
"data": {"genericWriteExecutionAvailable": True},
}
},
]
)
original_input = {
"moduleCode": "DYNAMIC-BILL",
"contractFingerprint": "d" * 64,
"masterValues": [
{"parameterId": "m1234567890abcdef", "value": "供应商甲"}
],
"detailRows": [],
}
result = json.loads(
await ErpPlanCommandTool(bridge=bridge).call(
_Context(),
"module.record.resolve-create",
original_input,
)
)
self.assertTrue(result["ok"])
self.assertEqual(2, len(bridge.calls))
self.assertEqual(
"module.record.create",
bridge.calls[1][1]["command"],
)
self.assertEqual(resolved_input, bridge.calls[1][1]["input"])
self.assertEqual(
bridge.calls[0][5],
bridge.calls[1][5],
)
self.assertEqual(
"module.record.resolve-create",
result["data"]["autoFollowedFrom"],
)
async def test_dynamic_update_auto_follow_uses_private_snapshot_proof(self) -> None:
resolved_input = {
"moduleCode": "BASE-CUSTOMER",
"contractFingerprint": "d" * 64,
"recordSnapshotProof": _proof("mup1"),
}
bridge = _Bridge(
[
_resolution_plan(
"module.record.resolve-update",
"module.record.update",
resolved_input,
),
{
"plan": {
"planId": "plan-update-0123456789",
"commandName": "module.record.update",
"risk": "write",
"valid": True,
"executionAllowed": True,
"data": {"outcomeCode": "dynamic_module_update_ready"},
}
},
]
)
original_input = {
"moduleCode": "BASE-CUSTOMER",
"contractFingerprint": "d" * 64,
"recordQuery": "C-001 朗速客户",
"changes": [
{"parameterId": "m1234567890abcdef", "value": "120.50"}
],
}
result = json.loads(
await ErpPlanCommandTool(bridge=bridge).call(
_Context(),
"module.record.resolve-update",
original_input,
)
)
self.assertTrue(result["ok"])
self.assertEqual(2, len(bridge.calls))
self.assertEqual("module.record.update", bridge.calls[1][1]["command"])
self.assertEqual(resolved_input, bridge.calls[1][1]["input"])
self.assertEqual(bridge.calls[0][5], bridge.calls[1][5])
self.assertEqual(
"module.record.resolve-update",
result["data"]["autoFollowedFrom"],
)
async def test_invalid_resolution_never_auto_follows(self) -> None:
bridge = _Bridge(
[
_resolution_plan(
"purchase.invoice.resolve",
"purchase.invoice.create",
{"resolutionProof": "forged", "supplierCode": "SUP-1"},
)
]
)
result = json.loads(
await ErpPlanCommandTool(bridge=bridge).call(
_Context(), "purchase.invoice.resolve", {"supplierText": "供应商甲"}
)
)
self.assertTrue(result["ok"])
self.assertEqual(1, len(bridge.calls))
self.assertNotIn("autoFollowedFrom", result["data"])
async def test_module_diagnose_returns_one_read_only_plan_unchanged(self) -> None:
plan = {
"plan": {
"commandName": "module.diagnose",
"risk": "read",
"valid": True,
"executionAllowed": False,
"data": {"moduleCode": "PURCHASE_ORDER", "findings": []},
}
}
bridge = _Bridge([plan])
result = json.loads(
await ErpPlanCommandTool(bridge=bridge).call(
_Context(), "module.diagnose", {"moduleCode": "PURCHASE_ORDER"}
)
)
self.assertTrue(result["ok"])
self.assertEqual(plan, result["data"])
self.assertEqual(
(
"command.plan",
{"command": "module.diagnose", "input": {"moduleCode": "PURCHASE_ORDER"}},
bridge_client_session_id(BOUND_SESSION_ID),
4321,
1786400000,
),
bridge.calls[0][:5],
)
self.assertRegex(bridge.calls[0][5] or "", r"^[a-f0-9]{32}$")
self.assertNotIn("autoFollowedFrom", result["data"])
async def test_process_bound_session_targets_exact_erp(self) -> None:
active_module = {
"moduleCode": "PURCHASE_ORDER",
"navigationCode": "menu.purchase.order",
"moduleName": "采购订单",
}
bridge = _Bridge([], context_responses=[{
"userId": "user-7",
"userName": "测试用户",
"accountBook": "lserp_test",
"subSystemId": "PURCHASE",
"databaseScopeFingerprint": "a" * 64,
"subSystemName": "采购管理",
"isAdministrator": False,
"activeModule": active_module,
"openModuleCount": 1,
"openModulesTruncated": False,
"openModules": [active_module],
}])
session_id = BOUND_EVENT_SESSION_ID
result = json.loads(
await ErpContextTool(bridge=bridge).call(
_Context(_Event(session_id=session_id))
)
)
self.assertTrue(result["ok"])
self.assertEqual(
"menu.purchase.order",
result["data"]["activeModule"]["navigationCode"],
)
self.assertEqual([], bridge.calls)
self.assertEqual(1, len(bridge.scope_calls))
self.assertEqual(4321, bridge.scope_calls[0][3])
self.assertEqual(1786400000, bridge.scope_calls[0][4])
async def test_capabilities_tool_preserves_permission_filtered_schema(self) -> None:
command = {
"name": "module.help",
"version": "1.0",
"description": "读取模块说明",
"schemaVersion": "1.0",
"inputSchema": {
"type": "object",
"properties": {
"moduleCode": {
"type": "string",
"description": "精确导航编号",
"minLength": 1,
"maxLength": 64,
}
},
"required": ["moduleCode"],
"additionalProperties": False,
},
"risk": "read",
"requiresConfirmation": False,
"requiresIdempotencyKey": False,
}
bridge = _Bridge([{"commands": [command]}])
result = json.loads(
await ErpCapabilitiesTool(bridge=bridge).call(_Context())
)
self.assertTrue(result["ok"])
self.assertEqual("module.help", result["data"]["commands"][0]["name"])
self.assertEqual(
False,
result["data"]["commands"][0]["inputSchema"][
"additionalProperties"
],
)
self.assertEqual("capabilities.list", bridge.calls[0][0])
self.assertEqual(2, len(bridge.scope_calls))
async def test_initialization_trace_never_auto_executes_or_auto_follows(self) -> None:
plan = {
"plan": {
"commandName": "module.trace-initialization",
"risk": "read",
"valid": True,
"executionAllowed": False,
"data": {"traceId": "trace-01234567", "steps": []},
}
}
bridge = _Bridge([plan])
result = json.loads(
await ErpPlanCommandTool(bridge=bridge).call(
_Context(),
"module.trace-initialization",
{"moduleCode": "PURCHASE_ORDER"},
)
)
self.assertTrue(result["ok"])
self.assertEqual(plan, result["data"])
self.assertEqual(1, len(bridge.calls))
self.assertEqual("command.plan", bridge.calls[0][0])
self.assertEqual(2, len(bridge.scope_calls))
self.assertNotIn("autoFollowedFrom", result["data"])
async def test_session_scope_mismatch_and_drift_block_plans(self) -> None:
wrong_scope = json.loads(json.dumps(DEFAULT_CONTEXT, ensure_ascii=False))
wrong_scope["userName"] = "管理员"
blocked = _Bridge([{"plan": {}}], context_responses=[wrong_scope])
blocked_result = json.loads(
await ErpPlanCommandTool(bridge=blocked).call(
_Context(),
"module.diagnose",
{"moduleCode": "PURCHASE_ORDER"},
)
)
self.assertFalse(blocked_result["ok"])
self.assertEqual(
"erp_session_scope_mismatch",
blocked_result["error"]["code"],
)
self.assertEqual([], blocked.calls)
drifted = json.loads(json.dumps(DEFAULT_CONTEXT, ensure_ascii=False))
drifted["subSystemId"] = "HR"
resolution = _resolution_plan(
"purchase.invoice.resolve",
"purchase.invoice.create",
{"resolutionProof": _proof("prp1")},
)
drifting = _Bridge(
[resolution, {"plan": {}}],
context_responses=[DEFAULT_CONTEXT, drifted],
)
drift_result = json.loads(
await ErpPlanCommandTool(bridge=drifting).call(
_Context(),
"purchase.invoice.resolve",
{"supplierReference": "供应商甲"},
)
)
self.assertFalse(drift_result["ok"])
self.assertEqual(
"erp_session_scope_changed",
drift_result["error"]["code"],
)
self.assertEqual(1, len(drifting.calls))
self.assertEqual("purchase.invoice.resolve", drifting.calls[0][1]["command"])
async def test_permission_and_ui_drift_discard_read_and_plan_results(self) -> None:
administrator = json.loads(json.dumps(DEFAULT_CONTEXT, ensure_ascii=False))
administrator["isAdministrator"] = True
permission_drift = _Bridge(
[{"commands": []}],
context_responses=[DEFAULT_CONTEXT, administrator],
)
permission_result = json.loads(
await ErpCapabilitiesTool(bridge=permission_drift).call(_Context())
)
self.assertFalse(permission_result["ok"])
self.assertEqual(
"erp_session_scope_changed",
permission_result["error"]["code"],
)
self.assertEqual(1, len(permission_drift.calls))
active_module = {
"moduleCode": "PURCHASE_ORDER",
"navigationCode": "PURCHASE_ORDER",
"moduleName": "采购订单",
}
changed_ui = json.loads(json.dumps(DEFAULT_CONTEXT, ensure_ascii=False))
changed_ui["activeModule"] = active_module
changed_ui["openModuleCount"] = 1
changed_ui["openModules"] = [active_module]
ui_drift = _Bridge(
[{"plan": {}}],
context_responses=[DEFAULT_CONTEXT, changed_ui],
)
ui_result = json.loads(
await ErpPlanCommandTool(bridge=ui_drift).call(
_Context(),
"module.diagnose",
{"moduleCode": "PURCHASE_ORDER"},
)
)
self.assertFalse(ui_result["ok"])
self.assertEqual(
"erp_session_scope_changed",
ui_result["error"]["code"],
)
self.assertEqual(1, len(ui_drift.calls))
async def test_unauthorized_session_denies_every_tool_without_bridge_call(self) -> None:
bridge = _Bridge([])
for session_id in (
"webchat!api-user!ordinary-chat",
"webchat!api-user!lserp-pet-tool-test",
"webchat!api-user!"
"lserp-pet-p4321-0123456789abcdef0123456789abcdef",
):
context = _Context(_Event(session_id=session_id))
results = (
await ErpContextTool(bridge=bridge).call(context),
await ErpCapabilitiesTool(bridge=bridge).call(context),
await ErpPlanCommandTool(bridge=bridge).call(
context,
"module.diagnose",
{"moduleCode": "PURCHASE_ORDER"},
),
)
self.assertEqual([], bridge.calls)
self.assertEqual([], bridge.scope_calls)
for result_text in results:
result = json.loads(result_text)
self.assertFalse(result["ok"])
self.assertEqual(
"erp_tool_session_denied",
result["error"]["code"],
)
if __name__ == "__main__":
unittest.main()