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.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 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_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_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 '