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
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public abstract class AbstractIntegrationTest {
POSTGRES.start();
KAFKA.start();
REDIS.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
REDIS.stop();
KAFKA.stop();
POSTGRES.stop();
}));
}

@Autowired
Expand All @@ -50,6 +55,7 @@ void cleanDatabase() {
oauth_clients,
rate_limit_events,
gateway_audit_log,
gatewayiam_idempotency_keys,
merchants
CASCADE
""");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.stablecoin.payments.gateway.iam.application.config;

import com.stablecoin.payments.gateway.iam.AbstractIntegrationTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;

import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@DisplayName("IdempotencyKeyFilter IT")
class IdempotencyKeyFilterIT extends AbstractIntegrationTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private JdbcTemplate jdbcTemplate;

private static final String MERCHANT_ENDPOINT = "/v1/merchants";

@Test
@DisplayName("should persist idempotency key after successful mutation")
void shouldPersistIdempotencyKey_afterSuccessfulMutation() throws Exception {
var idempotencyKey = UUID.randomUUID().toString();
var externalId = UUID.randomUUID();

mockMvc.perform(post(MERCHANT_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotencyKey)
.content("""
{
"externalId": "%s",
"name": "Idempotency Test Corp",
"country": "US"
}
""".formatted(externalId)))
.andExpect(status().isCreated());

var count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM gatewayiam_idempotency_keys WHERE idempotency_key = ?",
Integer.class, idempotencyKey);

assertThat(count).isEqualTo(1);
}

@Test
@DisplayName("should replay response on duplicate request with same key and body")
void shouldReplayResponse_onDuplicateRequest() throws Exception {
var idempotencyKey = UUID.randomUUID().toString();
var externalId = UUID.randomUUID();
var requestBody = """
{
"externalId": "%s",
"name": "Replay Test Corp",
"country": "US"
}
""".formatted(externalId);

// First request
var firstResponse = mockMvc.perform(post(MERCHANT_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotencyKey)
.content(requestBody))
.andExpect(status().isCreated())
.andReturn();

// Second request — same key, same body
var secondResponse = mockMvc.perform(post(MERCHANT_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotencyKey)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(header().string("Idempotency-Replay", "true"))
.andReturn();

assertThat(secondResponse.getResponse().getContentAsString())
.isEqualTo(firstResponse.getResponse().getContentAsString());
}

@Test
@DisplayName("should return 422 when same key but different body")
void shouldReturn422_whenSameKeyDifferentBody() throws Exception {
var idempotencyKey = UUID.randomUUID().toString();

// First request
mockMvc.perform(post(MERCHANT_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotencyKey)
.content("""
{
"externalId": "%s",
"name": "First Corp",
"country": "US"
}
""".formatted(UUID.randomUUID())))
.andExpect(status().isCreated());

// Second request — same key, different body
mockMvc.perform(post(MERCHANT_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotencyKey)
.content("""
{
"externalId": "%s",
"name": "Different Corp",
"country": "GB"
}
""".formatted(UUID.randomUUID())))
.andExpect(status().isUnprocessableEntity());
}

@Test
@DisplayName("should delete expired keys when cleanup job runs")
void shouldDeleteExpiredKeys_whenCleanupJobRuns() throws Exception {
// Insert an expired idempotency key directly
jdbcTemplate.update(
"INSERT INTO gatewayiam_idempotency_keys"
+ " (idempotency_key, request_method, request_path, request_hash, response_body, status_code, expires_at)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?)",
"expired-key", "POST", "/v1/merchants", "somehash", "{}", 200,
Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS)));

var cleanupJob = new com.stablecoin.payments.gateway.iam.application.job.IdempotencyCleanupJob(jdbcTemplate);
cleanupJob.cleanExpiredKeys();

var count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM gatewayiam_idempotency_keys WHERE idempotency_key = ?",
Integer.class, "expired-key");

assertThat(count).isEqualTo(0);
}
Comment on lines +32 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a concurrent duplicate-request integration test.

Current suite does not verify the “same key submitted concurrently” path. Add a parallel POST test asserting a single mutation and one replay/duplicate handling path to lock down the core guarantee.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilterIT.java`
around lines 32 - 141, Add a new concurrent integration test in
IdempotencyKeyFilterIT (e.g., shouldHandleConcurrentRequests_sameKeyConcurrent)
that submits two POSTs in parallel with the same "Idempotency-Key" and identical
body using MockMvc + an ExecutorService or CompletableFutures and a
CountDownLatch to synchronize start; from the two responses assert one resulted
in creation and the other either contains header "Idempotency-Replay" = "true"
(or returns the same response body) and then verify via jdbcTemplate that only
one row exists for that idempotency key (query SELECT COUNT(*) FROM
gatewayiam_idempotency_keys WHERE idempotency_key = ? and assert count == 1);
ensure the test uses the existing MERCHANT_ENDPOINT, builds the same requestBody
and same idempotencyKey, and cleans up any inserted test data if needed.

}
Loading
Loading