1037 lines
35 KiB
Python
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()
|