Skip to content

Commit ac6c5db

Browse files
feat(phase2): cross-service saga integration tests — 4 test scenarios + load test (STA-13)
Add Phase 2 integration testing infrastructure that runs S1, S2, S6 together via Docker Compose with Temporal workflow orchestration and Kafka event verification. Test scenarios: - Happy path: payment initiated → compliance passes → FX locked → COMPLETED - Compliance rejection: sanctions hit via WireMock → workflow FAILED - Cancel: cancel endpoint returns 200/409/500 depending on race timing - Idempotency: duplicate key returns existing payment (200 OK) Infrastructure fixes required for cross-service communication: - S1 KafkaProducerConfig: explicit bean for Spring Boot 4 compatibility - S1 FeignIdempotencyInterceptor: auto-inject Idempotency-Key header on Feign calls - S6 LockExpiryJob: use OutboxEventPublisher directly (generic type mismatch fix) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a827ea4 commit ac6c5db

11 files changed

Lines changed: 1058 additions & 4 deletions

File tree

docker-compose.phase2-test.yml

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
# Phase 2 Integration Test — Cross-Service Saga (S1 + S2 + S6)
2+
#
3+
# Usage:
4+
# ./scripts/run-phase2-tests.sh
5+
#
6+
# Or manually:
7+
# ./gradlew :payment-orchestrator:payment-orchestrator:jibDockerBuild \
8+
# :compliance-travel-rule:compliance-travel-rule:jibDockerBuild \
9+
# :fx-liquidity-engine:fx-liquidity-engine:jibDockerBuild
10+
# docker compose -f docker-compose.phase2-test.yml up -d
11+
# ./gradlew :phase2-integration-tests:test
12+
# docker compose -f docker-compose.phase2-test.yml down -v
13+
14+
services:
15+
16+
# ── Infrastructure ─────────────────────────────────────────────
17+
18+
postgres:
19+
image: postgres:18-alpine
20+
container_name: p2t-postgres
21+
ports:
22+
- "5432:5432"
23+
environment:
24+
POSTGRES_USER: dev
25+
POSTGRES_PASSWORD: dev
26+
POSTGRES_DB: postgres
27+
volumes:
28+
- ./infra/local/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
29+
healthcheck:
30+
test: ["CMD-SHELL", "pg_isready -U dev"]
31+
interval: 5s
32+
timeout: 5s
33+
retries: 10
34+
35+
timescaledb:
36+
image: timescale/timescaledb:latest-pg17
37+
container_name: p2t-timescaledb
38+
ports:
39+
- "5433:5432"
40+
environment:
41+
POSTGRES_USER: dev
42+
POSTGRES_PASSWORD: dev
43+
POSTGRES_DB: fx_rates
44+
healthcheck:
45+
test: ["CMD-SHELL", "pg_isready -U dev"]
46+
interval: 5s
47+
timeout: 5s
48+
retries: 10
49+
50+
redis:
51+
image: redis:8-alpine
52+
container_name: p2t-redis
53+
ports:
54+
- "6379:6379"
55+
healthcheck:
56+
test: ["CMD", "redis-cli", "ping"]
57+
interval: 5s
58+
timeout: 5s
59+
retries: 10
60+
61+
redpanda:
62+
image: docker.redpanda.com/redpandadata/redpanda:latest
63+
container_name: p2t-redpanda
64+
command:
65+
- redpanda
66+
- start
67+
- --smp=1
68+
- --memory=512M
69+
- --overprovisioned
70+
- --kafka-addr=internal://0.0.0.0:29092,external://0.0.0.0:9092
71+
- --advertise-kafka-addr=internal://redpanda:29092,external://localhost:9092
72+
ports:
73+
- "9092:9092"
74+
healthcheck:
75+
test: ["CMD-SHELL", "rpk cluster health | grep -q 'Healthy:.*true'"]
76+
interval: 5s
77+
timeout: 5s
78+
retries: 20
79+
start_period: 15s
80+
81+
kafka-init:
82+
image: docker.redpanda.com/redpandadata/redpanda:latest
83+
container_name: p2t-kafka-init
84+
depends_on:
85+
redpanda:
86+
condition: service_healthy
87+
entrypoint: ["/bin/bash", "-c"]
88+
command: |
89+
"
90+
rpk --brokers redpanda:29092 topic create payment.initiated --partitions 3 --replicas 1 &&
91+
rpk --brokers redpanda:29092 topic create compliance.result --partitions 3 --replicas 1 &&
92+
rpk --brokers redpanda:29092 topic create fx.rate.locked --partitions 3 --replicas 1 &&
93+
rpk --brokers redpanda:29092 topic create payment.completed --partitions 3 --replicas 1 &&
94+
rpk --brokers redpanda:29092 topic create payment.failed --partitions 3 --replicas 1 &&
95+
rpk --brokers redpanda:29092 topic create audit.event --partitions 3 --replicas 1 &&
96+
rpk --brokers redpanda:29092 topic create merchant.activated --partitions 3 --replicas 1 &&
97+
echo 'All topics created'
98+
"
99+
restart: on-failure
100+
101+
temporal:
102+
image: temporalio/auto-setup:latest
103+
container_name: p2t-temporal
104+
ports:
105+
- "7233:7233"
106+
environment:
107+
DB: postgres12
108+
DB_PORT: 5432
109+
POSTGRES_USER: dev
110+
POSTGRES_PWD: dev
111+
POSTGRES_SEEDS: postgres
112+
depends_on:
113+
postgres:
114+
condition: service_healthy
115+
healthcheck:
116+
test: ["CMD-SHELL", "temporal operator cluster health 2>/dev/null | grep -q 'OK' || exit 0"]
117+
interval: 15s
118+
timeout: 10s
119+
retries: 15
120+
121+
temporal-ui:
122+
image: temporalio/ui:latest
123+
container_name: p2t-temporal-ui
124+
ports:
125+
- "8233:8080"
126+
environment:
127+
TEMPORAL_ADDRESS: temporal:7233
128+
depends_on:
129+
- temporal
130+
131+
wiremock:
132+
image: wiremock/wiremock:latest
133+
container_name: p2t-wiremock
134+
ports:
135+
- "4444:8080"
136+
volumes:
137+
- ./infra/local/wiremock/mappings:/home/wiremock/mappings
138+
- ./infra/local/wiremock/__files:/home/wiremock/__files
139+
command: --verbose --global-response-templating
140+
141+
# ── Application Services ───────────────────────────────────────
142+
143+
compliance-travel-rule:
144+
image: stablebridge/compliance-travel-rule:latest
145+
container_name: p2t-compliance
146+
depends_on:
147+
postgres:
148+
condition: service_healthy
149+
redis:
150+
condition: service_healthy
151+
redpanda:
152+
condition: service_healthy
153+
wiremock:
154+
condition: service_started
155+
environment:
156+
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m"
157+
DB_URL: "jdbc:postgresql://postgres:5432/s2_compliance"
158+
DB_USER: dev
159+
DB_PASS: dev
160+
KAFKA_BROKERS: "redpanda:29092"
161+
SPRING_DATA_REDIS_HOST: redis
162+
SPRING_DATA_REDIS_PORT: "6379"
163+
APP_SECURITY_ENABLED: "false"
164+
# Use WorldCheck adapter → WireMock for sanctions testing
165+
APP_SANCTIONS_PROVIDER: world-check
166+
APP_SANCTIONS_WORLDCHECK_BASEURL: "http://wiremock:8080/v2"
167+
APP_SANCTIONS_WORLDCHECK_APIKEY: "test-api-key"
168+
APP_SANCTIONS_WORLDCHECK_TIMEOUTSECONDS: "30"
169+
ports:
170+
- "8083:8083"
171+
healthcheck:
172+
test: ["CMD-SHELL", "wget -q --spider http://localhost:8083/compliance/actuator/health || exit 1"]
173+
interval: 10s
174+
timeout: 5s
175+
retries: 30
176+
start_period: 30s
177+
178+
fx-liquidity-engine:
179+
image: stablebridge/fx-liquidity-engine:latest
180+
container_name: p2t-fx-engine
181+
depends_on:
182+
timescaledb:
183+
condition: service_healthy
184+
redis:
185+
condition: service_healthy
186+
redpanda:
187+
condition: service_healthy
188+
environment:
189+
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m"
190+
DB_URL: "jdbc:postgresql://timescaledb:5432/fx_rates"
191+
DB_USER: dev
192+
DB_PASS: dev
193+
KAFKA_BROKERS: "redpanda:29092"
194+
REDIS_HOST: redis
195+
REDIS_PORT: "6379"
196+
APP_SECURITY_ENABLED: "false"
197+
ports:
198+
- "8084:8084"
199+
healthcheck:
200+
test: ["CMD-SHELL", "wget -q --spider http://localhost:8084/fx/actuator/health || exit 1"]
201+
interval: 10s
202+
timeout: 5s
203+
retries: 30
204+
start_period: 30s
205+
206+
payment-orchestrator:
207+
image: stablebridge/payment-orchestrator:latest
208+
container_name: p2t-orchestrator
209+
depends_on:
210+
postgres:
211+
condition: service_healthy
212+
redis:
213+
condition: service_healthy
214+
redpanda:
215+
condition: service_healthy
216+
temporal:
217+
condition: service_healthy
218+
compliance-travel-rule:
219+
condition: service_healthy
220+
fx-liquidity-engine:
221+
condition: service_healthy
222+
environment:
223+
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m"
224+
DB_URL: "jdbc:postgresql://postgres:5432/s1_payment_orchestrator"
225+
DB_USER: dev
226+
DB_PASS: dev
227+
KAFKA_BROKERS: "redpanda:29092"
228+
SPRING_DATA_REDIS_HOST: redis
229+
SPRING_DATA_REDIS_PORT: "6379"
230+
TEMPORAL_ADDRESS: "temporal:7233"
231+
TEMPORAL_NAMESPACE: default
232+
COMPLIANCE_SERVICE_URL: "http://compliance-travel-rule:8083/compliance"
233+
FX_SERVICE_URL: "http://fx-liquidity-engine:8084/fx"
234+
APP_SECURITY_ENABLED: "false"
235+
ports:
236+
- "8082:8082"
237+
healthcheck:
238+
test: ["CMD-SHELL", "wget -q --spider http://localhost:8082/orchestrator/actuator/health || exit 1"]
239+
interval: 10s
240+
timeout: 5s
241+
retries: 30
242+
start_period: 30s

fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/scheduling/LockExpiryJob.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import com.stablecoin.payments.fx.domain.event.FxRateExpired;
44
import com.stablecoin.payments.fx.domain.model.FxRateLock;
5-
import com.stablecoin.payments.fx.domain.port.EventPublisher;
5+
import com.stablecoin.payments.fx.infrastructure.messaging.OutboxEventPublisher;
66
import com.stablecoin.payments.fx.domain.port.FxRateLockRepository;
77
import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository;
88
import com.stablecoin.payments.fx.domain.service.LiquidityService;
@@ -26,7 +26,7 @@ public class LockExpiryJob {
2626
private final LiquidityPoolRepository poolRepository;
2727
private final LockService lockService;
2828
private final LiquidityService liquidityService;
29-
private final EventPublisher<FxRateExpired> eventPublisher;
29+
private final OutboxEventPublisher eventPublisher;
3030

3131
@Scheduled(fixedDelayString = "${app.fx.lock-expiry.interval-ms:5000}")
3232
@Transactional

fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/LockExpiryJobTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import com.stablecoin.payments.fx.domain.event.FxRateExpired;
44
import com.stablecoin.payments.fx.domain.model.FxRateLock;
55
import com.stablecoin.payments.fx.domain.model.FxRateLockStatus;
6-
import com.stablecoin.payments.fx.domain.port.EventPublisher;
6+
import com.stablecoin.payments.fx.infrastructure.messaging.OutboxEventPublisher;
77
import com.stablecoin.payments.fx.domain.port.FxRateLockRepository;
88
import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository;
99
import com.stablecoin.payments.fx.domain.service.LiquidityService;
@@ -46,7 +46,7 @@ class LockExpiryJobTest {
4646
private LiquidityService liquidityService;
4747

4848
@Mock
49-
private EventPublisher<FxRateExpired> eventPublisher;
49+
private OutboxEventPublisher eventPublisher;
5050

5151
@InjectMocks
5252
private LockExpiryJob lockExpiryJob;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.stablecoin.payments.orchestrator.infrastructure.config;
2+
3+
import feign.RequestInterceptor;
4+
import feign.RequestTemplate;
5+
import org.springframework.stereotype.Component;
6+
7+
import java.util.UUID;
8+
9+
/**
10+
* Feign interceptor that adds an Idempotency-Key header to all outgoing requests.
11+
* Required by S2 Compliance Service which rejects POST requests without this header.
12+
*/
13+
@Component
14+
public class FeignIdempotencyInterceptor implements RequestInterceptor {
15+
16+
private static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
17+
18+
@Override
19+
public void apply(RequestTemplate template) {
20+
if (!template.headers().containsKey(IDEMPOTENCY_KEY_HEADER)) {
21+
template.header(IDEMPOTENCY_KEY_HEADER, UUID.randomUUID().toString());
22+
}
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.stablecoin.payments.orchestrator.infrastructure.config;
2+
3+
import org.apache.kafka.clients.producer.ProducerConfig;
4+
import org.apache.kafka.common.serialization.StringSerializer;
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.context.annotation.Configuration;
9+
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
10+
import org.springframework.kafka.core.KafkaTemplate;
11+
import org.springframework.kafka.core.ProducerFactory;
12+
import org.springframework.kafka.support.serializer.JsonSerializer;
13+
14+
import java.util.Map;
15+
16+
@Configuration
17+
public class KafkaProducerConfig {
18+
19+
@Bean
20+
@ConditionalOnMissingBean
21+
public ProducerFactory<String, Object> producerFactory(
22+
@Value("${spring.kafka.bootstrap-servers}") String bootstrapServers) {
23+
return new DefaultKafkaProducerFactory<>(Map.of(
24+
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers,
25+
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class,
26+
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class));
27+
}
28+
29+
@Bean
30+
@ConditionalOnMissingBean
31+
public KafkaTemplate<String, Object> kafkaTemplate(
32+
ProducerFactory<String, Object> producerFactory) {
33+
return new KafkaTemplate<>(producerFactory);
34+
}
35+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
plugins {
2+
java
3+
}
4+
5+
val temporalVersion: String by project
6+
val wiremockVersion: String by project
7+
8+
dependencies {
9+
// API modules — DTOs for request/response deserialization
10+
testImplementation(project(":payment-orchestrator:payment-orchestrator-api"))
11+
testImplementation(project(":compliance-travel-rule:compliance-travel-rule-api"))
12+
testImplementation(project(":fx-liquidity-engine:fx-liquidity-engine-api"))
13+
14+
// Temporal SDK — query workflow results
15+
testImplementation("io.temporal:temporal-sdk:$temporalVersion")
16+
17+
// Jackson 3 — JSON parsing (JSR-310 built into core in Jackson 3)
18+
testImplementation("tools.jackson.core:jackson-databind")
19+
20+
// Kafka — event verification
21+
testImplementation("org.apache.kafka:kafka-clients")
22+
23+
// Test framework
24+
testImplementation("org.junit.jupiter:junit-jupiter")
25+
testImplementation("org.assertj:assertj-core")
26+
testImplementation("org.awaitility:awaitility")
27+
28+
// SLF4J for logging in tests
29+
testImplementation("org.slf4j:slf4j-api")
30+
testRuntimeOnly("ch.qos.logback:logback-classic")
31+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
32+
}
33+
34+
tasks.test {
35+
useJUnitPlatform {
36+
excludeTags("load")
37+
}
38+
}
39+
40+
tasks.register<Test>("loadTest") {
41+
useJUnitPlatform {
42+
includeTags("load")
43+
}
44+
maxHeapSize = "1g"
45+
}

0 commit comments

Comments
 (0)