diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
new file mode 100644
index 000000000..0f8745d63
--- /dev/null
+++ b/.github/workflows/integration-tests.yml
@@ -0,0 +1,91 @@
+name: Integration Tests
+
+# Boots selected sample apps against real Postgres / MongoDB containers (Testcontainers) and
+# exercises synapse's controller, data-adapter, exception, and api-docs surfaces end-to-end.
+#
+# Default run: push or PR to develop -> runs against develop.
+# Custom run: workflow_dispatch with a `branch` input -> checks out that branch and tests it.
+# This is how a contributor verifies a branch (e.g. a synapse-data-mongodb patch) doesn't break
+# the routes/objects the framework ships, without setting up Docker locally.
+
+on:
+ push:
+ branches: [develop]
+ pull_request:
+ branches: [develop]
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: 'Branch to integration-test (defaults to develop)'
+ required: false
+ default: 'develop'
+ type: string
+
+# Opt into Node.js 24 for the @v4 javascript actions below (checkout, setup-java, cache,
+# upload-artifact). Node 20 will be removed from the runner in September 2026; opting in
+# now silences the deprecation warning and avoids a forced switch later.
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ integration-tests:
+ permissions:
+ contents: read
+ checks: write # required by mikepenz/action-junit-report to publish the test-results check
+ pull-requests: write
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.inputs.branch || github.ref }}
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'zulu'
+
+ - name: Cache Maven dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2
+ key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
+ restore-keys: ${{ runner.os }}-m2
+
+ - name: Install reactor (so sample apps resolve the checked-out synapse jars)
+ run: mvn -B -DskipTests -DskipITs install
+
+ - name: Run integration tests
+ # The previous step already installed every reactor module to ~/.m2, so we don't need
+ # `-am` here; the listed sample modules pull their synapse deps from the local repo.
+ # `-fae` (fail-at-end) keeps Maven going across modules so a failure in one sample
+ # doesn't hide the result of the other.
+ run: |
+ mvn -B -fae -P integration-tests verify \
+ -pl service/service-samples/sample-service-rest-postgres-book,service/service-samples/sample-service-reactive-mongodb-book
+
+ - name: Publish test report
+ # Always runs so the summary table is available on both green and red runs. The action
+ # itself reports failures back to the job, so the previous Maven step can stay green if
+ # we ever want to inspect reports without failing the workflow.
+ uses: mikepenz/action-junit-report@v5
+ if: always()
+ with:
+ report_paths: '**/target/failsafe-reports/TEST-*.xml'
+ check_name: Integration Test Results
+ detailed_summary: true
+ include_passed: true
+ require_tests: true
+ fail_on_failure: false # the Maven verify step already fails the job on test failures
+
+ - name: Upload Failsafe reports on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: failsafe-reports
+ path: |
+ **/target/failsafe-reports/
+ **/target/surefire-reports/
+ retention-days: 14
diff --git a/pom.xml b/pom.xml
index a8e1b90fe..06c8aecfc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -612,6 +612,21 @@
testcontainers
1.21.2
+
+ org.testcontainers
+ junit-jupiter
+ 1.21.2
+
+
+ org.testcontainers
+ postgresql
+ 1.21.2
+
+
+ org.testcontainers
+ mongodb
+ 1.21.2
+
@@ -1344,5 +1359,22 @@
+
+
+ integration-tests
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+ false
+
+
+
+
+
+
diff --git a/service/service-samples/sample-service-reactive-mongodb-book/pom.xml b/service/service-samples/sample-service-reactive-mongodb-book/pom.xml
index 8294fe28d..6fc153149 100644
--- a/service/service-samples/sample-service-reactive-mongodb-book/pom.xml
+++ b/service/service-samples/sample-service-reactive-mongodb-book/pom.xml
@@ -34,6 +34,37 @@
io.americanexpress.synapse
sample-data-mongodb-reactive
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ mongodb
+ test
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+
+
diff --git a/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookCrudIT.java b/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookCrudIT.java
new file mode 100644
index 000000000..d3d96ae68
--- /dev/null
+++ b/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookCrudIT.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2020 American Express Travel Related Services Company, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.americanexpress.service.book.rest.integration;
+
+import io.americanexpress.data.book.repository.BookRepository;
+import io.americanexpress.service.book.rest.BookApplication;
+import io.americanexpress.service.book.rest.model.CreateBookRequest;
+import io.americanexpress.service.book.rest.model.ReadBookRequest;
+import io.americanexpress.service.book.rest.model.ReadBookResponse;
+import io.americanexpress.service.book.rest.model.UpdateBookRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+@SpringBootTest(classes = BookApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
+class BookCrudIT {
+
+ @DynamicPropertySource
+ static void props(DynamicPropertyRegistry registry) {
+ BookMongoContainer.registerProperties(registry);
+ }
+
+ @Autowired
+ private WebTestClient web;
+
+ @Autowired
+ private BookRepository bookRepository;
+
+ @BeforeEach
+ void resetCollection() {
+ bookRepository.deleteAll().block();
+ }
+
+ @Test
+ void createReadUpdateDelete_givenValidBook_endToEndAgainstRealMongo() {
+ CreateBookRequest create = new CreateBookRequest();
+ create.setTitle("Reactive Synapse");
+ create.setAuthor("synapse-it");
+
+ web.post().uri("/v1/books")
+ .bodyValue(create)
+ .exchange()
+ .expectStatus().isCreated();
+
+ // BaseReadFluxReactiveController exposes POST /v1/books/multiple_results — Flux response
+ ReadBookRequest read = new ReadBookRequest();
+ web.post().uri("/v1/books/multiple_results")
+ .bodyValue(read)
+ .exchange()
+ .expectStatus().isOk()
+ .expectBodyList(ReadBookResponse.class)
+ .hasSize(1)
+ .value(books -> {
+ ReadBookResponse first = books.get(0);
+ org.junit.jupiter.api.Assertions.assertEquals("Reactive Synapse", first.getTitle());
+ org.junit.jupiter.api.Assertions.assertEquals("synapse-it", first.getAuthor());
+ });
+
+ UpdateBookRequest update = new UpdateBookRequest();
+ update.setTitle("Reactive Synapse");
+ update.setAuthor("synapse-it");
+ update.setNumberOfCopies(7);
+ web.put().uri("/v1/books")
+ .bodyValue(update)
+ .exchange()
+ .expectStatus().is2xxSuccessful();
+
+ // Verify the update landed in the data adapter
+ org.junit.jupiter.api.Assertions.assertEquals(
+ 7,
+ bookRepository.findByTitleAndAuthor("Reactive Synapse", "synapse-it")
+ .block()
+ .getNumberOfCopies());
+
+ web.delete().uri("/v1/books/Reactive Synapse")
+ .exchange()
+ .expectStatus().isNoContent();
+
+ org.junit.jupiter.api.Assertions.assertEquals(0L, bookRepository.count().block());
+ }
+}
diff --git a/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookExceptionEnvelopeIT.java b/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookExceptionEnvelopeIT.java
new file mode 100644
index 000000000..9e20f37de
--- /dev/null
+++ b/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookExceptionEnvelopeIT.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2020 American Express Travel Related Services Company, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.americanexpress.service.book.rest.integration;
+
+import io.americanexpress.data.book.repository.BookRepository;
+import io.americanexpress.service.book.rest.BookApplication;
+import io.americanexpress.service.book.rest.model.CreateBookRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+@SpringBootTest(classes = BookApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
+class BookExceptionEnvelopeIT {
+
+ @DynamicPropertySource
+ static void props(DynamicPropertyRegistry registry) {
+ BookMongoContainer.registerProperties(registry);
+ }
+
+ @Autowired
+ private WebTestClient web;
+
+ @Autowired
+ private BookRepository bookRepository;
+
+ @BeforeEach
+ void resetCollection() {
+ bookRepository.deleteAll().block();
+ }
+
+ @Test
+ void create_givenBlankRequiredFields_returnsBadRequest() {
+ // CreateBookRequest title and author are @NotBlank — sending blanks must be rejected.
+ CreateBookRequest invalid = new CreateBookRequest();
+ invalid.setTitle("");
+ invalid.setAuthor("");
+
+ web.post().uri("/v1/books")
+ .bodyValue(invalid)
+ .exchange()
+ .expectStatus().isBadRequest();
+ }
+
+ @Test
+ void create_givenMalformedJson_returnsBadRequest() {
+ web.post().uri("/v1/books")
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue("{not-valid-json")
+ .exchange()
+ .expectStatus().is4xxClientError();
+ }
+}
diff --git a/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookMongoContainer.java b/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookMongoContainer.java
new file mode 100644
index 000000000..9f1a66237
--- /dev/null
+++ b/service/service-samples/sample-service-reactive-mongodb-book/src/test/java/io/americanexpress/service/book/rest/integration/BookMongoContainer.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2020 American Express Travel Related Services Company, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.americanexpress.service.book.rest.integration;
+
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.testcontainers.containers.MongoDBContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * Singleton MongoDB container shared across every IT in this module so Spring's
+ * test-context cache can reuse one application context for all suites.
+ */
+final class BookMongoContainer {
+
+ private static final MongoDBContainer INSTANCE =
+ new MongoDBContainer(DockerImageName.parse("mongo:7"));
+
+ static {
+ INSTANCE.start();
+ }
+
+ private BookMongoContainer() {}
+
+ static void registerProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.data.mongodb.uri", INSTANCE::getReplicaSetUrl);
+ registry.add("spring.data.mongodb.database", () -> "synapse_it");
+ }
+}
diff --git a/service/service-samples/sample-service-rest-postgres-book/pom.xml b/service/service-samples/sample-service-rest-postgres-book/pom.xml
index 3fd2a83a1..86212c675 100644
--- a/service/service-samples/sample-service-rest-postgres-book/pom.xml
+++ b/service/service-samples/sample-service-rest-postgres-book/pom.xml
@@ -33,6 +33,36 @@
synapse-service-rest
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.postgresql
+ postgresql
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ postgresql
+ test
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+
+
diff --git a/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookCrudIT.java b/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookCrudIT.java
new file mode 100644
index 000000000..d38293118
--- /dev/null
+++ b/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookCrudIT.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020 American Express Travel Related Services Company, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.americanexpress.service.book.rest.integration;
+
+import io.americanexpress.service.book.rest.BookApplication;
+import io.americanexpress.service.book.rest.model.CreateBookRequest;
+import io.americanexpress.service.book.rest.model.ReadBookRequest;
+import io.americanexpress.service.book.rest.model.ReadBookResponse;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(classes = BookApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("integration-test")
+class BookCrudIT {
+
+ @DynamicPropertySource
+ static void props(DynamicPropertyRegistry registry) {
+ BookPostgresContainer.registerProperties(registry);
+ }
+
+ @Autowired
+ private TestRestTemplate http;
+
+ @Test
+ void createReadDelete_givenValidBook_endToEndAgainstRealPostgres() {
+ CreateBookRequest create = new CreateBookRequest();
+ create.setTitle("Synapse Crud Lifecycle");
+ create.setAuthor("synapse-it");
+
+ ResponseEntity created = http.postForEntity("/v1/books", create, Void.class);
+ assertEquals(HttpStatus.CREATED, created.getStatusCode());
+ assertNotNull(created.getHeaders().getLocation(), "BaseCreateController must emit Location");
+ assertTrue(created.getHeaders().getLocation().toString().endsWith("/v1/books/0"),
+ "CreateBookResponse has no id, so synapse falls back to /0");
+
+ ReadBookRequest read = new ReadBookRequest();
+ read.setTitle("Synapse Crud Lifecycle");
+ read.setAuthor("synapse-it");
+ ResponseEntity readResp = http.postForEntity(
+ "/v1/books/inquiry_results", read, ReadBookResponse.class);
+ assertEquals(HttpStatus.OK, readResp.getStatusCode());
+ assertNotNull(readResp.getBody());
+ assertEquals("Synapse Crud Lifecycle", readResp.getBody().getTitle());
+ assertEquals("synapse-it", readResp.getBody().getAuthor());
+
+ // BaseDeleteController exposes DELETE /v1/books/{identifier}; DeleteBookService treats
+ // the identifier as a title, so we delete by the book's title.
+ ResponseEntity deleted = http.exchange(
+ "/v1/books/Synapse Crud Lifecycle",
+ org.springframework.http.HttpMethod.DELETE,
+ null,
+ Void.class);
+ assertEquals(HttpStatus.NO_CONTENT, deleted.getStatusCode());
+
+ ResponseEntity readAfter = http.postForEntity(
+ "/v1/books/inquiry_results", read, ReadBookResponse.class);
+ assertEquals(HttpStatus.NO_CONTENT, readAfter.getStatusCode(),
+ "MonoResponseEntityCreator returns 204 when the service produces null");
+ }
+}
diff --git a/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookExceptionEnvelopeIT.java b/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookExceptionEnvelopeIT.java
new file mode 100644
index 000000000..79968154c
--- /dev/null
+++ b/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookExceptionEnvelopeIT.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020 American Express Travel Related Services Company, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.americanexpress.service.book.rest.integration;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.americanexpress.service.book.rest.BookApplication;
+import io.americanexpress.service.book.rest.model.CreateBookRequest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(classes = BookApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("integration-test")
+class BookExceptionEnvelopeIT {
+
+ @DynamicPropertySource
+ static void props(DynamicPropertyRegistry registry) {
+ BookPostgresContainer.registerProperties(registry);
+ }
+
+ @Autowired
+ private TestRestTemplate http;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Test
+ void create_givenBlankRequiredFields_returnsSynapseErrorEnvelope() throws Exception {
+ // BookRequest fields title and author are @NotBlank; sending blanks must trigger
+ // ControllerExceptionHandler.handleMethodArgumentNotValidException -> ErrorResponse @ 400.
+ CreateBookRequest invalid = new CreateBookRequest();
+ invalid.setTitle("");
+ invalid.setAuthor("");
+
+ ResponseEntity response = http.postForEntity("/v1/books", invalid, String.class);
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+
+ JsonNode body = objectMapper.readTree(response.getBody());
+ assertNotNull(body.get("code"), "ErrorResponse.code missing");
+ assertNotNull(body.get("message"), "ErrorResponse.message missing");
+ assertNotNull(body.get("moreInfo"), "ErrorResponse.moreInfo missing");
+ assertNotNull(body.get("developerMessage"), "ErrorResponse.developerMessage missing");
+ }
+
+ @Test
+ void create_givenMalformedJson_returnsSynapseErrorEnvelopeFor4xx() throws Exception {
+ // Triggers HttpMessageNotReadableException -> handler returns ErrorResponse with the
+ // GENERIC_4XX_ERROR code (400-series).
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity request = new HttpEntity<>("{not-valid-json", headers);
+
+ ResponseEntity response = http.exchange("/v1/books", HttpMethod.POST, request, String.class);
+ assertTrue(response.getStatusCode().is4xxClientError(),
+ "Expected 4xx, got " + response.getStatusCode());
+
+ JsonNode body = objectMapper.readTree(response.getBody());
+ assertNotNull(body.get("code"));
+ assertNotNull(body.get("message"));
+ assertNotNull(body.get("moreInfo"));
+ assertNotNull(body.get("developerMessage"));
+ }
+}
diff --git a/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookPostgresContainer.java b/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookPostgresContainer.java
new file mode 100644
index 000000000..df9454222
--- /dev/null
+++ b/service/service-samples/sample-service-rest-postgres-book/src/test/java/io/americanexpress/service/book/rest/integration/BookPostgresContainer.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020 American Express Travel Related Services Company, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.americanexpress.service.book.rest.integration;
+
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * Singleton PostgreSQL container shared across every IT in this module so Spring's
+ * test-context cache can reuse one application context for all suites.
+ */
+final class BookPostgresContainer {
+
+ private static final PostgreSQLContainer> INSTANCE =
+ new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));
+
+ static {
+ INSTANCE.start();
+ }
+
+ private BookPostgresContainer() {}
+
+ static void registerProperties(DynamicPropertyRegistry registry) {
+ // The default `test` profile in data-book-application.properties wires up H2 and gates
+ // the real Postgres config behind @Profile("!test"). ITs activate `integration-test`
+ // so DataBookConfig loads, then we point the datasource at the container here.
+ registry.add("spring.datasource.jdbcUrl", INSTANCE::getJdbcUrl);
+ registry.add("spring.datasource.url", INSTANCE::getJdbcUrl);
+ registry.add("spring.datasource.username", INSTANCE::getUsername);
+ registry.add("spring.datasource.password", INSTANCE::getPassword);
+ registry.add("spring.datasource.driver-class-name", INSTANCE::getDriverClassName);
+ registry.add("spring.jpa.properties.hibernate.default_schema", () -> "public");
+ registry.add("hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect");
+ registry.add("hibernate.hbm2ddl.auto", () -> "create-drop");
+ registry.add("hibernate.show_sql", () -> "false");
+ registry.add("hibernate.format_sql", () -> "false");
+ }
+}