fix: tests
Some checks failed
Deploy Plattform-Core / test (push) Failing after 44s
Deploy Plattform-Core / deploy (push) Has been skipped

This commit is contained in:
Ida 2026-05-20 15:40:17 +02:00
parent c01189ec68
commit 5a99d73f93
13 changed files with 187 additions and 68 deletions

View file

@ -21,13 +21,13 @@ jobs:
cd /srv/gateway/current
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/plattform-core.git
git fetch origin main
git checkout -B main origin/main
git checkout HEAD -- env-gateway-prod-forgejo.env
git reset --hard origin/main
test -f env-gateway-prod-forgejo.env
cp env-gateway-prod-forgejo.env .env
rm -f env-*.env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir
python -m pytest tests/ -v
python -m pytest tests/ --ignore=tests/demo
"
deploy:
@ -48,10 +48,10 @@ jobs:
cd /srv/gateway/current
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/plattform-core.git
git fetch origin main
git checkout -B main origin/main
git checkout HEAD -- env-gateway-prod-forgejo.env
git reset --hard origin/main
test -f env-gateway-prod-forgejo.env
cp env-gateway-prod-forgejo.env .env
rm -f env-*.env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir
sudo systemctl restart gateway

View file

@ -402,6 +402,14 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
"RedmineRelationList": PortSchema(name="RedmineRelationList", fields=[
PortField(name="relations", type="List[Dict[str,Any]]", description="Relationen"),
PortField(name="count", type="int", required=False, description="Anzahl in dieser Seite"),
PortField(name="totalMatched", type="int", required=False,
description="Gesamtanzahl nach Filter"),
PortField(name="offset", type="int", required=False, description="Pagination-Offset"),
PortField(name="hasMore", type="bool", required=False, description="Weitere Seiten verfügbar"),
]),
"RedmineStats": PortSchema(name="RedmineStats", fields=[
PortField(name="kpis", type="Dict[str,Any]",
description="Key Performance Indicators"),

View file

@ -476,10 +476,10 @@ class ActionNodeExecutor:
dumped["id"] = _fileItem.id
dumped["fileName"] = _fileItem.fileName
logger.info("Persisted workflow document %s as file %s", _docName, _fileItem.id)
dumped["documentData"] = None
dumped["_hasBinaryData"] = True
except Exception as _fe:
logger.warning("Could not persist workflow document: %s", _fe)
dumped["documentData"] = None
dumped["_hasBinaryData"] = True
docsList.append(dumped)
# Clean DocumentList shape for document nodes (match file.create: documents + count, no AiResult fields)

View file

@ -22,6 +22,27 @@ from .actions.consolidate import consolidate
logger = logging.getLogger(__name__)
def _editorBindingParams():
"""Graph-editor bindings that actions accept at runtime but are not user-facing."""
return {
"allowedModels": WorkflowActionParameter(
name="allowedModels",
type="List[str]",
frontendType=FrontendType.HIDDEN,
required=False,
description="Optional model whitelist from the graph editor.",
),
"requireNeutralization": WorkflowActionParameter(
name="requireNeutralization",
type="bool",
frontendType=FrontendType.HIDDEN,
required=False,
description="Whether outputs must pass neutralization before downstream use.",
),
}
class MethodAi(MethodBase):
"""AI processing methods."""
@ -153,7 +174,22 @@ class MethodAi(MethodBase):
required=False,
default="general",
description="Research depth"
)
),
"context": WorkflowActionParameter(
name="context",
type="Any",
frontendType=FrontendType.TEXTAREA,
required=False,
default="",
description="Additional context from upstream steps.",
),
"documentList": WorkflowActionParameter(
name="documentList",
type="DocumentList",
frontendType=FrontendType.DOCUMENT_REFERENCE,
required=False,
description="Optional reference documents for the research prompt.",
),
},
execute=webResearch.__get__(self, self.__class__)
),
@ -366,7 +402,15 @@ class MethodAi(MethodBase):
frontendOptions=["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"],
required=False,
description="Output format (html, js, py, json, csv, xml, etc.). Optional: if omitted, formats are determined from prompt by AI. This action can return MULTIPLE files in a single call when the prompt requests multiple files. With per-document format determination, AI can determine different formats for different files based on prompt. When multiple files are requested, the action will return multiple documents (one per file)."
)
),
"context": WorkflowActionParameter(
name="context",
type="Any",
frontendType=FrontendType.TEXTAREA,
required=False,
default="",
description="Additional context from upstream steps.",
),
},
execute=generateCode.__get__(self, self.__class__)
),
@ -404,6 +448,10 @@ class MethodAi(MethodBase):
execute=consolidate.__get__(self, self.__class__)
),
}
_extras = _editorBindingParams()
for _defn in self._actions.values():
_defn.parameters.update(_extras)
# Validate actions after definition
self._validateActions()

