This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""Unit-Tests für alle importierbaren Python-Kommandozeilenwerkzeuge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TOOLS = ROOT / "tools" / "python"
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
def __init__(self, get_result=None):
|
||||
self.calls = []
|
||||
self.get_result = get_result or {}
|
||||
|
||||
def request(self, method, path, **kwargs):
|
||||
self.calls.append((method, path, kwargs))
|
||||
if method == "GET":
|
||||
return dict(self.get_result)
|
||||
return {"method": method, "path": path}
|
||||
|
||||
|
||||
class CommandTests(unittest.TestCase):
|
||||
def module(self, name):
|
||||
return importlib.import_module(name)
|
||||
|
||||
def run_with_client(self, name, argv, *, get_result=None, extra_patches=()):
|
||||
module = self.module(name)
|
||||
client = RecordingClient(get_result)
|
||||
patches = [patch.object(module, "client_from_args", return_value=client)]
|
||||
patches.extend(extra_patches)
|
||||
for current in patches:
|
||||
current.start()
|
||||
self.addCleanup(current.stop)
|
||||
result = module.main(argv)
|
||||
return result, client
|
||||
|
||||
def test_create_and_append_commands(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
gpx = Path(directory) / "route.gpx"
|
||||
picture = Path(directory) / "picture.png"
|
||||
audio = Path(directory) / "audio.mp3"
|
||||
for file_path in (gpx, picture, audio):
|
||||
file_path.write_bytes(b"content")
|
||||
|
||||
_, client = self.run_with_client("append_route", ["--route-id", "1", "--gpx", str(gpx)])
|
||||
self.assertEqual(client.calls[0][0:2], ("POST", "/api/routes/1/append"))
|
||||
|
||||
_, client = self.run_with_client(
|
||||
"create_route",
|
||||
["--gpx", str(gpx), "--name", "Route", "--slug", "route", "--description", "Text", "--school-name", "Schule"],
|
||||
)
|
||||
self.assertEqual(client.calls[0][1], "/api/routes")
|
||||
self.assertEqual(client.calls[0][2]["fields"]["schoolName"], "Schule")
|
||||
|
||||
_, client = self.run_with_client("create_audio", ["--route-id", "1", "--poi-id", "2", "--file", str(audio)])
|
||||
self.assertIn("audio", client.calls[0][2]["files"])
|
||||
|
||||
_, client = self.run_with_client(
|
||||
"create_picture",
|
||||
["--route-id", "1", "--poi-id", "2", "--file", str(picture), "--caption", "Bild", "--sequence", "3"],
|
||||
)
|
||||
self.assertEqual(client.calls[0][2]["fields"], {"caption": "Bild", "sequence": 3})
|
||||
|
||||
_, client = self.run_with_client(
|
||||
"create_poi",
|
||||
["--route-id", "1", "--title", "POI", "--description", "Text", "--lat", "50.1", "--lon", "10.2", "--trigger-radius", "25", "--sequence", "0"],
|
||||
)
|
||||
self.assertEqual(client.calls[0][2]["json_body"]["sequence"], 0)
|
||||
self.assertEqual(client.calls[0][2]["json_body"]["description"], "Text")
|
||||
|
||||
def test_delete_commands_cover_confirmation_and_requests(self):
|
||||
cases = [
|
||||
("delete_route", ["--route-id", "1"], "/api/routes/1"),
|
||||
("delete_poi", ["--route-id", "1", "--poi-id", "2"], "/api/routes/1/pois/2"),
|
||||
("delete_picture", ["--route-id", "1", "--poi-id", "2", "--picture-id", "3"], "/api/routes/1/pois/2/pictures/3"),
|
||||
("delete_audio", ["--route-id", "1", "--poi-id", "2"], "/api/routes/1/pois/2/audio"),
|
||||
]
|
||||
for name, argv, path in cases:
|
||||
with self.subTest(name=name, confirmed=False):
|
||||
module = self.module(name)
|
||||
result, client = self.run_with_client(
|
||||
name,
|
||||
argv,
|
||||
extra_patches=(patch.object(module, "confirm", return_value=False),),
|
||||
)
|
||||
self.assertEqual(result, {"cancelled": True})
|
||||
self.assertEqual(client.calls, [])
|
||||
with self.subTest(name=name, confirmed=True):
|
||||
module = self.module(name)
|
||||
_, client = self.run_with_client(
|
||||
name,
|
||||
argv,
|
||||
extra_patches=(patch.object(module, "confirm", return_value=True),),
|
||||
)
|
||||
self.assertEqual(client.calls[0][0:2], ("DELETE", path))
|
||||
|
||||
def test_restore_and_update_commands(self):
|
||||
_, client = self.run_with_client("restore_route", ["--route-id", "7"])
|
||||
self.assertEqual(client.calls[0][0:2], ("POST", "/api/routes/7/restore"))
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
replacement = Path(directory) / "replacement.bin"
|
||||
replacement.write_bytes(b"content")
|
||||
|
||||
_, client = self.run_with_client(
|
||||
"update_audio", ["--route-id", "1", "--poi-id", "2", "--file", str(replacement)]
|
||||
)
|
||||
self.assertEqual(client.calls[0][0], "PUT")
|
||||
|
||||
_, client = self.run_with_client(
|
||||
"update_picture",
|
||||
["--route-id", "1", "--poi-id", "2", "--picture-id", "3", "--file", str(replacement)],
|
||||
get_result={"caption": "Alt", "sequence": 4},
|
||||
extra_patches=(patch.object(self.module("update_picture"), "ask_with_default", side_effect=lambda label, current, cast=str: current),),
|
||||
)
|
||||
self.assertEqual([call[0] for call in client.calls], ["GET", "PUT"])
|
||||
self.assertIn("files", client.calls[1][2])
|
||||
|
||||
module = self.module("update_picture")
|
||||
_, client = self.run_with_client(
|
||||
"update_picture",
|
||||
["--route-id", "1", "--poi-id", "2", "--picture-id", "3"],
|
||||
get_result={"caption": "Alt", "sequence": 4},
|
||||
extra_patches=(
|
||||
patch.object(module, "ask_path", return_value=None),
|
||||
patch.object(module, "ask_with_default", side_effect=lambda label, current, cast=str: current),
|
||||
),
|
||||
)
|
||||
self.assertIn("json_body", client.calls[1][2])
|
||||
|
||||
_, client = self.run_with_client(
|
||||
"update_route",
|
||||
["--route-id", "1", "--gpx", str(replacement)],
|
||||
get_result={"name": "Alt", "description": "Text", "schoolName": "Schule"},
|
||||
extra_patches=(patch.object(self.module("update_route"), "ask_with_default", side_effect=lambda label, current, cast=str: current),),
|
||||
)
|
||||
self.assertIn("files", client.calls[1][2])
|
||||
|
||||
module = self.module("update_route")
|
||||
_, client = self.run_with_client(
|
||||
"update_route",
|
||||
["--route-id", "1"],
|
||||
get_result={"name": "Alt", "description": "Text", "schoolName": "Schule"},
|
||||
extra_patches=(
|
||||
patch.object(module, "ask_path", return_value=None),
|
||||
patch.object(module, "ask_with_default", side_effect=lambda label, current, cast=str: current),
|
||||
),
|
||||
)
|
||||
self.assertIn("json_body", client.calls[1][2])
|
||||
|
||||
_, client = self.run_with_client(
|
||||
"update_poi",
|
||||
["--route-id", "1", "--poi-id", "2"],
|
||||
get_result={
|
||||
"title": "Alt",
|
||||
"description": "Text",
|
||||
"lat": 50.0,
|
||||
"lon": 10.0,
|
||||
"triggerRadiusM": 25.0,
|
||||
"sequence": 1,
|
||||
},
|
||||
extra_patches=(patch.object(self.module("update_poi"), "ask_with_default", side_effect=lambda label, current, cast=str: current),),
|
||||
)
|
||||
self.assertEqual(client.calls[1][2]["json_body"]["title"], "Alt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Unit-Tests für die gemeinsame Python-HTTP- und CLI-Schicht."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TOOLS = ROOT / "tools" / "python"
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
import common # noqa: E402
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status=200, body=b"", content_type="application/json"):
|
||||
self.status = status
|
||||
self._body = body
|
||||
self.headers = {"Content-Type": content_type}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class CommonTests(unittest.TestCase):
|
||||
def test_environment_credentials_are_trimmed_and_require_both_values(self):
|
||||
with patch.dict(os.environ, {common.API_USER_ENV: " user ", common.API_PASSWORD_ENV: " secret "}, clear=True):
|
||||
self.assertEqual(common.credentials_from_environment(), common.Credentials("user", "secret"))
|
||||
with patch.dict(os.environ, {common.API_USER_ENV: "user", common.API_PASSWORD_ENV: " "}, clear=True):
|
||||
self.assertIsNone(common.credentials_from_environment())
|
||||
|
||||
def test_prompt_credentials_prefers_environment_then_interactive_values(self):
|
||||
client = common.ApiClient("http://localhost:47145")
|
||||
with patch.dict(os.environ, {common.API_USER_ENV: " env-user ", common.API_PASSWORD_ENV: " env-pass "}, clear=True):
|
||||
client.prompt_credentials()
|
||||
self.assertEqual(client.credentials, common.Credentials("env-user", "env-pass"))
|
||||
|
||||
client = common.ApiClient("http://localhost:47145", "default", "old")
|
||||
with patch.dict(os.environ, {}, clear=True), patch("builtins.input", return_value=""), patch(
|
||||
"getpass.getpass", return_value=" new-pass "
|
||||
):
|
||||
client.prompt_credentials()
|
||||
self.assertEqual(client.credentials, common.Credentials("default", "new-pass"))
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch("builtins.input", return_value=""):
|
||||
with self.assertRaises(common.ApiError):
|
||||
common.ApiClient("http://localhost:47145").prompt_credentials()
|
||||
with patch.dict(os.environ, {}, clear=True), patch("builtins.input", return_value="user"), patch(
|
||||
"getpass.getpass", return_value=" "
|
||||
):
|
||||
with self.assertRaises(common.ApiError):
|
||||
common.ApiClient("http://localhost:47145").prompt_credentials()
|
||||
|
||||
def test_payload_generation_and_multipart_encoding(self):
|
||||
data, headers = common._request_payload({"ä": "ö"}, None, None)
|
||||
self.assertEqual(json.loads(data.decode("utf-8")), {"ä": "ö"})
|
||||
self.assertEqual(headers["Content-Type"], common.APPLICATION_JSON)
|
||||
|
||||
data, headers = common._request_payload(None, {"value": 7}, None)
|
||||
self.assertEqual(json.loads(data), {"value": 7})
|
||||
self.assertEqual(headers["Accept"], common.APPLICATION_JSON)
|
||||
|
||||
data, headers = common._request_payload(None, None, None)
|
||||
self.assertIsNone(data)
|
||||
self.assertNotIn("Content-Type", headers)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
file_path = Path(directory) / 'a"b.txt'
|
||||
file_path.write_bytes(b"content")
|
||||
data, headers = common._request_payload(None, {"skip": None, "caption": "Text"}, {"file": file_path})
|
||||
self.assertIn(b'filename="a_b.txt"', data)
|
||||
self.assertIn(b'name="caption"', data)
|
||||
self.assertNotIn(b'name="skip"', data)
|
||||
self.assertTrue(headers["Content-Type"].startswith("multipart/form-data; boundary="))
|
||||
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
common.encode_multipart({}, {"file": Path(directory) / "missing.bin"})
|
||||
|
||||
def test_response_parsing_and_error_decoding(self):
|
||||
self.assertIsNone(common.parse_json(b""))
|
||||
self.assertEqual(common.parse_json(b'{"ok":true}'), {"ok": True})
|
||||
self.assertIsNone(common.parse_json(b"\xff"))
|
||||
self.assertIsNone(common.parse_json(b"{"))
|
||||
|
||||
self.assertIsNone(common.parse_response(b"", common.APPLICATION_JSON))
|
||||
self.assertEqual(common.parse_response(b'{"ok":true}', "application/problem+json"), {"ok": True})
|
||||
self.assertEqual(common.parse_response(b"plain", "text/plain"), "plain")
|
||||
self.assertEqual(common.parse_response(b"not-json", common.APPLICATION_JSON), "not-json")
|
||||
|
||||
self.assertEqual(common.decode_error(b'{"message":"bad"}'), "bad")
|
||||
self.assertEqual(common.decode_error(b'{"error":"wrong"}'), "wrong")
|
||||
self.assertIn("value", common.decode_error(b'{"value":1}'))
|
||||
self.assertEqual(common.decode_error(b" text "), "text")
|
||||
self.assertEqual(common.decode_error(b""), "Unbekannter API-Fehler")
|
||||
|
||||
def test_client_request_success_retry_and_failures(self):
|
||||
anonymous = common.ApiClient("http://localhost:47145")
|
||||
self.assertIsNone(anonymous._authorization_header())
|
||||
self.assertEqual(anonymous._request_headers({"Accept": common.APPLICATION_JSON}), {"Accept": common.APPLICATION_JSON})
|
||||
|
||||
client = common.ApiClient("http://localhost:47145", " user ", " pass ")
|
||||
self.assertEqual(client.base_url, "http://localhost:47145/")
|
||||
self.assertTrue(client._authorization_header().startswith("Basic "))
|
||||
self.assertIn("Authorization", client._request_headers({"Accept": common.APPLICATION_JSON}))
|
||||
|
||||
with patch.object(common, "urlopen", return_value=FakeResponse(200, b'{"ok":true}')):
|
||||
self.assertEqual(client.request("GET", "/api/health"), {"ok": True})
|
||||
|
||||
with patch.object(common, "urlopen", return_value=FakeResponse(204, b"", "text/plain")):
|
||||
self.assertIsNone(client.request("DELETE", "/api/item", expected=(204,)))
|
||||
|
||||
with patch.object(common, "urlopen", return_value=FakeResponse(202, b'{"queued":true}')):
|
||||
with self.assertRaises(common.ApiError) as context:
|
||||
client.request("POST", "/api/item")
|
||||
self.assertEqual(context.exception.status, 202)
|
||||
|
||||
unauthorized = HTTPError(
|
||||
"http://localhost:47145/api/item",
|
||||
401,
|
||||
"Unauthorized",
|
||||
{},
|
||||
io.BytesIO(b'{"message":"login"}'),
|
||||
)
|
||||
with patch.object(common, "urlopen", side_effect=[unauthorized, FakeResponse(200, b'{"ok":true}')]), patch.object(
|
||||
client, "prompt_credentials"
|
||||
) as prompt:
|
||||
self.assertEqual(client.request("POST", "/api/item"), {"ok": True})
|
||||
prompt.assert_called_once_with()
|
||||
|
||||
forbidden = HTTPError(
|
||||
"http://localhost:47145/api/item",
|
||||
403,
|
||||
"Forbidden",
|
||||
{},
|
||||
io.BytesIO(b'{"error":"denied"}'),
|
||||
)
|
||||
with patch.object(common, "urlopen", side_effect=forbidden):
|
||||
with self.assertRaises(common.ApiError) as context:
|
||||
client.request("POST", "/api/item")
|
||||
self.assertEqual(context.exception.status, 403)
|
||||
self.assertEqual(context.exception.payload, {"error": "denied"})
|
||||
|
||||
with patch.object(common, "urlopen", side_effect=URLError("offline")):
|
||||
with self.assertRaisesRegex(RuntimeError, "offline"):
|
||||
client.request("GET", "/api/health")
|
||||
|
||||
def test_argument_and_prompt_helpers(self):
|
||||
parser = argparse.ArgumentParser()
|
||||
common.add_common_arguments(parser)
|
||||
args = parser.parse_args(["--base-url", "http://example.test", "--username", "alice"])
|
||||
client = common.client_from_args(args)
|
||||
self.assertEqual(client.base_url, "http://example.test/")
|
||||
self.assertIsNone(client.credentials)
|
||||
|
||||
env_args = argparse.Namespace(base_url=None, username=None)
|
||||
with patch.dict(os.environ, {"WEGWICHTEL_URL": "http://env.test"}, clear=True):
|
||||
self.assertEqual(common.client_from_args(env_args).base_url, "http://env.test/")
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(common.client_from_args(env_args).base_url, common.DEFAULT_BASE_URL + "/")
|
||||
|
||||
self.assertEqual(common.ask("Zahl", 4, cast=int), 4)
|
||||
stderr = io.StringIO()
|
||||
with patch("builtins.input", side_effect=["bad", "5"]), redirect_stderr(stderr):
|
||||
self.assertEqual(common.ask("Zahl", required=True, cast=int), 5)
|
||||
self.assertIn("Ungültiger Wert", stderr.getvalue())
|
||||
with patch("builtins.input", return_value=""):
|
||||
self.assertIsNone(common.ask("Optional"))
|
||||
with patch("builtins.input", side_effect=["", "value"]), redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(common.ask("Pflicht", required=True), "value")
|
||||
|
||||
with patch("builtins.input", return_value=""):
|
||||
self.assertEqual(common.ask_with_default("Wert", 3, int), 3)
|
||||
with patch("builtins.input", side_effect=["bad", "4"]), redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(common.ask_with_default("Wert", 3, int), 4)
|
||||
|
||||
def test_path_confirmation_output_and_run_error_tracebacks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
existing = Path(directory) / "file.txt"
|
||||
existing.write_text("ok", encoding="utf-8")
|
||||
self.assertEqual(common.ask_path("Datei", str(existing)), existing.resolve())
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
common.ask_path("Datei", str(Path(directory) / "missing"))
|
||||
with patch("builtins.input", return_value=""):
|
||||
self.assertIsNone(common.ask_path("Optional", required=False))
|
||||
with patch("builtins.input", side_effect=["", str(Path(directory) / "missing"), str(existing)]), redirect_stderr(
|
||||
io.StringIO()
|
||||
):
|
||||
self.assertEqual(common.ask_path("Pflicht"), existing.resolve())
|
||||
|
||||
with patch("builtins.input", return_value="Ja"):
|
||||
self.assertTrue(common.confirm("Fortfahren?"))
|
||||
with patch("builtins.input", return_value="nein"):
|
||||
self.assertFalse(common.confirm("Fortfahren?"))
|
||||
|
||||
stdout = io.StringIO()
|
||||
with redirect_stdout(stdout):
|
||||
common.print_result({"ok": True})
|
||||
common.print_result("text")
|
||||
self.assertIn('"ok": true', stdout.getvalue())
|
||||
self.assertTrue(stdout.getvalue().rstrip().endswith("text"))
|
||||
|
||||
stdout = io.StringIO()
|
||||
with redirect_stdout(stdout):
|
||||
common.run(lambda: {"ok": True})
|
||||
self.assertIn('"ok": true', stdout.getvalue())
|
||||
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr), self.assertRaises(SystemExit) as context:
|
||||
common.run(lambda: (_ for _ in ()).throw(ValueError("kaputt")))
|
||||
self.assertEqual(context.exception.code, 1)
|
||||
self.assertIn("Traceback", stderr.getvalue())
|
||||
self.assertIn("ValueError: kaputt", stderr.getvalue())
|
||||
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr), self.assertRaises(SystemExit) as context:
|
||||
common.run(lambda: (_ for _ in ()).throw(KeyboardInterrupt()))
|
||||
self.assertEqual(context.exception.code, 130)
|
||||
self.assertIn("KeyboardInterrupt", stderr.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user