diff --git a/rs-e2e/README.md b/rs-e2e/README.md index 0b3f36945..77becbc2e 100644 --- a/rs-e2e/README.md +++ b/rs-e2e/README.md @@ -10,6 +10,7 @@ Information on how to set up the sample files evaluated by the tests can be foun - The output files generated by the framework are stored in an Azure blob storage container. Every time the tests are run, a cleanup task moves the files to a folder with the year/month/day format for better organization. The files are retained in the container for 90 days before being deleted - The code that organizes the files is using EST time zone. This means that if you are in PST, you may run into an issue if you submit a run before 9 PM PST and then run the tests after 9pm. You'd need to make sure to run both tasks before or after 9pm so the files are where they are expected to be +- The HL7 parser and expression evaluator returns an empty string when the value is not found. It will only throw an exception if the path is not a valid HL7 notation ## Running the tests diff --git a/rs-e2e/build.gradle b/rs-e2e/build.gradle index c0bd1166d..b283cc015 100644 --- a/rs-e2e/build.gradle +++ b/rs-e2e/build.gradle @@ -20,10 +20,6 @@ dependencies { // azure implementation 'com.azure:azure-storage-blob:12.29.0' - // hapi hl7 - implementation 'ca.uhn.hapi:hapi-base:2.5.1' - implementation 'ca.uhn.hapi:hapi-structures-v251:2.5.1' - testImplementation 'org.apache.groovy:groovy:4.0.24' testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0' } diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/FileFetcher.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/FileFetcher.java index dcd998148..0e242f6a2 100644 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/FileFetcher.java +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/FileFetcher.java @@ -1,5 +1,6 @@ package gov.hhs.cdc.trustedintermediary.rse2e; +import gov.hhs.cdc.trustedintermediary.rse2e.hl7.HL7FileStream; import java.util.List; /** diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobFileFetcher.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobFileFetcher.java similarity index 94% rename from rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobFileFetcher.java rename to rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobFileFetcher.java index d6d950916..edae1f246 100644 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobFileFetcher.java +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobFileFetcher.java @@ -1,10 +1,12 @@ -package gov.hhs.cdc.trustedintermediary.rse2e; +package gov.hhs.cdc.trustedintermediary.rse2e.external.azure; import com.azure.storage.blob.BlobClient; import com.azure.storage.blob.BlobContainerClient; import com.azure.storage.blob.BlobContainerClientBuilder; import com.azure.storage.blob.models.BlobItem; import com.azure.storage.blob.models.ListBlobsOptions; +import gov.hhs.cdc.trustedintermediary.rse2e.FileFetcher; +import gov.hhs.cdc.trustedintermediary.rse2e.hl7.HL7FileStream; import java.time.LocalDate; import java.time.ZoneId; import java.time.ZoneOffset; diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobHelper.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobHelper.java similarity index 93% rename from rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobHelper.java rename to rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobHelper.java index d6548229b..5423e1bdd 100644 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobHelper.java +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobHelper.java @@ -1,4 +1,4 @@ -package gov.hhs.cdc.trustedintermediary.rse2e; +package gov.hhs.cdc.trustedintermediary.rse2e.external.azure; import java.time.LocalDate; diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobOrganizer.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobOrganizer.java similarity index 98% rename from rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobOrganizer.java rename to rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobOrganizer.java index 094b0c739..30cf05590 100644 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobOrganizer.java +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobOrganizer.java @@ -1,4 +1,4 @@ -package gov.hhs.cdc.trustedintermediary.rse2e; +package gov.hhs.cdc.trustedintermediary.rse2e.external.azure; import com.azure.storage.blob.BlobClient; import com.azure.storage.blob.BlobContainerClient; diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7ExpressionEvaluator.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7ExpressionEvaluator.java deleted file mode 100644 index 563a76823..000000000 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7ExpressionEvaluator.java +++ /dev/null @@ -1,237 +0,0 @@ -package gov.hhs.cdc.trustedintermediary.rse2e.external.hapi; - -import ca.uhn.hl7v2.HL7Exception; -import ca.uhn.hl7v2.model.Message; -import gov.hhs.cdc.trustedintermediary.wrappers.HealthData; -import gov.hhs.cdc.trustedintermediary.wrappers.HealthDataExpressionEvaluator; -import java.util.Arrays; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -/** - * The HapiHL7ExpressionEvaluator class is responsible for evaluating expressions on HL7 messages. - * The expressions can be used to compare fields, count segments, and check for membership. The - * expressions are in the form of: `field = value`, `field != value`, `field in (value1, value2)`, - * `field.count() = value`, etc. The field can be a literal value (e.g. 'EPIC') or a field reference - * (e.g. MSH-5.1, input.MSH-5.1). - */ -public class HapiHL7ExpressionEvaluator implements HealthDataExpressionEvaluator { - - private static final HapiHL7ExpressionEvaluator INSTANCE = new HapiHL7ExpressionEvaluator(); - - private static final String NEWLINE_REGEX = "\\r?\\n|\\r"; - private static final Pattern OPERATION_PATTERN = - Pattern.compile("^(\\S+)\\s*(=|!=|in)\\s*(.+)$"); - private static final Pattern HL7_COUNT_PATTERN = Pattern.compile("(\\S+)\\.count\\(\\)"); - private static final Pattern LITERAL_VALUE_PATTERN = Pattern.compile("'(.*)'"); - private static final Pattern LITERAL_VALUE_COLLECTION_PATTERN = - Pattern.compile("\\(([^)]+)\\)"); - private static final Pattern MESSAGE_SOURCE_PATTERN = - Pattern.compile("(input|output)?\\.?(\\S+)"); - private static final Pattern HL7_FIELD_NAME_PATTERN = Pattern.compile("(\\w+)(?:-(\\S+))?"); - - private HapiHL7ExpressionEvaluator() {} - - public static HapiHL7ExpressionEvaluator getInstance() { - return INSTANCE; - } - - @Override - public final boolean evaluateExpression(String expression, HealthData... data) { - if (data.length > 2) { - throw new IllegalArgumentException( - "Expected two messages, but received: " + data.length); - } - - Matcher matcher = OPERATION_PATTERN.matcher(expression); - - if (!matcher.matches()) { - throw new IllegalArgumentException("Invalid statement format."); - } - - String leftOperand = matcher.group(1); // e.g. MSH-5.1, input.MSH-5.1, 'EPIC', OBR.count() - String operator = matcher.group(2); // `=`, `!=`, or `in` - String rightOperand = - matcher.group(3); // e.g. MSH-5.1, input.MSH-5.1, 'EPIC', ('EPIC', 'CERNER'), 2 - - Message outputMessage = (Message) data[0].getUnderlyingData(); - Message inputMessage = (data.length > 1) ? (Message) data[1].getUnderlyingData() : null; - - // matches a count operation (e.g. OBR.count()) - Matcher hl7CountMatcher = HL7_COUNT_PATTERN.matcher(leftOperand); - if (hl7CountMatcher.matches()) { - return evaluateCollectionCount( - outputMessage, hl7CountMatcher.group(1), rightOperand, operator); - } - - // matches either a literal value (e.g. 'EPIC') or a field reference (e.g. MSH-5.1, - // input.MSH-5.1) - String leftValue = getLiteralOrFieldValue(outputMessage, inputMessage, leftOperand); - - // matches membership operator (e.g. MSH-5.1 in ('EPIC', 'CERNER')) - if (operator.equals("in")) { - return evaluateMembership(leftValue, rightOperand); - } - - // matches either a literal value (e.g. 'EPIC') or a field reference (e.g. MSH-5.1, - // input.MSH-5.1) - String rightValue = getLiteralOrFieldValue(outputMessage, inputMessage, rightOperand); - - // matches equality operators (e.g. MSH-5.1 = 'EPIC', MSH-5.1 != 'EPIC') - return evaluateEquality(leftValue, rightValue, operator); - } - - protected > boolean evaluateEquality( - T leftValue, T rightValue, String operator) { - if (operator.equals("=")) { - return leftValue.equals(rightValue); - } else if (operator.equals("!=")) { - return !leftValue.equals(rightValue); - } - throw new IllegalArgumentException("Unknown operator: " + operator); - } - - protected boolean evaluateMembership(String leftValue, String rightOperand) { - Matcher literalValueCollectionMatcher = - LITERAL_VALUE_COLLECTION_PATTERN.matcher(rightOperand); - if (!literalValueCollectionMatcher.matches()) { - throw new IllegalArgumentException("Invalid collection format: " + rightOperand); - } - String arrayString = literalValueCollectionMatcher.group(1); - Set values = - Arrays.stream(arrayString.split(",")) - .map(s -> s.trim().replace("'", "")) - .collect(Collectors.toSet()); - return values.contains(leftValue); - } - - protected boolean evaluateCollectionCount( - Message message, String segmentName, String rightOperand, String operator) { - try { - int count = countSegments(message.encode(), segmentName); - int rightValue = Integer.parseInt(rightOperand); - return evaluateEquality(count, rightValue, operator); - } catch (HL7Exception | NumberFormatException e) { - throw new IllegalArgumentException( - "Error evaluating collection count. Segment: " - + segmentName - + ", count: " - + rightOperand, - e); - } - } - - protected String getLiteralOrFieldValue( - Message outputMessage, Message inputMessage, String operand) { - Matcher literalValueMatcher = LITERAL_VALUE_PATTERN.matcher(operand); - if (literalValueMatcher.matches()) { - return literalValueMatcher.group(1); - } - return getFieldValue(outputMessage, inputMessage, operand); - } - - protected String getFieldValue(Message outputMessage, Message inputMessage, String fieldName) { - Matcher messageSourceMatcher = MESSAGE_SOURCE_PATTERN.matcher(fieldName); - if (!messageSourceMatcher.matches()) { - throw new IllegalArgumentException("Invalid field name format: " + fieldName); - } - - String fileSource = messageSourceMatcher.group(1); - String fieldNameWithoutFileSource = messageSourceMatcher.group(2); - Message message = getMessageBySource(fileSource, inputMessage, outputMessage); - - try { - String messageString = message.encode(); - char fieldSeparator = message.getFieldSeparatorValue(); - String encodingCharacters = message.getEncodingCharactersValue(); - return getSegmentFieldValue( - messageString, fieldNameWithoutFileSource, fieldSeparator, encodingCharacters); - } catch (HL7Exception | NumberFormatException e) { - throw new IllegalArgumentException( - "Failed to extract field value for: " + fieldName, e); - } - } - - // We decided to implement our own simple HL7 parser as the Hapi library was not adequate for - // our needs. - protected static String getSegmentFieldValue( - String hl7Message, String fieldName, char fieldSeparator, String encodingCharacters) { - Matcher hl7FieldNameMatcher = HL7_FIELD_NAME_PATTERN.matcher(fieldName); - if (!hl7FieldNameMatcher.matches()) { - throw new IllegalArgumentException("Invalid HL7 field format: " + fieldName); - } - - String segmentName = hl7FieldNameMatcher.group(1); - String segmentFieldIndex = hl7FieldNameMatcher.group(2); - - String[] lines = hl7Message.split(NEWLINE_REGEX); - for (String line : lines) { - if (!line.startsWith(segmentName)) { - continue; - } - - if (segmentFieldIndex == null) { - return line; - } - - String[] fields = line.split(Pattern.quote(String.valueOf(fieldSeparator))); - String[] indexParts = segmentFieldIndex.split("\\."); - - try { - int fieldPos = Integer.parseInt(indexParts[0]); - - if (segmentName.equals("MSH")) { - fieldPos--; - } - - if (fieldPos < 0 || fieldPos >= fields.length) { - throw new IllegalArgumentException( - "Invalid field index (out of bounds): " + segmentFieldIndex); - } - - String field = fields[fieldPos]; - - if (indexParts.length == 1 || field.isEmpty()) { - return field; - } - - int subFieldEncodingCharactersIndex = indexParts.length - 2; - if (subFieldEncodingCharactersIndex >= encodingCharacters.length()) { - throw new IllegalArgumentException( - "Invalid subfield index (out of bounds): " + segmentFieldIndex); - } - char subfieldSeparator = encodingCharacters.charAt(subFieldEncodingCharactersIndex); - String[] subfields = field.split(Pattern.quote(String.valueOf(subfieldSeparator))); - int subFieldPos = Integer.parseInt(indexParts[1]) - 1; - return subFieldPos >= 0 && subFieldPos < subfields.length - ? subfields[subFieldPos] - : ""; - } catch (NumberFormatException e) { - throw new IllegalArgumentException( - "Invalid field index formatting: " + segmentFieldIndex, e); - } - } - - return null; - } - - protected static int countSegments(String hl7Message, String segmentName) { - return (int) - Arrays.stream(hl7Message.split(NEWLINE_REGEX)) - .filter(line -> line.startsWith(segmentName)) - .count(); - } - - protected Message getMessageBySource( - String source, Message inputMessage, Message outputMessage) { - if ("input".equals(source)) { - if (inputMessage == null) { - throw new IllegalArgumentException("Input message is null for: " + source); - } - return inputMessage; - } - return outputMessage; - } -} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcher.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcher.java deleted file mode 100644 index ca9ca419f..000000000 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcher.java +++ /dev/null @@ -1,92 +0,0 @@ -package gov.hhs.cdc.trustedintermediary.rse2e.external.hapi; - -import ca.uhn.hl7v2.DefaultHapiContext; -import ca.uhn.hl7v2.HL7Exception; -import ca.uhn.hl7v2.HapiContext; -import ca.uhn.hl7v2.model.Message; -import ca.uhn.hl7v2.parser.Parser; -import com.google.common.collect.Sets; -import gov.hhs.cdc.trustedintermediary.rse2e.HL7FileStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * The HapiHL7FileMatcher class is responsible for matching input and output HL7 files based on the - * control ID (MSH-10). - */ -public class HapiHL7FileMatcher { - - private static final HapiHL7FileMatcher INSTANCE = new HapiHL7FileMatcher(); - - private HapiHL7FileMatcher() {} - - public static HapiHL7FileMatcher getInstance() { - return INSTANCE; - } - - public Map matchFiles( - List outputFiles, List inputFiles) - throws HapiHL7FileMatcherException { - // We pair up output and input files based on the control ID, which is in MSH-10 - // Any files (either input or output) that don't have a match are logged - Map inputMap = parseAndMapMessageByControlId(inputFiles); - Map outputMap = parseAndMapMessageByControlId(outputFiles); - - Set inputKeys = inputMap.keySet(); - Set outputKeys = outputMap.keySet(); - Set unmatchedKeys = new HashSet<>(); - unmatchedKeys.addAll(Sets.difference(inputKeys, outputKeys)); - unmatchedKeys.addAll(Sets.difference(outputKeys, inputKeys)); - - if (!unmatchedKeys.isEmpty()) { - throw new HapiHL7FileMatcherException( - "Found no match for messages with the following MSH-10 values: " - + unmatchedKeys); - } - - return inputKeys.stream().collect(Collectors.toMap(inputMap::get, outputMap::get)); - } - - public Map parseAndMapMessageByControlId(List files) - throws HapiHL7FileMatcherException { - - Map messageMap = new HashMap<>(); - - try (HapiContext context = new DefaultHapiContext()) { - Parser parser = context.getPipeParser(); - - for (HL7FileStream hl7FileStream : files) { - String fileName = hl7FileStream.fileName(); - try (InputStream inputStream = hl7FileStream.inputStream()) { - String content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - Message message = parser.parse(content); - HapiHL7Message hapiHL7Message = new HapiHL7Message(message); - String msh10 = hapiHL7Message.getIdentifier(); - if (msh10 == null || msh10.isEmpty()) { - throw new HapiHL7FileMatcherException( - String.format("MSH-10 is empty for file: %s", fileName)); - } - messageMap.put(msh10, hapiHL7Message); - } catch (HL7Exception e) { - throw new HapiHL7FileMatcherException( - String.format("Failed to parse HL7 message from file: %s", fileName), - e); - } catch (IOException e) { - throw new HapiHL7FileMatcherException( - String.format("Failed to read file: %s", fileName), e); - } - } - } catch (IOException e) { - throw new HapiHL7FileMatcherException("Failed to close input stream", e); - } - - return messageMap; - } -} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherException.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherException.java deleted file mode 100644 index fc3e0956d..000000000 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherException.java +++ /dev/null @@ -1,16 +0,0 @@ -package gov.hhs.cdc.trustedintermediary.rse2e.external.hapi; - -/** - * The HapiHL7FileMatcherException class is responsible for handling exceptions that occur in the - * HapiHL7FileMatcher class. - */ -public class HapiHL7FileMatcherException extends Exception { - - public HapiHL7FileMatcherException(String message, Throwable cause) { - super(message, cause); - } - - public HapiHL7FileMatcherException(String message) { - super(message); - } -} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7Message.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7Message.java deleted file mode 100644 index 2ff732add..000000000 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7Message.java +++ /dev/null @@ -1,34 +0,0 @@ -package gov.hhs.cdc.trustedintermediary.rse2e.external.hapi; - -import ca.uhn.hl7v2.HL7Exception; -import ca.uhn.hl7v2.model.Message; -import ca.uhn.hl7v2.model.v251.segment.MSH; -import gov.hhs.cdc.trustedintermediary.wrappers.HealthData; - -/** - * Represents a HAPI HL7 message that implements the HealthData interface. This class provides a - * wrapper around the HAPI Message object. - */ -public class HapiHL7Message implements HealthData { - - protected final Message underlyingData; - - public HapiHL7Message(Message innerResource) { - this.underlyingData = innerResource; - } - - @Override - public Message getUnderlyingData() { - return underlyingData; - } - - @Override - public String getIdentifier() { - try { - MSH mshSegment = (MSH) underlyingData.get("MSH"); - return mshSegment.getMessageControlID().getValue(); - } catch (HL7Exception e) { - return null; - } - } -} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/LocalFileFetcher.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/localfile/LocalFileFetcher.java similarity index 90% rename from rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/LocalFileFetcher.java rename to rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/localfile/LocalFileFetcher.java index 27b0d3f52..97237db8d 100644 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/LocalFileFetcher.java +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/external/localfile/LocalFileFetcher.java @@ -1,12 +1,13 @@ -package gov.hhs.cdc.trustedintermediary.rse2e; +package gov.hhs.cdc.trustedintermediary.rse2e.external.localfile; +import gov.hhs.cdc.trustedintermediary.rse2e.FileFetcher; +import gov.hhs.cdc.trustedintermediary.rse2e.hl7.HL7FileStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; -import java.util.stream.Collectors; import java.util.stream.Stream; /** @@ -45,7 +46,7 @@ public List fetchFiles() { throw new RuntimeException("Error opening file: " + p, e); } }) - .collect(Collectors.toList()); + .toList(); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Encoding.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Encoding.java new file mode 100644 index 000000000..1ab35ae7a --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Encoding.java @@ -0,0 +1,78 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +import java.util.Map; + +/** The HL7Encoding class represents the encoding characters used in an HL7 message. */ +public record HL7Encoding(Map characters) { + public static final String DEFAULT_SEGMENT_DELIMITER = "\n"; + public static final char DEFAULT_FIELD_DELIMITER = '|'; + public static final char DEFAULT_COMPONENT_DELIMITER = '^'; + public static final char DEFAULT_REPETITION_DELIMITER = '~'; + public static final char DEFAULT_ESCAPE_CHARACTER = '\\'; + public static final char DEFAULT_SUBCOMPONENT_DELIMITER = '&'; + + public static final String FIELD_DELIMITER_NAME = "field"; + public static final String COMPONENT_DELIMITER_NAME = "component"; + public static final String REPETITION_DELIMITER_NAME = "repetition"; + public static final String ESCAPE_CHARACTER_NAME = "escape"; + public static final String SUBCOMPONENT_DELIMITER_NAME = "subcomponent"; + + private static final Map DEFAULT_CHARACTERS = + Map.of( + FIELD_DELIMITER_NAME, DEFAULT_FIELD_DELIMITER, + COMPONENT_DELIMITER_NAME, DEFAULT_COMPONENT_DELIMITER, + REPETITION_DELIMITER_NAME, DEFAULT_REPETITION_DELIMITER, + ESCAPE_CHARACTER_NAME, DEFAULT_ESCAPE_CHARACTER, + SUBCOMPONENT_DELIMITER_NAME, DEFAULT_SUBCOMPONENT_DELIMITER); + + public static HL7Encoding defaultEncoding() { + return new HL7Encoding(DEFAULT_CHARACTERS); + } + + public static HL7Encoding fromEncodingField(String encodingField) { + if (encodingField == null || encodingField.isEmpty()) { + return defaultEncoding(); + } + + return new HL7Encoding( + Map.of( + FIELD_DELIMITER_NAME, DEFAULT_FIELD_DELIMITER, + COMPONENT_DELIMITER_NAME, encodingField.charAt(0), + REPETITION_DELIMITER_NAME, encodingField.charAt(1), + ESCAPE_CHARACTER_NAME, encodingField.charAt(2), + SUBCOMPONENT_DELIMITER_NAME, encodingField.charAt(3))); + } + + public char getCharacter(String type) { + return characters.get(type); + } + + public char getEscapeCharacter() { + return characters.get(ESCAPE_CHARACTER_NAME); + } + + public char getFieldDelimiter() { + return characters.get(FIELD_DELIMITER_NAME); + } + + public char getComponentDelimiter() { + return characters.get(COMPONENT_DELIMITER_NAME); + } + + public char getRepetitionDelimiter() { + return characters.get(REPETITION_DELIMITER_NAME); + } + + public char getSubcomponentDelimiter() { + return characters.get(SUBCOMPONENT_DELIMITER_NAME); + } + + public char[] getOrderedDelimiters() { + return new char[] { + getCharacter(FIELD_DELIMITER_NAME), + getCharacter(COMPONENT_DELIMITER_NAME), + getCharacter(REPETITION_DELIMITER_NAME), + getCharacter(SUBCOMPONENT_DELIMITER_NAME) + }; + } +} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ExpressionEvaluator.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ExpressionEvaluator.java new file mode 100644 index 000000000..77e89a304 --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ExpressionEvaluator.java @@ -0,0 +1,158 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +import gov.hhs.cdc.trustedintermediary.wrappers.HealthData; +import gov.hhs.cdc.trustedintermediary.wrappers.HealthDataExpressionEvaluator; +import java.util.Arrays; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * The HL7ExpressionEvaluator class is responsible for evaluating expressions on HL7 messages. The + * expressions can be used to compare fields, count segments, and check for membership. The + * expressions are in the form of: `field = value`, `field != value`, `field in (value1, value2)`, + * `field.count() = value`, etc. The field can be a literal value (e.g. 'EPIC') or a field reference + * (e.g. MSH-5.1, input.MSH-5.1). + */ +public class HL7ExpressionEvaluator implements HealthDataExpressionEvaluator { + + private static final HL7ExpressionEvaluator INSTANCE = new HL7ExpressionEvaluator(); + + private static final Pattern OPERATION_PATTERN = + Pattern.compile("^(\\S+)\\s*(=|!=|in)\\s*(.+)$"); + private static final Pattern HL7_COUNT_PATTERN = Pattern.compile("(\\S+)\\.count\\(\\)"); + private static final Pattern LITERAL_VALUE_PATTERN = Pattern.compile("'(.*)'"); + private static final Pattern LITERAL_VALUE_COLLECTION_PATTERN = + Pattern.compile("\\(([^)]+)\\)"); + private static final Pattern MESSAGE_SOURCE_PATTERN = + Pattern.compile("(input|output)?\\.?(\\S+)"); + + private HL7ExpressionEvaluator() {} + + public static HL7ExpressionEvaluator getInstance() { + return INSTANCE; + } + + @Override + public final boolean evaluateExpression(String expression, HealthData... data) { + if (data.length > 2) { + throw new HL7ParserException("Expected two messages, but received: " + data.length); + } + + Matcher matcher = OPERATION_PATTERN.matcher(expression); + + if (!matcher.matches()) { + throw new HL7ParserException("Invalid statement format."); + } + + String leftOperand = matcher.group(1); // e.g. MSH-5.1, input.MSH-5.1, 'EPIC', OBR.count() + String operator = matcher.group(2); // `=`, `!=`, or `in` + String rightOperand = + matcher.group(3); // e.g. MSH-5.1, input.MSH-5.1, 'EPIC', ('EPIC', 'CERNER'), 2 + + HL7Message outputMessage = (HL7Message) data[0]; + HL7Message inputMessage = (data.length > 1) ? (HL7Message) data[1] : null; + + // matches a count operation (e.g. OBR.count()) + Matcher hl7CountMatcher = HL7_COUNT_PATTERN.matcher(leftOperand); + if (hl7CountMatcher.matches()) { + return evaluateCollectionCount( + outputMessage, hl7CountMatcher.group(1), rightOperand, operator); + } + + // matches either a literal value (e.g. 'EPIC') or a field reference (e.g. MSH-5.1, + // input.MSH-5.1) + String leftValue = getLiteralOrFieldValue(outputMessage, inputMessage, leftOperand); + + // matches membership operator (e.g. MSH-5.1 in ('EPIC', 'CERNER')) + if (operator.equals("in")) { + return evaluateMembership(leftValue, rightOperand); + } + + // matches either a literal value (e.g. 'EPIC') or a field reference (e.g. MSH-5.1, + // input.MSH-5.1) + String rightValue = getLiteralOrFieldValue(outputMessage, inputMessage, rightOperand); + + // matches equality operators (e.g. MSH-5.1 = 'EPIC', MSH-5.1 != 'EPIC') + return evaluateEquality(leftValue, rightValue, operator); + } + + protected > boolean evaluateEquality( + T leftValue, T rightValue, String operator) { + if (operator.equals("=")) { + return leftValue.equals(rightValue); + } else if (operator.equals("!=")) { + return !leftValue.equals(rightValue); + } + throw new HL7ParserException("Unknown operator: " + operator); + } + + protected boolean evaluateMembership(String leftValue, String rightOperand) { + Matcher literalValueCollectionMatcher = + LITERAL_VALUE_COLLECTION_PATTERN.matcher(rightOperand); + if (!literalValueCollectionMatcher.matches()) { + throw new HL7ParserException("Invalid collection format: " + rightOperand); + } + String arrayString = literalValueCollectionMatcher.group(1); + Set values = + Arrays.stream(arrayString.split(",")) + .map(s -> s.trim().replace("'", "")) + .collect(Collectors.toSet()); + return values.contains(leftValue); + } + + protected boolean evaluateCollectionCount( + HL7Message message, String segmentName, String rightOperand, String operator) { + try { + int count = message.getSegmentCount(segmentName); + int rightValue = Integer.parseInt(rightOperand); + return evaluateEquality(count, rightValue, operator); + } catch (NumberFormatException e) { + throw new HL7ParserException( + "Error evaluating collection count. Segment: " + + segmentName + + ", count: " + + rightOperand, + e); + } + } + + protected String getLiteralOrFieldValue( + HL7Message outputMessage, HL7Message inputMessage, String operand) { + Matcher literalValueMatcher = LITERAL_VALUE_PATTERN.matcher(operand); + if (literalValueMatcher.matches()) { + return literalValueMatcher.group(1); + } + return getFieldValue(outputMessage, inputMessage, operand); + } + + protected String getFieldValue( + HL7Message outputMessage, HL7Message inputMessage, String fieldName) { + Matcher messageSourceMatcher = MESSAGE_SOURCE_PATTERN.matcher(fieldName); + if (!messageSourceMatcher.matches()) { + throw new HL7ParserException("Invalid field name format: " + fieldName); + } + + String fileSource = messageSourceMatcher.group(1); + String hl7Path = messageSourceMatcher.group(2); + HL7Message message = getMessageBySource(fileSource, inputMessage, outputMessage); + + try { + return message.getValue(hl7Path); + } catch (HL7MessageException e) { + return null; + } + } + + protected HL7Message getMessageBySource( + String source, HL7Message inputMessage, HL7Message outputMessage) { + if ("input".equals(source)) { + if (inputMessage == null) { + throw new HL7ParserException("Input message is null for: " + source); + } + return inputMessage; + } + return outputMessage; + } +} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcher.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcher.java new file mode 100644 index 000000000..b640ae415 --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcher.java @@ -0,0 +1,75 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +import com.google.common.collect.Sets; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * The HL7FileMatcher class is responsible for matching input and output HL7 files based on the + * control ID (MSH-10). + */ +public class HL7FileMatcher { + + private static final HL7FileMatcher INSTANCE = new HL7FileMatcher(); + + private HL7FileMatcher() {} + + public static HL7FileMatcher getInstance() { + return INSTANCE; + } + + public Map matchFiles( + List outputFiles, List inputFiles) + throws HL7FileMatcherException { + // We pair up output and input files based on the control ID, which is in MSH-10 + // Any files (either input or output) that don't have a match are logged + Map inputMap = parseAndMapMessageByControlId(inputFiles); + Map outputMap = parseAndMapMessageByControlId(outputFiles); + + Set inputKeys = inputMap.keySet(); + Set outputKeys = outputMap.keySet(); + Set unmatchedKeys = new HashSet<>(); + unmatchedKeys.addAll(Sets.difference(inputKeys, outputKeys)); + unmatchedKeys.addAll(Sets.difference(outputKeys, inputKeys)); + + if (!unmatchedKeys.isEmpty()) { + throw new HL7FileMatcherException( + "Found no match for messages with the following MSH-10 values: " + + unmatchedKeys); + } + + return inputKeys.stream().collect(Collectors.toMap(inputMap::get, outputMap::get)); + } + + public Map parseAndMapMessageByControlId(List files) + throws HL7FileMatcherException { + + Map messageMap = new HashMap<>(); + + for (HL7FileStream hl7FileStream : files) { + String fileName = hl7FileStream.fileName(); + try (InputStream inputStream = hl7FileStream.inputStream()) { + String content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + HL7Message message = HL7Message.parse(content); + String msh10 = message.getIdentifier(); + if (msh10 == null || msh10.isEmpty()) { + throw new HL7FileMatcherException( + String.format("MSH-10 is empty for file: %s", fileName)); + } + messageMap.put(msh10, message); + } catch (IOException e) { + throw new HL7FileMatcherException( + String.format("Failed to read file: %s", fileName), e); + } + } + + return messageMap; + } +} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherException.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherException.java new file mode 100644 index 000000000..99e5de7f7 --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherException.java @@ -0,0 +1,16 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +/** + * The HL7FileMatcherException class is responsible for handling exceptions that occur in the + * HL7FileMatcher class. + */ +public class HL7FileMatcherException extends Exception { + + public HL7FileMatcherException(String message, Throwable cause) { + super(message, cause); + } + + public HL7FileMatcherException(String message) { + super(message); + } +} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/HL7FileStream.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileStream.java similarity index 81% rename from rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/HL7FileStream.java rename to rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileStream.java index 6064c9d13..1ac74e436 100644 --- a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/HL7FileStream.java +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileStream.java @@ -1,4 +1,4 @@ -package gov.hhs.cdc.trustedintermediary.rse2e; +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; import java.io.InputStream; diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Message.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Message.java new file mode 100644 index 000000000..f89cb0f78 --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Message.java @@ -0,0 +1,141 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +import gov.hhs.cdc.trustedintermediary.wrappers.HealthData; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Represents a HL7 message that implements the HealthData interface and adds methods to access the + * HL7 data + */ +public class HL7Message implements HealthData { + + static final String MSH_SEGMENT_NAME = "MSH"; + static final String NEWLINE_REGEX = "\\r?\\n|\\r"; + + private final List segments; + private final HL7Encoding encoding; + + HL7Message(List segments, HL7Encoding encoding) { + this.segments = segments; + this.encoding = encoding; + } + + @Override + public HL7Message getUnderlyingData() { + return this; + } + + @Override + public String getIdentifier() { + try { + return getValue("MSH-10"); + } catch (HL7MessageException e) { + return null; + } + } + + @Override + public String toString() { + return String.join( + HL7Encoding.DEFAULT_SEGMENT_DELIMITER, + getSegments().stream() + .map(segment -> segmentToString(segment, this.encoding)) + .toList()); + } + + public static HL7Message parse(String content) { + List segments = new ArrayList<>(); + String encodingCharactersField = null; + String[] lines = content.split(NEWLINE_REGEX); + for (String line : lines) { + if (line.trim().isEmpty()) continue; + String[] fields = + line.split( + Pattern.quote(String.valueOf(HL7Encoding.DEFAULT_FIELD_DELIMITER)), -1); + String segmentName = fields[0]; + List segmentFields = + new ArrayList<>(Arrays.asList(fields).subList(1, fields.length)); + if (Objects.equals(segmentName, MSH_SEGMENT_NAME)) { + encodingCharactersField = fields[1]; + segmentFields.add(0, String.valueOf(HL7Encoding.DEFAULT_FIELD_DELIMITER)); + } + segments.add(new HL7Segment(segmentName, segmentFields)); + } + + return new HL7Message(segments, HL7Encoding.fromEncodingField(encodingCharactersField)); + } + + public List getSegments() { + return this.segments; + } + + public HL7Encoding getEncoding() { + return this.encoding; + } + + public List getSegments(String name) { + return this.segments.stream().filter(segment -> segment.name().equals(name)).toList(); + } + + public int getSegmentCount(String name) { + return getSegments(name).size(); + } + + public boolean hasSegment(String name, int index) { + return getSegmentCount(name) > index; + } + + public HL7Segment getSegment(String name, int index) throws HL7MessageException { + if (!hasSegment(name, index)) { + throw new HL7MessageException( + String.format("Segment %s at index %d not found", name, index)); + } + return getSegments(name).get(index); + } + + public HL7Segment getSegment(String name) throws HL7MessageException { + return getSegment(name, 0); + } + + public String getValue(String path) throws HL7MessageException { + HL7Path hl7Path = HL7Path.parse(path); + int[] indices = hl7Path.indices(); + List fields = this.getSegment(hl7Path.segmentName()).fields(); + char[] delimiters = this.getEncoding().getOrderedDelimiters(); + + if (indices[0] > fields.size()) { + return ""; + } + + String value = fields.get(indices[0] - 1); + for (int i = 1; i < indices.length; i++) { + if (i >= delimiters.length) { + throw new HL7ParserException("Invalid HL7 path: too many sub-levels provided"); + } + char segmentDelimiter = delimiters[i]; + int index = indices[i] - 1; + String[] parts = value.split(Pattern.quote(String.valueOf(segmentDelimiter))); + if (index < 0 || index >= parts.length) { + return ""; + } + value = parts[index]; + } + return value; + } + + protected static String segmentToString(HL7Segment segment, HL7Encoding encoding) { + String fieldSeparator = String.valueOf(encoding.getFieldDelimiter()); + + if (segment.name().equals(MSH_SEGMENT_NAME)) { + return segment.name() + + segment.fields().get(0) + + String.join( + fieldSeparator, segment.fields().subList(1, segment.fields().size())); + } + return segment.name() + fieldSeparator + String.join(fieldSeparator, segment.fields()); + } +} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7MessageException.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7MessageException.java new file mode 100644 index 000000000..773888711 --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7MessageException.java @@ -0,0 +1,12 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +/** + * The HL7MessageException class is responsible for handling exceptions that occur in the HL7Message + * class. + */ +public class HL7MessageException extends Exception { + + public HL7MessageException(String message) { + super(message); + } +} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ParserException.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ParserException.java new file mode 100644 index 000000000..e5c549b53 --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ParserException.java @@ -0,0 +1,16 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +/** + * The HL7ParserException class is responsible for handling exceptions that occur in the HL7Parser + * and HL7ExpressionEvaluator class. + */ +public class HL7ParserException extends RuntimeException { + + public HL7ParserException(String message, Throwable cause) { + super(message, cause); + } + + public HL7ParserException(String message) { + super(message); + } +} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Path.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Path.java new file mode 100644 index 000000000..b8eda42e7 --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Path.java @@ -0,0 +1,43 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +import java.util.Arrays; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** The HL7Path class represents a path to a specific field in an HL7 message. */ +public record HL7Path(String segmentName, int[] indices) { + static final Pattern HL7_FIELD_NAME_PATTERN = Pattern.compile("(\\w+)-(\\d+(?:\\.\\d+)*)"); + + public static HL7Path parse(String path) { + Matcher matcher = HL7_FIELD_NAME_PATTERN.matcher(path); + if (!matcher.matches()) { + throw new HL7ParserException("Invalid HL7 path format: " + path); + } + + String segmentName = matcher.group(1); + int[] indices = + Arrays.stream(matcher.group(2).split("\\.")).mapToInt(Integer::parseInt).toArray(); + + return new HL7Path(segmentName, indices); + } + + // Need to override equals, hashCode, and toString to handle array comparison + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof HL7Path hl7Path)) return false; + return (Objects.equals(segmentName, hl7Path.segmentName)) + && Arrays.equals(indices, hl7Path.indices); + } + + @Override + public int hashCode() { + return Objects.hash(segmentName, Arrays.hashCode(indices)); + } + + @Override + public String toString() { + return "HL7Path[segmentName=" + segmentName + ", indices=" + Arrays.toString(indices) + "]"; + } +} diff --git a/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Segment.java b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Segment.java new file mode 100644 index 000000000..ec40cdc91 --- /dev/null +++ b/rs-e2e/src/main/java/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7Segment.java @@ -0,0 +1,6 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7; + +import java.util.List; + +/** The HL7Segment class represents an HL7 segment and its fields. */ +public record HL7Segment(String name, List fields) {} diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/AutomatedTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/AutomatedTest.groovy index c1a4d45ab..125d6c2ed 100644 --- a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/AutomatedTest.groovy +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/AutomatedTest.groovy @@ -1,9 +1,12 @@ package gov.hhs.cdc.trustedintermediary.rse2e import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext -import gov.hhs.cdc.trustedintermediary.rse2e.external.hapi.HapiHL7FileMatcher -import gov.hhs.cdc.trustedintermediary.rse2e.external.hapi.HapiHL7ExpressionEvaluator +import gov.hhs.cdc.trustedintermediary.rse2e.external.azure.AzureBlobFileFetcher +import gov.hhs.cdc.trustedintermediary.rse2e.hl7.HL7FileStream +import gov.hhs.cdc.trustedintermediary.rse2e.hl7.HL7FileMatcher +import gov.hhs.cdc.trustedintermediary.rse2e.hl7.HL7ExpressionEvaluator import gov.hhs.cdc.trustedintermediary.external.jackson.Jackson +import gov.hhs.cdc.trustedintermediary.rse2e.external.localfile.LocalFileFetcher import gov.hhs.cdc.trustedintermediary.wrappers.HealthDataExpressionEvaluator import gov.hhs.cdc.trustedintermediary.wrappers.Logger import gov.hhs.cdc.trustedintermediary.wrappers.formatter.Formatter @@ -16,13 +19,13 @@ class AutomatedTest extends Specification { List azureFiles List localFiles AssertionRuleEngine engine - HapiHL7FileMatcher fileMatcher + HL7FileMatcher fileMatcher Logger mockLogger = Mock(Logger) List loggedErrorsAndWarnings = [] def setup() { engine = AssertionRuleEngine.getInstance() - fileMatcher = HapiHL7FileMatcher.getInstance() + fileMatcher = HL7FileMatcher.getInstance() TestApplicationContext.reset() TestApplicationContext.init() @@ -30,8 +33,8 @@ class AutomatedTest extends Specification { TestApplicationContext.register(RuleLoader, RuleLoader.getInstance()) TestApplicationContext.register(Logger, mockLogger) TestApplicationContext.register(Formatter, Jackson.getInstance()) - TestApplicationContext.register(HapiHL7FileMatcher, fileMatcher) - TestApplicationContext.register(HealthDataExpressionEvaluator, HapiHL7ExpressionEvaluator.getInstance()) + TestApplicationContext.register(HL7FileMatcher, fileMatcher) + TestApplicationContext.register(HealthDataExpressionEvaluator, HL7ExpressionEvaluator.getInstance()) TestApplicationContext.register(LocalFileFetcher, LocalFileFetcher.getInstance()) TestApplicationContext.injectRegisteredImplementations() diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobHelperTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobHelperTest.groovy similarity index 93% rename from rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobHelperTest.groovy rename to rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobHelperTest.groovy index 29b39b674..42ec02e2e 100644 --- a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/AzureBlobHelperTest.groovy +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/azure/AzureBlobHelperTest.groovy @@ -1,4 +1,5 @@ -package gov.hhs.cdc.trustedintermediary.rse2e +package gov.hhs.cdc.trustedintermediary.rse2e.external.azure + import spock.lang.Specification diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7MessageTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7MessageTest.groovy deleted file mode 100644 index c7df2dd72..000000000 --- a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7MessageTest.groovy +++ /dev/null @@ -1,35 +0,0 @@ -package gov.hhs.cdc.trustedintermediary.rse2e.external.hapi - -import ca.uhn.hl7v2.model.Message -import ca.uhn.hl7v2.model.v251.message.ORU_R01 -import ca.uhn.hl7v2.model.v251.segment.MSH -import spock.lang.Specification - -class HapiHL7MessageTest extends Specification { - - def "getUnderlyingData should correctly initialize and return underlying data"() { - given: - def mockMessage = Mock(Message) - def hl7Message = new HapiHL7Message(mockMessage) - - expect: - hl7Message.getUnderlyingData() == mockMessage - } - - def "getIdentifier should return the MSH-10 identifier of the underlying message"() { - given: - def expectedIdentifier = "0001" - def oruMessage = new ORU_R01() - MSH mshSegment = oruMessage.getMSH() - mshSegment.getMessageControlID().setValue(expectedIdentifier) - - and: - def hl7Message = new HapiHL7Message(oruMessage) - - when: - def name = hl7Message.getIdentifier() - - then: - name == expectedIdentifier - } -} diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7EncodingTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7EncodingTest.groovy new file mode 100644 index 000000000..17ef6ef81 --- /dev/null +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7EncodingTest.groovy @@ -0,0 +1,55 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7 + +import spock.lang.Specification + +class HL7EncodingTest extends Specification { + + def "defaultEncoding should use expected default characters"() { + when: + def encodingChars = HL7Encoding.defaultEncoding() + + then: + encodingChars.getFieldDelimiter() == '|' as char + encodingChars.getComponentDelimiter() == '^' as char + encodingChars.getRepetitionDelimiter() == '~' as char + encodingChars.getEscapeCharacter() == '\\' as char + encodingChars.getSubcomponentDelimiter() == '&' as char + } + + def "fromEncodingField should use custom encoding characters when provided"() { + given: + def customEncodingChars = "@#+_" + + when: + def encodingChars = HL7Encoding.fromEncodingField(customEncodingChars) + + then: + encodingChars.getFieldDelimiter() == '|' as char + encodingChars.getComponentDelimiter() == '@' as char + encodingChars.getRepetitionDelimiter() == '#' as char + encodingChars.getEscapeCharacter() == '+' as char + encodingChars.getSubcomponentDelimiter() == '_' as char + } + + def "fromEncodingField should use default characters when encoding characters passed is blank or null"() { + when: + def blankEncodingChars = HL7Encoding.fromEncodingField("") + + then: + blankEncodingChars.getFieldDelimiter() == '|' as char + blankEncodingChars.getComponentDelimiter() == '^' as char + blankEncodingChars.getRepetitionDelimiter() == '~' as char + blankEncodingChars.getEscapeCharacter() == '\\' as char + blankEncodingChars.getSubcomponentDelimiter() == '&' as char + + when: + def nullEncodingChars = HL7Encoding.fromEncodingField(null) + + then: + nullEncodingChars.getFieldDelimiter() == '|' as char + nullEncodingChars.getComponentDelimiter() == '^' as char + nullEncodingChars.getRepetitionDelimiter() == '~' as char + nullEncodingChars.getEscapeCharacter() == '\\' as char + nullEncodingChars.getSubcomponentDelimiter() == '&' as char + } +} diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7ExpressionEvaluatorTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ExpressionEvaluatorTest.groovy similarity index 55% rename from rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7ExpressionEvaluatorTest.groovy rename to rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ExpressionEvaluatorTest.groovy index aaba6b9d7..b87c17c94 100644 --- a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7ExpressionEvaluatorTest.groovy +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7ExpressionEvaluatorTest.groovy @@ -1,46 +1,36 @@ -package gov.hhs.cdc.trustedintermediary.rse2e.external.hapi +package gov.hhs.cdc.trustedintermediary.rse2e.hl7 -import ca.uhn.hl7v2.model.Message -import ca.uhn.hl7v2.model.Segment -import ca.uhn.hl7v2.parser.PipeParser import gov.hhs.cdc.trustedintermediary.wrappers.HealthData import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext import spock.lang.Specification -class HapiHL7ExpressionEvaluatorTest extends Specification { +class HL7ExpressionEvaluatorTest extends Specification { - def evaluator = HapiHL7ExpressionEvaluator.getInstance() + def evaluator = HL7ExpressionEvaluator.getInstance() - char hl7FieldSeparator = '|' - String hl7FieldEncodingCharacters = "^~\\&" - Message mshMessage - Segment mshSegment - String mshSegmentText + HL7Message hl7Message + String messageContent def setup() { TestApplicationContext.reset() TestApplicationContext.init() - TestApplicationContext.register(HapiHL7ExpressionEvaluator, evaluator) + TestApplicationContext.register(HL7ExpressionEvaluator, evaluator) - mshSegmentText = "MSH|^~\\&|Sender Application^sender.test.com^DNS|Sender Facility^0.0.0.0.0.0.0.0^ISO|Receiver Application^0.0.0.0.0.0.0.0^ISO|Receiver Facility^simulated-lab-id^DNS|20230101010000-0000||ORM^O01^ORM_O01|111111|T|2.5.1" - def pipeParser = new PipeParser() - mshMessage = pipeParser.parse(mshSegmentText) - mshSegment = (Segment) mshMessage.get("MSH") + messageContent = """MSH|^~\\&|Sender Application^sender.test.com^DNS|Sender Facility^0.0.0.0.0.0.0.0^ISO|Receiver Application^0.0.0.0.0.0.0.0^ISO|Receiver Facility^simulated-lab-id^DNS|20230101010000-0000||ORM^O01^ORM_O01|111111|T|2.5.1 +PID|1||11102779^^^CR^MR||SMITH^BB SARAH^^^^^L""" + hl7Message = HL7Message.parse(messageContent) TestApplicationContext.injectRegisteredImplementations() } def "evaluateExpression returns boolean when evaluating valid assertions"() { given: - def spyEvaluator = Spy(HapiHL7ExpressionEvaluator.getInstance()) - spyEvaluator.getLiteralOrFieldValue(_ as Message, _ as Message, _ as String) >> "mockedValue" + def spyEvaluator = Spy(HL7ExpressionEvaluator.getInstance()) + spyEvaluator.getLiteralOrFieldValue(_ as HL7Message, _ as HL7Message, _ as String) >> "mockedValue" spyEvaluator.evaluateEquality(_ as String, _ as String, _ as String) >> true spyEvaluator.evaluateMembership(_ as String, _ as String) >> true - spyEvaluator.evaluateCollectionCount(_ as Message, _ as String, _ as String, _ as String) >> true - - def healthData = Mock(HealthData) { - getUnderlyingData() >> Mock(Message) - } + spyEvaluator.evaluateCollectionCount(_ as HL7Message, _ as String, _ as String, _ as String) >> true + def healthData = Mock(HL7Message) expect: spyEvaluator.evaluateExpression(assertion, healthData, healthData) @@ -63,15 +53,12 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def "evaluateExpression allows null input message when no assertions use input"() { given: - def spyEvaluator = Spy(HapiHL7ExpressionEvaluator.getInstance()) - spyEvaluator.getLiteralOrFieldValue(_ as Message, null, _ as String) >> "mockedValue" + def spyEvaluator = Spy(HL7ExpressionEvaluator.getInstance()) + spyEvaluator.getLiteralOrFieldValue(_ as HL7Message, null, _ as String) >> "mockedValue" spyEvaluator.evaluateEquality(_ as String, _ as String, _ as String) >> true spyEvaluator.evaluateMembership(_ as String, _ as String) >> true - spyEvaluator.evaluateCollectionCount(_ as Message, _ as String, _ as String, _ as String) >> true - - def healthData = Mock(HealthData) { - getUnderlyingData() >> Mock(Message) - } + spyEvaluator.evaluateCollectionCount(_ as HL7Message, _ as String, _ as String, _ as String) >> true + def healthData = Mock(HL7Message) expect: spyEvaluator.evaluateExpression(assertion, healthData) @@ -90,7 +77,7 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { evaluator.evaluateExpression("invalid format", Mock(HealthData)) then: - def e = thrown(IllegalArgumentException) + def e = thrown(HL7ParserException) e.getMessage().contains("Invalid statement format") } @@ -99,7 +86,7 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { evaluator.evaluateExpression("'EPIC' = 'EPIC'", Mock(HealthData), Mock(HealthData), Mock(HealthData)) then: - def e = thrown(IllegalArgumentException) + def e = thrown(HL7ParserException) e.getMessage().contains("Expected two messages") } @@ -111,7 +98,7 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { evaluator.evaluateExpression(condition, Mock(HealthData)) then: - def e = thrown(IllegalArgumentException) + def e = thrown(HL7ParserException) e.getMessage().contains("Invalid statement format") } @@ -152,7 +139,7 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { evaluator.evaluateEquality("left", "right", unknownOperator) then: - def e = thrown(IllegalArgumentException) + def e = thrown(HL7ParserException) e.getMessage().contains("Unknown operator") } @@ -183,7 +170,7 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { evaluator.evaluateMembership("value", invalidSet) then: - def e = thrown(IllegalArgumentException) + def e = thrown(HL7ParserException) e.getMessage().contains("Invalid collection format") } @@ -194,7 +181,7 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def operator = "=" when: - def result = evaluator.evaluateCollectionCount(mshMessage, segmentName, rightOperand, operator) + def result = evaluator.evaluateCollectionCount(hl7Message, segmentName, rightOperand, operator) then: result @@ -207,7 +194,7 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def operator = "=" when: - def result = evaluator.evaluateCollectionCount(mshMessage, segmentName, rightOperand, operator) + def result = evaluator.evaluateCollectionCount(hl7Message, segmentName, rightOperand, operator) then: !result @@ -220,10 +207,10 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def operator = "=" when: - evaluator.evaluateCollectionCount(mshMessage, segmentName, rightOperand, operator) + evaluator.evaluateCollectionCount(hl7Message, segmentName, rightOperand, operator) then: - def e = thrown(IllegalArgumentException) + def e = thrown(HL7ParserException) e.getCause().getClass() == NumberFormatException } @@ -234,7 +221,7 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def operator = "=" when: - def result = evaluator.evaluateCollectionCount(mshMessage, segmentName, rightOperand, operator) + def result = evaluator.evaluateCollectionCount(hl7Message, segmentName, rightOperand, operator) then: !result @@ -243,8 +230,8 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def "getLiteralOrFieldValue returns literal value when literal is specified"() { given: def operand = "'Epic'" - def inputMessage = Mock(Message) - def outputMessage = Mock(Message) + def inputMessage = Mock(HL7Message) + def outputMessage = Mock(HL7Message) when: def result = evaluator.getLiteralOrFieldValue(outputMessage, inputMessage, operand) @@ -256,164 +243,82 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def "getLiteralOrFieldValue returns field value when field is specified"() { given: def operand = "MSH-3" - def inputMessage = Mock(Message) + def inputMessage = Mock(HL7Message) def msh3 = "Sender Application^sender.test.com^DNS" when: - def result = evaluator.getLiteralOrFieldValue(mshMessage, inputMessage, operand) + def result = evaluator.getLiteralOrFieldValue(hl7Message, inputMessage, operand) then: result == msh3 } - def "getFieldValue returns specified field value"() { + def "getLiteralOrFieldValue throws exception when using invalid operand"() { given: - def fieldName = "MSH-3" - def msh3 = "Sender Application^sender.test.com^DNS" - def inputMessage = Mock(Message) + def operand = "invalidField" + def inputMessage = Mock(HL7Message) when: - def result = evaluator.getFieldValue(mshMessage, inputMessage, fieldName) + evaluator.getLiteralOrFieldValue(hl7Message, inputMessage, operand) then: - result == msh3 + thrown(HL7ParserException) } - def "getFieldValue throws exception for non numeric field index"() { - given: - def fieldName = "MSH-three" - def inputMessage = Mock(Message) - - when: - evaluator.getFieldValue(mshMessage, inputMessage, fieldName) - - then: - def e = thrown(IllegalArgumentException) - e.getCause().getClass() == NumberFormatException - } - - def "getFieldValue throws exception for empty field name"() { - given: - def fieldName = "" - def inputMessage = Mock(Message) - - when: - evaluator.getFieldValue(mshMessage, inputMessage, fieldName) - - then: - def e = thrown(IllegalArgumentException) - e.getMessage().contains("Invalid field name format") - } - - def "getSegmentFieldValue should return segment when field components indicate segment"() { - given: - def fieldName = "MSH" - - when: - def result = evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) - - then: - result == mshSegmentText - } - - def "getSegmentFieldValue should return field when field components indicate field"() { + def "getFieldValue returns specified field value"() { given: def fieldName = "MSH-3" def msh3 = "Sender Application^sender.test.com^DNS" + def inputMessage = Mock(HL7Message) when: - def result = evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) + def result = evaluator.getFieldValue(hl7Message, inputMessage, fieldName) then: result == msh3 } - def "getSegmentFieldValue should return subfield when field components indicate subfield"() { - given: - def fieldName = "MSH-3.2" - def msh32 = "sender.test.com" - - when: - def result = evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) - - then: - result == msh32 - } - - def "getSegmentFieldValue should return empty string when field components indicate subfield but subfield not present"() { + def "getFieldValue throws exception for non numeric field index"() { given: - def fieldName = "MSH-3.4" + def fieldName = "MSH-three" + def inputMessage = Mock(HL7Message) when: - def result = evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) + evaluator.getFieldValue(hl7Message, inputMessage, fieldName) then: - result == "" + thrown(HL7ParserException) } - def "getSegmentFieldValue returns null when looking for segment that isn't in message"() { + def "getFieldValue returns null for fields that don't exist"() { given: - def fieldName = "OBX" + def fieldName = "ZZZ-1" + def inputMessage = Mock(HL7Message) when: - def result = evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) + def result = evaluator.getFieldValue(hl7Message, inputMessage, fieldName) then: result == null } - def "getSegmentFieldValue throws exception when field name is invalid"() { - given: - def fieldName = "MSH-" - - when: - evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) - - then: - def e = thrown(IllegalArgumentException) - e.getMessage().contains("Invalid HL7 field format: ") - } - - def "getSegmentFieldValue throws exception when field index is out of bounds"() { - given: - def fieldName = "MSH-99" - - when: - evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) - - then: - def e = thrown(IllegalArgumentException) - e.getMessage().contains("Invalid field index (out of bounds)") - } - - def "getSegmentFieldValue throws exception when there are too many subfield levels"() { - given: - def fieldName = "MSH-3.3.3.3.3.3" - - when: - evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) - - then: - def e = thrown(IllegalArgumentException) - e.getMessage().contains("Invalid subfield index (out of bounds)") - } - - def "getSegmentFieldValue returns empty string when sub-field index is out of bounds"() { + def "getFieldValue throws exception for empty field name"() { given: - def fieldName = "MSH-3.99" + def fieldName = "" + def inputMessage = Mock(HL7Message) when: - def result = evaluator.getSegmentFieldValue(mshSegmentText, fieldName, hl7FieldSeparator, hl7FieldEncodingCharacters) + evaluator.getFieldValue(hl7Message, inputMessage, fieldName) then: - result == "" + thrown(HL7ParserException) } def "getMessageBySource should return input message when source is input"() { given: def source = "input" - def inputMessage = Mock(Message) - def outputMessage = Mock(Message) + def inputMessage = Mock(HL7Message) + def outputMessage = Mock(HL7Message) when: def result = evaluator.getMessageBySource(source, inputMessage, outputMessage) @@ -425,8 +330,8 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def "getMessageBySource should return output message when source is not input"() { given: def source = "output" - def inputMessage = Mock(Message) - def outputMessage = Mock(Message) + def inputMessage = Mock(HL7Message) + def outputMessage = Mock(HL7Message) when: def result = evaluator.getMessageBySource(source, inputMessage, outputMessage) @@ -438,8 +343,8 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { def "getMessageBySource should return output message when source is empty"() { given: def source = "" - def inputMessage = Mock(Message) - def outputMessage = Mock(Message) + def inputMessage = Mock(HL7Message) + def outputMessage = Mock(HL7Message) when: def result = evaluator.getMessageBySource(source, inputMessage, outputMessage) @@ -452,13 +357,13 @@ class HapiHL7ExpressionEvaluatorTest extends Specification { given: def source = "input" def inputMessage = null - def outputMessage = Mock(Message) + def outputMessage = Mock(HL7Message) when: evaluator.getMessageBySource(source, inputMessage, outputMessage) then: - def e = thrown(IllegalArgumentException) + def e = thrown(HL7ParserException) e.getMessage().contains("Input message is null for") } } diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherExceptionTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherExceptionTest.groovy similarity index 65% rename from rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherExceptionTest.groovy rename to rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherExceptionTest.groovy index 07e65529e..c4ed62ac5 100644 --- a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherExceptionTest.groovy +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherExceptionTest.groovy @@ -1,8 +1,9 @@ -package gov.hhs.cdc.trustedintermediary.rse2e.external.hapi +package gov.hhs.cdc.trustedintermediary.rse2e.hl7 + import spock.lang.Specification -class HapiHL7FileMatcherExceptionTest extends Specification { +class HL7FileMatcherExceptionTest extends Specification { def "two param constructor works"() { given: @@ -10,7 +11,7 @@ class HapiHL7FileMatcherExceptionTest extends Specification { def cause = new NullPointerException() when: - def exception = new HapiHL7FileMatcherException(message, cause) + def exception = new HL7FileMatcherException(message, cause) then: exception.getMessage() == message @@ -22,7 +23,7 @@ class HapiHL7FileMatcherExceptionTest extends Specification { def message = "something blew up!" when: - def exception = new HapiHL7FileMatcherException(message) + def exception = new HL7FileMatcherException(message) then: exception.getMessage() == message diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherTest.groovy similarity index 72% rename from rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherTest.groovy rename to rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherTest.groovy index c00789a4d..0773388bb 100644 --- a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/external/hapi/HapiHL7FileMatcherTest.groovy +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7FileMatcherTest.groovy @@ -1,27 +1,26 @@ -package gov.hhs.cdc.trustedintermediary.rse2e.external.hapi +package gov.hhs.cdc.trustedintermediary.rse2e.hl7 import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext -import gov.hhs.cdc.trustedintermediary.rse2e.HL7FileStream import gov.hhs.cdc.trustedintermediary.wrappers.Logger import spock.lang.Specification -class HapiHL7FileMatcherTest extends Specification { +class HL7FileMatcherTest extends Specification { def mockLogger = Mock(Logger) - def fileMatcher = HapiHL7FileMatcher.getInstance() + def fileMatcher = HL7FileMatcher.getInstance() def setup() { TestApplicationContext.reset() TestApplicationContext.init() TestApplicationContext.register(Logger, mockLogger) - TestApplicationContext.register(HapiHL7FileMatcher, fileMatcher) + TestApplicationContext.register(HL7FileMatcher, fileMatcher) TestApplicationContext.injectRegisteredImplementations() } def "should correctly match input and output files"() { given: - def spyFileMatcher = Spy(HapiHL7FileMatcher.getInstance()) + def spyFileMatcher = Spy(HL7FileMatcher.getInstance()) def mockInputFiles = [ new HL7FileStream("inputFileStream1", Mock(InputStream)), new HL7FileStream("inputFileStream2", Mock(InputStream)) @@ -30,10 +29,10 @@ class HapiHL7FileMatcherTest extends Specification { new HL7FileStream("outputFileStream1", Mock(InputStream)), new HL7FileStream("outputFileStream2", Mock(InputStream)) ] - def mockInputMessage1 = Mock(HapiHL7Message) - def mockInputMessage2 = Mock(HapiHL7Message) - def mockOutputMessage1 = Mock(HapiHL7Message) - def mockOutputMessage2 = Mock(HapiHL7Message) + def mockInputMessage1 = Mock(HL7Message) + def mockInputMessage2 = Mock(HL7Message) + def mockOutputMessage1 = Mock(HL7Message) + def mockOutputMessage2 = Mock(HL7Message) spyFileMatcher.parseAndMapMessageByControlId(mockInputFiles) >> ["001": mockInputMessage1, "002": mockInputMessage2 ] spyFileMatcher.parseAndMapMessageByControlId(mockOutputFiles) >> ["001": mockOutputMessage1, "002": mockOutputMessage2 ] @@ -46,11 +45,11 @@ class HapiHL7FileMatcherTest extends Specification { } - def "should throw HapiHL7FileMatcherException if didn't find a match for at least one file in either input or output"() { + def "should throw HL7FileMatcherException if didn't find a match for at least one file in either input or output"() { given: def mockInputFiles def mockOutputFiles - def spyFileMatcher = Spy(HapiHL7FileMatcher.getInstance()) + def spyFileMatcher = Spy(HL7FileMatcher.getInstance()) when: mockInputFiles = [ @@ -60,12 +59,12 @@ class HapiHL7FileMatcherTest extends Specification { mockOutputFiles = [ new HL7FileStream("matchingOutputFileStream", Mock(InputStream)) ] - spyFileMatcher.parseAndMapMessageByControlId(mockInputFiles) >> ["001": Mock(HapiHL7Message), "002": Mock(HapiHL7Message) ] - spyFileMatcher.parseAndMapMessageByControlId(mockOutputFiles) >> ["001": Mock(HapiHL7Message) ] + spyFileMatcher.parseAndMapMessageByControlId(mockInputFiles) >> ["001": Mock(HL7Message), "002": Mock(HL7Message) ] + spyFileMatcher.parseAndMapMessageByControlId(mockOutputFiles) >> ["001": Mock(HL7Message) ] spyFileMatcher.matchFiles(mockOutputFiles, mockInputFiles) then: - def nonMatchingInputException = thrown(HapiHL7FileMatcherException) + def nonMatchingInputException = thrown(HL7FileMatcherException) with(nonMatchingInputException.getMessage()) { contains("Found no match") contains("002") @@ -79,12 +78,12 @@ class HapiHL7FileMatcherTest extends Specification { new HL7FileStream("matchingOutputFileStream", Mock(InputStream)), new HL7FileStream("nonMatchingOutputFileStream", Mock(InputStream)) ] - spyFileMatcher.parseAndMapMessageByControlId(mockInputFiles) >> ["001": Mock(HapiHL7Message) ] - spyFileMatcher.parseAndMapMessageByControlId(mockOutputFiles) >> ["001": Mock(HapiHL7Message), "003": Mock(HapiHL7Message) ] + spyFileMatcher.parseAndMapMessageByControlId(mockInputFiles) >> ["001": Mock(HL7Message) ] + spyFileMatcher.parseAndMapMessageByControlId(mockOutputFiles) >> ["001": Mock(HL7Message), "003": Mock(HL7Message) ] spyFileMatcher.matchFiles(mockOutputFiles, mockInputFiles) then: - def nonMatchingOutputException = thrown(HapiHL7FileMatcherException) + def nonMatchingOutputException = thrown(HL7FileMatcherException) with(nonMatchingOutputException.getMessage()) { contains("Found no match") contains("003") @@ -117,11 +116,11 @@ class HapiHL7FileMatcherTest extends Specification { def message2 = result[file2Msh10] message1 != null message2 != null - file1MshSegment == message1.getUnderlyingData().encode().trim() - file2MshSegment == message2.getUnderlyingData().encode().trim() + file1MshSegment == message1.getUnderlyingData().toString() + file2MshSegment == message2.getUnderlyingData().toString() } - def "should throw HapiHL7FileMatcherException when MSH-10 is empty"() { + def "should throw HL7FileMatcherException when MSH-10 is empty"() { given: def msh1to9 = "MSH|^~\\&|Sender Application^sender.test.com^DNS|Sender Facility^0.0.0.0.0.0.0.0^ISO|Receiver Application^0.0.0.0.0.0.0.0^ISO|Receiver Facility^simulated-lab-id^DNS|20230101010000-0000||ORM^O01^ORM_O01|" def msh11to12 = "|T|2.5.1" @@ -134,10 +133,10 @@ class HapiHL7FileMatcherTest extends Specification { fileMatcher.parseAndMapMessageByControlId([hl7FileStream]) then: - thrown(HapiHL7FileMatcherException) + thrown(HL7FileMatcherException) } - def "should throw HapiHL7FileMatcherException when not able to parse the file as HL7 message"() { + def "should throw HL7FileMatcherException when not able to parse the file as HL7 message"() { given: def inputStream = new ByteArrayInputStream("".bytes) def hl7FileStream = new HL7FileStream("badFile", inputStream) @@ -146,6 +145,20 @@ class HapiHL7FileMatcherTest extends Specification { fileMatcher.parseAndMapMessageByControlId([hl7FileStream]) then: - thrown(HapiHL7FileMatcherException) + thrown(HL7FileMatcherException) + } + + def "should throw HL7FileMatcherException when unable to load a file to parse and map"() { + def inputStream = Mock(ByteArrayInputStream) + inputStream.readAllBytes() >> { + throw new IOException("") + } + def hl7FileStream = new HL7FileStream("file1", inputStream) + + when: + fileMatcher.parseAndMapMessageByControlId([hl7FileStream]) + + then: + thrown(HL7FileMatcherException) } } diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7MessageTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7MessageTest.groovy new file mode 100644 index 000000000..abddd47fd --- /dev/null +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7MessageTest.groovy @@ -0,0 +1,155 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7 + +import spock.lang.Specification + +class HL7MessageTest extends Specification { + + HL7Message message + String messageContent + + def setup() { + messageContent = """MSH|^~\\&|Sender Application^sender.test.com^DNS|Sender Facility^0.0.0.0.0.0.0.0^ISO|Receiver Application^0.0.0.0.0.0.0.0^ISO|Receiver Facility^automated-staging-test-receiver-id^DNS|20230101010000-0000||ORM^O01^ORM_O01|001|N|2.5.1|||||||||| +PID|1||1300974^^^Baptist East^MR||ONE^TESTCASE||202402210152-0500|F^Female^HL70001||2106-3^White^HL70005|1234 GPCS WAY^^MONTGOMERY^Alabama^36117^USA^home^^Montgomery|||||||2227600015||||N^Not Hispanic or Latino^HL70189|||1|||||||||||||||LRI_NG_FRN_PROFILE^^2.16.840.1.113883.9.195.3.4^ISO~LRI_NDBS_COMPONENT^^2.16.840.1.113883.9.195.3.6^ISO~LAB_PRN_Component^^2.16.840.1.113883.9.81^ISO~LAB_TO_COMPONENT^^2.16.840.1.113883.9.22^ISO| +NK1|1|ONE^MOMFIRST|MTH^Mother^HL70063||^^^^^804^5693861||||||||||||||||||||||||||||123456789^^^Medicaid&2.16.840.1.113883.4.446&ISO^MD||||000-00-0000^^^ssn&2.16.840.1.113883.4.1&ISO^SS +ORC|NW|4560411583^ORDERID||||||||||12345^^^^^^^^NPI&2.16.840.1.113883.4.6&ISO^L||||||||| +OBR|1|4560411583^ORDERID||54089-8^Newborn screening panel AHIC^LN|||202402221854-0500|||||||||12345^^^^^^^^NPI&2.16.840.1.113883.4.6&ISO^L|||||||| +OBX|1|ST|57723-9^Unique bar code number of Current sample^LN||123456||||||F|||202402221854-0500 +OBX|2|NM|||3122||||||F|||202402221854-0500||""" + message = HL7Message.parse(messageContent) + } + + def "parse should handle basic HL7 message"() { + given: + def content = """MSH|^~\\&|sending_app|sending_facility +PID|||12345||Doe^John||19800101|M""" + + when: + def message = HL7Message.parse(content) + def segments = message.getSegments() + + then: + segments.size() == 2 + segments[0].name() == "MSH" + segments[0].fields().size() == 4 + segments[0].fields().get(0) == "|" + segments[0].fields().get(1) == "^~\\&" + segments[1].name() == "PID" + segments[1].fields().size() == 8 + segments[1].fields().get(2) == "12345" + segments[1].fields().get(4) == "Doe^John" + } + + def "parse should handle empty lines in message"() { + given: + def content = """MSH|^~\\&|sending_app + +PID|||12345""" + + when: + def result = HL7Message.parse(content) + + then: + result.getSegments().size() == 2 + } + + def "parse should preserve empty fields"() { + given: + def content = "MSH|^~\\&|sending_app||sending_facility" + + when: + def result = HL7Message.parse(content) + + then: + result.getSegments().get(0).fields().get(3) == "" + } + + def "getUnderlyingData should correctly return itself"() { + expect: + message.getUnderlyingData() == message + } + + def "toString should return the original string content"() { + expect: + message.toString() == messageContent + } + + def "getIdentifier should return the MSH-10 identifier of the underlying message"() { + expect: + message.getIdentifier() == "001" + } + + def "getSegments returns the expected segments"() { + when: + def actualSegment = message.getSegments("OBX").get(1) + + then: + actualSegment.name() == "OBX" + actualSegment.fields().get(0) == "2" + } + + def "getSegments returns all segments if no called with no arguments"() { + expect: + message.getSegments().size() == 7 + } + + def "getSegmentCount returns the expected number of segments"() { + expect: + message.getSegmentCount("MSH") == 1 + message.getSegmentCount("PID") == 1 + message.getSegmentCount("OBX") == 2 + } + + def "hasSegment returns expected boolean"() { + expect: + message.hasSegment("MSH", 0) + message.hasSegment("PID", 0) + message.hasSegment("OBX", 0) + message.hasSegment("OBX", 1) + !message.hasSegment("MSH", 1) + !message.hasSegment("ZZZ", 0) + } + + def "getSegment returns the expected segment"() { + when: + def obxSegment1 = message.getSegment("OBX") + def obxSegment2 = message.getSegment("OBX", 1) + + then: + obxSegment1.name() == "OBX" + obxSegment1.fields().get(0) == "1" + obxSegment2.name() == "OBX" + obxSegment2.fields().get(0) == "2" + } + + def "getValue returns the expected value given the segment name and indices"() { + expect: + message.getValue("PID-3") == "1300974^^^Baptist East^MR" + message.getValue("PID-3.0") == "" + message.getValue("PID-3.1") == "1300974" + message.getValue("PID-3.2") == "" + message.getValue("PID-3.4") == "Baptist East" + message.getValue("PID-3.5") == "MR" + message.getValue("PID-3.6") == "" + message.getValue("PID-40.4.1") == "ISO" + message.getValue("PID-40.4.2") == "LRI_NDBS_COMPONENT" + message.getValue("PID-40.4.3") == "" + message.getValue("NK1-33.4.1.1") == "Medicaid" + message.getValue("PID-99") == "" + } + + def "getValue should throws an exception when the hl7 field index has too many sublevels"() { + when: + message.getValue("NK1-33.4.1.1.1") + + then: + thrown(HL7ParserException) + } + + def "getValue should throws an exception when the segment is not found"() { + when: + message.getValue("ZZZ-1") + + then: + thrown(HL7MessageException) + } +} diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7PathTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7PathTest.groovy new file mode 100644 index 000000000..d866babac --- /dev/null +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/hl7/HL7PathTest.groovy @@ -0,0 +1,61 @@ +package gov.hhs.cdc.trustedintermediary.rse2e.hl7 + +import spock.lang.Specification + +class HL7PathTest extends Specification { + + def "should create HL7Path with segment name and indices"() { + when: + def path = new HL7Path("MSH", [1, 2, 3] as int[]) + + then: + path.segmentName() == "MSH" + path.indices() == [1, 2, 3] as int[] + } + + def "equals should compare array contents"() { + given: + def path1 = new HL7Path("MSH", [1, 2] as int[]) + def path2 = new HL7Path("MSH", [1, 2] as int[]) + def path3 = new HL7Path("MSH", [1, 3] as int[]) + + expect: + path1.equals(path2) + !path1.equals(path3) + !path1.equals(null) + path1.equals(path1) + } + + def "equals should handle different segment names"() { + given: + def path1 = new HL7Path(segment1, [1, 2] as int[]) + def path2 = new HL7Path(segment2, [1, 2] as int[]) + + expect: + (path1.equals(path2)) == expectedResult + + where: + scenario | segment1 | segment2 | expectedResult + "same segment" | "MSH" | "MSH" | true + "both null" | null | null | true + "diff segment" | "MSH" | "PID" | false + "null vs string" | null | "MSH" | false + } + + def "hashCode should consider array contents"() { + given: + def path1 = new HL7Path("MSH", [1, 2] as int[]) + def path2 = new HL7Path("MSH", [1, 2] as int[]) + + expect: + path1.hashCode() == path2.hashCode() + } + + def "toString should include array contents"() { + given: + def path = new HL7Path("MSH", [1, 2] as int[]) + + expect: + path.toString() == "HL7Path[segmentName=MSH, indices=[1, 2]]" + } +} diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/ruleengine/AssertionRuleEngineTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/ruleengine/AssertionRuleEngineTest.groovy index c3bb3ef29..f8367039b 100644 --- a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/ruleengine/AssertionRuleEngineTest.groovy +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/ruleengine/AssertionRuleEngineTest.groovy @@ -1,7 +1,7 @@ package gov.hhs.cdc.trustedintermediary.rse2e.ruleengine -import ca.uhn.hl7v2.model.Message -import gov.hhs.cdc.trustedintermediary.rse2e.external.hapi.HapiHL7Message + +import gov.hhs.cdc.trustedintermediary.rse2e.hl7.HL7Message import gov.hhs.cdc.trustedintermediary.ruleengine.RuleLoader import gov.hhs.cdc.trustedintermediary.ruleengine.RuleLoaderException import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext @@ -85,7 +85,7 @@ class AssertionRuleEngineTest extends Specification { } when: - ruleEngine.runRules(Mock(HapiHL7Message), Mock(HapiHL7Message)) + ruleEngine.runRules(Mock(HL7Message), Mock(HL7Message)) then: 1 * mockLogger.logError(_ as String, exception) @@ -97,7 +97,7 @@ class AssertionRuleEngineTest extends Specification { mockRuleLoader.loadRules(_ as InputStream, _ as TypeReference) >> { throw exception } when: - ruleEngine.runRules(Mock(HapiHL7Message), Mock(HapiHL7Message)) + ruleEngine.runRules(Mock(HL7Message), Mock(HL7Message)) then: 1 * mockLogger.logError(_ as String, exception) @@ -105,7 +105,7 @@ class AssertionRuleEngineTest extends Specification { def "runRules returns nothing when there are no rules"() { when: - def result = ruleEngine.runRules(Mock(HapiHL7Message), Mock(HapiHL7Message)) + def result = ruleEngine.runRules(Mock(HL7Message), Mock(HL7Message)) then: result.isEmpty() @@ -117,7 +117,7 @@ class AssertionRuleEngineTest extends Specification { mockRuleLoader.loadRules(_ as InputStream, _ as TypeReference) >> [rule] when: - def result = ruleEngine.runRules(Mock(HapiHL7Message), Mock(HapiHL7Message)) + def result = ruleEngine.runRules(Mock(HL7Message), Mock(HL7Message)) then: result.size() == 1 @@ -130,7 +130,7 @@ class AssertionRuleEngineTest extends Specification { mockRuleLoader.loadRules(_ as InputStream, _ as TypeReference) >> [rule] when: - def result = ruleEngine.runRules(Mock(HapiHL7Message), Mock(HapiHL7Message)) + def result = ruleEngine.runRules(Mock(HL7Message), Mock(HL7Message)) then: result.isEmpty() diff --git a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/ruleengine/RuleLoaderTest.groovy b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/ruleengine/RuleLoaderTest.groovy index ecd65257d..cccfe8813 100644 --- a/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/ruleengine/RuleLoaderTest.groovy +++ b/rs-e2e/src/test/groovy/gov/hhs/cdc/trustedintermediary/rse2e/ruleengine/RuleLoaderTest.groovy @@ -4,7 +4,7 @@ import gov.hhs.cdc.trustedintermediary.ruleengine.RuleLoader import gov.hhs.cdc.trustedintermediary.ruleengine.RuleLoaderException import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext import gov.hhs.cdc.trustedintermediary.external.jackson.Jackson -import gov.hhs.cdc.trustedintermediary.rse2e.external.hapi.HapiHL7ExpressionEvaluator +import gov.hhs.cdc.trustedintermediary.rse2e.hl7.HL7ExpressionEvaluator import gov.hhs.cdc.trustedintermediary.wrappers.formatter.Formatter import gov.hhs.cdc.trustedintermediary.wrappers.formatter.TypeReference import gov.hhs.cdc.trustedintermediary.wrappers.HealthDataExpressionEvaluator @@ -23,7 +23,7 @@ class RuleLoaderTest extends Specification { TestApplicationContext.init() TestApplicationContext.register(RuleLoader, RuleLoader.getInstance()) TestApplicationContext.register(Formatter, Jackson.getInstance()) - TestApplicationContext.register(HealthDataExpressionEvaluator, HapiHL7ExpressionEvaluator.getInstance()) + TestApplicationContext.register(HealthDataExpressionEvaluator, HL7ExpressionEvaluator.getInstance()) TestApplicationContext.injectRegisteredImplementations() tempFile = Files.createTempFile("test_validation_definition", ".json")