Files
RollCalcPython/tests/test_openwebui_pipe.py
T

290 lines
9.5 KiB
Python

import importlib.util
from pathlib import Path
import sys
import types
import unittest
from unittest.mock import patch
class _BaseModel:
def __init__(self, **values):
for name in self.__class__.__annotations__:
setattr(self, name, values.get(name, getattr(self.__class__, name)))
class _SecretStr:
def __init__(self, value):
self._value = value
def get_secret_value(self):
return self._value
def _field(*, default, **_kwargs):
return default
class _RequestError(Exception):
pass
class _TimeoutException(_RequestError):
pass
class _BasicAuth:
def __init__(self, username, password):
self.username = username
self.password = password
def _load_pipe_module():
pydantic = types.ModuleType("pydantic")
pydantic.BaseModel = _BaseModel
pydantic.Field = _field
pydantic.SecretStr = _SecretStr
httpx = types.ModuleType("httpx")
httpx.AsyncClient = object
httpx.BasicAuth = _BasicAuth
httpx.RequestError = _RequestError
httpx.TimeoutException = _TimeoutException
module_path = (
Path(__file__).parents[1]
/ "integrations"
/ "openwebui"
/ "rollcalc_pipe.py"
)
spec = importlib.util.spec_from_file_location("rollcalc_pipe_test", module_path)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"pydantic": pydantic, "httpx": httpx}):
spec.loader.exec_module(module)
return module
PIPE_MODULE = _load_pipe_module()
class FakeResponse:
def __init__(self, status_code, payload):
self.status_code = status_code
self.payload = payload
def json(self):
return self.payload
class FakeClientFactory:
def __init__(self, responses=None, error=None):
self.responses = list(responses or [])
self.error = error
self.calls = []
self.options = []
def __call__(self, **options):
self.options.append(options)
return self
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, url, **options):
self.calls.append((url, options))
if self.error:
raise self.error
return self.responses.pop(0)
class OpenWebUIPipeTests(unittest.IsolatedAsyncioTestCase):
def configured_pipe(self, factory):
pipe = PIPE_MODULE.Pipe(client_factory=factory)
pipe.valves.ROLLCALC_API_BASE_URL = "http://rollcalc.internal:5000"
pipe.valves.ROLLCALC_PUBLIC_BASE_URL = "https://rollcalc.example.test"
pipe.valves.ROLLCALC_USERNAME = "service-user"
pipe.valves.ROLLCALC_PASSWORD = _SecretStr("secret")
return pipe
@staticmethod
def context(chat_id="chat-1"):
return {
"body": {"messages": [{"role": "user", "content": "Fallback"}]},
"__user__": {"id": "user-1"},
"__metadata__": {
"chat_id": chat_id,
"user_prompt": "Berechne die Rolle",
},
}
async def test_same_openwebui_chat_reuses_rollcalc_conversation(self):
factory = FakeClientFactory([
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
FakeResponse(200, {"message": "Welcher Kern?"}),
FakeResponse(200, {"message": "Berechnung abgeschlossen."}),
])
pipe = self.configured_pipe(factory)
first = await pipe.pipe(**self.context())
second_context = self.context()
second_context["__metadata__"]["user_prompt"] = "150 mm"
second = await pipe.pipe(**second_context)
self.assertEqual(first, "Welcher Kern?")
self.assertEqual(second, "Berechnung abgeschlossen.")
self.assertEqual(len(factory.calls), 3)
self.assertEqual(
factory.calls[1][0],
"http://rollcalc.internal:5000/api/conversations/rollcalc-1/messages",
)
self.assertEqual(factory.calls[2][0], factory.calls[1][0])
self.assertEqual(factory.calls[2][1]["json"], {"message": "150 mm"})
self.assertEqual(factory.options[0]["auth"].username, "service-user")
self.assertEqual(factory.options[0]["auth"].password, "secret")
async def test_different_chat_gets_different_rollcalc_conversation(self):
factory = FakeClientFactory([
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
FakeResponse(200, {"message": "Antwort 1"}),
FakeResponse(201, {"conversation_id": "rollcalc-2"}),
FakeResponse(200, {"message": "Antwort 2"}),
])
pipe = self.configured_pipe(factory)
await pipe.pipe(**self.context("chat-1"))
await pipe.pipe(**self.context("chat-2"))
self.assertEqual(len(pipe._conversations), 2)
self.assertEqual(
set(pipe._conversations.values()),
{"rollcalc-1", "rollcalc-2"},
)
async def test_only_deterministic_message_and_public_pdf_link_are_shown(self):
factory = FakeClientFactory([
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
FakeResponse(200, {
"message": (
"Berechnung abgeschlossen.\n\n"
"Artikel: Test (180205)\n\n"
"PDF: /api/conversations/reports/report-1.pdf"
),
"pdf": {
"url": "/api/conversations/reports/report-1.pdf",
},
"interpretation": {"internal": "must not leak"},
"result": {"calculation": {"average_diameter_mm": 999}},
}),
])
pipe = self.configured_pipe(factory)
response = await pipe.pipe(**self.context())
self.assertIn("Berechnung abgeschlossen.", response)
self.assertIn(
"[PDF herunterladen](https://rollcalc.example.test/"
"api/conversations/reports/report-1.pdf)",
response,
)
self.assertNotIn("interpretation", response)
self.assertNotIn("999", response)
self.assertNotIn("PDF: /api", response)
async def test_invalid_report_path_is_not_exposed(self):
factory = FakeClientFactory([
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
FakeResponse(200, {
"message": "Berechnung abgeschlossen.",
"pdf": {"url": "http://internal-secret/report.pdf"},
}),
])
pipe = self.configured_pipe(factory)
response = await pipe.pipe(**self.context())
self.assertEqual(response, "Berechnung abgeschlossen.")
async def test_expired_conversation_is_removed_without_replaying_message(self):
factory = FakeClientFactory([
FakeResponse(201, {"conversation_id": "expired"}),
FakeResponse(404, {"status": "conversation_not_found"}),
FakeResponse(201, {"conversation_id": "fresh"}),
FakeResponse(200, {"message": "Neue Sitzung"}),
])
pipe = self.configured_pipe(factory)
response = await pipe.pipe(**self.context())
retry = await pipe.pipe(**self.context())
self.assertIn("abgelaufen", response)
self.assertEqual(retry, "Neue Sitzung")
self.assertEqual(len(factory.calls), 4)
async def test_timeout_is_friendly_and_does_not_expose_exception(self):
factory = FakeClientFactory(error=_TimeoutException("private details"))
pipe = self.configured_pipe(factory)
response = await pipe.pipe(**self.context())
self.assertIn("zu lange", response)
self.assertNotIn("private", response)
async def test_authentication_and_ollama_errors_are_friendly(self):
cases = (
(401, "Authentifizierung"),
(503, "Spracherkennung"),
)
for status_code, expected in cases:
with self.subTest(status_code=status_code):
factory = FakeClientFactory([
FakeResponse(status_code, {"debug": "must not leak"}),
])
pipe = self.configured_pipe(factory)
response = await pipe.pipe(**self.context())
self.assertIn(expected, response)
self.assertNotIn("debug", response)
async def test_validation_error_uses_the_api_deterministic_message(self):
factory = FakeClientFactory([
FakeResponse(201, {"conversation_id": "conversation"}),
FakeResponse(
400,
{
"status": "invalid_parameter",
"message": "Die Eingabe für roll_length_m ist ungültig.",
},
),
])
pipe = self.configured_pipe(factory)
response = await pipe.pipe(**self.context())
self.assertEqual(response, "Die Eingabe für roll_length_m ist ungültig.")
async def test_missing_configuration_does_not_make_http_request(self):
factory = FakeClientFactory()
pipe = PIPE_MODULE.Pipe(client_factory=factory)
response = await pipe.pipe(**self.context())
self.assertIn("nicht vollständig konfiguriert", response)
self.assertEqual(factory.calls, [])
async def test_internal_openwebui_task_does_not_touch_conversation(self):
factory = FakeClientFactory()
pipe = self.configured_pipe(factory)
response = await pipe.pipe(
**self.context(),
__task__="title_generation",
)
self.assertEqual(response, "RollCalc")
self.assertEqual(factory.calls, [])
if __name__ == "__main__":
unittest.main()