feat: Editor-Aufruf mit Task- und Typenrückgabe statt MongoDB

Tasks, eigene Typen und Metadaten pro Editor-Aufruf laden und an den Aufrufer zurückgeben. MongoDB-Persistenz entfernen und Header auf Rückgängig, Abbrechen und Fertig umstellen.

Python-Testserver und Regressionstests ergänzen; Verbindungslinien nach Import und Layoutänderungen neu berechnen.
This commit is contained in:
Sven committed 2026-10-07 21:06:28 +02:00
1 parent 93ba40c476
commit f979c797ba
39 files changed
+1980 -1140

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TaskList Editor · Testaufrufer</title>
<style>
:root { color-scheme: light; font-family: system-ui, sans-serif; color: #172033; background: #f3f5f9; }
* { box-sizing: border-box; }
body { margin: 0; }
main { max-width: 1100px; padding: 32px 24px; margin: auto; }
h1 { font-size: 27px; margin: 0 0 10px; }
p { line-height: 1.5; }
.muted { color: #566278; }
section { padding: 24px; margin-top: 24px; border: 1px solid #dfe5ef; border-radius: 12px; background: white; }
label { display: block; font-weight: 600; margin-bottom: 10px; }
textarea { display: block; width: 100%; min-height: 300px; padding: 16px; border: 1px solid #bac5d6; border-radius: 8px; font: 14px/1.5 ui-monospace, monospace; resize: vertical; }
button, .file-label { font: inherit; cursor: pointer; border: 1px solid #bac5d6; border-radius: 6px; padding: 10px 16px; color: #172033; background: #fff; margin: 0; }
button.primary { background: #2563eb; color: white; border-color: #2563eb; }
button:disabled { cursor: wait; opacity: .55; }
input[type=file] { max-width: 100%; font: inherit; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 16px; }
#status { min-height: 24px; margin-top: 20px; white-space: pre-wrap; overflow-wrap: anywhere; }
#status.error { color: #b42318; }
#result { background: #f4f6fa; padding: 16px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow-y: auto; }
#editor-panel { position: fixed; inset: 0; background: #fff; z-index: 5; display: flex; flex-direction: column; }
#editor-panel[hidden], [hidden] { display: none !important; }
#editor-header { padding: 10px 20px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; border-bottom: 1px solid #dfe5ef; background: #f3f5f9; }
#editor-status { flex: 1; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
iframe { width: 100%; border: 0; flex: 1; min-height: 0; }
code { font: .93em ui-monospace, monospace; }
</style>
</head>
<body>
<main>
<h1>TaskList Editor testen</h1>
<p class="muted">Tasks, eigene Typen und Metadaten übergeben, im Editor bearbeiten und das Ergebnis in der Python-Konsole lesen.</p>
<p class="muted" id="editor-url">Verbindung wird vorbereitet …</p>
<section>
<label for="payload">JSON für den Editor</label>
<textarea id="payload" spellcheck="false" aria-describedby="payload-help"></textarea>
<p class="muted" id="payload-help">Ein <code>tasks</code>-Array oder ein Objekt mit <code>name</code>, <code>tasks</code>, dem vollständigen Katalog eigener Typen in <code>customTypes</code> und beliebigen <code>metadata</code>. Für eine neue Liste: <code>"tasks": []</code>. Nach „Fertig“ wird das Eingabefeld mit den zurückgegebenen Tasks und Typen aktualisiert.</p>
<div class="actions">
<button class="primary" id="open" disabled>Im Editor öffnen</button>
<button id="new" disabled>Neue Liste</button>
<input id="file" type="file" accept=".json,application/json" aria-label="JSON-Datei laden" disabled>
</div>
<p id="status" role="status" aria-live="polite"></p>
</section>
<section id="result-section" hidden>
<h2>Empfangene Rückgabe</h2>
<pre id="result"></pre>
</section>
</main>
<div id="editor-panel" hidden>
<div id="editor-header">
<p id="editor-status" role="status" aria-live="polite">Mit „Fertig“ übernehmen oder mit „Abbrechen“ ohne Änderungen zurückkehren.</p>
<button id="retry" hidden>Rückgabe erneut senden</button>
</div>
<iframe id="editor" title="TaskList Editor"></iframe>
</div>
<script>
'use strict';
const payloadInput = document.querySelector('#payload');
const status = document.querySelector('#status');
const openButton = document.querySelector('#open');
const newButton = document.querySelector('#new');
const fileInput = document.querySelector('#file');
const panel = document.querySelector('#editor-panel');
const frame = document.querySelector('#editor');
const editorStatus = document.querySelector('#editor-status');
const retryButton = document.querySelector('#retry');
let config;
let activeToken = null;
let pendingResult = null;
let submitting = false;
function setStatus(message, error = false) {
status.textContent = message;
status.classList.toggle('error', error);
}
async function request(path, value) {
let response;
try {
response = await fetch(path, value === undefined ? {} : {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(value)
});
} catch (error) {
throw new Error(`Verbindung zum Python-Testserver unter ${window.location.origin} fehlgeschlagen. ` +
'Starte ihn mit „python3 tools/editor_test_server.py“ und lasse das Terminal geöffnet. ' +
'Versuche es anschließend erneut.');
}
const result = await response.json();
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
return result;
}
openButton.addEventListener('click', async () => {
openButton.disabled = true;
setStatus('JSON wird an den Editor übergeben …');
try {
const payload = JSON.parse(payloadInput.value);
const result = await request('/api/open', payload);
if (new URL(result.deepLink).origin !== config.editorOrigin) {
throw new Error('Der Editor hat einen unerwarteten Origin zurückgegeben.');
}
activeToken = result.token;
pendingResult = null;
retryButton.hidden = true;
editorStatus.textContent = 'Mit „Fertig“ übernehmen oder mit „Abbrechen“ ohne Änderungen zurückkehren.';
frame.src = result.deepLink;
panel.hidden = false;
setStatus('Editor geöffnet.');
} catch (error) {
setStatus(error.message, true);
} finally {
openButton.disabled = false;
}
});
newButton.addEventListener('click', () => {
try {
const previous = JSON.parse(payloadInput.value);
const customTypes = previous?.customTypes ?? [];
payloadInput.value = JSON.stringify({name: 'Neue Testliste', tasks: [], customTypes,
metadata: {source: 'python-testserver'}}, null, 2);
setStatus('Neue leere Liste mit den vorhandenen eigenen Typen vorbereitet. Mit „Im Editor öffnen“ beginnen.');
} catch (error) {
setStatus('Die Eingabe enthält ungültiges JSON. Bitte korrigieren, damit eigene Typen übernommen werden können.', true);
}
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
try {
if (file.size > 2 * 1024 * 1024) throw new Error('Die JSON-Datei darf höchstens 2 MiB groß sein.');
const value = JSON.parse((await file.text()).replace(/^\uFEFF/, ''));
payloadInput.value = JSON.stringify(value, null, 2);
setStatus(`Datei „${file.name}“ geladen.`);
} catch (error) {
setStatus(error.message, true);
} finally {
fileInput.value = '';
}
});
async function submitResult() {
if (submitting || !pendingResult) return;
submitting = true;
retryButton.disabled = true;
editorStatus.textContent = 'Rückgabe wird an die Python-Konsole gesendet …';
try {
await request('/api/result', pendingResult);
document.querySelector('#result').textContent = JSON.stringify(pendingResult, null, 2);
document.querySelector('#result-section').hidden = false;
if (pendingResult.status === 'completed') {
const payload = {tasks: pendingResult.tasks, customTypes: pendingResult.customTypes};
for (const key of ['name', 'metadata']) {
if (Object.hasOwn(pendingResult, key)) payload[key] = pendingResult[key];
}
payloadInput.value = JSON.stringify(payload, null, 2);
}
setStatus(pendingResult.status === 'completed'
? 'Bearbeitung abgeschlossen. Tasks und eigene Typen wurden für den nächsten Aufruf übernommen und in der Python-Konsole ausgegeben.'
: 'Bearbeitung abgebrochen. Die Abbruchmeldung wurde in der Python-Konsole ausgegeben.');
activeToken = null;
pendingResult = null;
panel.hidden = true;
frame.src = 'about:blank';
openButton.focus();
} catch (error) {
editorStatus.textContent = `Rückgabe noch nicht angekommen: ${error.message}`;
retryButton.hidden = false;
} finally {
submitting = false;
retryButton.disabled = false;
}
}
window.addEventListener('message', event => {
if (!config || !activeToken || event.origin !== config.editorOrigin || event.source !== frame.contentWindow) return;
const result = event.data;
if (!result || typeof result !== 'object' || result.type !== 'tasklist-editor:result' || result.token !== activeToken) return;
if (!['completed', 'cancelled'].includes(result.status)) return;
if (result.status === 'completed' && (!Array.isArray(result.tasks) || !Array.isArray(result.customTypes))) return;
if (result.status === 'cancelled' && (Object.hasOwn(result, 'tasks') || Object.hasOwn(result, 'customTypes'))) return;
if (submitting) return;
pendingResult = result;
submitResult();
});
retryButton.addEventListener('click', submitResult);
request('/api/config').then(value => {
config = value;
payloadInput.value = JSON.stringify(config.initialPayload, null, 2);
document.querySelector('#editor-url').textContent = 'Editor: ' + config.editorUrl;
openButton.disabled = newButton.disabled = fileInput.disabled = false;
}).catch(error => setStatus(error.message, true));
</script>
</body>
</html>
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""Lokaler Aufrufer für den TaskList Editor; benötigt nur Python 3.9+.
Start: python3 tools/editor_test_server.py --json tools/example_tasks.json
Der Editor muss separat laufen (standardmäßig http://127.0.0.1:8080).
"""
import argparse
import json
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
MAX_BODY_BYTES = 2 * 1024 * 1024
TOKEN_TTL_SECONDS = 60 * 60
RESULT_TYPE = "tasklist-editor:result"
HTML_PATH = Path(__file__).with_suffix(".html")
PRINT_LOCK = threading.Lock()
class RequestError(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
def reject_constant(value):
raise ValueError("Ungültiger JSON-Zahlenwert: " + value)
def decode_json(raw):
try:
return json.loads(raw.decode("utf-8-sig"), parse_constant=reject_constant)
except (UnicodeError, ValueError, RecursionError) as exc:
raise RequestError(400, "Ungültiges JSON: " + str(exc)) from exc
def origin_for(url):
parsed = urllib.parse.urlsplit(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError("Die Editor-URL muss eine vollständige http(s)-URL sein.")
if parsed.username or parsed.password:
raise ValueError("Zugangsdaten in der Editor-URL werden nicht unterstützt.")
port = parsed.port
host = parsed.hostname.lower()
if ":" in host:
host = "[" + host + "]"
if port and port != {"http": 80, "https": 443}[parsed.scheme]:
host += ":" + str(port)
return parsed.scheme + "://" + host
def normalize_payload(value):
if isinstance(value, list):
value = {"tasks": value}
if not isinstance(value, dict):
raise RequestError(400, "Erwartet wird ein tasks-Array oder ein Objekt mit name, tasks, customTypes und metadata.")
tasks = value.get("tasks")
if tasks is None:
tasks = []
if not isinstance(tasks, list) or any(not isinstance(task, dict) for task in tasks):
raise RequestError(400, "tasks muss ein Array aus Task-Objekten sein; [] legt eine neue Liste an.")
custom_types = value.get("customTypes")
if custom_types is None:
custom_types = []
if not isinstance(custom_types, list) or any(not isinstance(item, dict) for item in custom_types):
raise RequestError(400, "customTypes muss ein Array aus Typ-Objekten sein; [] übergibt keine eigenen Typen.")
if value.get("name") is not None and not isinstance(value["name"], str):
raise RequestError(400, "name muss eine Zeichenkette oder null sein.")
payload = {"tasks": tasks, "customTypes": custom_types}
for key in ("name", "metadata"):
if key in value:
payload[key] = value[key]
return payload
class EditorTestServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, port, editor_url, initial_payload):
editor_url = editor_url.rstrip("/")
self.editor_origin = origin_for(editor_url)
parsed = urllib.parse.urlsplit(editor_url)
if parsed.query or parsed.fragment:
raise ValueError("Die Editor-URL darf keine Query-Parameter oder Fragmente enthalten.")
self.editor_url = editor_url
self.initial_payload = normalize_payload(initial_payload)
self.sessions = {}
self.session_lock = threading.Lock()
super().__init__(("127.0.0.1", port), EditorTestHandler)
self.local_origin = "http://127.0.0.1:" + str(self.server_port)
def open_editor(self, value):
payload = normalize_payload(value)
payload["returnOrigin"] = self.local_origin
request = urllib.request.Request(
self.editor_url + "/api/editor/import",
data=json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
raw = response.read(MAX_BODY_BYTES + 1)
except urllib.error.HTTPError as exc:
detail = exc.read(4096).decode("utf-8", errors="replace").strip()
raise RequestError(502, "Editor lehnt den Import ab (HTTP {}): {}".format(
exc.code, detail or exc.reason)) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise RequestError(502, "Editor unter {} nicht erreichbar: {}".format(self.editor_url, exc)) from exc
if len(raw) > MAX_BODY_BYTES:
raise RequestError(502, "Die Antwort des Editors ist zu groß.")
try:
result = decode_json(raw)
token = result.get("token") if isinstance(result, dict) else None
deep_link = result.get("deepLink") if isinstance(result, dict) else None
if not isinstance(token, str) or not token or len(token) > 512:
raise ValueError("token fehlt oder ist ungültig")
if not isinstance(deep_link, str) or origin_for(deep_link) != self.editor_origin:
raise ValueError("deepLink muss auf den konfigurierten Editor-Origin zeigen")
except (RequestError, ValueError) as exc:
raise RequestError(502, "Ungültige Import-Antwort des Editors: " + str(exc)) from exc
with self.session_lock:
now = time.monotonic()
self.sessions = {key: session for key, session in self.sessions.items()
if now - session["created"] < TOKEN_TTL_SECONDS}
if len(self.sessions) >= 1000:
raise RequestError(503, "Zu viele offene Tests. Bitte den Testserver neu starten.")
self.sessions[token] = {"created": now, "result": None}
return {"token": token, "deepLink": deep_link}
def receive_result(self, result):
if not isinstance(result, dict) or result.get("type") != RESULT_TYPE:
raise RequestError(400, "Ungültiger Rückgabetyp.")
token = result.get("token")
if not isinstance(token, str):
raise RequestError(400, "Die Rückgabe benötigt ein token.")
status = result.get("status")
if status not in ("completed", "cancelled"):
raise RequestError(400, "status muss completed oder cancelled sein.")
if status == "completed" and (not isinstance(result.get("tasks"), list)
or any(not isinstance(task, dict) for task in result["tasks"])):
raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt ein tasks-Array.")
if status == "completed" and (not isinstance(result.get("customTypes"), list)
or any(not isinstance(item, dict) for item in result["customTypes"])):
raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt den vollständigen customTypes-Katalog als Array.")
if status == "cancelled" and ("tasks" in result or "customTypes" in result):
raise RequestError(400, "Eine abgebrochene Bearbeitung darf keine tasks oder customTypes zurückgeben.")
if result.get("name") is not None and not isinstance(result["name"], str):
raise RequestError(400, "name muss eine Zeichenkette oder null sein.")
with self.session_lock:
session = self.sessions.get(token)
if session is None or time.monotonic() - session["created"] >= TOKEN_TTL_SECONDS:
raise RequestError(404, "Unbekanntes oder abgelaufenes token. Bitte einen neuen Test öffnen.")
# Ein Retry nach verlorener HTTP-Antwort darf dieselbe Rückgabe erneut bestätigen.
if session["result"] is not None:
if session["result"] != result:
raise RequestError(409, "Für dieses token wurde bereits ein anderes Ergebnis empfangen.")
return {"ok": True, "duplicate": True}
with PRINT_LOCK:
print("\n--- Editor-Rückgabe ---", flush=True)
print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False), flush=True)
session["result"] = result
return {"ok": True}
class EditorTestHandler(BaseHTTPRequestHandler):
server_version = "TaskListTestServer/1.0"
def setup(self):
super().setup()
self.connection.settimeout(20)
def log_message(self, fmt, *args):
with PRINT_LOCK:
print("[{}] {}".format(self.log_date_time_string(), fmt % args), file=sys.stderr, flush=True)
def send_bytes(self, status, body, content_type):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.end_headers()
self.wfile.write(body)
def send_json(self, status, value):
self.send_bytes(status, json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8"),
"application/json; charset=utf-8")
def check_host(self):
expected = urllib.parse.urlsplit(self.server.local_origin).netloc
if self.headers.get("Host") != expected:
raise RequestError(403, "Bitte den Testserver über {} öffnen.".format(self.server.local_origin))
def read_json(self):
if self.headers.get("Origin") != self.server.local_origin:
raise RequestError(403, "Anfragen müssen von der lokalen Testseite stammen.")
if self.headers.get_content_type() != "application/json":
raise RequestError(415, "Content-Type application/json wird benötigt.")
if self.headers.get("Transfer-Encoding"):
raise RequestError(400, "Transfer-Encoding wird nicht unterstützt.")
try:
length = int(self.headers["Content-Length"])
except (TypeError, ValueError) as exc:
raise RequestError(411, "Content-Length wird benötigt.") from exc
if length <= 0 or length > MAX_BODY_BYTES:
raise RequestError(413, "Der JSON-Body muss zwischen 1 Byte und 2 MiB groß sein.")
raw = self.rfile.read(length)
if len(raw) != length:
raise RequestError(400, "Unvollständiger Request-Body.")
return decode_json(raw)
def do_GET(self):
try:
self.check_host()
if self.path in ("/", "/index.html"):
self.send_bytes(200, HTML_PATH.read_bytes(), "text/html; charset=utf-8")
elif self.path == "/api/config":
self.send_json(200, {"editorUrl": self.server.editor_url,
"editorOrigin": self.server.editor_origin,
"initialPayload": self.server.initial_payload})
else:
raise RequestError(404, "Nicht gefunden.")
except RequestError as exc:
self.send_json(exc.status, {"error": str(exc)})
except OSError as exc:
self.send_json(500, {"error": "Testseite konnte nicht geladen werden: " + str(exc)})
def do_POST(self):
try:
self.check_host()
if self.path not in ("/api/open", "/api/result"):
raise RequestError(404, "Nicht gefunden.")
value = self.read_json()
result = self.server.open_editor(value) if self.path == "/api/open" else self.server.receive_result(value)
self.send_json(200, result)
except RequestError as exc:
self.send_json(exc.status, {"error": str(exc)})
except (TimeoutError, OSError) as exc:
self.send_json(408, {"error": "Zeitüberschreitung beim Lesen der Anfrage: " + str(exc)})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=8090, help="Lokaler Port (Standard: 8090)")
parser.add_argument("--editor-url", default="http://127.0.0.1:8080", help="Basis-URL des laufenden Editors")
parser.add_argument("--json", type=Path, help="JSON-Datei zum Vorbelegen (Standard: tools/example_tasks.json)")
parser.add_argument("--no-browser", action="store_true", help="Browser nicht automatisch öffnen")
args = parser.parse_args()
if not 0 <= args.port <= 65535:
parser.error("--port muss zwischen 0 und 65535 liegen.")
try:
json_path = args.json or Path(__file__).with_name("example_tasks.json")
with json_path.open("rb") as source:
raw = source.read(MAX_BODY_BYTES + 1)
if len(raw) > MAX_BODY_BYTES:
raise ValueError("Die JSON-Datei darf höchstens 2 MiB groß sein.")
initial_payload = decode_json(raw)
server = EditorTestServer(args.port, args.editor_url, initial_payload)
except (RequestError, ValueError, OSError) as exc:
parser.error(str(exc))
print("Testseite: " + server.local_origin + "/", flush=True)
print("Editor: " + server.editor_url, flush=True)
print("Rückgaben erscheinen hier als JSON. Beenden mit Strg+C.", flush=True)
if not args.no_browser:
webbrowser.open(server.local_origin + "/")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nTestserver beendet.", flush=True)
finally:
server.server_close()
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
{
"name": "Testtour 4711",
"tasks": [
{
"s_id": 1,
"type": 3,
"topic": "Ankunft melden",
"track": true,
"txt": "Bitte Ankunft am Einsatzort bestätigen.",
"opt": false,
"reenter": false
},
{
"s_id": 2,
"type": 1000,
"topic": "Zustellung fotografieren",
"min": 1,
"max": 3,
"opt": false,
"reenter": true
}
],
"customTypes": [
{
"typeId": "custom-1000",
"taskType": 1000,
"baseTypeId": "fotos-aufnehmen",
"label": "Zustellnachweis",
"description": "Fotos zur Dokumentation einer Zustellung.",
"icon": "vaadin:camera",
"color": "#059669",
"inputs": 1,
"outputs": 1,
"properties": [
{ "id": "min", "label": "Mindestanzahl Fotos", "type": "NUMBER", "defaultValue": 1 },
{ "id": "max", "label": "Maximale Anzahl Fotos", "type": "NUMBER", "defaultValue": 3 },
{ "id": "opt", "label": "Optionale Aufgabe", "type": "BOOLEAN", "defaultValue": false },
{ "id": "reenter", "label": "Erneut bearbeitbar", "type": "BOOLEAN", "defaultValue": true }
]
}
],
"metadata": {
"tourId": 4711,
"requestId": "test-001",
"source": "python-testserver"
}
}
+219
View File
@@ -0,0 +1,219 @@
"""HTTP-Roundtrip-Tests ohne laufende Java-App oder externe Python-Pakete.
Ausführen: python3 -B -m unittest discover -s tools -p 'test_*.py' -v
"""
import contextlib
import http.client
import io
import json
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
from editor_test_server import EditorTestServer, MAX_BODY_BYTES, RESULT_TYPE
class FakeEditorHandler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
self.server.imports.append(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
token = "test-token-" + str(len(self.server.imports))
response = {"token": token, "deepLink": self.server.origin + "/?import=" + token}
if self.server.external_link:
response["deepLink"] = "https://unexpected.example/?import=" + token
body = json.dumps(response).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
class TestEditorTestServer(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.editor = ThreadingHTTPServer(("127.0.0.1", 0), FakeEditorHandler)
cls.editor.origin = "http://127.0.0.1:" + str(cls.editor.server_port)
cls.server = EditorTestServer(0, cls.editor.origin, {"tasks": []})
cls.threads = []
for server in (cls.editor, cls.server):
thread = threading.Thread(target=lambda s=server: s.serve_forever(poll_interval=0.05), daemon=True)
thread.start()
cls.threads.append(thread)
@classmethod
def tearDownClass(cls):
for server in (cls.server, cls.editor):
server.shutdown()
server.server_close()
for thread in cls.threads:
thread.join(timeout=2)
def setUp(self):
self.editor.imports = []
self.editor.external_link = False
self.server.sessions.clear()
def request(self, method, path, value=None, origin="local", raw=None, extra_headers=None):
connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
headers = {"Content-Type": "application/json"}
if origin:
headers["Origin"] = self.server.local_origin if origin == "local" else origin
if extra_headers:
headers.update(extra_headers)
body = raw if raw is not None else json.dumps(value).encode("utf-8") if value is not None else None
try:
connection.request(method, path, body=body, headers=headers)
response = connection.getresponse()
body = response.read()
if response.getheader("Content-Type", "").startswith("application/json"):
body = json.loads(body)
return response.status, body
finally:
connection.close()
def open_editor(self, payload=None):
status, result = self.request("POST", "/api/open", {"tasks": []} if payload is None else payload)
self.assertEqual(status, 200, result)
return result["token"]
def test_page_and_config(self):
status, body = self.request("GET", "/")
self.assertEqual(status, 200)
self.assertIn(b"TaskList Editor testen", body)
status, config = self.request("GET", "/api/config")
self.assertEqual(status, 200)
self.assertEqual(config["editorOrigin"], self.editor.origin)
self.assertEqual(config["initialPayload"], {"tasks": [], "customTypes": []})
def test_import_sets_local_return_origin_and_preserves_metadata(self):
payload = {"name": "Grüße", "tasks": [{"type": 3}],
"metadata": {"id": 42, "nested": [True, None]}, "returnOrigin": "https://untrusted.example"}
self.open_editor(payload)
imported = self.editor.imports[0]
self.assertEqual(imported["returnOrigin"], self.server.local_origin)
self.assertEqual(imported["tasks"], payload["tasks"])
self.assertEqual(imported["metadata"], payload["metadata"])
self.assertEqual(imported["name"], payload["name"])
def test_bare_array_and_missing_tasks_create_new_lists(self):
self.open_editor([])
self.open_editor({"name": "Neu"})
self.open_editor({"tasks": None})
self.assertEqual([value["tasks"] for value in self.editor.imports], [[], [], []])
self.assertEqual([value["customTypes"] for value in self.editor.imports], [[], [], []])
def test_custom_types_roundtrip_preserves_full_catalog_and_deletions(self):
payload = json.loads(Path(__file__).with_name("example_tasks.json").read_text(encoding="utf-8"))
original_types = payload["customTypes"]
self.assertEqual(payload["tasks"][1]["type"], original_types[0]["taskType"])
token = self.open_editor(payload)
self.assertEqual(self.editor.imports[0]["customTypes"], original_types)
result = {"type": RESULT_TYPE, "token": token, "status": "completed", **payload}
with contextlib.redirect_stdout(io.StringIO()) as output:
self.assertEqual(self.request("POST", "/api/result", result), (200, {"ok": True}))
printed = json.loads(output.getvalue().split("--- Editor-Rückgabe ---", 1)[1])
self.assertEqual(printed["customTypes"], original_types)
# Der Aufrufer ersetzt den Katalog durch die gesamte Rückgabe, auch [] nach einer Löschung.
token = self.open_editor({"tasks": [], "customTypes": original_types})
result = {"type": RESULT_TYPE, "token": token, "status": "completed", "tasks": [], "customTypes": []}
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(self.request("POST", "/api/result", result), (200, {"ok": True}))
self.open_editor({"tasks": result["tasks"], "customTypes": result["customTypes"]})
self.assertEqual(self.editor.imports[-1]["customTypes"], [])
def test_new_list_can_receive_custom_types_without_tasks(self):
custom_types = [{"typeId": "custom-1000", "taskType": 1000}]
self.open_editor({"customTypes": custom_types})
self.assertEqual(self.editor.imports[0]["tasks"], [])
self.assertEqual(self.editor.imports[0]["customTypes"], custom_types)
def test_completed_and_retry_print_once(self):
token = self.open_editor()
result = {"type": RESULT_TYPE, "token": token, "status": "completed", "name": "Grüße",
"tasks": [{"type": 3}], "customTypes": [], "metadata": {"id": 42}}
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(self.request("POST", "/api/result", result), (200, {"ok": True}))
self.assertEqual(self.request("POST", "/api/result", result), (200, {"ok": True, "duplicate": True}))
self.assertEqual(output.getvalue().count("--- Editor-Rückgabe ---"), 1)
self.assertIn('"name": "Grüße"', output.getvalue())
printed = output.getvalue().split("--- Editor-Rückgabe ---", 1)[1]
self.assertEqual(json.loads(printed), result)
result["tasks"] = []
self.assertEqual(self.request("POST", "/api/result", result)[0], 409)
def test_cancelled_has_no_tasks_or_custom_types(self):
result = {"type": RESULT_TYPE, "token": self.open_editor(), "status": "cancelled", "metadata": {"id": 42}}
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(self.request("POST", "/api/result", result)[0], 200)
for key in ("tasks", "customTypes"):
with self.subTest(key=key):
self.assertEqual(self.request("POST", "/api/result", {**result, key: []})[0], 400)
def test_invalid_result_and_unknown_token(self):
result = {"type": RESULT_TYPE, "token": "unknown", "status": "completed", "tasks": [], "customTypes": []}
self.assertEqual(self.request("POST", "/api/result", result)[0], 404)
result["token"] = self.open_editor()
result["tasks"] = "bad"
self.assertEqual(self.request("POST", "/api/result", result)[0], 400)
result["status"] = "invalid"
self.assertEqual(self.request("POST", "/api/result", result)[0], 400)
def test_completed_requires_full_custom_type_catalog(self):
result = {"type": RESULT_TYPE, "token": self.open_editor(), "status": "completed", "tasks": []}
self.assertEqual(self.request("POST", "/api/result", result)[0], 400)
for invalid_types in (None, False, {}, [1]):
with self.subTest(custom_types=invalid_types):
result["customTypes"] = invalid_types
self.assertEqual(self.request("POST", "/api/result", result)[0], 400)
def test_expired_token(self):
result = {"type": RESULT_TYPE, "token": self.open_editor(), "status": "cancelled"}
self.server.sessions[result["token"]]["created"] -= 3601
self.assertEqual(self.request("POST", "/api/result", result)[0], 404)
def test_untrusted_origin_and_host(self):
for origin in (None, "https://untrusted.example"):
with self.subTest(origin=origin):
self.assertEqual(self.request("POST", "/api/open", [], origin=origin)[0], 403)
self.assertEqual(self.request("GET", "/api/config", extra_headers={"Host": "untrusted.example"})[0], 403)
self.assertFalse(self.editor.imports)
def test_invalid_payloads(self):
for payload in (42, "bad", {"tasks": False}, {"tasks": [1]}, {"name": 42},
{"customTypes": {}}, {"customTypes": [1]}):
with self.subTest(payload=payload):
self.assertEqual(self.request("POST", "/api/open", payload)[0], 400)
for raw in (b'{"tasks": [}', b'{"tasks": [], "metadata": NaN}', b'\xff'):
with self.subTest(raw=raw):
self.assertEqual(self.request("POST", "/api/open", raw=raw)[0], 400)
self.assertFalse(self.editor.imports)
def test_body_limit_and_content_type(self):
self.assertEqual(self.request("POST", "/api/open", [],
extra_headers={"Content-Length": str(MAX_BODY_BYTES + 1)})[0], 413)
self.assertEqual(self.request("POST", "/api/open", [],
extra_headers={"Content-Type": "text/plain"})[0], 415)
def test_external_deep_link_rejected(self):
self.editor.external_link = True
self.assertEqual(self.request("POST", "/api/open", [])[0], 502)
self.assertFalse(self.server.sessions)
def test_unreachable_editor_has_useful_error(self):
with patch("editor_test_server.urllib.request.urlopen", side_effect=OSError("Connection refused")):
status, body = self.request("POST", "/api/open", [])
self.assertEqual(status, 502)
self.assertIn("nicht erreichbar", body["error"])
if __name__ == "__main__":
unittest.main()