1132 lines
36 KiB
Python
1132 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.db.base import Base
|
|
from app.models.customer import Customer, CustomerType
|
|
from app.models.contact import Contact
|
|
from app.models.device import Device
|
|
from app.models.equipment import Equipment
|
|
from app.models.user import User, UserRole
|
|
from app.models.location import Location
|
|
from app.models.validation import Validation, ValidationStatus
|
|
from app.services.validation_workflow import ValidationWorkflowService
|
|
from app.services.auth_service import AuthService
|
|
from app.services.reference_masterdata import ReferenceMasterdataImportService
|
|
from app.services.quickstart_service import QuickStartService
|
|
from app.api.v1.auth import login as auth_login
|
|
from app.api.v1 import domain as domain_api
|
|
from app.schemas.auth import LoginRequest
|
|
from app.schemas.domain import QuickStartCreateRequest, UserCreate, UserUpdate, ValidationCreate
|
|
from app.modules.orion.service import OrionReportService
|
|
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
|
|
from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
|
|
from app.modules.helios.service import HeliosImportService
|
|
|
|
|
|
def session() -> Session:
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
return Session(engine)
|
|
|
|
|
|
def seed(session: Session):
|
|
customer = Customer(customer_type=CustomerType.practice, name="Praxis Test")
|
|
session.add(customer)
|
|
session.flush()
|
|
location = Location(customer_id=customer.id, name="OP")
|
|
session.add(location)
|
|
session.flush()
|
|
device = Device(
|
|
customer_id=customer.id,
|
|
location_id=location.id,
|
|
manufacturer="MELAG",
|
|
model="Vacuklav",
|
|
serial_number="SN-1",
|
|
)
|
|
session.add(device)
|
|
session.flush()
|
|
return customer, location, device
|
|
|
|
|
|
def seed_contact(session: Session, customer: Customer) -> Contact:
|
|
contact = Contact(customer_id=customer.id, full_name="Kontakt")
|
|
session.add(contact)
|
|
session.flush()
|
|
return contact
|
|
|
|
|
|
def valid_validation(customer: Customer, location: Location, device: Device) -> Validation:
|
|
return Validation(
|
|
report_number="VAL-TEST-1",
|
|
validation_type="Erstvalidierung",
|
|
performed_on=date.today(),
|
|
customer_id=customer.id,
|
|
location_id=location.id,
|
|
device_id=device.id,
|
|
examiner_name="Pruefer",
|
|
status=ValidationStatus.draft.value,
|
|
)
|
|
|
|
|
|
def test_required_fields_are_reported():
|
|
db = session()
|
|
validation = Validation(status=ValidationStatus.draft.value)
|
|
|
|
review = ValidationWorkflowService(db).review(validation)
|
|
|
|
assert {item["field"] for item in review["errors"]} >= {
|
|
"report_number",
|
|
"validation_type",
|
|
"performed_on",
|
|
"customer_id",
|
|
"location_id",
|
|
"device_id",
|
|
"examiner_name",
|
|
}
|
|
|
|
|
|
def test_status_changes_to_ready_when_no_errors():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
review = ValidationWorkflowService(db).mark_ready_for_review(validation)
|
|
|
|
assert review["errors"] == []
|
|
assert validation.status == ValidationStatus.ready_for_review.value
|
|
|
|
|
|
def test_search_finds_report_number():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
db.add(valid_validation(customer, location, device))
|
|
db.flush()
|
|
|
|
result = ValidationWorkflowService(db).query_validations(
|
|
search="VAL-TEST",
|
|
page=1,
|
|
page_size=10,
|
|
sort_by="updated_at",
|
|
sort_order="desc",
|
|
filters={},
|
|
)
|
|
|
|
assert result["total"] == 1
|
|
|
|
|
|
def test_csv_import_preview_detects_duplicate_and_missing_fields():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
db.add(valid_validation(customer, location, device))
|
|
db.flush()
|
|
csv_content = (
|
|
"report_number,validation_type,test_date,customer_reference,location_reference,"
|
|
"device_serial_number,examiner,status,result,notes\n"
|
|
"VAL-TEST-1,Erstvalidierung,2026-07-11,Praxis Test,OP,SN-1,Pruefer,ENTWURF,offen,\n"
|
|
"VAL-NEW,,,,,,,\n"
|
|
).encode()
|
|
|
|
preview = ValidationWorkflowService(db).preview_csv(csv_content)
|
|
|
|
assert preview["duplicates"] == 1
|
|
assert preview["invalid_rows"] == 1
|
|
|
|
|
|
def test_validation_without_contact_id_can_be_saved():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
validation.contact_id = None
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
assert validation.contact_id is None
|
|
|
|
|
|
def test_validation_with_valid_contact_id_can_be_saved():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
contact = seed_contact(db, customer)
|
|
validation = valid_validation(customer, location, device)
|
|
validation.contact_id = contact.id
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
assert validation.contact_id == contact.id
|
|
|
|
|
|
def test_empty_contact_id_string_is_normalized_to_none():
|
|
payload = ValidationCreate(
|
|
report_number="VAL-EMPTY",
|
|
validation_type="Erstvalidierung",
|
|
performed_on=date.today(),
|
|
customer_id=None,
|
|
location_id="",
|
|
contact_id="",
|
|
device_id=None,
|
|
examiner_name="Pruefer",
|
|
)
|
|
|
|
assert payload.contact_id is None
|
|
assert payload.location_id is None
|
|
|
|
|
|
def test_invalid_uuid_returns_validation_error():
|
|
try:
|
|
ValidationCreate(
|
|
report_number="VAL-BAD",
|
|
validation_type="Erstvalidierung",
|
|
performed_on=date.today(),
|
|
customer_id="not-a-uuid",
|
|
device_id=None,
|
|
examiner_name="Pruefer",
|
|
)
|
|
except Exception as exc:
|
|
assert "uuid" in str(exc).lower()
|
|
else:
|
|
raise AssertionError("Invalid UUID was accepted")
|
|
|
|
|
|
def test_user_model_supports_password_management_fields():
|
|
db = session()
|
|
user = User(
|
|
email="user@schubamed.de",
|
|
first_name="Max",
|
|
last_name="Mustermann",
|
|
role=UserRole.PRUEFER.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=True,
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
assert user.full_name == "Max Mustermann"
|
|
assert user.must_change_password is True
|
|
assert user.role == UserRole.PRUEFER
|
|
|
|
|
|
def test_admin_appears_in_user_list():
|
|
db = session()
|
|
admin = User(
|
|
email="admin@schubamed.de",
|
|
first_name="Admin",
|
|
last_name="User",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(admin)
|
|
db.flush()
|
|
|
|
result = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
|
|
|
|
assert result["total"] == 1
|
|
assert result["pages"] == 1
|
|
assert any(item.id == admin.id for item in result["items"])
|
|
|
|
|
|
def test_user_list_pagination_and_filters_work():
|
|
db = session()
|
|
admin = User(
|
|
email="admin@schubamed.de",
|
|
first_name="Admin",
|
|
last_name="User",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
inactive = User(
|
|
email="inactive@schubamed.de",
|
|
first_name="Inactive",
|
|
last_name="User",
|
|
role=UserRole.LESER.value,
|
|
password_hash="hash",
|
|
is_active=False,
|
|
must_change_password=False,
|
|
)
|
|
db.add_all([admin, inactive])
|
|
db.flush()
|
|
|
|
result = domain_api.list_users(session=db, _=admin, page=1, page_size=1, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
|
|
assert result["total"] == 2
|
|
assert result["pages"] == 2
|
|
assert len(result["items"]) == 1
|
|
|
|
active_only = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=True, sort_by="created_at", sort_order="desc")
|
|
assert active_only["total"] == 1
|
|
assert all(item.is_active for item in active_only["items"])
|
|
|
|
inactive_only = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=False, sort_by="created_at", sort_order="desc")
|
|
assert inactive_only["total"] == 1
|
|
assert all(not item.is_active for item in inactive_only["items"])
|
|
|
|
role_filtered = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=UserRole.ADMIN.value, active=None, sort_by="created_at", sort_order="desc")
|
|
assert role_filtered["total"] == 1
|
|
assert role_filtered["items"][0].role == UserRole.ADMIN.value
|
|
|
|
|
|
def test_paginated_lists_expose_pages_for_masterdata_and_validations():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
db.add(valid_validation(customer, location, device))
|
|
db.flush()
|
|
|
|
customers = domain_api.list_customers(params={"page": 1, "page_size": 20, "search": None}, session=db)
|
|
devices = domain_api.list_devices(params={"page": 1, "page_size": 20, "search": None}, session=db)
|
|
validations = domain_api.list_validations(
|
|
search=None,
|
|
page=1,
|
|
page_size=20,
|
|
sort_by="created_at",
|
|
sort_order="desc",
|
|
status=None,
|
|
customer_id=None,
|
|
device_id=None,
|
|
validation_type=None,
|
|
result=None,
|
|
date_from=None,
|
|
date_to=None,
|
|
overdue_only=False,
|
|
session=db,
|
|
)
|
|
|
|
assert customers["pages"] == 1
|
|
assert devices["pages"] == 1
|
|
assert validations["pages"] == 1
|
|
|
|
|
|
def test_admin_can_create_users_with_supported_roles():
|
|
db = session()
|
|
admin = User(
|
|
email="admin@schubamed.de",
|
|
first_name="Admin",
|
|
last_name="User",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(admin)
|
|
db.flush()
|
|
|
|
for role in (UserRole.MITARBEITER, UserRole.PRUEFER, UserRole.LESER):
|
|
response = domain_api.create_user(
|
|
UserCreate(
|
|
email=f"{role.value}@schubamed.de",
|
|
first_name="Test",
|
|
last_name="User",
|
|
role=role,
|
|
is_active=True,
|
|
must_change_password=False,
|
|
temporary_password="TempPassword123!",
|
|
),
|
|
session=db,
|
|
_=admin,
|
|
)
|
|
payload = json.loads(response.body)
|
|
assert response.status_code == 201
|
|
assert payload["user"]["role"] == role.value
|
|
assert db.scalar(select(User).where(User.email == f"{role.value}@schubamed.de")) is not None
|
|
|
|
|
|
def test_create_user_trims_and_normalizes_email():
|
|
db = session()
|
|
admin = User(
|
|
email="admin@schubamed.de",
|
|
first_name="Admin",
|
|
last_name="User",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(admin)
|
|
db.flush()
|
|
|
|
response = domain_api.create_user(
|
|
UserCreate(
|
|
email=" New.User@Schubamed.DE ",
|
|
first_name="New",
|
|
last_name="User",
|
|
role=UserRole.MITARBEITER,
|
|
is_active=True,
|
|
must_change_password=False,
|
|
temporary_password="TempPassword123!",
|
|
),
|
|
session=db,
|
|
_=admin,
|
|
)
|
|
|
|
payload = json.loads(response.body)
|
|
assert response.status_code == 201
|
|
assert payload["user"]["email"] == "new.user@schubamed.de"
|
|
assert db.scalar(select(User).where(User.email == "new.user@schubamed.de")) is not None
|
|
|
|
|
|
def test_duplicate_email_returns_409_and_rolls_back_session():
|
|
from fastapi import HTTPException
|
|
|
|
db = session()
|
|
admin = User(
|
|
email="admin@schubamed.de",
|
|
first_name="Admin",
|
|
last_name="User",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(admin)
|
|
db.flush()
|
|
|
|
first = domain_api.create_user(
|
|
UserCreate(
|
|
email="duplicate@schubamed.de",
|
|
first_name="First",
|
|
last_name="User",
|
|
role=UserRole.MITARBEITER,
|
|
is_active=True,
|
|
must_change_password=False,
|
|
temporary_password="TempPassword123!",
|
|
),
|
|
session=db,
|
|
_=admin,
|
|
)
|
|
assert first.status_code == 201
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
domain_api.create_user(
|
|
UserCreate(
|
|
email="duplicate@schubamed.de",
|
|
first_name="Second",
|
|
last_name="User",
|
|
role=UserRole.PRUEFER,
|
|
is_active=True,
|
|
must_change_password=False,
|
|
temporary_password="TempPassword123!",
|
|
),
|
|
session=db,
|
|
_=admin,
|
|
)
|
|
assert exc_info.value.status_code == 409
|
|
|
|
follow_up = domain_api.create_user(
|
|
UserCreate(
|
|
email="fresh@schubamed.de",
|
|
first_name="Fresh",
|
|
last_name="User",
|
|
role=UserRole.LESER,
|
|
is_active=True,
|
|
must_change_password=False,
|
|
temporary_password="TempPassword123!",
|
|
),
|
|
session=db,
|
|
_=admin,
|
|
)
|
|
assert follow_up.status_code == 201
|
|
|
|
|
|
def test_update_user_keeps_same_email_without_false_duplicate():
|
|
db = session()
|
|
admin = User(
|
|
email="admin@schubamed.de",
|
|
first_name="Admin",
|
|
last_name="User",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
target = User(
|
|
email="target@schubamed.de",
|
|
first_name="Target",
|
|
last_name="User",
|
|
role=UserRole.MITARBEITER.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add_all([admin, target])
|
|
db.flush()
|
|
|
|
response = domain_api.update_user(
|
|
target.id,
|
|
UserUpdate(
|
|
first_name="Target",
|
|
last_name="User",
|
|
email="target@schubamed.de",
|
|
role=UserRole.MITARBEITER,
|
|
is_active=True,
|
|
must_change_password=False,
|
|
),
|
|
session=db,
|
|
me=admin,
|
|
)
|
|
|
|
assert response.email == "target@schubamed.de"
|
|
assert response.id == target.id
|
|
|
|
|
|
def test_delete_user_without_references_removes_row():
|
|
db = session()
|
|
admin = User(
|
|
email="admin@schubamed.de",
|
|
first_name="Admin",
|
|
last_name="User",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
user = User(
|
|
email="delete-me@schubamed.de",
|
|
first_name="Delete",
|
|
last_name="Me",
|
|
role=UserRole.MITARBEITER.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add_all([admin, user])
|
|
db.flush()
|
|
|
|
response = domain_api.delete_user(user.id, session=db, me=admin)
|
|
|
|
assert response.status_code == 204
|
|
assert db.get(User, user.id) is None
|
|
|
|
|
|
def test_delete_user_with_validation_reference_soft_deletes_row():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
admin = User(
|
|
email="admin@schubamed.de",
|
|
first_name="Admin",
|
|
last_name="User",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
examiner = User(
|
|
email="examiner@schubamed.de",
|
|
first_name="Examiner",
|
|
last_name="User",
|
|
role=UserRole.PRUEFER.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add_all([admin, examiner, validation])
|
|
db.flush()
|
|
validation.examiner_id = examiner.id
|
|
db.flush()
|
|
|
|
response = domain_api.delete_user(examiner.id, session=db, me=admin)
|
|
|
|
assert response.status_code == 204
|
|
db.refresh(examiner)
|
|
assert examiner.deleted_at is not None
|
|
assert examiner.is_active is False
|
|
listed = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
|
|
assert all(item.id != examiner.id for item in listed["items"])
|
|
|
|
|
|
def test_invalid_user_role_is_rejected_by_schema():
|
|
with pytest.raises(Exception):
|
|
UserCreate(
|
|
email="bad-role@schubamed.de",
|
|
first_name="Bad",
|
|
last_name="Role",
|
|
role="invalid-role", # type: ignore[arg-type]
|
|
is_active=True,
|
|
must_change_password=False,
|
|
temporary_password="TempPassword123!",
|
|
)
|
|
|
|
|
|
def test_login_updates_last_login_at():
|
|
from app.core.security import hash_password
|
|
|
|
db = session()
|
|
user = User(
|
|
email="login@schubamed.de",
|
|
first_name="Login",
|
|
last_name="Test",
|
|
role=UserRole.MITARBEITER.value,
|
|
password_hash=hash_password("VerySecretPass123"),
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
AuthService(db).login("login@schubamed.de", "VerySecretPass123")
|
|
|
|
assert user.last_login_at is not None
|
|
|
|
|
|
def test_login_response_contains_user_and_expires_in():
|
|
from app.core.security import hash_password
|
|
from app.core.config import settings
|
|
|
|
db = session()
|
|
user = User(
|
|
email="response@schubamed.de",
|
|
first_name="Response",
|
|
last_name="Test",
|
|
role=UserRole.PRUEFER.value,
|
|
password_hash=hash_password("VerySecretPass123"),
|
|
is_active=True,
|
|
must_change_password=True,
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
response = auth_login(LoginRequest(email="response@schubamed.de", password="VerySecretPass123"), db)
|
|
|
|
assert response.expires_in == settings.access_token_minutes * 60
|
|
assert response.user.email == "response@schubamed.de"
|
|
assert response.user.must_change_password is True
|
|
|
|
|
|
def test_login_rejects_inactive_user_with_403():
|
|
from app.core.security import hash_password
|
|
from fastapi import HTTPException
|
|
|
|
db = session()
|
|
user = User(
|
|
email="inactive@schubamed.de",
|
|
first_name="Inactive",
|
|
last_name="User",
|
|
role=UserRole.PRUEFER.value,
|
|
password_hash=hash_password("VerySecretPass123"),
|
|
is_active=False,
|
|
must_change_password=False,
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
try:
|
|
AuthService(db).login("inactive@schubamed.de", "VerySecretPass123")
|
|
except HTTPException as exc:
|
|
assert exc.status_code == 403
|
|
else:
|
|
raise AssertionError("Inactive user was accepted")
|
|
|
|
|
|
def test_reference_masterdata_import_is_idempotent_and_links_entities(tmp_path):
|
|
db = session()
|
|
reference = next(
|
|
candidate
|
|
for parent in Path(__file__).resolve().parents
|
|
if (candidate := parent / "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx").exists()
|
|
)
|
|
|
|
first = ReferenceMasterdataImportService(db).import_reference_docx(reference)
|
|
second = ReferenceMasterdataImportService(db).import_reference_docx(reference)
|
|
|
|
assert "customer" in first.created
|
|
assert "location" in first.created
|
|
assert "device" in first.created
|
|
assert first.validation_id is None
|
|
assert "customer" in second.unchanged
|
|
assert "device" in second.unchanged
|
|
assert db.scalar(select(Customer).where(Customer.name == "Urologische Praxis Dr. Durmaz")) is not None
|
|
assert db.scalar(select(Device).where(Device.serial_number == "EXN250688")) is not None
|
|
assert db.scalar(select(Equipment).where(Equipment.serial_number == "15125738")) is not None
|
|
|
|
|
|
def test_reference_masterdata_import_can_create_validation():
|
|
db = session()
|
|
reference = next(
|
|
candidate
|
|
for parent in Path(__file__).resolve().parents
|
|
if (candidate := parent / "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx").exists()
|
|
)
|
|
|
|
result = ReferenceMasterdataImportService(db).import_reference_docx(reference, create_validation=True)
|
|
|
|
assert result.validation_id is not None
|
|
validation = db.get(Validation, result.validation_id)
|
|
assert validation is not None
|
|
assert validation.status == ValidationStatus.draft.value
|
|
|
|
|
|
def test_quickstart_customer_data_and_validation_creation():
|
|
db = session()
|
|
reference = next(
|
|
candidate
|
|
for parent in Path(__file__).resolve().parents
|
|
if (candidate := parent / "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx").exists()
|
|
)
|
|
ReferenceMasterdataImportService(db).import_reference_docx(reference, create_validation=True)
|
|
customer = db.scalar(select(Customer).where(Customer.name == "Urologische Praxis Dr. Durmaz"))
|
|
assert customer is not None
|
|
customer_data = QuickStartService(db).customer_data(customer.id)
|
|
assert customer_data.locations
|
|
assert customer_data.contacts
|
|
assert customer_data.devices
|
|
assert customer_data.validations
|
|
|
|
admin = User(
|
|
email="quickstart-admin@schubamed.de",
|
|
first_name="Quickstart",
|
|
last_name="Admin",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(admin)
|
|
db.flush()
|
|
created = QuickStartService(db).create_validation(
|
|
QuickStartCreateRequest(
|
|
customer_id=customer.id,
|
|
location_id=customer_data.locations[0].id,
|
|
contact_id=customer_data.contacts[0].id,
|
|
device_id=customer_data.devices[0].id,
|
|
validation_type="Erstvalidierung",
|
|
),
|
|
examiner=admin,
|
|
)
|
|
db.commit()
|
|
assert created.id is not None
|
|
assert created.status == ValidationStatus.draft.value
|
|
assert created.customer_id == customer.id
|
|
assert created.examiner_id == admin.id
|
|
assert created.documentation_checklist
|
|
assert created.programs
|
|
assert created.loading_patterns
|
|
|
|
|
|
def test_quickstart_auto_selects_single_customer_masterdata():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
contact = seed_contact(db, customer)
|
|
admin = User(
|
|
email="single-data-admin@schubamed.de",
|
|
first_name="Quickstart",
|
|
last_name="Admin",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(admin)
|
|
db.flush()
|
|
|
|
created = QuickStartService(db).create_validation(
|
|
QuickStartCreateRequest(
|
|
customer_id=customer.id,
|
|
validation_type="Erstvalidierung",
|
|
),
|
|
examiner=admin,
|
|
)
|
|
|
|
assert created.customer_id == customer.id
|
|
assert created.location_id == location.id
|
|
assert created.contact_id == contact.id
|
|
assert created.device_id == device.id
|
|
|
|
|
|
def test_quickstart_device_last_validation_returns_latest_completed_validation():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
draft = valid_validation(customer, location, device)
|
|
draft.status = ValidationStatus.draft.value
|
|
completed = valid_validation(customer, location, device)
|
|
completed.report_number = "VAL-PAST"
|
|
completed.status = ValidationStatus.completed.value
|
|
completed.performed_on = date(2026, 1, 1)
|
|
db.add_all([draft, completed])
|
|
db.flush()
|
|
|
|
summary = QuickStartService(db).device_last_validation(device.id)
|
|
|
|
assert summary is not None
|
|
assert summary.report_number == "VAL-PAST"
|
|
assert summary.device_id == device.id
|
|
|
|
|
|
def test_quickstart_revalidation_copies_structured_data_but_not_measurements():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
admin = User(
|
|
email="revalidation-admin@schubamed.de",
|
|
first_name="Quickstart",
|
|
last_name="Admin",
|
|
role=UserRole.ADMIN.value,
|
|
password_hash="hash",
|
|
is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
base = valid_validation(customer, location, device)
|
|
base.status = ValidationStatus.approved.value
|
|
base.documentation_checklist = [{"text": "Prüfung A", "value": "yes"}]
|
|
base.performance_checklist = [{"text": "Prüfung B", "value": "no"}]
|
|
base.programs = [{"name": "Vakuumtest", "selected": True}]
|
|
base.loading_patterns = [{"run": 1, "pattern": "Standard", "description": "Basis", "images": ["x"]}]
|
|
base.measurement_data = [{"field": "temperatur"}]
|
|
base.attachments = [{"filename": "bild.png"}]
|
|
db.add_all([admin, base])
|
|
db.flush()
|
|
|
|
created = QuickStartService(db).create_validation(
|
|
QuickStartCreateRequest(
|
|
customer_id=customer.id,
|
|
device_id=device.id,
|
|
validation_type="Revalidierung",
|
|
),
|
|
examiner=admin,
|
|
)
|
|
|
|
assert created.previous_validation_id == base.id
|
|
assert created.version == 2
|
|
assert created.documentation_checklist == base.documentation_checklist
|
|
assert created.performance_checklist == base.performance_checklist
|
|
assert created.programs == base.programs
|
|
assert created.loading_patterns == base.loading_patterns
|
|
assert created.measurement_data == []
|
|
assert created.attachments == []
|
|
|
|
|
|
def test_revalidation_date_uses_calendar_months():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
validation.performed_on = date(2026, 1, 31)
|
|
validation.revalidation_interval_months = 1
|
|
|
|
ValidationWorkflowService(db).apply_revalidation_date(validation)
|
|
|
|
assert validation.next_validation_on == date(2026, 2, 28)
|
|
|
|
|
|
def test_new_version_keeps_structured_json_and_previous_reference():
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
validation.status = ValidationStatus.approved.value
|
|
validation.documentation_checklist = [{"text": "A", "value": "yes"}]
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
clone = ValidationWorkflowService(db).create_new_version(validation)
|
|
|
|
assert clone.previous_validation_id == validation.id
|
|
assert clone.version == 2
|
|
assert clone.documentation_checklist == validation.documentation_checklist
|
|
|
|
|
|
def test_orion_renders_reference_main_chapters(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
|
|
assert "1 Funktionsqualifikation" in html
|
|
assert "2.2 Prüfmittel zur thermoelektrischen Untersuchung" in html
|
|
assert "4 Ergebnisse der Validierung" in html
|
|
assert "9 Werkskalibrierzertifikate Sensoren" in html
|
|
|
|
|
|
def test_orion_renders_uploaded_images_as_real_images(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
validation.attachments = [
|
|
{
|
|
"category": "Beladung",
|
|
"filename": "beladung.png",
|
|
"content_type": "image/png",
|
|
"description": "Beladungsmuster Testlauf 1",
|
|
"order": 1,
|
|
"url": "/uploads/validations/example/beladung.png",
|
|
}
|
|
]
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
|
|
assert '<img class="report-image"' in html
|
|
assert "http://localhost:8000/uploads/validations/example/beladung.png" in html
|
|
assert "Abbildung 1: Beladungsmuster Testlauf 1" in html
|
|
|
|
|
|
def test_weasyprint_renders_minimal_pdf_bytes():
|
|
from weasyprint import HTML
|
|
|
|
pdf_bytes = HTML(string="<html><body><h1>Orion PDF Test</h1></body></html>").write_pdf()
|
|
|
|
assert pdf_bytes.startswith(b"%PDF")
|
|
assert len(pdf_bytes) > 1024
|
|
|
|
|
|
def test_orion_renders_real_pdf_with_logo(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
assert SCHUBAMED_LOGO_PATH.exists()
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
pdf_path = OrionReportService(db, tmp_path).render_pdf(validation.id)
|
|
|
|
assert schubamed_logo_uri() in html
|
|
assert "Neutraler Logo-Platzhalter" not in html
|
|
assert pdf_path.read_bytes().startswith(b"%PDF")
|
|
assert pdf_path.stat().st_size > 1024
|
|
|
|
|
|
def test_orion_header_footer_layout_markers_are_rendered(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
validation.report_number = "SV-2026-00005"
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
|
|
assert 'class="report-header"' in html
|
|
assert 'class="report-header-brand"' in html
|
|
assert "SCHUBAMED®" in html
|
|
assert "Aufbereitung mit System" in html
|
|
assert "SV-2026-00005" in html
|
|
assert "Version" in html
|
|
assert "Datum" in html
|
|
assert 'class="report-footer"' in html
|
|
assert "Validation Suite" in html
|
|
assert "Seite" in html
|
|
assert "page-count" in html
|
|
|
|
|
|
def test_orion_layout_css_reserves_header_footer_space():
|
|
css = Path("app/modules/orion/templates/report.css").read_text(encoding="utf-8")
|
|
|
|
assert "margin: 34mm 18mm 24mm 18mm" in css
|
|
assert "position: running(report-header)" in css
|
|
assert "position: running(report-footer)" in css
|
|
assert "grid-template-columns: 1fr 54mm" in css
|
|
assert "height: 24mm" in css
|
|
assert "height: 20mm" in css
|
|
assert "font-size: 22pt" in css
|
|
assert "counter(pages)" in css
|
|
|
|
|
|
def test_reference_template_textblocks_and_checklists_are_loaded():
|
|
db = session()
|
|
bundle = ReportTemplateService(db).ensure_default_template()
|
|
|
|
assert bundle.template.template_key == "small_steam_sterilizer_initial_validation"
|
|
assert ReportTemplateService(db).reference_path.exists()
|
|
assert {"summary", "bq_goal", "legal", "performance"} <= set(bundle.text_blocks)
|
|
assert len(bundle.checklists) >= 6
|
|
assert any(item.title == "Beschreibung Sterilisator" for item in bundle.checklists)
|
|
|
|
|
|
def test_orion_contains_required_reference_sections_and_three_runs(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
|
|
for title in [
|
|
"Zusammenfassendes Ergebnis der Validierung",
|
|
"1.1 Anlass und Ziel der Prüfung",
|
|
"1.2 Gesetzliche Grundlagen",
|
|
"3.2 Standardbeladung (1. Durchlauf)",
|
|
"3.3 Standardbeladung (2. Durchlauf)",
|
|
"3.4 Standardbeladung (3. Durchlauf)",
|
|
]:
|
|
assert title in html
|
|
assert "Neutraler Logo-Platzhalter" not in html
|
|
assert "report-header-brand" in html
|
|
|
|
|
|
def test_orion_toc_uses_central_section_numbers_and_titles(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
|
|
expected = [
|
|
"1.3 Angaben zum Gerät",
|
|
"1.8 Umgebungsbedingungen",
|
|
"2.3 Prüfkonfiguration",
|
|
"3.1.1 Vakuumtest",
|
|
"4.2 Empfehlungen und Auflagen",
|
|
"9 Werkskalibrierzertifikate Sensoren",
|
|
]
|
|
for label in expected:
|
|
assert label in html
|
|
assert "3.1.1 Leistungsqualifikation" not in html
|
|
assert "4.2 Trocknung" not in html
|
|
assert html.index("1 Funktionsqualifikation") < html.index("9 Werkskalibrierzertifikate")
|
|
|
|
|
|
def test_orion_html_has_single_cover_before_report_content(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
|
|
assert html.count('class="cover-page"') == 1
|
|
assert html.index('class="cover-page"') < html.index('class="report-content"')
|
|
assert html.index('class="cover-page"') < html.index('class="report-meta"')
|
|
assert "<h1>PRÜFBERICHT ZUR VALIDIERUNG</h1>" in html
|
|
|
|
|
|
def test_orion_pdf_first_page_is_cover_not_blank(tmp_path):
|
|
from pypdf import PdfReader
|
|
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
pdf_path = OrionReportService(db, tmp_path).render_pdf(validation.id)
|
|
first_page_text = PdfReader(str(pdf_path)).pages[0].extract_text() or ""
|
|
normalized_text = re.sub(r"\s+", "", first_page_text.upper())
|
|
|
|
assert "PRÜFBERICHTZURVALIDIERUNG" in normalized_text
|
|
assert "FUNKTIONS-UNDLEISTUNGSQUALIFIKATION" in normalized_text
|
|
|
|
|
|
def test_orion_bookmark_labels_are_created_from_central_sections(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
bookmark_count = html.count("data-bookmark-label=")
|
|
toc_count = len([item for item in REPORT_SECTIONS if item.get("toc")])
|
|
|
|
assert bookmark_count == toc_count
|
|
for item in REPORT_SECTIONS:
|
|
number = item.get("number")
|
|
label = f"{number} {item['title']}" if number else item["title"]
|
|
assert f'data-bookmark-label="{label}"' in html
|
|
|
|
|
|
def test_orion_appends_pdf_attachments(tmp_path):
|
|
from pypdf import PdfReader, PdfWriter
|
|
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
attachment_path = tmp_path / "anlage.pdf"
|
|
writer = PdfWriter()
|
|
writer.add_blank_page(width=200, height=200)
|
|
with attachment_path.open("wb") as handle:
|
|
writer.write(handle)
|
|
validation.attachments = [
|
|
{
|
|
"category": "Kalibrierschein",
|
|
"filename": "anlage.pdf",
|
|
"content_type": "application/pdf",
|
|
"description": "Anlage",
|
|
"order": 1,
|
|
"storage_path": str(attachment_path),
|
|
}
|
|
]
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
pdf_path = OrionReportService(db, tmp_path).render_pdf(validation.id)
|
|
|
|
assert len(PdfReader(str(pdf_path)).pages) >= 2
|
|
|
|
|
|
def test_winlog_pdf_import_preview_and_confirmation(tmp_path):
|
|
from weasyprint import HTML
|
|
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
pdf_bytes = HTML(
|
|
string="<p>Vakuumtest</p><p>Programm: Vakuum</p><p>Leckrate: 0,1</p><p>Ergebnis: bestanden</p>"
|
|
).write_pdf()
|
|
|
|
preview = HeliosImportService(db, tmp_path).save_winlog_pdf(
|
|
validation.id, "winlog.pdf", pdf_bytes
|
|
)
|
|
|
|
assert preview["status"] == "VORSCHAU_BEREIT"
|
|
assert preview["sha256"]
|
|
assert any(value["field_name"] == "leak_rate" for value in preview["values"])
|
|
first = preview["values"][0]
|
|
confirmed = HeliosImportService(db, tmp_path).confirm_values(
|
|
preview["id"], [{"id": first["id"], "confirmed": True, "corrected_value": "korrigiert"}]
|
|
)
|
|
assert confirmed["status"] == "BESTAETIGT"
|
|
|
|
|
|
def test_unreadable_winlog_pdf_does_not_crash(tmp_path):
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
|
|
preview = HeliosImportService(db, tmp_path).save_winlog_pdf(
|
|
validation.id, "broken.pdf", b"not a pdf"
|
|
)
|
|
|
|
assert preview["status"] in {"FEHLER", "NUR_ANLAGE"}
|
|
assert preview["values"] == []
|
|
|
|
|
|
def test_confirmed_measurements_appear_in_orion_report(tmp_path):
|
|
from weasyprint import HTML
|
|
|
|
db = session()
|
|
customer, location, device = seed(db)
|
|
validation = valid_validation(customer, location, device)
|
|
db.add(validation)
|
|
db.flush()
|
|
pdf_bytes = HTML(string="<p>Testlauf 1</p><p>Leckrate: 0,2</p>").write_pdf()
|
|
preview = HeliosImportService(db, tmp_path).save_winlog_pdf(validation.id, "winlog.pdf", pdf_bytes)
|
|
leak_rate = next(value for value in preview["values"] if value["field_name"] == "leak_rate")
|
|
HeliosImportService(db, tmp_path).confirm_values(
|
|
preview["id"], [{"id": leak_rate["id"], "confirmed": True}]
|
|
)
|
|
|
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
|
|
|
assert "leak_rate" in html
|
|
assert "0,2" in html
|