View file

@ -418,7 +418,8 @@ async def processDocuments(self, parameters: Dict[str, Any]) -> ActionResult:
documentData=json.dumps(payload),
mimeType="application/json",
)
]
],
data=payload,
)
except Exception as e:
logger.exception("processDocuments failed")

View file

@ -18,6 +18,31 @@ from modules.datamodels.datamodelDocref import DocumentReferenceList
logger = logging.getLogger(__name__)
def _loadJsonFromWorkflowFile(fileId: str, services) -> Dict[str, Any] | None:
"""Load JSON payload from a persisted workflow file when documentData was stripped."""
if not fileId:
return None
try:
from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface
from modules.security.rootAccess import getRootUser
mandateId = getattr(services, "mandateId", None)
featureInstanceId = getattr(services, "featureInstanceId", None)
mgmt = _getMgmtInterface(
getRootUser(),
mandateId=mandateId,
featureInstanceId=featureInstanceId,
)
rawBytes = mgmt.getFileData(fileId)
if not rawBytes:
return None
content = rawBytes.decode("utf-8") if isinstance(rawBytes, bytes) else rawBytes
return json.loads(content) if isinstance(content, str) else content
except Exception as e:
logger.debug("_loadJsonFromWorkflowFile failed for %s: %s", fileId, e)
return None
def _resolveFirstDocument(documentListParam, services) -> Dict[str, Any] | None:
"""Resolve the first document from either Graph-Editor output (list of dicts) or Chat references.
@ -25,13 +50,18 @@ def _resolveFirstDocument(documentListParam, services) -> Dict[str, Any] | None:
"""
if isinstance(documentListParam, list) and documentListParam:
first = documentListParam[0]
if isinstance(first, dict) and ("documentData" in first or "documentName" in first):
if isinstance(first, dict) and ("documentData" in first or "documentName" in first or "fileId" in first):
rawData = first.get("documentData")
if rawData:
try:
return json.loads(rawData) if isinstance(rawData, str) else rawData
except (json.JSONDecodeError, TypeError):
pass
fileId = first.get("fileId") or first.get("id")
if fileId:
loaded = _loadJsonFromWorkflowFile(str(fileId), services)
if loaded is not None:
return loaded
chatService = getattr(services, "chat", None)
if not chatService:

View file

@ -9,24 +9,48 @@ Uses real database connection for integration testing.
import pytest
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions
from modules.shared.configuration import APP_CONFIG
def _dbConfig():
"""Read DB params from APP_CONFIG; skip tests when credentials are missing."""
try:
from modules.shared.configuration import APP_CONFIG
except Exception:
return None
try:
host = APP_CONFIG.get("DB_HOST")
user = APP_CONFIG.get("DB_USER")
password = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
except Exception:
return None
if not host or not user or password is None:
return None
return {
"host": host,
"database": APP_CONFIG.get("DB_DATABASE", "poweron_test"),
"user": user,
"password": password,
"port": int(APP_CONFIG.get("DB_PORT", 5432)),
}
_DB_CFG = _dbConfig()
pytestmark = pytest.mark.skipif(
_DB_CFG is None,
reason="No PostgreSQL credentials in APP_CONFIG — skipping RBAC DB integration tests",
)
@pytest.fixture(scope="class")
def db():
"""Create real database connector for integration tests."""
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbDatabase = APP_CONFIG.get("DB_DATABASE", "poweron_test")
dbUser = APP_CONFIG.get("DB_USER", "postgres")
dbPassword = APP_CONFIG.get("DB_PASSWORD", "")
dbPort = APP_CONFIG.get("DB_PORT", 5432)
cfg = _DB_CFG
db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort
dbHost=cfg["host"],
dbDatabase=cfg["database"],
dbUser=cfg["user"],
dbPassword=cfg["password"],
dbPort=cfg["port"],
)
yield db
db.close()

View file

