diff --git a/service/src/main/java/uk/nhs/adaptors/gp2gp/common/service/FhirParseService.java b/service/src/main/java/uk/nhs/adaptors/gp2gp/common/service/FhirParseService.java index da4caed482..cad0923425 100644 --- a/service/src/main/java/uk/nhs/adaptors/gp2gp/common/service/FhirParseService.java +++ b/service/src/main/java/uk/nhs/adaptors/gp2gp/common/service/FhirParseService.java @@ -2,7 +2,6 @@ import org.hl7.fhir.instance.model.api.IBaseResource; import org.springframework.stereotype.Service; - import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.parser.StrictErrorHandler; @@ -10,6 +9,7 @@ @Service public class FhirParseService { + private final IParser jsonParser = prepareParser(); public T parseResource(String body, Class fhirClass) { @@ -20,6 +20,10 @@ public T parseResource(String body, Class fhirClass } } + public String encodeToJson(IBaseResource resource) { + return jsonParser.setPrettyPrint(true).encodeResourceToString(resource); + } + private IParser prepareParser() { FhirContext ctx = FhirContext.forDstu3(); ctx.newJsonParser(); diff --git a/service/src/main/java/uk/nhs/adaptors/gp2gp/ehr/EhrResendController.java b/service/src/main/java/uk/nhs/adaptors/gp2gp/ehr/EhrResendController.java new file mode 100644 index 0000000000..23cadb5701 --- /dev/null +++ b/service/src/main/java/uk/nhs/adaptors/gp2gp/ehr/EhrResendController.java @@ -0,0 +1,130 @@ +package uk.nhs.adaptors.gp2gp.ehr; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.dstu3.model.CodeableConcept; +import org.hl7.fhir.dstu3.model.Coding; +import org.hl7.fhir.dstu3.model.Meta; +import org.hl7.fhir.dstu3.model.OperationOutcome; +import org.hl7.fhir.dstu3.model.UriType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import uk.nhs.adaptors.gp2gp.common.service.FhirParseService; +import uk.nhs.adaptors.gp2gp.common.service.RandomIdGeneratorService; +import uk.nhs.adaptors.gp2gp.common.service.TimestampService; +import uk.nhs.adaptors.gp2gp.common.task.TaskDispatcher; +import uk.nhs.adaptors.gp2gp.ehr.model.EhrExtractStatus; +import uk.nhs.adaptors.gp2gp.gpc.GetGpcStructuredTaskDefinition; + +import java.util.Collections; + +@Slf4j +@RestController +@AllArgsConstructor(onConstructor = @__(@Autowired)) +@RequestMapping(path = "/ehr-resend") +public class EhrResendController { + + private static final String OPERATION_OUTCOME_URL = "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-OperationOutcome-1"; + private static final String PRECONDITION_FAILED = "PRECONDITION_FAILED"; + private static final String INVALID_IDENTIFIER_VALUE = "INVALID_IDENTIFIER_VALUE"; + + private EhrExtractStatusRepository ehrExtractStatusRepository; + private TaskDispatcher taskDispatcher; + private RandomIdGeneratorService randomIdGeneratorService; + private final TimestampService timestampService; + private final FhirParseService fhirParseService; + + @PostMapping("/{conversationId}") + public ResponseEntity scheduleEhrExtractResend(@PathVariable String conversationId) { + EhrExtractStatus ehrExtractStatus = ehrExtractStatusRepository.findByConversationId(conversationId).orElseGet(() -> null); + + if (ehrExtractStatus == null) { + var details = getCodeableConcept(INVALID_IDENTIFIER_VALUE); + var diagnostics = "Provide a conversationId that exists and retry the operation"; + + var operationOutcome = createOperationOutcome(OperationOutcome.IssueType.VALUE, + OperationOutcome.IssueSeverity.ERROR, + details, + diagnostics); + var errorBody = fhirParseService.encodeToJson(operationOutcome); + + return new ResponseEntity<>(errorBody, HttpStatus.NOT_FOUND); + } + + if (hasNoErrorsInEhrReceivedAcknowledgement(ehrExtractStatus) && ehrExtractStatus.getError() == null) { + + var details = getCodeableConcept(PRECONDITION_FAILED); + var diagnostics = "The current resend operation is still in progress. Please wait for it to complete before retrying"; + var operationOutcome = createOperationOutcome(OperationOutcome.IssueType.BUSINESSRULE, + OperationOutcome.IssueSeverity.ERROR, + details, + diagnostics); + var errorBody = fhirParseService.encodeToJson(operationOutcome); + return new ResponseEntity<>(errorBody, HttpStatus.CONFLICT); + } + + var updatedEhrExtractStatus = prepareEhrExtractStatusForNewResend(ehrExtractStatus); + ehrExtractStatusRepository.save(updatedEhrExtractStatus); + createGetGpcStructuredTask(updatedEhrExtractStatus); + LOGGER.info("Scheduled GetGpcStructuredTask for resend of ConversationId: {}", conversationId); + + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + private static CodeableConcept getCodeableConcept(String codeableConceptCode) { + return new CodeableConcept().addCoding( + new Coding("https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", codeableConceptCode, null)); + } + + private static boolean hasNoErrorsInEhrReceivedAcknowledgement(EhrExtractStatus ehrExtractStatus) { + var ehrReceivedAcknowledgement = ehrExtractStatus.getEhrReceivedAcknowledgement(); + if (ehrReceivedAcknowledgement == null) { + return true; + } + + var errors = ehrReceivedAcknowledgement.getErrors(); + if (errors == null || errors.isEmpty()) { + return true; + } + return false; + } + + private EhrExtractStatus prepareEhrExtractStatusForNewResend(EhrExtractStatus ehrExtractStatus) { + + var now = timestampService.now(); + ehrExtractStatus.setUpdatedAt(now); + ehrExtractStatus.setMessageTimestamp(now); + ehrExtractStatus.setEhrExtractCorePending(null); + ehrExtractStatus.setGpcAccessDocument(null); + ehrExtractStatus.setEhrContinue(null); + ehrExtractStatus.setEhrReceivedAcknowledgement(null); + + return ehrExtractStatus; + } + + private void createGetGpcStructuredTask(EhrExtractStatus ehrExtractStatus) { + var getGpcStructuredTaskDefinition = GetGpcStructuredTaskDefinition.getGetGpcStructuredTaskDefinition(randomIdGeneratorService, + ehrExtractStatus); + taskDispatcher.createTask(getGpcStructuredTaskDefinition); + } + + public static OperationOutcome createOperationOutcome( + OperationOutcome.IssueType type, OperationOutcome.IssueSeverity severity, CodeableConcept details, String diagnostics) { + var operationOutcome = new OperationOutcome(); + Meta meta = new Meta(); + meta.setProfile(Collections.singletonList(new UriType(OPERATION_OUTCOME_URL))); + operationOutcome.setMeta(meta); + operationOutcome.addIssue() + .setCode(type) + .setSeverity(severity) + .setDetails(details) + .setDiagnostics(diagnostics); + return operationOutcome; + } + +} diff --git a/service/src/test/java/uk/nhs/adaptors/gp2gp/common/service/FhirParseServiceTest.java b/service/src/test/java/uk/nhs/adaptors/gp2gp/common/service/FhirParseServiceTest.java new file mode 100644 index 0000000000..d644441789 --- /dev/null +++ b/service/src/test/java/uk/nhs/adaptors/gp2gp/common/service/FhirParseServiceTest.java @@ -0,0 +1,64 @@ +package uk.nhs.adaptors.gp2gp.common.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hl7.fhir.dstu3.model.CodeableConcept; +import org.hl7.fhir.dstu3.model.Coding; +import org.hl7.fhir.dstu3.model.OperationOutcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import uk.nhs.adaptors.gp2gp.common.configuration.ObjectMapperBean; +import uk.nhs.adaptors.gp2gp.ehr.EhrResendController; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +@ExtendWith(MockitoExtension.class) +class FhirParseServiceTest { + + public static final String INVALID_IDENTIFIER_VALUE = "INVALID_IDENTIFIER_VALUE"; + private static final String OPERATION_OUTCOME_URL = "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-OperationOutcome-1"; + private OperationOutcome operationOutcome; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + ObjectMapperBean objectMapperBean = new ObjectMapperBean(); + objectMapper = objectMapperBean.objectMapper(new Jackson2ObjectMapperBuilder()); + + var details = getCodeableConcept(); + var diagnostics = "Provide a conversationId that exists and retry the operation"; + operationOutcome = EhrResendController.createOperationOutcome(OperationOutcome.IssueType.VALUE, + OperationOutcome.IssueSeverity.ERROR, + details, + diagnostics); + } + + @Test + void ableToEncodeOperationOutcomeToJson() throws JsonProcessingException { + FhirParseService fhirParseService = new FhirParseService(); + + String convertedToJsonOperationOutcome = fhirParseService.encodeToJson(operationOutcome); + + JsonNode rootNode = objectMapper.readTree(convertedToJsonOperationOutcome); + String code = + rootNode.path("issue").get(0).path("details").path("coding").get(0).path("code").asText(); + String operationOutcomeUrl = rootNode.path("meta").path("profile").get(0).asText(); + + assertEquals(INVALID_IDENTIFIER_VALUE, code); + assertEquals(OPERATION_OUTCOME_URL, operationOutcomeUrl); + } + + private static CodeableConcept getCodeableConcept() { + var details = new CodeableConcept(); + var codeableConceptCoding = new Coding(); + codeableConceptCoding.setSystem("http://fhir.nhs.net/ValueSet/gpconnect-error-or-warning-code-1"); + codeableConceptCoding.setCode(FhirParseServiceTest.INVALID_IDENTIFIER_VALUE); + details.setCoding(List.of(codeableConceptCoding)); + return details; + } +} \ No newline at end of file diff --git a/service/src/test/java/uk/nhs/adaptors/gp2gp/ehr/EhrResendControllerTest.java b/service/src/test/java/uk/nhs/adaptors/gp2gp/ehr/EhrResendControllerTest.java new file mode 100644 index 0000000000..d4cd3bd32b --- /dev/null +++ b/service/src/test/java/uk/nhs/adaptors/gp2gp/ehr/EhrResendControllerTest.java @@ -0,0 +1,244 @@ +package uk.nhs.adaptors.gp2gp.ehr; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import uk.nhs.adaptors.gp2gp.common.configuration.ObjectMapperBean; +import uk.nhs.adaptors.gp2gp.common.service.FhirParseService; +import uk.nhs.adaptors.gp2gp.common.service.RandomIdGeneratorService; +import uk.nhs.adaptors.gp2gp.common.service.TimestampService; +import uk.nhs.adaptors.gp2gp.common.task.TaskDispatcher; +import uk.nhs.adaptors.gp2gp.ehr.model.EhrExtractStatus; +import uk.nhs.adaptors.gp2gp.gpc.GetGpcStructuredTaskDefinition; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class EhrResendControllerTest { + + public static final String DIAGNOSTICS_MSG = + "The current resend operation is still in progress. Please wait for it to complete before retrying"; + private static final Instant NOW = Instant.parse("2024-01-01T10:00:00Z"); + private static final Instant FIVE_DAYS_AGO = NOW.minus(Duration.ofDays(5)); + private static final String URI_TYPE = "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-OperationOutcome-1"; + private static final String CONVERSATION_ID = "123-456"; + private static final String NHS_NUMBER = "12345"; + private static final String TO_ASID_CODE = "test-to-asid"; + private static final String FROM_ASID_CODE = "test-from-asid"; + private static final String INCUMBENT_NACK_CODE = "99"; + private static final String INCUMBENT_NACK_DISPLAY = "Unexpected condition."; + private static final String PRECONDITION_FAILED = "PRECONDITION_FAILED"; + private static final String GPCONNECT_ERROR_OR_WARNING_CODE = "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1"; + private static final String INVALID_IDENTIFIER_VALUE = "INVALID_IDENTIFIER_VALUE"; + public static final String ISSUE_CODE_VALUE = "value"; + public static final String ISSUE_CODE_BUSINESS_RULE = "business-rule"; + + private ObjectMapper objectMapper; + + @Mock + private EhrExtractStatusRepository ehrExtractStatusRepository; + + @Mock + private TimestampService timestampService; + + @Mock + private RandomIdGeneratorService randomIdGeneratorService; + + @Mock + private TaskDispatcher taskDispatcher; + + private EhrResendController ehrResendController; + + + @BeforeEach + void setUp() { + ObjectMapperBean objectMapperBean = new ObjectMapperBean(); + objectMapper = objectMapperBean.objectMapper(new Jackson2ObjectMapperBuilder()); + FhirParseService fhirParseService = new FhirParseService(); + ehrResendController = new EhrResendController(ehrExtractStatusRepository, + taskDispatcher, + randomIdGeneratorService, + timestampService, + fhirParseService); + } + + @Test + void When_AnEhrExtractHasFailed_Expect_GetGpcStructuredTaskScheduledAndEhrExtractStatusIsReset() { + + String ehrMessageRef = generateRandomUppercaseUUID(); + var ehrExtractStatus = new EhrExtractStatus(); + + ehrExtractStatus.setConversationId(CONVERSATION_ID); + ehrExtractStatus.setEhrReceivedAcknowledgement(EhrExtractStatus.EhrReceivedAcknowledgement.builder() + .conversationClosed(FIVE_DAYS_AGO) + .errors(List.of( + EhrExtractStatus.EhrReceivedAcknowledgement.ErrorDetails.builder() + .code(INCUMBENT_NACK_CODE) + .display(INCUMBENT_NACK_DISPLAY) + .build())) + .messageRef(ehrMessageRef) + .received(FIVE_DAYS_AGO) + .rootId(generateRandomUppercaseUUID()) + .build()); + ehrExtractStatus.setEhrRequest(EhrExtractStatus.EhrRequest.builder().nhsNumber(NHS_NUMBER).build()); + ehrExtractStatus.setEhrExtractCorePending(EhrExtractStatus.EhrExtractCorePending.builder().build()); + ehrExtractStatus.setEhrContinue(EhrExtractStatus.EhrContinue.builder().build()); + ehrExtractStatus.setGpcAccessDocument(EhrExtractStatus.GpcAccessDocument.builder().build()); + ehrExtractStatus.setCreated(FIVE_DAYS_AGO); + + when(ehrExtractStatusRepository.findByConversationId(CONVERSATION_ID)).thenReturn(Optional.of(ehrExtractStatus)); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + when(timestampService.now()).thenReturn(now); + + ehrResendController.scheduleEhrExtractResend(CONVERSATION_ID); + + var taskDefinition = GetGpcStructuredTaskDefinition.getGetGpcStructuredTaskDefinition(randomIdGeneratorService, ehrExtractStatus); + + assertAll( + () -> verify(taskDispatcher, times(1)).createTask(taskDefinition), + () -> verify(ehrExtractStatusRepository, times(1)).save(ehrExtractStatus), + () -> assertEquals(now, ehrExtractStatus.getMessageTimestamp()), + () -> assertEquals(FIVE_DAYS_AGO, ehrExtractStatus.getCreated()), + () -> assertEquals(now, ehrExtractStatus.getUpdatedAt()), + () -> assertNull(ehrExtractStatus.getEhrExtractCorePending()), + () -> assertNull(ehrExtractStatus.getEhrContinue()), + () -> assertNull(ehrExtractStatus.getAckPending()), + () -> assertNull(ehrExtractStatus.getEhrReceivedAcknowledgement()), + () -> assertNull(ehrExtractStatus.getGpcAccessDocument()) + ); + } + + @Test + void When_AnEhrExtractIsStillInProgress_Expect_FailedOperationOutcome() throws JsonProcessingException { + + final EhrExtractStatus IN_PROGRESS_EXTRACT_STATUS = EhrExtractStatus.builder() + .conversationId(CONVERSATION_ID) + .ackPending(EhrExtractStatus.AckPending.builder().typeCode("AA").build()) + .ackToRequester(EhrExtractStatus.AckToRequester.builder().typeCode("AA").build()) + .ehrRequest(EhrExtractStatus.EhrRequest.builder().nhsNumber(NHS_NUMBER).toAsid(TO_ASID_CODE).fromAsid(FROM_ASID_CODE).build()) + .build(); + + when(ehrExtractStatusRepository.findByConversationId(CONVERSATION_ID)).thenReturn(Optional.of(IN_PROGRESS_EXTRACT_STATUS)); + + var response = ehrResendController.scheduleEhrExtractResend(CONVERSATION_ID); + + JsonNode rootNode = objectMapper.readTree(response.getBody()); + + assertAll( + () -> assertResponseHasExpectedOperationOutcome(rootNode, PRECONDITION_FAILED, DIAGNOSTICS_MSG, ISSUE_CODE_BUSINESS_RULE), + () -> assertEquals(HttpStatus.CONFLICT, response.getStatusCode()) + ); + } + + @Test + void When_AnEhrExtractHasFailed_Expect_RespondsWith202() { + + String ehrMessageRef = generateRandomUppercaseUUID(); + var ehrExtractStatus = new EhrExtractStatus(); + + ehrExtractStatus.setConversationId(CONVERSATION_ID); + ehrExtractStatus.setEhrReceivedAcknowledgement(EhrExtractStatus.EhrReceivedAcknowledgement.builder() + .conversationClosed(FIVE_DAYS_AGO) + .errors(List.of( + EhrExtractStatus.EhrReceivedAcknowledgement.ErrorDetails.builder() + .code(INCUMBENT_NACK_CODE) + .display(INCUMBENT_NACK_DISPLAY) + .build())) + .messageRef(ehrMessageRef) + .received(FIVE_DAYS_AGO) + .rootId(generateRandomUppercaseUUID()) + .build()); + ehrExtractStatus.setEhrRequest(EhrExtractStatus.EhrRequest.builder().nhsNumber(NHS_NUMBER).build()); + + when(ehrExtractStatusRepository.findByConversationId(CONVERSATION_ID)).thenReturn(Optional.of(ehrExtractStatus)); + + var response = ehrResendController.scheduleEhrExtractResend(CONVERSATION_ID); + + assertAll( + () -> assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()), + () -> assertNull(response.getBody()) + ); + } + + @Test + void When_AnEhrExtractHasAPositiveAcknowledgement_Expect_FailedOperationOutcome() throws JsonProcessingException { + + String ehrMessageRef = generateRandomUppercaseUUID(); + var ehrExtractStatus = new EhrExtractStatus(); + ehrExtractStatus.setConversationId(CONVERSATION_ID); + ehrExtractStatus.setEhrReceivedAcknowledgement(EhrExtractStatus.EhrReceivedAcknowledgement.builder() + .conversationClosed(FIVE_DAYS_AGO) + .errors(List.of()) + .messageRef(ehrMessageRef) + .received(FIVE_DAYS_AGO) + .rootId(generateRandomUppercaseUUID()) + .build()); + ehrExtractStatus.setEhrRequest(EhrExtractStatus.EhrRequest.builder().nhsNumber(NHS_NUMBER).build()); + + when(ehrExtractStatusRepository.findByConversationId(CONVERSATION_ID)).thenReturn(Optional.of(ehrExtractStatus)); + + var response = ehrResendController.scheduleEhrExtractResend(CONVERSATION_ID); + + JsonNode rootNode = objectMapper.readTree(response.getBody()); + + assertAll( + () -> assertResponseHasExpectedOperationOutcome(rootNode, PRECONDITION_FAILED, DIAGNOSTICS_MSG, ISSUE_CODE_BUSINESS_RULE), + () -> assertEquals(HttpStatus.CONFLICT, response.getStatusCode()) + ); + } + + @Test + void When_AnEhrExtractDoesNotExist_Expect_RespondsWith404() throws JsonProcessingException { + + var diagnosticsMsg = "Provide a conversationId that exists and retry the operation"; + + when(ehrExtractStatusRepository.findByConversationId(CONVERSATION_ID)).thenReturn(Optional.empty()); + + var response = ehrResendController.scheduleEhrExtractResend(CONVERSATION_ID); + + JsonNode rootNode = objectMapper.readTree(response.getBody()); + + assertAll( + () -> assertResponseHasExpectedOperationOutcome(rootNode, INVALID_IDENTIFIER_VALUE, diagnosticsMsg, ISSUE_CODE_VALUE), + () -> assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()) + ); + } + + private void assertResponseHasExpectedOperationOutcome(JsonNode rootNode, String serverErrMsg, + String diagnosticsMsg, String issueCode) { + var coding = rootNode.path("issue").get(0).path("details").path("coding").get(0); + assertAll( + () -> assertEquals(serverErrMsg, coding.path("code").asText()), + () -> assertEquals("error", rootNode.path("issue").get(0).path("severity").asText()), + () -> assertEquals(issueCode, rootNode.path("issue").get(0).path("code").asText()), + () -> assertEquals(GPCONNECT_ERROR_OR_WARNING_CODE, coding.path("system").asText()), + () -> assertEquals(URI_TYPE, rootNode.path("meta").path("profile").get(0).asText()), + () -> assertEquals(diagnosticsMsg, rootNode.path("issue").get(0).path("diagnostics").asText()) + ); + } + + private String generateRandomUppercaseUUID() { + return UUID.randomUUID().toString().toUpperCase(); + } + +}