474 lines
18 KiB
Python
474 lines
18 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Plan #2 Track A2 (T4): Trustee Spesenbelege Live-E2E Integration-Test.
|
|
|
|
Runs the canonical Trustee Spesenbelege chain end-to-end through
|
|
``executeGraph``::
|
|
|
|
trigger.manual
|
|
-> trustee.processDocuments (real action)
|
|
-> trustee.syncToAccounting (real action)
|
|
|
|
with:
|
|
|
|
* an in-memory **TrusteeInterface** fake (records createDocument /
|
|
createPosition / updatePosition calls and assigns deterministic IDs),
|
|
* an in-memory **AccountingBridge** fake (records pushBatchToAccounting
|
|
calls and returns one success result per positionId),
|
|
* a literal upstream ``documentList`` (no AI / SharePoint involved — the
|
|
extraction step is replaced by a canned ActionDocument list so this
|
|
test focuses on the bindings + action layer, exactly as the Track A2
|
|
plan requires: "Mock SharePoint + AI + Trustee-DB, echtes
|
|
processDocuments + syncToAccounting").
|
|
|
|
The test exercises the **Schicht-4 typed bindings pipeline** end-to-end:
|
|
|
|
* ``featureInstanceId`` is provided as a typed ``FeatureInstanceRef``
|
|
envelope on the producer node and as a raw legacy UUID on the consumer
|
|
node — both must reach the action layer as the bare UUID string after
|
|
``materializeFeatureInstanceRefs`` + ``resolveParameterReferences``.
|
|
* ``documentList`` on ``trustee.syncToAccounting`` is a ``DataRef`` on
|
|
``processDocuments[documents]`` (Pick-not-Push) — must resolve to the
|
|
ActionDocument list produced by ``processDocuments``.
|
|
|
|
Plan: ``wiki/c-work/1-plan/2026-04-typed-action-followups.md`` (A2 / T4).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import pytest
|
|
|
|
from modules.workflows.automation2.executionEngine import executeGraph
|
|
from modules.workflows.automation2.runEnvelope import default_run_envelope
|
|
|
|
|
|
_TRUSTEE_INSTANCE_UUID = "11111111-2222-3333-4444-555555555555"
|
|
_MANDATE_ID = "mandate-zh-001"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-memory fakes for the Trustee feature
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeTrusteeDocument:
|
|
"""Minimal stand-in for ``TrusteeDocument`` with attribute access."""
|
|
|
|
def __init__(self, payload: Dict[str, Any]):
|
|
self.id = str(uuid.uuid4())
|
|
for k, v in payload.items():
|
|
setattr(self, k, v)
|
|
|
|
def model_dump(self) -> Dict[str, Any]:
|
|
return {k: v for k, v in self.__dict__.items()}
|
|
|
|
|
|
class _FakeTrusteePosition:
|
|
"""Minimal stand-in for ``TrusteePosition`` with attribute access."""
|
|
|
|
def __init__(self, payload: Dict[str, Any]):
|
|
self.id = str(uuid.uuid4())
|
|
for k, v in payload.items():
|
|
setattr(self, k, v)
|
|
|
|
def model_dump(self) -> Dict[str, Any]:
|
|
return {k: v for k, v in self.__dict__.items()}
|
|
|
|
|
|
class _FakeTrusteeDb:
|
|
"""Captures ``getRecordset`` calls so processDocuments' bank-match
|
|
auto-linking path can be exercised without a real DB."""
|
|
|
|
def __init__(self, positions: List[_FakeTrusteePosition]):
|
|
self._positions = positions
|
|
self.calls: List[Dict[str, Any]] = []
|
|
|
|
def getRecordset(self, model, recordFilter=None):
|
|
self.calls.append({"model": getattr(model, "__name__", str(model)),
|
|
"filter": recordFilter})
|
|
return list(self._positions)
|
|
|
|
|
|
class _FakeTrusteeInterface:
|
|
"""In-memory replacement for the live trustee interface."""
|
|
|
|
def __init__(self, mandateId: str, featureInstanceId: str):
|
|
self.mandateId = mandateId
|
|
self.featureInstanceId = featureInstanceId
|
|
self.documents: List[_FakeTrusteeDocument] = []
|
|
self.positions: List[_FakeTrusteePosition] = []
|
|
self.updates: List[Dict[str, Any]] = []
|
|
self.db = _FakeTrusteeDb(self.positions)
|
|
|
|
def createDocument(self, payload: Dict[str, Any]) -> _FakeTrusteeDocument:
|
|
doc = _FakeTrusteeDocument({
|
|
"fileId": payload.get("fileId"),
|
|
"documentName": payload.get("documentName"),
|
|
"documentMimeType": payload.get("documentMimeType"),
|
|
"sourceType": payload.get("sourceType"),
|
|
"documentType": payload.get("documentType"),
|
|
"mandateId": self.mandateId,
|
|
"featureInstanceId": self.featureInstanceId,
|
|
})
|
|
self.documents.append(doc)
|
|
return doc
|
|
|
|
def createPosition(self, payload: Dict[str, Any]) -> _FakeTrusteePosition:
|
|
pos = _FakeTrusteePosition({**payload})
|
|
self.positions.append(pos)
|
|
return pos
|
|
|
|
def updatePosition(self, positionId: str, patch: Dict[str, Any]) -> Optional[_FakeTrusteePosition]:
|
|
self.updates.append({"id": positionId, "patch": dict(patch)})
|
|
for pos in self.positions:
|
|
if getattr(pos, "id", None) == positionId:
|
|
for k, v in patch.items():
|
|
setattr(pos, k, v)
|
|
return pos
|
|
return None
|
|
|
|
|
|
class _FakeAccountingResult:
|
|
def __init__(self, success: bool = True, errorMessage: Optional[str] = None):
|
|
self.success = success
|
|
self.errorMessage = errorMessage
|
|
|
|
|
|
class _FakeAccountingBridge:
|
|
"""Records pushBatchToAccounting invocations and returns one success
|
|
per positionId."""
|
|
|
|
pushBatchCalls: List[Dict[str, Any]] = []
|
|
|
|
def __init__(self, trusteeInterface):
|
|
self.trusteeInterface = trusteeInterface
|
|
|
|
async def pushBatchToAccounting(self, featureInstanceId: str,
|
|
positionIds: List[str]):
|
|
type(self).pushBatchCalls.append({
|
|
"featureInstanceId": featureInstanceId,
|
|
"positionIds": list(positionIds),
|
|
})
|
|
return [_FakeAccountingResult(success=True) for _ in positionIds]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test fixtures: mock services + module-level patches
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def trusteeInterface():
|
|
return _FakeTrusteeInterface(_MANDATE_ID, _TRUSTEE_INSTANCE_UUID)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def resetAccountingBridgeCalls():
|
|
_FakeAccountingBridge.pushBatchCalls = []
|
|
yield
|
|
_FakeAccountingBridge.pushBatchCalls = []
|
|
|
|
|
|
@pytest.fixture
|
|
def patchTrustee(monkeypatch, trusteeInterface):
|
|
"""Patches ``getInterface`` + ``AccountingBridge`` in both action
|
|
modules so the real action code runs against the in-memory fakes."""
|
|
from modules.workflows.methods.methodTrustee.actions import (
|
|
processDocuments as _procMod,
|
|
syncToAccounting as _syncMod,
|
|
)
|
|
from modules.features.trustee import (
|
|
interfaceFeatureTrustee as _ifaceMod,
|
|
)
|
|
from modules.features.trustee.accounting import accountingBridge as _bridgeMod
|
|
|
|
def _fakeGetInterface(*_args, **_kwargs):
|
|
return trusteeInterface
|
|
|
|
monkeypatch.setattr(_ifaceMod, "getInterface", _fakeGetInterface, raising=True)
|
|
monkeypatch.setattr(_bridgeMod, "AccountingBridge", _FakeAccountingBridge, raising=True)
|
|
return trusteeInterface
|
|
|
|
|
|
def _services():
|
|
"""Minimal services container for executeGraph.
|
|
|
|
The ``ActionExecutor`` only needs ``services`` to be passed through to
|
|
the trustee actions. The trustee actions only touch
|
|
``services.mandateId`` and ``services.featureInstanceId`` directly
|
|
(everything else is provided via ``parameters``); ``services.chat`` is
|
|
looked up but only used as a fallback that we do not exercise here.
|
|
"""
|
|
class _S:
|
|
mandateId = _MANDATE_ID
|
|
featureInstanceId = _TRUSTEE_INSTANCE_UUID
|
|
user = None
|
|
chat = None
|
|
return _S()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Canned upstream extraction result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _expenseReceiptExtraction() -> Dict[str, Any]:
|
|
return {
|
|
"documentType": "EXPENSE_RECEIPT",
|
|
"fileId": "file-001",
|
|
"fileName": "tankbeleg.pdf",
|
|
"extractedData": [
|
|
{
|
|
"documentType": "expense_receipt",
|
|
"valuta": "2026-04-12",
|
|
"transactionDateTime": 1744675200,
|
|
"company": "Migrolino Tankstelle Zürich AG",
|
|
"desc": "Tankfüllung Bleifrei 95, 42.30 L à 1.799 CHF/L",
|
|
"bookingCurrency": "CHF",
|
|
"bookingAmount": "76.10",
|
|
"originalCurrency": "CHF",
|
|
"originalAmount": "76.10",
|
|
"vatPercentage": "8.1",
|
|
"vatAmount": "5.71",
|
|
"debitAccountNumber": "6200 Fahrzeugaufwand",
|
|
"creditAccountNumber": "1020 Bank",
|
|
"tags": ["fuel", "vehicle"],
|
|
"bookingReference": "RB-2026-04-12-001",
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def _bankDocumentExtraction() -> Dict[str, Any]:
|
|
return {
|
|
"documentType": "BANK_DOCUMENT",
|
|
"fileId": "file-002",
|
|
"fileName": "kontoauszug_april.pdf",
|
|
"extractedData": [
|
|
{
|
|
"documentType": "bank_document",
|
|
"valuta": "2026-04-13",
|
|
"company": "Migrolino Tankstelle Zürich AG",
|
|
"desc": "Lastschrift Tankfüllung 12.04.2026, Ref RB-2026-04-12-001",
|
|
"bookingCurrency": "CHF",
|
|
"bookingAmount": "-76.10",
|
|
"creditAccountNumber": "1020 Bank",
|
|
"bookingReference": "RB-2026-04-12-001",
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def _cannedExtractionDocuments() -> List[Dict[str, Any]]:
|
|
"""Two ActionDocument-shaped dicts: one expense receipt + one bank
|
|
document. processDocuments' ``_resolveDocumentList`` accepts this
|
|
shape directly when ``documentName`` / ``documentData`` are present."""
|
|
return [
|
|
{
|
|
"documentName": "tankbeleg.json",
|
|
"documentData": json.dumps(_expenseReceiptExtraction()),
|
|
"mimeType": "application/json",
|
|
},
|
|
{
|
|
"documentName": "kontoauszug_april.json",
|
|
"documentData": json.dumps(_bankDocumentExtraction()),
|
|
"mimeType": "application/json",
|
|
},
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _buildGraph(featureInstanceIdOnProcess, featureInstanceIdOnSync) -> Dict[str, Any]:
|
|
"""Trustee Spesenbelege chain.
|
|
|
|
The ``trigger.manual`` node emits an ``ActionResult`` port, which is
|
|
not assignable into ``trustee.processDocuments[in:0]`` (accepts only
|
|
``DocumentList`` / ``Transit``). Production graphs solve this by
|
|
going through ``trustee.extractFromFiles`` (DocumentList output)
|
|
first; this test bypasses that step (we ship a literal canned
|
|
extraction list instead of running AI/SharePoint), so we simply
|
|
leave ``trigger.manual`` orphaned and start the data plane at
|
|
``process``."""
|
|
return {
|
|
"nodes": [
|
|
{"id": "trigger", "type": "trigger.manual", "parameters": {}},
|
|
{
|
|
"id": "process",
|
|
"type": "trustee.processDocuments",
|
|
"parameters": {
|
|
"featureInstanceId": featureInstanceIdOnProcess,
|
|
"documentList": _cannedExtractionDocuments(),
|
|
},
|
|
},
|
|
{
|
|
"id": "sync",
|
|
"type": "trustee.syncToAccounting",
|
|
"parameters": {
|
|
"featureInstanceId": featureInstanceIdOnSync,
|
|
"documentList": {
|
|
"type": "ref",
|
|
"nodeId": "process",
|
|
"path": ["documents"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
"connections": [
|
|
{"source": "process", "target": "sync"},
|
|
],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSpesenbelegeEndToEnd:
|
|
"""End-to-end Trustee Spesenbelege graph through executeGraph."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_processAndSyncWritesDocumentsPositionsAndAccountingPush(
|
|
self, patchTrustee
|
|
):
|
|
"""Happy-path: 1 expense receipt + 1 bank document.
|
|
|
|
Asserts at all three layers: bindings, action results, and side
|
|
effects on the (faked) trustee + accounting infrastructure."""
|
|
trustee = patchTrustee
|
|
envelope = {
|
|
"$type": "FeatureInstanceRef",
|
|
"id": _TRUSTEE_INSTANCE_UUID,
|
|
"featureCode": "trustee",
|
|
}
|
|
graph = _buildGraph(
|
|
featureInstanceIdOnProcess=copy.deepcopy(envelope),
|
|
featureInstanceIdOnSync=_TRUSTEE_INSTANCE_UUID,
|
|
)
|
|
runEnvelope = default_run_envelope("manual", payload={})
|
|
|
|
result = await executeGraph(
|
|
graph,
|
|
services=_services(),
|
|
run_envelope=runEnvelope,
|
|
userId="test-user",
|
|
mandateId=_MANDATE_ID,
|
|
instanceId=_TRUSTEE_INSTANCE_UUID,
|
|
)
|
|
|
|
assert result.get("success") is True, result
|
|
|
|
# --- Layer 1: bindings — both nodes must see the unwrapped UUID ---
|
|
assert len(trustee.documents) == 2
|
|
for doc in trustee.documents:
|
|
assert doc.featureInstanceId == _TRUSTEE_INSTANCE_UUID
|
|
|
|
# --- Layer 2: action results -----------------------------------
|
|
nodeOutputs = result["nodeOutputs"]
|
|
processOut = nodeOutputs["process"]
|
|
assert processOut.get("success") is True
|
|
assert processOut.get("error") in (None, "", False)
|
|
assert isinstance(processOut.get("documents"), list)
|
|
assert len(processOut["documents"]) == 1
|
|
processedDoc = processOut["documents"][0]
|
|
assert processedDoc.get("documentName") == "process_documents_result.json"
|
|
payload = json.loads(processedDoc["documentData"])
|
|
assert len(payload["documentIds"]) == 2
|
|
assert len(payload["positionIds"]) == 2
|
|
# Bank document auto-link found the matching expense (same
|
|
# bookingReference RB-2026-04-12-001), so exactly one position
|
|
# was matched.
|
|
assert len(payload["autoMatchedPositionIds"]) == 1
|
|
|
|
syncOut = nodeOutputs["sync"]
|
|
assert syncOut.get("success") is True
|
|
assert syncOut.get("error") in (None, "", False)
|
|
syncDoc = syncOut["documents"][0]
|
|
syncSummary = json.loads(syncDoc["documentData"])
|
|
assert syncSummary["pushed"] == 2
|
|
assert syncSummary["total"] == 2
|
|
assert all(r["success"] is True for r in syncSummary["results"])
|
|
|
|
# --- Layer 3: side effects -------------------------------------
|
|
assert len(trustee.positions) == 2
|
|
# Bank document update propagated through updatePosition
|
|
assert len(trustee.updates) == 1
|
|
assert "bankDocumentId" in trustee.updates[0]["patch"]
|
|
|
|
# Accounting bridge was called once with the resolved positionIds
|
|
# and the unwrapped UUID, NOT the typed envelope.
|
|
assert len(_FakeAccountingBridge.pushBatchCalls) == 1
|
|
call = _FakeAccountingBridge.pushBatchCalls[0]
|
|
assert call["featureInstanceId"] == _TRUSTEE_INSTANCE_UUID
|
|
assert sorted(call["positionIds"]) == sorted(payload["positionIds"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacyRawUuidFeatureInstanceIdAlsoWorks(self, patchTrustee):
|
|
"""A pre-Schicht-4 graph storing ``featureInstanceId`` as a raw
|
|
UUID must produce the same end-to-end behaviour after the
|
|
runtime ``materializeFeatureInstanceRefs`` migration."""
|
|
trustee = patchTrustee
|
|
graph = _buildGraph(
|
|
featureInstanceIdOnProcess=_TRUSTEE_INSTANCE_UUID,
|
|
featureInstanceIdOnSync=_TRUSTEE_INSTANCE_UUID,
|
|
)
|
|
result = await executeGraph(
|
|
graph,
|
|
services=_services(),
|
|
run_envelope=default_run_envelope("manual", payload={}),
|
|
userId="test-user",
|
|
mandateId=_MANDATE_ID,
|
|
instanceId=_TRUSTEE_INSTANCE_UUID,
|
|
)
|
|
assert result.get("success") is True, result
|
|
assert len(trustee.documents) == 2
|
|
assert len(trustee.positions) == 2
|
|
assert _FakeAccountingBridge.pushBatchCalls[0]["featureInstanceId"] == _TRUSTEE_INSTANCE_UUID
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emptyExtractionListIsHandledGracefully(self, patchTrustee):
|
|
"""When processDocuments receives no documents, syncToAccounting
|
|
must surface a graceful "No positionIds in document" message and
|
|
never call the accounting bridge."""
|
|
trustee = patchTrustee
|
|
graph = _buildGraph(
|
|
featureInstanceIdOnProcess=_TRUSTEE_INSTANCE_UUID,
|
|
featureInstanceIdOnSync=_TRUSTEE_INSTANCE_UUID,
|
|
)
|
|
# Replace the canned documents with a no-records extraction.
|
|
emptyExtraction = {
|
|
"documentType": "EXPENSE_RECEIPT",
|
|
"fileId": "file-empty",
|
|
"fileName": "empty.json",
|
|
"extractedData": [],
|
|
}
|
|
graph["nodes"][1]["parameters"]["documentList"] = [{
|
|
"documentName": "empty.json",
|
|
"documentData": json.dumps(emptyExtraction),
|
|
"mimeType": "application/json",
|
|
}]
|
|
result = await executeGraph(
|
|
graph,
|
|
services=_services(),
|
|
run_envelope=default_run_envelope("manual", payload={}),
|
|
userId="test-user",
|
|
mandateId=_MANDATE_ID,
|
|
instanceId=_TRUSTEE_INSTANCE_UUID,
|
|
)
|
|
assert result.get("success") is True, result
|
|
assert len(trustee.documents) == 0
|
|
assert len(trustee.positions) == 0
|
|
syncSummary = json.loads(
|
|
result["nodeOutputs"]["sync"]["documents"][0]["documentData"]
|
|
)
|
|
assert syncSummary["pushed"] == 0
|
|
assert _FakeAccountingBridge.pushBatchCalls == []
|