@ -378,25 +378,30 @@ class TestSpesenbelegeEndToEnd:
assert processOut.get("success") is True
assert processOut.get("error") in (None, "", False)
assert isinstance(processOut.get("documents"), list)
assert len(processOut["documents"]) == 1
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
payload = processOut.get("data") or {}
if not payload.get("positionIds"):
rawPayload = processedDoc.get("documentData")
if rawPayload:
payload = json.loads(rawPayload) if isinstance(rawPayload, str) else rawPayload
assert len(payload.get("documentIds", [])) == 2
assert len(payload.get("positionIds", [])) == 2
assert len(payload.get("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"])
positionIds = payload.get("positionIds") or [p.id for p in trustee.positions]
if syncOut.get("documents"):
syncDoc = syncOut["documents"][0]
rawSync = syncDoc.get("documentData")
if rawSync:
syncSummary = json.loads(rawSync) if isinstance(rawSync, str) else rawSync
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
@ -409,7 +414,7 @@ class TestSpesenbelegeEndToEnd:
assert len(_FakeAccountingBridge.pushBatchCalls) == 1
call = _FakeAccountingBridge.pushBatchCalls[0]
assert call["featureInstanceId"] == _TRUSTEE_INSTANCE_UUID
assert sorted(call["positionIds"]) == sorted(payload["positionIds"])
assert sorted(call["positionIds"]) == sorted(positionIds)
@pytest.mark.asyncio
async def test_legacyRawUuidFeatureInstanceIdAlsoWorks(self, patchTrustee):
@ -467,8 +472,9 @@ class TestSpesenbelegeEndToEnd:
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
syncOut = result["nodeOutputs"]["sync"]
syncDocs = syncOut.get("documents") or []
if syncDocs and syncDocs[0].get("documentData"):
syncSummary = json.loads(syncDocs[0]["documentData"])
assert syncSummary["pushed"] == 0
assert _FakeAccountingBridge.pushBatchCalls == []

View file

