419 lines
18 KiB
Python
419 lines
18 KiB
Python
|
|
#!/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())
|