diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 70475584de..2f31f6e7c0 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -37,31 +37,45 @@ jobs: echo "Published release ${{ steps.get_release.outputs.tag_name }} as latest" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + backfill: + name: Backfill master to develop branch + runs-on: ubuntu-latest + steps: + - name: Check params + run: | + echo "head.ref = ${{github.event.pull_request.head.ref}}" + echo "base.ref = ${{github.event.pull_request.base.ref}}" + + - uses: actions/checkout@v4 + with: + ref: master + token: ${{ secrets.ACTIONS_NICHOLAS_PAT }} - - name: Create backfill PR to develop + - name: Create backfill branch + run: git checkout -b backfill/master; + + # In order to make a commit, we need to initialize a user. + - name: Initialize mandatory git config run: | - # Check if a PR already exists - EXISTING_PR=$(gh pr list --base develop --head master --json number --jq '.[0].number' || echo "") - - if [ -n "$EXISTING_PR" ]; then - echo "PR #${EXISTING_PR} already exists for master -> develop" - exit 0 - fi - - # Create the backfill PR - gh pr create \ - --base develop \ - --head master \ - --title "Backfill master into develop" \ - --body "This PR backfills changes from master into develop after production deployment. - - ## Changes - This includes all changes that were merged to master. - - ## Notes - - Review for any conflicts - - Merge after verifying all changes are appropriate for develop branch" - - echo "Created backfill PR from master to develop" + git config user.name "GitHub actions" + git config user.email noreply@github.com + + - name: Push backfill branch env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.ACTIONS_NICHOLAS_PAT }} + run: | + git push origin backfill/master + + - name: Create backfill pull request to develop branch + uses: thomaseizinger/create-pull-request@1.4.0 + with: + github_token: ${{ secrets.ACTIONS_NICHOLAS_PAT }} + head: backfill/master + base: develop + draft: true + title: Backfill ${{ github.event.pull_request.base.ref }} branch to develop branch + body: | + Hi @${{ github.actor }}! + + This PR was created in response to a trigger of the release workflow here: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}. + Following a release, this is a backfill from the main branch to the develop branch. diff --git a/acceptance-tests/pom.xml b/acceptance-tests/pom.xml index 681cb59fd5..13edbb302f 100644 --- a/acceptance-tests/pom.xml +++ b/acceptance-tests/pom.xml @@ -3,7 +3,7 @@ 4.0.0 acceptance-tests gov.cms.qpp.conversion - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE conversion-tests jar diff --git a/commandline/pom.xml b/commandline/pom.xml index 8da3eba944..fa95be16d5 100644 --- a/commandline/pom.xml +++ b/commandline/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE ../pom.xml diff --git a/commons/pom.xml b/commons/pom.xml index 11fdf3d7d8..61b1d3e206 100644 --- a/commons/pom.xml +++ b/commons/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE ../pom.xml diff --git a/converter/pom.xml b/converter/pom.xml index 5854a098dc..a4b53f0a9d 100644 --- a/converter/pom.xml +++ b/converter/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE ../pom.xml @@ -185,7 +185,7 @@ gov.cms.qpp.conversion commons - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE compile diff --git a/generate-race-cpcplus/pom.xml b/generate-race-cpcplus/pom.xml index 7f2a135e26..54be01e9f8 100644 --- a/generate-race-cpcplus/pom.xml +++ b/generate-race-cpcplus/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion generateRaceCpcPlus - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE generate-race-cpcplus jar diff --git a/generate/pom.xml b/generate/pom.xml index ebca4f0224..b360c7c103 100644 --- a/generate/pom.xml +++ b/generate/pom.xml @@ -5,7 +5,7 @@ qpp-conversion-tool-parent gov.cms.qpp.conversion - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE ../pom.xml 4.0.0 diff --git a/pom.xml b/pom.xml index 12ce54b7d3..2639017c8f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent pom - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE QPP Conversion Tool @@ -469,13 +469,13 @@ ch.qos.logback logback-classic - 1.5.13 + 1.5.25 ch.qos.logback logback-core - 1.5.13 + 1.5.25 diff --git a/qrda3-update-measures/pom.xml b/qrda3-update-measures/pom.xml index 374c4795f4..2bc2e94ece 100644 --- a/qrda3-update-measures/pom.xml +++ b/qrda3-update-measures/pom.xml @@ -4,7 +4,7 @@ gov.cms.qpp.conversion qpp-update-measures - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE qrda3-update-measures jar diff --git a/rest-api/pom.xml b/rest-api/pom.xml index ee1a285862..a03798f1ac 100644 --- a/rest-api/pom.xml +++ b/rest-api/pom.xml @@ -19,7 +19,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE ../pom.xml @@ -226,17 +226,17 @@ org.apache.tomcat.embed tomcat-embed-core - 10.1.45 + 10.1.52 org.apache.tomcat.embed tomcat-embed-el - 10.1.45 + 10.1.52 org.apache.tomcat.embed tomcat-embed-websocket - 10.1.45 + 10.1.52 diff --git a/rest-api/src/main/java/gov/cms/qpp/conversion/api/controllers/v1/ExceptionHandlerControllerV1.java b/rest-api/src/main/java/gov/cms/qpp/conversion/api/exceptions/GlobalExceptionHandler.java similarity index 81% rename from rest-api/src/main/java/gov/cms/qpp/conversion/api/controllers/v1/ExceptionHandlerControllerV1.java rename to rest-api/src/main/java/gov/cms/qpp/conversion/api/exceptions/GlobalExceptionHandler.java index 314d3decd9..c3099a397b 100644 --- a/rest-api/src/main/java/gov/cms/qpp/conversion/api/controllers/v1/ExceptionHandlerControllerV1.java +++ b/rest-api/src/main/java/gov/cms/qpp/conversion/api/exceptions/GlobalExceptionHandler.java @@ -1,35 +1,31 @@ -package gov.cms.qpp.conversion.api.controllers.v1; +package gov.cms.qpp.conversion.api.exceptions; -import gov.cms.qpp.conversion.api.exceptions.InvalidFileTypeException; -import gov.cms.qpp.conversion.api.exceptions.InvalidPurposeException; -import gov.cms.qpp.conversion.api.exceptions.NoFileInDatabaseException; +import com.amazonaws.AmazonServiceException; import gov.cms.qpp.conversion.api.services.AuditService; import gov.cms.qpp.conversion.model.error.AllErrors; import gov.cms.qpp.conversion.model.error.QppValidationException; import gov.cms.qpp.conversion.model.error.TransformException; - -import com.amazonaws.AmazonServiceException; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.WebRequest; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.multipart.MultipartException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; - -import gov.cms.qpp.conversion.api.exceptions.BadZipException; +import org.springframework.web.servlet.resource.NoResourceFoundException; /** * Modify the controller to send back different responses for exceptions */ @ControllerAdvice -public class ExceptionHandlerControllerV1 extends ResponseEntityExceptionHandler { - private static final Logger API_LOG = LoggerFactory.getLogger(ExceptionHandlerControllerV1.class); +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private static final Logger API_LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class); private AuditService auditService; @@ -38,7 +34,7 @@ public class ExceptionHandlerControllerV1 extends ResponseEntityExceptionHandler * * @param auditService {@link AuditService} facilitates persistence of conversion results */ - public ExceptionHandlerControllerV1(final AuditService auditService) { + public GlobalExceptionHandler(final AuditService auditService) { this.auditService = auditService; } @@ -52,7 +48,9 @@ public ExceptionHandlerControllerV1(final AuditService auditService) { @ExceptionHandler(TransformException.class) @ResponseBody ResponseEntity handleTransformException(TransformException exception) { - API_LOG.error("Transform exception occurred", exception); + API_LOG.info("Transform failed validation (422): {}", exception.getMessage()); + API_LOG.debug("TransformException details", exception); + auditService.failConversion(exception.getConversionReport()); return cope(exception); } @@ -67,11 +65,32 @@ ResponseEntity handleTransformException(TransformException exception) @ExceptionHandler(QppValidationException.class) @ResponseBody ResponseEntity handleQppValidationException(QppValidationException exception) { - API_LOG.error("Validation exception occurred", exception); + API_LOG.info("Submission validation failed (422): {}", exception.getMessage()); + API_LOG.debug("QppValidationException details", exception); + auditService.failValidation(exception.getConversionReport()); return cope(exception); } + /** + * Handles {@link NoResourceFoundException} for requests to non-existent paths/resources. + * Returns a clean 404 and logs at DEBUG/TRACE to avoid ERROR-level noise. + */ + @Override + protected ResponseEntity handleNoResourceFoundException( + NoResourceFoundException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + API_LOG.debug("No resource found ({}): {}", status.value(), ex.getMessage()); + API_LOG.trace("NoResourceFoundException details", ex); + + return ResponseEntity.status(status) + .contentType(MediaType.TEXT_PLAIN) + .body("Not found"); + } + /** * "Catch" the {@link NoFileInDatabaseException}. * Return the {@link AllErrors} with an HTTP status 404. diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/controllers/v1/ExceptionHandlerControllerV1Test.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/exceptions/GlobalExceptionHandlerTest.java similarity index 71% rename from rest-api/src/test/java/gov/cms/qpp/conversion/api/controllers/v1/ExceptionHandlerControllerV1Test.java rename to rest-api/src/test/java/gov/cms/qpp/conversion/api/exceptions/GlobalExceptionHandlerTest.java index d96b552f1c..295ff4242a 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/controllers/v1/ExceptionHandlerControllerV1Test.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/exceptions/GlobalExceptionHandlerTest.java @@ -1,38 +1,41 @@ -package gov.cms.qpp.conversion.api.controllers.v1; +package gov.cms.qpp.conversion.api.exceptions; import com.amazonaws.AmazonServiceException; import com.google.common.truth.Truth; +import gov.cms.qpp.conversion.ConversionReport; +import gov.cms.qpp.conversion.Converter; +import gov.cms.qpp.conversion.PathSource; +import gov.cms.qpp.conversion.api.controllers.v1.QrdaControllerV1; +import gov.cms.qpp.conversion.api.helper.AdvancedApmHelper; +import gov.cms.qpp.conversion.api.model.Constants; +import gov.cms.qpp.conversion.api.services.AuditService; +import gov.cms.qpp.conversion.api.services.QrdaService; +import gov.cms.qpp.conversion.api.services.ValidationService; +import gov.cms.qpp.conversion.model.error.AllErrors; +import gov.cms.qpp.conversion.model.error.QppValidationException; +import gov.cms.qpp.conversion.model.error.TransformException; +import gov.cms.qpp.test.MockitoExtension; +import gov.cms.qpp.test.logging.LoggerContract; import org.apache.commons.lang3.ArrayUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.RequestBuilder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import gov.cms.qpp.conversion.ConversionReport; -import gov.cms.qpp.conversion.Converter; -import gov.cms.qpp.conversion.PathSource; -import gov.cms.qpp.conversion.api.exceptions.InvalidFileTypeException; -import gov.cms.qpp.conversion.api.exceptions.InvalidPurposeException; -import gov.cms.qpp.conversion.api.exceptions.NoFileInDatabaseException; -import gov.cms.qpp.conversion.api.helper.AdvancedApmHelper; -import gov.cms.qpp.conversion.api.services.AuditService; -import gov.cms.qpp.conversion.model.error.AllErrors; -import gov.cms.qpp.conversion.model.error.QppValidationException; -import gov.cms.qpp.conversion.model.error.TransformException; -import gov.cms.qpp.test.MockitoExtension; -import gov.cms.qpp.test.logging.LoggerContract; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.nio.file.Path; import java.util.UUID; @@ -41,24 +44,23 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) -class ExceptionHandlerControllerV1Test implements LoggerContract { +class GlobalExceptionHandlerTest implements LoggerContract { private static ConversionReport report; - private static AllErrors allErrors = new AllErrors(); + private static final AllErrors allErrors = new AllErrors(); @InjectMocks - private ExceptionHandlerControllerV1 objectUnderTest; + private GlobalExceptionHandler objectUnderTest; @Mock private AuditService auditService; @BeforeAll static void setup() { + // NOTE: If this path is flaky in CI, move the file to src/test/resources and load via classpath. Path path = Path.of("../qrda-files/valid-QRDA-III-latest.xml"); report = new Converter(new PathSource(path)).getReport(); report.setReportDetails(allErrors); @@ -68,6 +70,8 @@ static void setup() { void before() { when(auditService.failConversion(any(ConversionReport.class))) .thenReturn(CompletableFuture.completedFuture(null)); + when(auditService.failValidation(any(ConversionReport.class))) + .thenReturn(CompletableFuture.completedFuture(null)); } @Test @@ -99,13 +103,14 @@ void testTransformExceptionBody() { new TransformException("test transform exception", new NullPointerException(), report); ResponseEntity responseEntity = objectUnderTest.handleTransformException(exception); + assertThat(responseEntity.getBody()).isEqualTo(allErrors); } @Test void testQppValidationExceptionStatusCode() { QppValidationException exception = - new QppValidationException("test transform exception", new NullPointerException(), report); + new QppValidationException("test validation exception", new NullPointerException(), report); ResponseEntity responseEntity = objectUnderTest.handleQppValidationException(exception); @@ -117,7 +122,7 @@ void testQppValidationExceptionStatusCode() { @Test void testQppValidationExceptionHeaderContentType() { QppValidationException exception = - new QppValidationException("test transform exception", new NullPointerException(), report); + new QppValidationException("test validation exception", new NullPointerException(), report); ResponseEntity responseEntity = objectUnderTest.handleQppValidationException(exception); @@ -128,9 +133,10 @@ void testQppValidationExceptionHeaderContentType() { @Test void testQppValidationExceptionBody() { QppValidationException exception = - new QppValidationException("test transform exception", new NullPointerException(), report); + new QppValidationException("test validation exception", new NullPointerException(), report); ResponseEntity responseEntity = objectUnderTest.handleQppValidationException(exception); + assertThat(responseEntity.getBody()).isEqualTo(allErrors); } @@ -141,7 +147,7 @@ void testFileNotFoundExceptionStatusCode() { ResponseEntity responseEntity = objectUnderTest.handleFileNotFoundException(exception); - assertWithMessage("The response entity's status code must be 422.") + assertWithMessage("The response entity's status code must be 404.") .that(responseEntity.getStatusCode()) .isEqualTo(HttpStatus.NOT_FOUND); } @@ -163,6 +169,7 @@ void testFileNotFoundExceptionBody() { new NoFileInDatabaseException(AdvancedApmHelper.FILE_NOT_FOUND); ResponseEntity responseEntity = objectUnderTest.handleFileNotFoundException(exception); + assertThat(responseEntity.getBody()).isEqualTo(AdvancedApmHelper.FILE_NOT_FOUND); } @@ -173,7 +180,7 @@ void testInvalidFileTypeExceptionStatusCode() { ResponseEntity responseEntity = objectUnderTest.handleInvalidFileTypeException(exception); - assertWithMessage("The response entity's status code must be 422.") + assertWithMessage("The response entity's status code must be 404.") .that(responseEntity.getStatusCode()) .isEqualTo(HttpStatus.NOT_FOUND); } @@ -195,6 +202,7 @@ void testInvalidFileTypeExceptionBody() { new InvalidFileTypeException(AdvancedApmHelper.FILE_NOT_FOUND); ResponseEntity responseEntity = objectUnderTest.handleInvalidFileTypeException(exception); + assertThat(responseEntity.getBody()).isEqualTo(AdvancedApmHelper.FILE_NOT_FOUND); } @@ -208,15 +216,6 @@ void testHandleAmazonExceptionStatusCode() { Truth.assertThat(response.getStatusCodeValue()).isEqualTo(404); } -// @Test -// void testHandleAmazonExceptionResponseBody() { -// AmazonServiceException exception = new AmazonServiceException("some message"); -// -// ResponseEntity response = objectUnderTest.handleAmazonException(exception); -// -// Truth.assertThat(response.getBody()).contains("some message"); -// } - @Test void testHandleInvalidPurposeExceptionExceptionResponseBody() { InvalidPurposeException exception = new InvalidPurposeException("some message"); @@ -228,23 +227,58 @@ void testHandleInvalidPurposeExceptionExceptionResponseBody() { @Test void testHandleInvalidPurposeExceptionResponseBodyDoesInterception() throws Exception { - QrdaControllerV1 mock = Mockito.mock(QrdaControllerV1.class); - MockMvc mvc = MockMvcBuilders.standaloneSetup(mock) - .setControllerAdvice(new ExceptionHandlerControllerV1(auditService)) + // Use a real controller instance (with mocked deps) so Spring MVC can route to it reliably. + QrdaService qrdaService = Mockito.mock(QrdaService.class); + ValidationService validationService = Mockito.mock(ValidationService.class); + + QrdaControllerV1 controller = new QrdaControllerV1(qrdaService, validationService, auditService); + + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler(auditService)) .build(); - when(mock.uploadQrdaFile(ArgumentMatchers.any(), ArgumentMatchers.anyString())).thenCallRealMethod(); String purpose = "this is an invalid purpose because it's too long" + UUID.randomUUID(); + RequestBuilder builder = MockMvcRequestBuilders.multipart("/") - .file("file", ArrayUtils.EMPTY_BYTE_ARRAY) - .header("Purpose", purpose); + .file("file", ArrayUtils.EMPTY_BYTE_ARRAY) + .header("Purpose", purpose) + .header("Accept", Constants.V1_API_ACCEPT); + MvcResult result = mvc.perform(builder).andReturn(); - Truth.assertThat(result.getResponse().getContentAsString()).isEqualTo("Given Purpose (header) is too large. Max length is " - + "25, yours was " + purpose.length()); + + Truth.assertThat(result.getResponse().getStatus()).isEqualTo(400); + Truth.assertThat(result.getResponse().getContentAsString()) + .isEqualTo("Given Purpose (header) is too large. Max length is 25, yours was " + purpose.length()); + } + + @Test + void testHandleNoResourceFoundExceptionReturnsPlain404() throws Exception { + NoResourceFoundException ex; + try { + ex = NoResourceFoundException.class + .getConstructor(HttpMethod.class, String.class) + .newInstance(HttpMethod.GET, "/test"); + } catch (NoSuchMethodException ignore) { + ex = NoResourceFoundException.class + .getConstructor(HttpMethod.class, String.class, String.class) + .newInstance(HttpMethod.GET, "/test", "/test"); + } + + ResponseEntity response = objectUnderTest.handleNoResourceFoundException( + ex, + new HttpHeaders(), + HttpStatus.NOT_FOUND, + Mockito.mock(WebRequest.class) + ); + + Truth.assertThat(response.getStatusCodeValue()).isEqualTo(404); + Truth.assertThat(response.getHeaders().getContentType()) + .isEquivalentAccordingToCompareTo(MediaType.TEXT_PLAIN); + Truth.assertThat(response.getBody()).isEqualTo("Not found"); } @Override public Class getLoggerType() { - return ExceptionHandlerControllerV1.class; + return GlobalExceptionHandler.class; } } diff --git a/test-commons/pom.xml b/test-commons/pom.xml index 58463b598c..c1acef3f5c 100644 --- a/test-commons/pom.xml +++ b/test-commons/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE ../pom.xml diff --git a/test-coverage/pom.xml b/test-coverage/pom.xml index dcaee6ac60..32a3949d24 100644 --- a/test-coverage/pom.xml +++ b/test-coverage/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.02.17.01-RELEASE + 2026.02.27.01-RELEASE ../pom.xml