diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..02660d7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build and verify
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Java 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: maven
+
+ - name: Make Maven Wrapper executable
+ run: chmod +x mvnw
+
+ - name: Verify with Maven
+ run: ./mvnw --batch-mode clean verify
diff --git a/README.md b/README.md
index 93c7479..f0f00dc 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ Merchant API is a Spring Boot REST service for managing merchants and their paym
- Oracle JDBC for local and production-style runtime
- Flyway for SQL database migrations
- H2 for test execution
+- Testcontainers with Oracle Free for integration testing
- Springdoc OpenAPI / Swagger UI
- Docker Compose with Oracle Free
- Maven Wrapper
@@ -72,8 +73,11 @@ The `test` profile uses an in-memory H2 database in Oracle compatibility mode so
```powershell
.\mvnw.cmd clean compile
.\mvnw.cmd clean test
+.\mvnw.cmd clean verify
```
+`clean verify` includes a Testcontainers-backed Oracle integration test. Docker must be running locally.
+
## Docker Compose
Start the API with Oracle Free:
@@ -84,6 +88,16 @@ docker compose up --build
The API is exposed on `localhost:8080`, and Oracle is exposed on `localhost:1521`.
+## CI
+
+GitHub Actions runs on every push and pull request to `main`:
+
+```text
+.github/workflows/ci.yml
+```
+
+The workflow uses Java 21, Maven dependency caching, Docker-enabled Ubuntu runners, and `./mvnw --batch-mode clean verify`.
+
## Local Run
Set database credentials if your Oracle setup differs from the defaults:
diff --git a/pom.xml b/pom.xml
index d45fb66..91bd4d3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -61,6 +61,21 @@
h2
test
+
+ org.testcontainers
+ testcontainers
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ oracle-xe
+ test
+
org.springframework.boot
spring-boot-starter-test
diff --git a/src/main/java/com/portfolio/merchantapi/transaction/TransactionService.java b/src/main/java/com/portfolio/merchantapi/transaction/TransactionService.java
index 6c58a33..f7bc6b0 100644
--- a/src/main/java/com/portfolio/merchantapi/transaction/TransactionService.java
+++ b/src/main/java/com/portfolio/merchantapi/transaction/TransactionService.java
@@ -77,6 +77,10 @@ private boolean isConnectionMode(String connectionMode) {
}
private IdempotencyRecord createIdempotencyRecord(String idempotencyKey) {
+ if (idempotencyRecordRepository.existsById(idempotencyKey)) {
+ throw new DuplicateIdempotencyKeyException(idempotencyKey);
+ }
+
IdempotencyRecord record = new IdempotencyRecord();
record.setIdempotencyKey(idempotencyKey);
record.setCreatedAt(LocalDateTime.now());
diff --git a/src/test/java/com/portfolio/merchantapi/transaction/TransactionIntegrationTest.java b/src/test/java/com/portfolio/merchantapi/transaction/TransactionIntegrationTest.java
new file mode 100644
index 0000000..45c7c95
--- /dev/null
+++ b/src/test/java/com/portfolio/merchantapi/transaction/TransactionIntegrationTest.java
@@ -0,0 +1,111 @@
+package com.portfolio.merchantapi.transaction;
+
+import com.portfolio.merchantapi.merchant.MerchantRequest;
+import com.portfolio.merchantapi.merchant.MerchantResponse;
+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.boot.test.web.server.LocalServerPort;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.OracleContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Testcontainers
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class TransactionIntegrationTest {
+
+ private static final DockerImageName ORACLE_IMAGE = DockerImageName
+ .parse("gvenzl/oracle-free:23-slim")
+ .asCompatibleSubstituteFor("gvenzl/oracle-xe");
+
+ @Container
+ static final OracleContainer oracle = new OracleContainer(ORACLE_IMAGE)
+ .withUsername("merchant_api")
+ .withPassword("merchant_api");
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @DynamicPropertySource
+ static void configureOracle(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", oracle::getJdbcUrl);
+ registry.add("spring.datasource.username", oracle::getUsername);
+ registry.add("spring.datasource.password", oracle::getPassword);
+ registry.add("spring.flyway.locations", () -> "classpath:db/migration/oracle");
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
+ }
+
+ @Test
+ void createTransactionRejectsDuplicateIdempotencyKey() {
+ MerchantResponse merchant = createMerchant();
+ TransactionRequest request = new TransactionRequest(
+ BigDecimal.valueOf(25.50),
+ merchant.id(),
+ "SALE",
+ "Live"
+ );
+
+ HttpEntity transactionEntity = new HttpEntity<>(request, idempotencyHeaders());
+
+ ResponseEntity firstResponse = restTemplate.postForEntity(
+ url("/api/v1/transactions"),
+ transactionEntity,
+ TransactionResponse.class
+ );
+ ResponseEntity duplicateResponse = restTemplate.postForEntity(
+ url("/api/v1/transactions"),
+ transactionEntity,
+ String.class
+ );
+
+ assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
+ assertThat(firstResponse.getBody()).isNotNull();
+ assertThat(firstResponse.getBody().merchantId()).isEqualTo(merchant.id());
+
+ assertThat(duplicateResponse.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
+ assertThat(duplicateResponse.getBody()).contains("Duplicate idempotency key");
+ }
+
+ private MerchantResponse createMerchant() {
+ MerchantRequest request = new MerchantRequest("Acme Market", "100 Main Street");
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ url("/api/v1/merchants"),
+ new HttpEntity<>(request, headers),
+ MerchantResponse.class
+ );
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
+ assertThat(response.getBody()).isNotNull();
+ return response.getBody();
+ }
+
+ private HttpHeaders idempotencyHeaders() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("Idempotency-Key", "txn-integration-0001");
+ return headers;
+ }
+
+ private String url(String path) {
+ return "http://localhost:" + port + path;
+ }
+}