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:
1 parent
93ba40c476
commit
f979c797ba
39 files changed
+1980
-1140
No files matched your search
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user