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