bmc_hub/app/modules/sag/scripts/sag_module_e2e.py

419 lines
18 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Reversibel end-to-end test af Sag-modulets HTTP API.
Eksempel:
BMC_TEST_PASSWORD='...' python app/modules/sag/scripts/sag_module_e2e.py \
--username admin --customer-id 1
Alternativt kan et eksisterende JWT angives via BMC_TEST_TOKEN eller --token.
Testen opretter to tydeligt navngivne testsager og sletter dem igen i finally.
"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
import time
import uuid
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
@dataclass
class Result:
name: str
status: str
detail: str = ""
duration_ms: int = 0
class ApiError(RuntimeError):
def __init__(self, method: str, path: str, status: int, body: Any):
super().__init__(f"{method} {path} -> HTTP {status}: {body}")
self.status = status
self.body = body
class SagE2E:
def __init__(self, args: argparse.Namespace):
self.args = args
self.base = args.base_url.rstrip("/")
self.token = args.token or os.getenv("BMC_TEST_TOKEN")
self.results: list[Result] = []
self.created_cases: list[int] = []
self.cleanup: list[tuple[str, Callable[[], Any]]] = []
self.run_id = f"{datetime.now():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:6]}"
self.marker = f"SAG-E2E-{self.run_id}"
def request(
self,
method: str,
path: str,
data: Any = None,
*,
expected: tuple[int, ...] = (200,),
headers: dict[str, str] | None = None,
raw: bool = False,
) -> Any:
request_headers = {"Accept": "application/json"}
if self.token:
request_headers["Authorization"] = f"Bearer {self.token}"
if headers:
request_headers.update(headers)
body = data
if data is not None and not isinstance(data, bytes):
body = json.dumps(data).encode()
request_headers["Content-Type"] = "application/json"
req = Request(self.base + path, data=body, headers=request_headers, method=method)
try:
with urlopen(req, timeout=self.args.timeout) as response:
payload = response.read()
if response.status not in expected:
raise ApiError(method, path, response.status, payload.decode(errors="replace"))
if raw:
return payload
if not payload:
return None
content_type = response.headers.get("Content-Type", "")
return json.loads(payload) if "json" in content_type else payload.decode(errors="replace")
except HTTPError as exc:
payload = exc.read().decode(errors="replace")
try:
payload = json.loads(payload)
except json.JSONDecodeError:
pass
if exc.code in expected:
return payload
raise ApiError(method, path, exc.code, payload) from exc
except URLError as exc:
raise RuntimeError(f"Kan ikke forbinde til {self.base}: {exc.reason}") from exc
def check(self, name: str, operation: Callable[[], Any]) -> Any:
started = time.monotonic()
try:
value = operation()
elapsed = int((time.monotonic() - started) * 1000)
self.results.append(Result(name, "PASS", duration_ms=elapsed))
print(f"PASS {name} ({elapsed} ms)")
return value
except Exception as exc:
elapsed = int((time.monotonic() - started) * 1000)
detail = str(exc).replace("\n", " ")[:800]
self.results.append(Result(name, "FAIL", detail, elapsed))
print(f"FAIL {name}: {detail}")
return None
def skip(self, name: str, reason: str) -> None:
self.results.append(Result(name, "SKIP", reason))
print(f"SKIP {name}: {reason}")
def authenticate(self) -> dict[str, Any]:
if not self.token:
if not self.args.username:
raise RuntimeError("Angiv --token/BMC_TEST_TOKEN eller --username.")
password = self.args.password or os.getenv("BMC_TEST_PASSWORD")
if not password and sys.stdin.isatty():
password = getpass.getpass("Adgangskode: ")
if not password:
raise RuntimeError("Angiv --password eller BMC_TEST_PASSWORD.")
payload = {"username": self.args.username, "password": password}
if self.args.otp:
payload["otp_code"] = self.args.otp
response = self.request("POST", "/api/v1/auth/login", payload)
self.token = response.get("access_token")
if not self.token:
raise RuntimeError("Login returnerede intet access_token.")
me = self.request("GET", "/api/v1/auth/me")
print(f"Bruger: {me.get('full_name') or me.get('username') or me.get('user_id')}")
return me
def resolve_customer_id(self) -> int:
if self.args.customer_id:
return self.args.customer_id
customers = self.request("GET", "/api/v1/customers?limit=1&is_active=true")
rows = customers.get("customers", []) if isinstance(customers, dict) else customers
if not rows:
raise RuntimeError("Ingen aktiv kunde fundet. Angiv --customer-id.")
return int(rows[0]["id"])
@staticmethod
def require(value: Any, message: str) -> Any:
if not value:
raise AssertionError(message)
return value
def create_case(self, customer_id: int, suffix: str) -> dict[str, Any]:
case = self.request(
"POST",
"/api/v1/sag",
{
"titel": f"[{self.marker}] {suffix}",
"beskrivelse": "Automatisk testsag. Må slettes.",
"customer_id": customer_id,
"type": "ticket",
"status": "åben",
},
expected=(200, 201),
)
case_id = int(case["id"])
self.created_cases.append(case_id)
return case
def multipart_file(
self, field: str, filename: str, content: bytes, media_type: str
) -> tuple[bytes, str]:
boundary = f"----SagE2E{uuid.uuid4().hex}"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'
f"Content-Type: {media_type}\r\n\r\n"
).encode() + content + f"\r\n--{boundary}--\r\n".encode()
return body, f"multipart/form-data; boundary={boundary}"
def run(self) -> None:
self.check("Serverens health endpoint", lambda: self.request("GET", "/health"))
if self.check("Login og aktuel bruger", self.authenticate) is None:
return
customer_id = self.check("Find testkunde", self.resolve_customer_id)
if customer_id is None:
return
parent = self.check("Opret hovedsag", lambda: self.create_case(customer_id, "Hovedsag"))
child = self.check("Opret undersag", lambda: self.create_case(customer_id, "Undersag"))
if not parent or not child:
return
parent_id, child_id = int(parent["id"]), int(child["id"])
self.check("Hent sag", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}").get("id") == parent_id,
"Forkert sag returneret",
))
self.check("Sag vises i sagslisten", lambda: self.require(
any(int(row["id"]) == parent_id for row in self.request(
"GET", f"/api/v1/sag?search={self.marker}"
)),
"Testsagen blev ikke fundet i listen",
))
self.check("Redigér sag", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/{parent_id}", {
"titel": f"[{self.marker}] Hovedsag redigeret",
"status": "under behandling",
"deadline": (datetime.now(timezone.utc) + timedelta(days=14)).isoformat(),
}).get("id") == parent_id,
"Sag blev ikke opdateret",
))
self.check("Gem beskrivelse", lambda: self.request(
"PATCH", f"/api/v1/sag/{parent_id}/beskrivelse",
{"beskrivelse": "<p>Sikker <strong>HTML</strong></p><script>nope()</script>"},
))
self.check("Beskrivelseshistorik", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}/beskrivelse/history"),
"Historikken er tom",
))
self.check("Hent modulindstillinger", lambda: self.request(
"GET", f"/api/v1/sag/{parent_id}/modules"
))
comment = self.check("Opret kommentar", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/kommentarer",
{"indhold": f"Kommentar {self.marker}", "er_intern": True},
expected=(200, 201),
))
self.check("Læs kommentarer", lambda: self.require(
any(self.marker in str(row.get("indhold", "")) for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/kommentarer"
)),
"Kommentaren blev ikke fundet",
))
self.check("Læs tidslinje", lambda: self.request(
"GET", f"/api/v1/sag/{parent_id}/timeline"
))
todo = self.check("Opret todo", lambda: self.request(
"POST", f"/api/v1/sag/{child_id}/todo-steps",
{"title": f"Næste handling {self.marker}", "description": "E2E", "due_date": None},
expected=(200, 201),
))
if todo:
todo_id = int(todo["id"])
self.check("Markér todo som næste", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/todo-steps/{todo_id}", {"is_next": True}).get("is_next"),
"Todo er ikke markeret som næste",
))
self.check("Afslut todo", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/todo-steps/{todo_id}", {"is_done": True}).get("is_done"),
"Todo er ikke afsluttet",
))
self.check("Slet todo", lambda: self.request(
"DELETE", f"/api/v1/sag/todo-steps/{todo_id}"
))
relation = self.check("Opret logisk undersag-relation", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/relationer",
{"målsag_id": child_id, "relationstype": "undersag"},
expected=(200, 201),
))
if relation:
relation_id = int(relation["id"])
self.check("Læs relationer", lambda: self.require(
any(int(row["id"]) == relation_id for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/relationer"
)),
"Relationen blev ikke fundet",
))
self.check("Afvis dubletrelation", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/relationer",
{"målsag_id": child_id, "relationstype": "undersag"},
expected=(409,),
))
self.check("Slet relation", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/relationer/{relation_id}"
))
tag = self.check("Opret tag", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/tags", {"tag_navn": self.marker},
expected=(200, 201),
))
if tag:
self.check("Læs tags", lambda: self.require(
any(row.get("tag_navn") == self.marker for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/tags"
)), "Tag blev ikke fundet",
))
self.check("Slet tag", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/tags/{tag['id']}"
))
buzz = self.check("Opret buzzword", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/buzzwords",
{"buzzword": self.marker.lower()}, expected=(200, 201),
))
if buzz:
self.check("Global søgning finder buzzword", lambda: self.require(
any(int(row.get("id", -1)) == parent_id for row in self.request(
"GET", "/api/v1/search/sag?" + urlencode({"q": self.marker.lower()})
)), "Global søgning fandt ikke sagen via buzzword",
))
self.check("Slet buzzword-link", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/buzzwords/{buzz['buzzword_id']}"
))
item = self.check("Opret salgs-/ordrelinje", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/sale-items",
{"type": "sale", "description": self.marker, "quantity": 1,
"unit_price": 125, "amount": 125, "currency": "DKK", "status": "draft"},
expected=(200, 201),
))
if item:
item_id = int(item["id"])
self.check("Redigér salgs-/ordrelinje", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/{parent_id}/sale-items/{item_id}",
{"status": "confirmed", "amount": 150}).get("status") == "confirmed",
"Linjen blev ikke bekræftet",
))
self.check("Slet salgs-/ordrelinje", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/sale-items/{item_id}"
))
# Minimal gyldig 1x1 PNG; PNG er blandt Sag-modulets tilladte filtyper.
file_content = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d49444154789c6360000000020001e221bc330000000049454e44ae426082"
)
body, content_type = self.multipart_file(
"files", f"{self.marker}.png", file_content, "image/png"
)
uploaded = self.check("Upload fil", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/files", body,
headers={"Content-Type": content_type}, expected=(200, 201),
))
if uploaded:
file_id = int(uploaded[0]["id"])
self.check("Download fil og kontrollér indhold", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}/files/{file_id}", raw=True) == file_content,
"Downloadet fil har forkert indhold",
))
self.check("Slet fil", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/files/{file_id}"
))
for name, reason in (
("Mailafsendelse og mail-link", "kræver en rigtig mailkonto/mail-ID"),
("AnyDesk-forbindelse", "kræver installeret klient og gyldigt AnyDesk-ID"),
("Direkte labelprint", "kræver fysisk printer"),
("Arbejdsseddel og underskrift", "kræver aktiv work-order/token"),
("Faktura-/økonomisynkronisering", "må ikke skrive i eksternt økonomisystem fra E2E"),
):
self.skip(name, reason)
def cleanup_all(self) -> None:
if self.args.keep_data:
print("Beholder testdata (--keep-data).")
return
for case_id in reversed(self.created_cases):
try:
self.request("DELETE", f"/api/v1/sag/{case_id}", expected=(200, 204, 404))
print(f"CLEAN Sag #{case_id}")
except Exception as exc:
self.results.append(Result(f"Oprydning af sag #{case_id}", "FAIL", str(exc)))
print(f"FAIL Oprydning af sag #{case_id}: {exc}")
def write_report(self) -> None:
counts = {status: sum(r.status == status for r in self.results)
for status in ("PASS", "FAIL", "SKIP")}
report = {
"run_id": self.run_id,
"base_url": self.base,
"created_at": datetime.now(timezone.utc).isoformat(),
"summary": counts,
"results": [asdict(result) for result in self.results],
}
if self.args.json_report:
path = Path(self.args.json_report)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"Rapport: {path.resolve()}")
print(f"\nResultat: {counts['PASS']} PASS, {counts['FAIL']} FAIL, {counts['SKIP']} SKIP")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Reversibel E2E-test af BMC Hub Sag-modulet")
parser.add_argument("--base-url", default=os.getenv("BMC_BASE_URL", "http://127.0.0.1:8001"))
parser.add_argument("--token", help="JWT; alternativt BMC_TEST_TOKEN")
parser.add_argument("--username", help="Loginbrugernavn")
parser.add_argument("--password", help="Frarådes i shellhistorik; brug BMC_TEST_PASSWORD")
parser.add_argument("--otp", help="2FA-kode hvis påkrævet")
parser.add_argument("--customer-id", type=int, help="Kunde til midlertidige testsager")
parser.add_argument("--timeout", type=float, default=20.0)
parser.add_argument("--keep-data", action="store_true", help="Behold testsager ved fejlsøgning")
parser.add_argument("--json-report", default="test-results/sag-e2e.json")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
suite = SagE2E(parse_args(argv))
print(f"Sag E2E {suite.run_id} mod {suite.base}")
try:
suite.run()
except KeyboardInterrupt:
print("\nAfbrudt.")
except Exception as exc:
suite.results.append(Result("Testkørsel", "FAIL", str(exc)))
print(f"FAIL Testkørsel: {exc}")
finally:
suite.cleanup_all()
suite.write_report()
return 1 if any(result.status == "FAIL" for result in suite.results) else 0
if __name__ == "__main__":
raise SystemExit(main())