290 lines
9.5 KiB
Python
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()
|