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()