Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>oracle-xe</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TransactionRequest> transactionEntity = new HttpEntity<>(request, idempotencyHeaders());

ResponseEntity<TransactionResponse> firstResponse = restTemplate.postForEntity(
url("/api/v1/transactions"),
transactionEntity,
TransactionResponse.class
);
ResponseEntity<String> 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<MerchantResponse> 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;
}
}
Loading