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; + } +}