1178 lines
42 KiB
Python
1178 lines
42 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Comprehensive test suite for FHIR to PADneXt converter.
|
|
|
|
Run with:
|
|
pytest test_fhir_to_pad_converter.py -v
|
|
pytest test_fhir_to_pad_converter.py -v --cov=. --cov-report=html
|
|
"""
|
|
|
|
import pytest
|
|
import json
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List
|
|
from typer.testing import CliRunner
|
|
|
|
# Import modules to test
|
|
from utils import (
|
|
parse_iso_date,
|
|
format_iso_date,
|
|
get_ref_id,
|
|
ensure_text,
|
|
collect_effective_dates,
|
|
validate_file_path,
|
|
validate_output_path,
|
|
validate_directory_path
|
|
)
|
|
from validation import (
|
|
validate_temporal_consistency,
|
|
validate_codes,
|
|
run_validation
|
|
)
|
|
from translator import CodeTranslator
|
|
from fhir_to_pad_converter import (
|
|
validate_fhir_json,
|
|
compute_fhir_stats,
|
|
group_entries,
|
|
claim_item_to_position,
|
|
find_resource_by_ref,
|
|
claim_to_rechnung_header,
|
|
validate_ziffer,
|
|
get_with_placeholder,
|
|
validate_pad_xml,
|
|
compute_pad_stats,
|
|
build_pad_xml,
|
|
PAD_NS,
|
|
app
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# FIXTURES
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
def sample_fhir_bundle():
|
|
"""Minimal valid FHIR bundle for testing."""
|
|
return {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": [
|
|
{
|
|
"resource": {
|
|
"resourceType": "Patient",
|
|
"id": "patient-1",
|
|
"name": [{"family": "Test", "given": ["John"]}],
|
|
"birthDate": "1980-01-01",
|
|
"gender": "male"
|
|
}
|
|
},
|
|
{
|
|
"resource": {
|
|
"resourceType": "Encounter",
|
|
"id": "encounter-1",
|
|
"status": "finished",
|
|
"subject": {"reference": "Patient/patient-1"},
|
|
"period": {
|
|
"start": "2024-01-01T10:00:00+00:00",
|
|
"end": "2024-01-01T12:00:00+00:00"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"resource": {
|
|
"resourceType": "Observation",
|
|
"id": "obs-1",
|
|
"status": "final",
|
|
"code": {
|
|
"coding": [{
|
|
"system": "http://loinc.org",
|
|
"code": "12345-6",
|
|
"display": "Test Observation"
|
|
}]
|
|
},
|
|
"subject": {"reference": "Patient/patient-1"},
|
|
"encounter": {"reference": "Encounter/encounter-1"},
|
|
"effectiveDateTime": "2024-01-01T10:30:00+00:00"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_claim_bundle():
|
|
"""FHIR bundle with Claim resource."""
|
|
return {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": [
|
|
{
|
|
"resource": {
|
|
"resourceType": "Patient",
|
|
"id": "patient-1",
|
|
"name": [{"family": "Smith", "given": ["Jane"]}],
|
|
"birthDate": "1990-05-15",
|
|
"gender": "female"
|
|
}
|
|
},
|
|
{
|
|
"resource": {
|
|
"resourceType": "Organization",
|
|
"id": "provider-org-1",
|
|
"name": "Test Hospital"
|
|
}
|
|
},
|
|
{
|
|
"resource": {
|
|
"resourceType": "Organization",
|
|
"id": "insurer-org-1",
|
|
"name": "Test Insurance"
|
|
}
|
|
},
|
|
{
|
|
"resource": {
|
|
"resourceType": "Claim",
|
|
"id": "claim-1",
|
|
"status": "active",
|
|
"patient": {"reference": "Patient/patient-1"},
|
|
"provider": {"reference": "Organization/provider-org-1"},
|
|
"insurer": {"reference": "Organization/insurer-org-1"},
|
|
"created": "2024-01-15T10:00:00+00:00",
|
|
"diagnosis": [{
|
|
"diagnosisCodeableConcept": {
|
|
"coding": [{
|
|
"system": "http://hl7.org/fhir/sid/icd-10",
|
|
"code": "Z00.0",
|
|
"display": "General examination"
|
|
}]
|
|
}
|
|
}],
|
|
"item": [
|
|
{
|
|
"sequence": 1,
|
|
"productOrService": {
|
|
"coding": [{
|
|
"system": "http://test.de/goa",
|
|
"code": "1",
|
|
"display": "Consultation"
|
|
}]
|
|
},
|
|
"servicedDate": "2024-01-15"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_header_config():
|
|
"""Sample header configuration."""
|
|
return {
|
|
"nachrichtentyp_version": "1.0",
|
|
"rechnungsersteller_name": "Test Clinic",
|
|
"rechnungsersteller_kundennr": "12345",
|
|
"rechnungsersteller_strasse": "Main St 1",
|
|
"rechnungsersteller_plz": "12345",
|
|
"rechnungsersteller_ort": "Berlin",
|
|
"leistungserbringer_id": "LE001",
|
|
"leistungserbringer_vorname": "Dr. Max",
|
|
"leistungserbringer_name": "Mustermann",
|
|
"behandelter_vorname": "John",
|
|
"behandelter_name": "Doe",
|
|
"behandelter_gebdatum": "1980-01-01",
|
|
"behandelter_geschlecht": "m",
|
|
"versicherter_vorname": "John",
|
|
"versicherter_name": "Doe",
|
|
"versicherter_gebdatum": "1980-01-01",
|
|
"versicherter_geschlecht": "m",
|
|
"behandlungsart": "0",
|
|
"vertragsart": "1",
|
|
"diagnose_text": "Test diagnosis",
|
|
"diagnose_datum": "2024-01-01"
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_placeholder_config():
|
|
"""Sample placeholder configuration."""
|
|
return {
|
|
"rechnungsersteller": {
|
|
"name": "PLACEHOLDER_CLINIC",
|
|
"plz": "00000",
|
|
"ort": "PLACEHOLDER_CITY",
|
|
"strasse": "PLACEHOLDER_STREET"
|
|
},
|
|
"leistungserbringer": {
|
|
"vorname": "PLACEHOLDER_FIRSTNAME",
|
|
"name": "PLACEHOLDER_LASTNAME"
|
|
},
|
|
"behandelter": {
|
|
"anrede": "Ohne Anrede",
|
|
"vorname": "PLACEHOLDER_PATIENT",
|
|
"name": "PLACEHOLDER_PATIENT",
|
|
"gebdatum": "1900-01-01",
|
|
"geschlecht": "u"
|
|
},
|
|
"goziffer": {
|
|
"ziffer": "99999",
|
|
"datum": "1900-01-01"
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# UTILS.PY TESTS
|
|
# ============================================================================
|
|
|
|
class TestUtils:
|
|
"""Tests for utility functions."""
|
|
|
|
def test_parse_iso_date_valid_with_z(self):
|
|
"""Test parsing ISO date with Z suffix."""
|
|
result = parse_iso_date("2024-01-01T10:00:00Z")
|
|
assert result is not None
|
|
assert result.year == 2024
|
|
assert result.month == 1
|
|
assert result.day == 1
|
|
|
|
def test_parse_iso_date_valid_with_timezone(self):
|
|
"""Test parsing ISO date with timezone."""
|
|
result = parse_iso_date("2024-01-01T10:00:00+02:00")
|
|
assert result is not None
|
|
assert result.year == 2024
|
|
|
|
def test_parse_iso_date_invalid(self):
|
|
"""Test parsing invalid date returns None."""
|
|
assert parse_iso_date("not-a-date") is None
|
|
assert parse_iso_date("") is None
|
|
assert parse_iso_date(None) is None
|
|
|
|
def test_parse_iso_date_edge_cases(self):
|
|
"""Test edge cases for date parsing."""
|
|
# Just date without time
|
|
result = parse_iso_date("2024-01-01")
|
|
assert result is not None
|
|
assert result.year == 2024
|
|
|
|
def test_format_iso_date(self):
|
|
"""Test formatting datetime to ISO date string."""
|
|
dt = datetime(2024, 1, 15, 10, 30, 45)
|
|
result = format_iso_date(dt)
|
|
assert result == "2024-01-15"
|
|
|
|
def test_get_ref_id_valid(self):
|
|
"""Test extracting ID from valid reference."""
|
|
assert get_ref_id("Patient/123") == "123"
|
|
assert get_ref_id("Encounter/abc-def") == "abc-def"
|
|
assert get_ref_id("Organization/org-1") == "org-1"
|
|
|
|
def test_get_ref_id_invalid(self):
|
|
"""Test handling invalid references."""
|
|
assert get_ref_id("NoSlash") is None
|
|
assert get_ref_id("") is None
|
|
assert get_ref_id(None) is None
|
|
assert get_ref_id("Patient/") is None
|
|
|
|
def test_ensure_text_with_element(self):
|
|
"""Test extracting text from XML element."""
|
|
elem = ET.Element("test")
|
|
elem.text = " content "
|
|
assert ensure_text(elem) == "content"
|
|
|
|
def test_ensure_text_none(self):
|
|
"""Test ensure_text with None returns default."""
|
|
assert ensure_text(None) == ""
|
|
assert ensure_text(None, "default") == "default"
|
|
|
|
def test_collect_effective_dates(self):
|
|
"""Test collecting dates from resource."""
|
|
resource = {
|
|
"effectiveDateTime": "2024-01-01T10:00:00Z",
|
|
"issued": "2024-01-02T10:00:00Z",
|
|
"meta": {
|
|
"lastUpdated": "2024-01-03T10:00:00Z"
|
|
}
|
|
}
|
|
dates = collect_effective_dates(resource)
|
|
assert len(dates) == 3
|
|
assert all(isinstance(d, datetime) for d in dates)
|
|
|
|
def test_collect_effective_dates_empty(self):
|
|
"""Test collecting dates from resource with no dates."""
|
|
resource = {"resourceType": "Patient"}
|
|
dates = collect_effective_dates(resource)
|
|
assert len(dates) == 0
|
|
|
|
|
|
# ============================================================================
|
|
# VALIDATION.PY TESTS
|
|
# ============================================================================
|
|
|
|
class TestValidation:
|
|
"""Tests for validation functions."""
|
|
|
|
def test_validate_temporal_consistency_valid(self):
|
|
"""Test validation passes for valid dates."""
|
|
resources = [{
|
|
"id": "test-1",
|
|
"effectiveDateTime": "2024-01-01T10:00:00Z"
|
|
}]
|
|
warnings = validate_temporal_consistency(resources)
|
|
assert len(warnings) == 0
|
|
|
|
def test_validate_temporal_consistency_future_date(self):
|
|
"""Test validation warns for future dates."""
|
|
resources = [{
|
|
"id": "test-1",
|
|
"effectiveDateTime": "2099-01-01T10:00:00Z"
|
|
}]
|
|
warnings = validate_temporal_consistency(resources)
|
|
assert len(warnings) > 0
|
|
assert "future" in warnings[0].lower()
|
|
|
|
def test_validate_codes(self):
|
|
"""Test code validation (currently stub)."""
|
|
resources = [{"id": "test-1"}]
|
|
warnings = validate_codes(resources)
|
|
assert isinstance(warnings, list)
|
|
|
|
def test_run_validation(self):
|
|
"""Test running all validations."""
|
|
resources = [{
|
|
"id": "test-1",
|
|
"effectiveDateTime": "2024-01-01T10:00:00Z"
|
|
}]
|
|
warnings = run_validation(resources)
|
|
assert isinstance(warnings, list)
|
|
|
|
|
|
# ============================================================================
|
|
# TRANSLATOR.PY TESTS
|
|
# ============================================================================
|
|
|
|
class TestCodeTranslator:
|
|
"""Tests for code translation."""
|
|
|
|
def test_translator_init(self):
|
|
"""Test translator initialization."""
|
|
translator = CodeTranslator()
|
|
assert translator.maps == {}
|
|
|
|
def test_translator_parse_concept_map(self):
|
|
"""Test parsing a concept map."""
|
|
translator = CodeTranslator()
|
|
concept_map = {
|
|
"group": [{
|
|
"source": "http://loinc.org",
|
|
"target": "http://test.de/goa",
|
|
"element": [
|
|
{
|
|
"code": "12345-6",
|
|
"target": [{"code": "001"}]
|
|
}
|
|
]
|
|
}]
|
|
}
|
|
translator._parse_concept_map(concept_map)
|
|
assert "http://loinc.org" in translator.maps
|
|
result = translator.translate("http://loinc.org", "12345-6")
|
|
assert result == "001"
|
|
|
|
def test_translator_no_match(self):
|
|
"""Test translation returns None when no match."""
|
|
translator = CodeTranslator()
|
|
result = translator.translate("http://unknown.org", "12345")
|
|
assert result is None
|
|
|
|
|
|
# ============================================================================
|
|
# FHIR VALIDATION TESTS
|
|
# ============================================================================
|
|
|
|
class TestFhirValidation:
|
|
"""Tests for FHIR validation functions."""
|
|
|
|
def test_validate_fhir_json_valid_bundle(self, sample_fhir_bundle):
|
|
"""Test validation of valid FHIR bundle."""
|
|
ok, messages = validate_fhir_json(sample_fhir_bundle)
|
|
assert ok is True
|
|
assert len(messages) > 0
|
|
|
|
def test_validate_fhir_json_invalid_resource_type(self):
|
|
"""Test validation fails for wrong resourceType."""
|
|
bundle = {"resourceType": "NotABundle"}
|
|
ok, messages = validate_fhir_json(bundle)
|
|
assert ok is False
|
|
assert any("Bundle" in m for m in messages)
|
|
|
|
def test_validate_fhir_json_missing_type(self):
|
|
"""Test validation warns for missing bundle type."""
|
|
bundle = {"resourceType": "Bundle", "entry": []}
|
|
ok, messages = validate_fhir_json(bundle)
|
|
assert ok is False
|
|
|
|
def test_validate_fhir_json_invalid_entry(self):
|
|
"""Test validation handles invalid entry."""
|
|
bundle = {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": "not-a-list"
|
|
}
|
|
ok, messages = validate_fhir_json(bundle)
|
|
assert ok is False
|
|
|
|
def test_compute_fhir_stats(self, sample_fhir_bundle):
|
|
"""Test computing FHIR statistics."""
|
|
stats = compute_fhir_stats(sample_fhir_bundle)
|
|
assert stats["bundle_type"] == "collection"
|
|
assert stats["total_entries"] == 3
|
|
assert "Patient" in stats["resource_type_counts"]
|
|
assert stats["resource_type_counts"]["Patient"] == 1
|
|
|
|
|
|
# ============================================================================
|
|
# GROUPING TESTS
|
|
# ============================================================================
|
|
|
|
class TestGrouping:
|
|
"""Tests for resource grouping logic."""
|
|
|
|
def test_group_entries_by_encounter(self, sample_fhir_bundle):
|
|
"""Test grouping by encounter when no Claims."""
|
|
groups = group_entries(sample_fhir_bundle)
|
|
assert len(groups) > 0
|
|
# Should have one group for (patient-1, encounter-1)
|
|
assert ("patient-1", "encounter-1") in groups
|
|
|
|
def test_group_entries_by_claim(self, sample_claim_bundle):
|
|
"""Test grouping by claim when Claims present."""
|
|
groups = group_entries(sample_claim_bundle)
|
|
assert len(groups) > 0
|
|
# Should have one group for (patient-1, claim-1)
|
|
assert ("patient-1", "claim-1") in groups
|
|
|
|
def test_group_entries_empty_bundle(self):
|
|
"""Test grouping empty bundle."""
|
|
bundle = {"resourceType": "Bundle", "type": "collection", "entry": []}
|
|
groups = group_entries(bundle)
|
|
assert len(groups) == 0
|
|
|
|
|
|
# ============================================================================
|
|
# CLAIM MAPPING TESTS
|
|
# ============================================================================
|
|
|
|
class TestClaimMapping:
|
|
"""Tests for Claim-to-PAD mapping functions."""
|
|
|
|
def test_claim_item_to_position(self):
|
|
"""Test converting Claim item to position."""
|
|
item = {
|
|
"sequence": 1,
|
|
"productOrService": {
|
|
"coding": [{
|
|
"code": "12345",
|
|
"display": "Test Service"
|
|
}]
|
|
},
|
|
"servicedDate": "2024-01-15"
|
|
}
|
|
position = claim_item_to_position(item)
|
|
assert position["id"] == 1
|
|
assert position["ziffer"] == "12345"
|
|
assert position["text"] == "Test Service"
|
|
assert position["datum"] == "2024-01-15"
|
|
assert position["anzahl"] == "1"
|
|
|
|
def test_claim_item_to_position_missing_fields(self):
|
|
"""Test converting Claim item with missing fields."""
|
|
item = {"sequence": 2}
|
|
position = claim_item_to_position(item)
|
|
assert position["id"] == 2
|
|
assert position["ziffer"] == ""
|
|
assert position["text"] == ""
|
|
|
|
def test_find_resource_by_ref(self, sample_claim_bundle):
|
|
"""Test finding resource by reference."""
|
|
resource = find_resource_by_ref(sample_claim_bundle, "Patient/patient-1")
|
|
assert resource is not None
|
|
assert resource["resourceType"] == "Patient"
|
|
assert resource["id"] == "patient-1"
|
|
|
|
def test_find_resource_by_ref_not_found(self, sample_claim_bundle):
|
|
"""Test finding non-existent resource."""
|
|
resource = find_resource_by_ref(sample_claim_bundle, "Patient/does-not-exist")
|
|
assert resource is None
|
|
|
|
def test_find_resource_by_ref_invalid_format(self, sample_claim_bundle):
|
|
"""Test finding resource with invalid reference format."""
|
|
resource = find_resource_by_ref(sample_claim_bundle, "InvalidReference")
|
|
assert resource is None
|
|
|
|
def test_claim_to_rechnung_header(self, sample_claim_bundle):
|
|
"""Test extracting header info from Claim."""
|
|
claim = sample_claim_bundle["entry"][3]["resource"]
|
|
header = claim_to_rechnung_header(claim, sample_claim_bundle)
|
|
|
|
assert "behandelter_vorname" in header
|
|
assert header["behandelter_name"] == "Smith"
|
|
assert header["behandelter_gebdatum"] == "1990-05-15"
|
|
assert header["leistungserbringer_name"] == "Test Hospital"
|
|
assert header["empfaenger_name"] == "Test Insurance"
|
|
|
|
|
|
# ============================================================================
|
|
# PLACEHOLDER & VALIDATION TESTS
|
|
# ============================================================================
|
|
|
|
class TestPlaceholders:
|
|
"""Tests for placeholder and validation functions."""
|
|
|
|
def test_validate_ziffer_valid(self):
|
|
"""Test validating valid ziffer code."""
|
|
auto_filled = []
|
|
result = validate_ziffer("12345", "99999", "test.ziffer", auto_filled)
|
|
assert result == "12345"
|
|
assert len(auto_filled) == 0
|
|
|
|
def test_validate_ziffer_empty(self):
|
|
"""Test validating empty ziffer uses placeholder."""
|
|
auto_filled = []
|
|
result = validate_ziffer("", "99999", "test.ziffer", auto_filled)
|
|
assert result == "99999"
|
|
assert len(auto_filled) == 1
|
|
assert "99999" in auto_filled[0]
|
|
|
|
def test_validate_ziffer_too_long(self):
|
|
"""Test validating ziffer that's too long gets truncated."""
|
|
auto_filled = []
|
|
result = validate_ziffer("123456789", "99999", "test.ziffer", auto_filled)
|
|
assert result == "12345678"
|
|
assert len(result) == 8
|
|
assert len(auto_filled) == 1
|
|
assert "truncated" in auto_filled[0].lower()
|
|
|
|
def test_get_with_placeholder_has_value(self):
|
|
"""Test get_with_placeholder with existing value."""
|
|
auto_filled = []
|
|
result = get_with_placeholder("RealValue", "Placeholder", "test.field", auto_filled)
|
|
assert result == "RealValue"
|
|
assert len(auto_filled) == 0
|
|
|
|
def test_get_with_placeholder_uses_placeholder(self):
|
|
"""Test get_with_placeholder uses placeholder for empty value."""
|
|
auto_filled = []
|
|
result = get_with_placeholder("", "Placeholder", "test.field", auto_filled)
|
|
assert result == "Placeholder"
|
|
assert len(auto_filled) == 1
|
|
assert "Placeholder" in auto_filled[0]
|
|
|
|
|
|
# ============================================================================
|
|
# XML BUILDING TESTS
|
|
# ============================================================================
|
|
|
|
class TestXmlBuilding:
|
|
"""Tests for PAD XML building functions."""
|
|
|
|
def test_build_pad_xml_basic(self, sample_fhir_bundle, sample_header_config, sample_placeholder_config):
|
|
"""Test building basic PAD XML."""
|
|
root, warnings, header, auto_filled = build_pad_xml(
|
|
sample_fhir_bundle,
|
|
header_cfg=sample_header_config,
|
|
placeholder_cfg=sample_placeholder_config
|
|
)
|
|
|
|
assert root is not None
|
|
assert root.tag == f"{{{PAD_NS}}}rechnungen"
|
|
assert root.get("anzahl") is not None
|
|
|
|
def test_build_pad_xml_with_claim(self, sample_claim_bundle, sample_header_config, sample_placeholder_config):
|
|
"""Test building PAD XML with Claim resource."""
|
|
root, warnings, header, auto_filled = build_pad_xml(
|
|
sample_claim_bundle,
|
|
header_cfg=sample_header_config,
|
|
placeholder_cfg=sample_placeholder_config
|
|
)
|
|
|
|
assert root is not None
|
|
# Check that claim data overrode config
|
|
assert header["behandelter_name"] == "Smith"
|
|
assert header["leistungserbringer_name"] == "Test Hospital"
|
|
|
|
def test_build_pad_xml_namespace(self, sample_fhir_bundle, sample_header_config):
|
|
"""Test PAD XML has correct namespace."""
|
|
root, _, _, _ = build_pad_xml(sample_fhir_bundle, header_cfg=sample_header_config)
|
|
assert root.get("xmlns") == PAD_NS
|
|
|
|
|
|
# ============================================================================
|
|
# PAD VALIDATION TESTS
|
|
# ============================================================================
|
|
|
|
class TestPadValidation:
|
|
"""Tests for PAD XML validation functions."""
|
|
|
|
def test_validate_pad_xml_well_formed(self, sample_fhir_bundle, sample_header_config):
|
|
"""Test validation of well-formed XML."""
|
|
root, _, _, _ = build_pad_xml(sample_fhir_bundle, header_cfg=sample_header_config)
|
|
ok, messages = validate_pad_xml(root)
|
|
assert ok is True
|
|
assert any("well-formed" in m for m in messages)
|
|
|
|
def test_compute_pad_stats(self, sample_fhir_bundle, sample_header_config):
|
|
"""Test computing PAD statistics."""
|
|
root, _, _, _ = build_pad_xml(sample_fhir_bundle, header_cfg=sample_header_config)
|
|
stats = compute_pad_stats(root)
|
|
|
|
assert "rechnungen_declared" in stats
|
|
assert "rechnungen_actual" in stats
|
|
assert "goziffer_count" in stats
|
|
assert isinstance(stats["warnings"], list)
|
|
|
|
|
|
# ============================================================================
|
|
# INTEGRATION TESTS
|
|
# ============================================================================
|
|
|
|
class TestIntegration:
|
|
"""End-to-end integration tests."""
|
|
|
|
def test_full_conversion_encounter_based(self, sample_fhir_bundle, sample_header_config, sample_placeholder_config):
|
|
"""Test complete conversion workflow (encounter-based)."""
|
|
# Validate input
|
|
fhir_ok, _ = validate_fhir_json(sample_fhir_bundle)
|
|
assert fhir_ok
|
|
|
|
# Compute stats
|
|
stats = compute_fhir_stats(sample_fhir_bundle)
|
|
assert stats["total_entries"] == 3
|
|
|
|
# Build XML
|
|
root, warnings, header, auto_filled = build_pad_xml(
|
|
sample_fhir_bundle,
|
|
header_cfg=sample_header_config,
|
|
placeholder_cfg=sample_placeholder_config
|
|
)
|
|
assert root is not None
|
|
|
|
# Validate output
|
|
pad_ok, _ = validate_pad_xml(root)
|
|
assert pad_ok
|
|
|
|
# Compute output stats
|
|
pad_stats = compute_pad_stats(root)
|
|
assert pad_stats["goziffer_count"] > 0
|
|
|
|
def test_full_conversion_claim_based(self, sample_claim_bundle, sample_header_config, sample_placeholder_config):
|
|
"""Test complete conversion workflow (claim-based)."""
|
|
# Validate input
|
|
fhir_ok, _ = validate_fhir_json(sample_claim_bundle)
|
|
assert fhir_ok
|
|
|
|
# Build XML
|
|
root, warnings, header, auto_filled = build_pad_xml(
|
|
sample_claim_bundle,
|
|
header_cfg=sample_header_config,
|
|
placeholder_cfg=sample_placeholder_config
|
|
)
|
|
assert root is not None
|
|
|
|
# Check claim data was used
|
|
assert header["behandelter_name"] == "Smith"
|
|
|
|
# Validate output
|
|
pad_ok, _ = validate_pad_xml(root)
|
|
assert pad_ok
|
|
|
|
def test_conversion_with_missing_data(self, sample_placeholder_config):
|
|
"""Test conversion with incomplete FHIR data uses placeholders."""
|
|
# Minimal bundle with missing fields
|
|
minimal_bundle = {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": [{
|
|
"resource": {
|
|
"resourceType": "Patient",
|
|
"id": "p1"
|
|
# Missing name, birthDate, etc.
|
|
}
|
|
}]
|
|
}
|
|
|
|
root, warnings, header, auto_filled = build_pad_xml(
|
|
minimal_bundle,
|
|
header_cfg={},
|
|
placeholder_cfg=sample_placeholder_config
|
|
)
|
|
|
|
# Should have many auto-filled fields
|
|
assert len(auto_filled) > 0
|
|
# XML should still be valid
|
|
assert root is not None
|
|
|
|
|
|
# ============================================================================
|
|
# EDGE CASE TESTS
|
|
# ============================================================================
|
|
|
|
class TestEdgeCases:
|
|
"""Tests for edge cases and error conditions."""
|
|
|
|
def test_empty_bundle(self):
|
|
"""Test handling empty bundle."""
|
|
bundle = {"resourceType": "Bundle", "type": "collection", "entry": []}
|
|
ok, messages = validate_fhir_json(bundle)
|
|
assert ok is True
|
|
|
|
def test_bundle_with_null_entries(self):
|
|
"""Test handling bundle with None entries."""
|
|
bundle = {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": [None, {"resource": {"resourceType": "Patient", "id": "p1"}}]
|
|
}
|
|
groups = group_entries(bundle)
|
|
# Should handle None gracefully
|
|
assert isinstance(groups, dict)
|
|
|
|
def test_resource_without_subject(self):
|
|
"""Test handling resource without subject reference."""
|
|
bundle = {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": [{
|
|
"resource": {
|
|
"resourceType": "Observation",
|
|
"id": "obs-1",
|
|
"status": "final"
|
|
# No subject field
|
|
}
|
|
}]
|
|
}
|
|
stats = compute_fhir_stats(bundle)
|
|
assert stats["entries_missing_subject"] == 1
|
|
|
|
def test_claim_with_empty_items(self):
|
|
"""Test handling Claim with empty items array."""
|
|
item = {"sequence": 1} # Missing productOrService
|
|
position = claim_item_to_position(item)
|
|
assert position["ziffer"] == ""
|
|
assert position["text"] == ""
|
|
|
|
def test_reference_with_multiple_slashes(self):
|
|
"""Test handling malformed reference with multiple slashes."""
|
|
# Current implementation will only split on first slash
|
|
ref_id = get_ref_id("http://example.com/fhir/Patient/123")
|
|
# Should return the last part or None
|
|
assert ref_id is not None or ref_id is None # Either is acceptable
|
|
|
|
def test_date_parsing_various_formats(self):
|
|
"""Test parsing various date formats."""
|
|
dates = [
|
|
"2024-01-01",
|
|
"2024-01-01T10:00:00",
|
|
"2024-01-01T10:00:00Z",
|
|
"2024-01-01T10:00:00+02:00",
|
|
"2024-01-01T10:00:00.123456Z"
|
|
]
|
|
for date_str in dates:
|
|
result = parse_iso_date(date_str)
|
|
assert result is not None or result is None # Should not crash
|
|
|
|
|
|
# ============================================================================
|
|
# PERFORMANCE TESTS (Optional)
|
|
# ============================================================================
|
|
|
|
class TestPerformance:
|
|
"""Basic performance tests."""
|
|
|
|
def test_large_bundle_grouping(self):
|
|
"""Test grouping with larger bundle."""
|
|
# Create bundle with 100 observations
|
|
entries = []
|
|
for i in range(100):
|
|
entries.append({
|
|
"resource": {
|
|
"resourceType": "Observation",
|
|
"id": f"obs-{i}",
|
|
"status": "final",
|
|
"subject": {"reference": f"Patient/patient-{i % 10}"},
|
|
"encounter": {"reference": f"Encounter/enc-{i % 10}"},
|
|
"effectiveDateTime": "2024-01-01T10:00:00Z"
|
|
}
|
|
})
|
|
|
|
bundle = {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": entries
|
|
}
|
|
|
|
# Should complete quickly
|
|
import time
|
|
start = time.time()
|
|
groups = group_entries(bundle)
|
|
elapsed = time.time() - start
|
|
|
|
assert elapsed < 1.0 # Should take less than 1 second
|
|
assert len(groups) == 10 # 10 unique (patient, encounter) combinations
|
|
|
|
|
|
# ============================================================================
|
|
# INPUT VALIDATION TESTS
|
|
# ============================================================================
|
|
|
|
class TestInputValidation:
|
|
"""Tests for input path validation."""
|
|
|
|
def test_validate_file_path_existing_file(self, tmp_path):
|
|
"""Test validation of existing file."""
|
|
# Create a temporary file
|
|
test_file = tmp_path / "test.json"
|
|
test_file.write_text('{"test": true}')
|
|
|
|
# Should succeed
|
|
result = validate_file_path(str(test_file), must_exist=True)
|
|
assert Path(result).exists()
|
|
assert Path(result).is_absolute()
|
|
|
|
def test_validate_file_path_nonexistent_file(self, tmp_path):
|
|
"""Test validation fails for nonexistent file."""
|
|
test_file = tmp_path / "nonexistent.json"
|
|
|
|
# Should raise FileNotFoundError
|
|
with pytest.raises(FileNotFoundError):
|
|
validate_file_path(str(test_file), must_exist=True)
|
|
|
|
def test_validate_file_path_empty_path(self):
|
|
"""Test validation fails for empty path."""
|
|
with pytest.raises(ValueError, match="File path cannot be empty"):
|
|
validate_file_path("")
|
|
|
|
def test_validate_file_path_none_path(self):
|
|
"""Test validation fails for None path."""
|
|
with pytest.raises(ValueError, match="File path must be a string"):
|
|
validate_file_path(None)
|
|
|
|
def test_validate_output_path_creates_directory(self, tmp_path):
|
|
"""Test output path validation creates parent directory."""
|
|
output_file = tmp_path / "new_dir" / "output.xml"
|
|
|
|
# Should succeed and create parent directory
|
|
result = validate_output_path(str(output_file))
|
|
assert Path(result).parent.exists()
|
|
assert Path(result).parent.is_dir()
|
|
|
|
def test_validate_output_path_existing_file_overwrite(self, tmp_path):
|
|
"""Test output path with existing file (overwrite=True)."""
|
|
output_file = tmp_path / "existing.xml"
|
|
output_file.write_text('<test/>')
|
|
|
|
# Should succeed (overwrite allowed by default)
|
|
result = validate_output_path(str(output_file), overwrite=True)
|
|
assert Path(result).exists()
|
|
|
|
def test_validate_output_path_existing_file_no_overwrite(self, tmp_path):
|
|
"""Test output path fails with existing file when overwrite=False."""
|
|
output_file = tmp_path / "existing.xml"
|
|
output_file.write_text('<test/>')
|
|
|
|
# Should raise FileExistsError
|
|
with pytest.raises(FileExistsError):
|
|
validate_output_path(str(output_file), overwrite=False)
|
|
|
|
def test_validate_directory_path_existing(self, tmp_path):
|
|
"""Test directory validation for existing directory."""
|
|
result = validate_directory_path(str(tmp_path), must_exist=True)
|
|
assert Path(result).is_dir()
|
|
assert Path(result).is_absolute()
|
|
|
|
def test_validate_directory_path_create(self, tmp_path):
|
|
"""Test directory validation creates directory."""
|
|
new_dir = tmp_path / "new_directory"
|
|
|
|
result = validate_directory_path(str(new_dir), must_exist=False, create=True)
|
|
assert Path(result).exists()
|
|
assert Path(result).is_dir()
|
|
|
|
def test_validate_directory_path_nonexistent(self, tmp_path):
|
|
"""Test directory validation fails for nonexistent directory."""
|
|
new_dir = tmp_path / "nonexistent_dir"
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
validate_directory_path(str(new_dir), must_exist=True, create=False)
|
|
|
|
def test_validate_directory_path_file_not_directory(self, tmp_path):
|
|
"""Test directory validation fails when path is a file."""
|
|
test_file = tmp_path / "file.txt"
|
|
test_file.write_text("content")
|
|
|
|
with pytest.raises(NotADirectoryError):
|
|
validate_directory_path(str(test_file), must_exist=True)
|
|
|
|
|
|
# ============================================================================
|
|
# CONFIG VALIDATION TESTS
|
|
# ============================================================================
|
|
|
|
class TestConfigValidation:
|
|
"""Tests for configuration validation."""
|
|
|
|
def test_validate_placeholder_config(self):
|
|
"""Test placeholder config validation."""
|
|
try:
|
|
from config_schemas import validate_placeholder_config
|
|
|
|
# Valid config
|
|
valid_config = {
|
|
"rechnungsersteller": {
|
|
"name": "Test",
|
|
"plz": "12345",
|
|
"ort": "Berlin",
|
|
"strasse": "Main St"
|
|
},
|
|
"leistungserbringer": {
|
|
"vorname": "Dr. John",
|
|
"name": "Doe"
|
|
},
|
|
"goziffer": {
|
|
"go": "EBM",
|
|
"ziffer": "12345",
|
|
"datum": "2024-01-01"
|
|
}
|
|
}
|
|
|
|
warnings = validate_placeholder_config(valid_config)
|
|
assert isinstance(warnings, list)
|
|
|
|
except ImportError:
|
|
pytest.skip("config_schemas module not available")
|
|
|
|
def test_validate_invalid_placeholder_config(self):
|
|
"""Test placeholder config validation with invalid data."""
|
|
try:
|
|
from config_schemas import validate_placeholder_config
|
|
|
|
# Invalid config - missing required fields
|
|
invalid_config = {
|
|
"rechnungsersteller": {
|
|
"name": "Test"
|
|
# Missing required fields
|
|
}
|
|
}
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_placeholder_config(invalid_config)
|
|
|
|
except ImportError:
|
|
pytest.skip("config_schemas module not available")
|
|
|
|
def test_validate_mapping_config(self):
|
|
"""Test mapping config validation."""
|
|
try:
|
|
from config_schemas import validate_mapping_config
|
|
|
|
# Valid mapping config
|
|
valid_config = {
|
|
"resources": {
|
|
"Observation": {
|
|
"target": "goziffer",
|
|
"fields": {
|
|
"ziffer": {
|
|
"source": "code.coding[0].code"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
warnings = validate_mapping_config(valid_config)
|
|
assert isinstance(warnings, list)
|
|
|
|
except ImportError:
|
|
pytest.skip("config_schemas module not available")
|
|
|
|
|
|
# ============================================================================
|
|
# CLI TESTS (Typer)
|
|
# ============================================================================
|
|
|
|
class TestCLI:
|
|
"""Tests for command-line interface using typer."""
|
|
|
|
def setup_method(self):
|
|
"""Set up CLI test runner."""
|
|
self.runner = CliRunner()
|
|
|
|
def test_cli_help(self):
|
|
"""Test --help output."""
|
|
result = self.runner.invoke(app, ["--help"])
|
|
assert result.exit_code == 0
|
|
assert "Convert FHIR Bundle JSON to PADneXt 2.12 XML format" in result.stdout
|
|
assert "--input-json" in result.stdout
|
|
assert "--output-dir" in result.stdout
|
|
assert "--verbose" in result.stdout
|
|
|
|
def test_cli_missing_required_arg(self):
|
|
"""Test error when required argument missing."""
|
|
result = self.runner.invoke(app, [])
|
|
assert result.exit_code != 0
|
|
assert "Missing option '--input-json'" in result.stdout
|
|
|
|
def test_cli_short_aliases_in_help(self):
|
|
"""Test that short aliases are shown in help."""
|
|
result = self.runner.invoke(app, ["--help"])
|
|
assert result.exit_code == 0
|
|
assert "-i" in result.stdout # Short alias for --input-json
|
|
assert "-o" in result.stdout # Short alias for --output-dir
|
|
assert "-v" in result.stdout # Short alias for --verbose
|
|
assert "-m" in result.stdout # Short alias for --mapping-config
|
|
|
|
def test_cli_invalid_file(self):
|
|
"""Test error with nonexistent file."""
|
|
result = self.runner.invoke(app, ["--input-json", "nonexistent.json"])
|
|
assert result.exit_code != 0
|
|
# Check for error message (may have formatting/line breaks)
|
|
assert "does not" in result.stdout and "exist" in result.stdout
|
|
|
|
def test_cli_full_conversion(self, tmp_path):
|
|
"""Test complete conversion workflow."""
|
|
# Create test input file
|
|
input_file = tmp_path / "input.json"
|
|
test_bundle = {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": [
|
|
{
|
|
"resource": {
|
|
"resourceType": "Patient",
|
|
"id": "patient-1",
|
|
"name": [{"family": "Test", "given": ["John"]}],
|
|
"birthDate": "1980-01-01",
|
|
"gender": "male"
|
|
}
|
|
},
|
|
{
|
|
"resource": {
|
|
"resourceType": "Encounter",
|
|
"id": "encounter-1",
|
|
"status": "finished",
|
|
"subject": {"reference": "Patient/patient-1"}
|
|
}
|
|
},
|
|
{
|
|
"resource": {
|
|
"resourceType": "Observation",
|
|
"id": "obs-1",
|
|
"status": "final",
|
|
"code": {"coding": [{"code": "12345", "display": "Test"}]},
|
|
"subject": {"reference": "Patient/patient-1"},
|
|
"encounter": {"reference": "Encounter/encounter-1"},
|
|
"effectiveDateTime": "2024-01-01T10:00:00Z"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
input_file.write_text(json.dumps(test_bundle))
|
|
|
|
# Run conversion
|
|
result = self.runner.invoke(app, [
|
|
"--input-json", str(input_file),
|
|
"--output-dir", str(tmp_path)
|
|
])
|
|
|
|
# Check result
|
|
assert result.exit_code == 0
|
|
assert "SUCCESS" in result.stdout or "Conversion completed" in result.stdout
|
|
|
|
def test_cli_with_short_aliases(self, tmp_path):
|
|
"""Test using short aliases."""
|
|
# Create test input file
|
|
input_file = tmp_path / "input.json"
|
|
test_bundle = {
|
|
"resourceType": "Bundle",
|
|
"type": "collection",
|
|
"entry": [{
|
|
"resource": {
|
|
"resourceType": "Patient",
|
|
"id": "patient-1",
|
|
"name": [{"family": "Test"}]
|
|
}
|
|
}]
|
|
}
|
|
input_file.write_text(json.dumps(test_bundle))
|
|
|
|
# Run conversion with short aliases
|
|
result = self.runner.invoke(app, [
|
|
"-i", str(input_file),
|
|
"-o", str(tmp_path)
|
|
])
|
|
|
|
# Should work the same as long form
|
|
assert result.exit_code == 0
|
|
|
|
def test_cli_verbose_flag(self, tmp_path):
|
|
"""Test verbose flag."""
|
|
# Create test input
|
|
input_file = tmp_path / "input.json"
|
|
input_file.write_text('{"resourceType": "Bundle", "type": "collection", "entry": []}')
|
|
|
|
# Run with verbose flag (short form)
|
|
result = self.runner.invoke(app, [
|
|
"-i", str(input_file),
|
|
"-o", str(tmp_path),
|
|
"-v"
|
|
])
|
|
|
|
# Verbose flag should not cause errors
|
|
assert result.exit_code == 0 or result.exit_code == 1 # May exit with error for empty bundle
|
|
|
|
def test_cli_with_config_files(self, tmp_path):
|
|
"""Test with configuration files."""
|
|
# Create test input
|
|
input_file = tmp_path / "input.json"
|
|
input_file.write_text('{"resourceType": "Bundle", "type": "collection", "entry": []}')
|
|
|
|
# Create minimal config
|
|
placeholder_cfg = tmp_path / "placeholder.json"
|
|
placeholder_cfg.write_text(json.dumps({
|
|
"rechnungsersteller": {"name": "Test", "plz": "12345", "ort": "Test", "strasse": "Test"},
|
|
"leistungserbringer": {"vorname": "Dr.", "name": "Test"},
|
|
"goziffer": {"go": "EBM", "ziffer": "99999", "datum": "2024-01-01"}
|
|
}))
|
|
|
|
# Run with config
|
|
result = self.runner.invoke(app, [
|
|
"-i", str(input_file),
|
|
"-o", str(tmp_path),
|
|
"--placeholder-cfg", str(placeholder_cfg)
|
|
])
|
|
|
|
# Should work (may warn about empty bundle but shouldn't crash)
|
|
assert result.exit_code in [0, 1] # 0 for success, 1 for validation errors
|
|
|
|
def test_cli_shell_completion(self):
|
|
"""Test that shell completion is available."""
|
|
result = self.runner.invoke(app, ["--help"])
|
|
assert result.exit_code == 0
|
|
# Typer automatically adds these
|
|
assert "--install-completion" in result.stdout
|
|
assert "--show-completion" in result.stdout
|
|
|
|
|
|
# ============================================================================
|
|
# RUN CONFIGURATION
|
|
# ============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
# Run with: python test_fhir_to_pad_converter.py
|
|
pytest.main([__file__, "-v", "--tb=short"])
|