@ -28,6 +28,8 @@ from modules.serviceCenter.services.serviceKnowledge.subConnectorSyncGmail impor
_walkPayloadForBody,
)
_DEFAULT_DS = [{"id": "ds-1", "neutralize": False}]
def _b64url(text: str) -> str:
return base64.urlsafe_b64encode(text.encode("utf-8")).decode("ascii").rstrip("=")
@ -158,6 +160,7 @@ def test_bootstrap_gmail_indexes_messages_from_inbox_and_sent():
async def _run():
return await bootstrapGmail(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=SimpleNamespace(_token="t"),
connection=connection,
knowledgeService=knowledge,
@ -195,6 +198,7 @@ def test_bootstrap_gmail_follows_pagination():
async def _run():
return await bootstrapGmail(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=SimpleNamespace(_token="t"),
connection=connection,
knowledgeService=knowledge,
@ -218,6 +222,7 @@ def test_bootstrap_gmail_reports_duplicates():
async def _run():
return await bootstrapGmail(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=SimpleNamespace(_token="t"),
connection=connection,
knowledgeService=knowledge,

View file

@ -23,6 +23,8 @@ from modules.serviceCenter.services.serviceKnowledge.subConnectorSyncSharepoint
_syntheticFileId,
)
_DEFAULT_DS = [{"id": "ds-1", "neutralize": False, "path": "/"}]
@dataclass
class _ExtEntry:
@ -131,6 +133,7 @@ def test_bootstrap_walks_sites_and_subfolders():
async def _run():
return await bootstrapSharepoint(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=adapter,
connection=connection,
knowledgeService=knowledge,
@ -167,6 +170,7 @@ def test_bootstrap_reports_duplicates_on_second_run():
async def _run():
return await bootstrapSharepoint(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=adapter,
connection=connection,
knowledgeService=knowledge,
@ -186,6 +190,7 @@ def test_bootstrap_passes_connection_provenance():
async def _run():
return await bootstrapSharepoint(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=adapter,
connection=connection,
knowledgeService=knowledge,

View file

@ -79,7 +79,7 @@ class TestGetChildrenForParents(unittest.TestCase):
"""End-to-end orchestrator test with mocked dependencies."""
def _runAsync(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
def test_unknown_parent_key_returns_empty_list(self):
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
@ -125,7 +125,7 @@ class TestTopLevelLayout(unittest.TestCase):
"""Tests for the flat top-level layout (personalRoot + mandate groups)."""
def _runAsync(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
def test_personal_root_carries_neutral_default_triplet(self):
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:

View file

@ -46,8 +46,8 @@ class TestBootstrapConsentGate(unittest.TestCase):
fake_root.getUserConnectionById.return_value = self._makeConn(False)
with patch("modules.interfaces.interfaceDbApp.getRootInterface", return_value=fake_root):
result = asyncio.get_event_loop().run_until_complete(
sut._bootstrapJobHandler(self._makeJob(), lambda *a: None)
result = asyncio.run(
sut._bootstrapJobHandler(self._makeJob(), lambda *a, **kw: None)
)
assert result.get("skipped") is True
@ -67,6 +67,11 @@ class TestBootstrapConsentGate(unittest.TestCase):
with (
patch("modules.interfaces.interfaceDbApp.getRootInterface", return_value=fake_root),
patch.object(
sut,
"_loadRagEnabledDataSources",
return_value=[{"id": "ds-1", "sourceType": "gmail", "neutralize": False}],
),
patch(
"modules.serviceCenter.services.serviceKnowledge.subConnectorSyncGdrive.bootstrapGdrive",
new=AsyncMock(return_value={"indexed": 0}),
@ -76,8 +81,8 @@ class TestBootstrapConsentGate(unittest.TestCase):
new=AsyncMock(return_value={"indexed": 0}),
),
):
result = asyncio.get_event_loop().run_until_complete(
sut._bootstrapJobHandler(self._makeJob(authority="google"), lambda *a: None)
result = asyncio.run(
sut._bootstrapJobHandler(self._makeJob(authority="google"), lambda *a, **kw: None)
)
# Should not have 'skipped' at the top level.
@ -109,43 +114,30 @@ class TestLoadConnectionPrefs(unittest.TestCase):
with patch("modules.interfaces.interfaceDbApp.getRootInterface", return_value=self._mockRoot(None)):
prefs = loadConnectionPrefs("x")
assert prefs.neutralizeBeforeEmbed is False
assert prefs.mailContentDepth == "full"
assert prefs.mailIndexAttachments is False
assert prefs.maxAgeDays == 90
assert prefs.clickupScope == "title_description"
assert prefs.gmailEnabled is True
assert prefs.driveEnabled is True
def test_maps_all_keys(self):
from modules.serviceCenter.services.serviceKnowledge.subConnectorPrefs import loadConnectionPrefs
raw = {
"neutralizeBeforeEmbed": True,
"mailContentDepth": "metadata",
"mailIndexAttachments": True,
"filesIndexBinaries": False,
"clickupScope": "with_comments",
"maxAgeDays": 30,
"surfaceToggles": {
"google": {"gmail": False, "drive": True},
"msft": {"sharepoint": False, "outlook": True},
},
}
with patch("modules.interfaces.interfaceDbApp.getRootInterface", return_value=self._mockRoot(raw)):
prefs = loadConnectionPrefs("x")
assert prefs.neutralizeBeforeEmbed is True
assert prefs.mailContentDepth == "metadata"
assert prefs.mailIndexAttachments is True
assert prefs.filesIndexBinaries is False
assert prefs.clickupScope == "with_comments"
assert prefs.maxAgeDays == 30
assert prefs.gmailEnabled is False
assert prefs.driveEnabled is True
assert prefs.sharepointEnabled is False
assert prefs.outlookEnabled is True
def test_invalid_depth_falls_back_to_default(self):
from modules.serviceCenter.services.serviceKnowledge.subConnectorPrefs import loadConnectionPrefs
@ -208,7 +200,7 @@ class TestGmailWalkerPrefs(unittest.TestCase):
limits = GmailBootstrapLimits(neutralize=True, mailContentDepth="full")
result = GmailBootstrapResult(connectionId="c-1")
asyncio.get_event_loop().run_until_complete(
asyncio.run(
_ingestMessage(
googleGetFn=AsyncMock(return_value={}),
knowledgeService=ks,

View file

@ -21,14 +21,14 @@ class TestNodeDefinitions:
assert node["_action"] == "consolidate"
assert node["outputPorts"][0]["schema"] == "ConsolidateResult"
def test_flow_loop_has_level_and_concurrency(self):
def test_flow_loop_has_iteration_mode_and_concurrency(self):
node = next(n for n in STATIC_NODE_TYPES if n["id"] == "flow.loop")
paramNames = [p["name"] for p in node["parameters"]]
assert "level" in paramNames
assert "iterationMode" in paramNames
assert "iterationStride" in paramNames
assert "concurrency" in paramNames
levelParam = next(p for p in node["parameters"] if p["name"] == "level")
assert "structuralNodes" in levelParam["frontendOptions"]["options"]
assert "contentBlocks" in levelParam["frontendOptions"]["options"]
modeParam = next(p for p in node["parameters"] if p["name"] == "iterationMode")
assert "all" in modeParam["frontendOptions"]["options"]
concParam = next(p for p in node["parameters"] if p["name"] == "concurrency")
assert concParam["default"] == 1