add IT test for cross app call activity

Signed-off-by: Cassandra Coyle <cassie@diagrid.io>
This commit is contained in:
Cassandra Coyle 2025-08-12 10:37:29 -05:00 committed by Javier Aliaga
parent 7411384b6a
commit 2876fee65d
7 changed files with 525 additions and 20 deletions

View File

@ -825,26 +825,6 @@ dapr run --app-id app3 --resources-path ./components/workflows --dapr-grpc-port
java -Djava.util.logging.ConsoleHandler.level=FINE -Dio.dapr.durabletask.level=FINE -jar target/dapr-java-sdk-examples-exec.jar io.dapr.examples.workflows.crossapp.CrossAppWorkflowClient "Hello World"
```
<!-- STEP
name: Run Cross-App Pattern workflow
match_order: none
output_match_mode: substring
expected_stdout_lines:
- "=== Starting Cross-App Workflow Client ==="
- "Input: Hello World"
- "Created DaprWorkflowClient successfully"
- "Attempting to start new workflow..."
- "Started a new cross-app workflow with instance ID:"
- "Waiting for workflow completion..."
- "Workflow instance with ID:"
- "completed with result: HELLO WORLD [TRANSFORMED BY APP2] [FINALIZED BY APP3]"
background: true
sleep: 60
timeout_seconds: 60
-->
<!-- END_STEP -->
**Expected Output:**
The client will show:

View File

@ -0,0 +1,34 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.dapr.it.testcontainers.workflows.crossapp;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class App2TransformActivity implements WorkflowActivity {
private static final Logger logger = LoggerFactory.getLogger(App2TransformActivity.class);
@Override
public Object run(WorkflowActivityContext ctx) {
String input = ctx.getInput(String.class);
logger.info("=== App2: TransformActivity called ===");
logger.info("Input: " + input);
String output = input.toUpperCase() + " [TRANSFORMED BY APP2]";
logger.info("Output: " + output);
return output;
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.dapr.it.testcontainers.workflows.crossapp;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class App3FinalizeActivity implements WorkflowActivity {
private static final Logger logger = LoggerFactory.getLogger(App3FinalizeActivity.class);
@Override
public Object run(WorkflowActivityContext ctx) {
String input = ctx.getInput(String.class);
logger.info("=== App3: FinalizeActivity called ===");
logger.info("Input: " + input);
String output = input + " [FINALIZED BY APP3]";
logger.info("Output: " + output);
return output;
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.dapr.it.testcontainers.workflows.crossapp;
import io.dapr.workflows.runtime.WorkflowRuntime;
import io.dapr.workflows.runtime.WorkflowRuntimeBuilder;
/**
* App3Worker - registers the App3FinalizeActivity.
* This app will handle cross-app activity calls from the main workflow.
*/
public class App3Worker {
public static void main(String[] args) throws Exception {
System.out.println("=== Starting App3Worker (App3FinalizeActivity) ===");
// Register the Activity with the builder
WorkflowRuntimeBuilder builder = new WorkflowRuntimeBuilder()
.registerActivity(App3FinalizeActivity.class);
// Build and start the workflow runtime
try (WorkflowRuntime runtime = builder.build()) {
System.out.println("App3Worker started - registered App3FinalizeActivity only");
System.out.println("App3 is ready to receive cross-app activity calls...");
System.out.println("Waiting for cross-app activity calls...");
runtime.start();
}
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.dapr.it.testcontainers.workflows.crossapp;
import io.dapr.workflows.runtime.WorkflowRuntime;
import io.dapr.workflows.runtime.WorkflowRuntimeBuilder;
/**
* CrossAppWorker - registers only the CrossAppWorkflow.
* This is the main workflow orchestrator that will call activities in other apps.
*/
public class CrossAppWorker {
public static void main(String[] args) throws Exception {
System.out.println("=== Starting CrossAppWorker (Workflow Orchestrator) ===");
// Register the Workflow with the builder
WorkflowRuntimeBuilder builder = new WorkflowRuntimeBuilder()
.registerWorkflow(CrossAppWorkflow.class);
// Build and start the workflow runtime
try (WorkflowRuntime runtime = builder.build()) {
System.out.println("CrossAppWorker started - registered CrossAppWorkflow only");
System.out.println("Waiting for workflow orchestration requests...");
runtime.start();
}
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.dapr.it.testcontainers.workflows.crossapp;
import io.dapr.workflows.Workflow;
import io.dapr.workflows.WorkflowStub;
import io.dapr.workflows.WorkflowTaskOptions;
import org.slf4j.Logger;
public class CrossAppWorkflow implements Workflow {
@Override
public WorkflowStub create() {
return ctx -> {
Logger logger = ctx.getLogger();
String instanceId = ctx.getInstanceId();
logger.info("Starting CrossAppWorkflow: " + ctx.getName());
logger.info("Instance ID: " + instanceId);
String input = ctx.getInput(String.class);
logger.info("Workflow input: " + input);
// Call App2TransformActivity in app2
logger.info("Calling cross-app activity in 'app2'...");
String transformedByApp2 = ctx.callActivity(
App2TransformActivity.class.getName(),
input,
new WorkflowTaskOptions("app2"),
String.class
).await();
// Call App3FinalizeActivity in app3
logger.info("Calling cross-app activity in 'app3'...");
String finalizedByApp3 = ctx.callActivity(
App3FinalizeActivity.class.getName(),
transformedByApp2,
new WorkflowTaskOptions("app3"),
String.class
).await();
logger.info("Final cross-app activity result: " + finalizedByApp3);
ctx.complete(finalizedByApp3);
};
}
}

View File

@ -0,0 +1,327 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.dapr.it.testcontainers.workflows.crossapp;
import io.dapr.testcontainers.Component;
import io.dapr.testcontainers.DaprContainer;
import io.dapr.testcontainers.DaprLogLevel;
import io.dapr.workflows.client.DaprWorkflowClient;
import io.dapr.workflows.client.WorkflowInstanceStatus;
import io.dapr.workflows.client.WorkflowRuntimeStatus;
import io.dapr.config.Properties;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.MountableFile;
import java.time.Duration;
import java.util.Collections;
import java.util.Map;
import static io.dapr.it.testcontainers.ContainerConstants.DAPR_RUNTIME_IMAGE_TAG;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.testcontainers.images.builder.Transferable;
/**
* Cross-App Pattern integration test.
*
* This test demonstrates the cross-app pattern by:
* 1. Starting 3 Dapr containers (crossapp-worker, app2, app3)
* 2. Launching Java processes that register workflows/activities in separate apps
* 3. Executing a cross-app workflow
* 4. Asserting successful completion
*/
@Testcontainers
@Tag("testcontainers")
public class WorkflowsCrossAppCallActivityIT {
private static final Network DAPR_NETWORK = Network.newNetwork();
// Main workflow orchestrator container
@Container
private static final DaprContainer MAIN_WORKFLOW_CONTAINER = new DaprContainer(DAPR_RUNTIME_IMAGE_TAG)
.withAppName("crossapp-worker")
.withNetwork(DAPR_NETWORK)
.withNetworkAliases("main-workflow-sidecar")
.withComponent(new Component("kvstore", "state.in-memory", "v1",
Map.of("actorStateStore", "true")))
.withComponent(new Component("pubsub", "pubsub.in-memory", "v1", Collections.emptyMap()))
.withDaprLogLevel(DaprLogLevel.DEBUG)
.withLogConsumer(outputFrame -> System.out.println("MAIN_WORKFLOW: " + outputFrame.getUtf8String()))
.withAppChannelAddress("host.testcontainers.internal");
// App2 container for App2TransformActivity - using GenericContainer for custom ports
@Container
private static final GenericContainer<?> APP2_CONTAINER = new GenericContainer<>(DAPR_RUNTIME_IMAGE_TAG)
.withNetwork(DAPR_NETWORK)
.withNetworkAliases("app2-sidecar")
.withExposedPorts(3501, 50002)
.withCommand("./daprd",
"--app-id", "app2",
"--dapr-listen-addresses=0.0.0.0",
"--dapr-http-port", "3501",
"--dapr-grpc-port", "50002",
"--app-protocol", "http",
"--placement-host-address", "placement:50005",
"--scheduler-host-address", "scheduler:51005",
"--app-channel-address", "main-workflow-sidecar:3500", // cant use host.testcontainers.internal because it's not a valid hostname
"--log-level", "DEBUG",
"--resources-path", "/dapr-resources")
.withCopyToContainer(Transferable.of("apiVersion: dapr.io/v1alpha1\n" +
"kind: Component\n" +
"metadata:\n" +
" name: kvstore\n" +
"spec:\n" +
" type: state.in-memory\n" +
" version: v1\n" +
" metadata:\n" +
" - name: actorStateStore\n" +
" value: 'true'\n"), "/dapr-resources/kvstore.yaml")
.withCopyToContainer(Transferable.of("apiVersion: dapr.io/v1alpha1\n" +
"kind: Component\n" +
"metadata:\n" +
" name: pubsub\n" +
"spec:\n" +
" type: pubsub.in-memory\n" +
" version: v1\n"), "/dapr-resources/pubsub.yaml")
.withCopyToContainer(Transferable.of("apiVersion: dapr.io/v1alpha1\n" +
"kind: Subscription\n" +
"metadata:\n" +
" name: local\n" +
"spec:\n" +
" pubsubname: pubsub\n" +
" topic: topic\n" +
" route: /events\n"), "/dapr-resources/subscription.yaml")
.waitingFor(Wait.forHttp("/v1.0/healthz/outbound")
.forPort(3501)
.forStatusCodeMatching(statusCode -> statusCode >= 200 && statusCode <= 399))
.withLogConsumer(outputFrame -> System.out.println("APP2: " + outputFrame.getUtf8String()));
// App3 container for App3FinalizeActivity - using GenericContainer for custom ports
@Container
private static final GenericContainer<?> APP3_CONTAINER = new GenericContainer<>(DAPR_RUNTIME_IMAGE_TAG)
.withNetwork(DAPR_NETWORK)
.withNetworkAliases("app3-sidecar")
.withExposedPorts(3502, 50003)
.withCommand("./daprd",
"--app-id", "app3",
"--dapr-listen-addresses=0.0.0.0",
"--dapr-http-port", "3502",
"--dapr-grpc-port", "50003",
"--app-protocol", "http",
"--placement-host-address", "placement:50005",
"--scheduler-host-address", "scheduler:51005",
"--app-channel-address", "main-workflow-sidecar:3500", // cant use host.testcontainers.internal because it's not a valid hostname
"--log-level", "DEBUG",
"--resources-path", "/dapr-resources")
.withCopyToContainer(Transferable.of("apiVersion: dapr.io/v1alpha1\n" +
"kind: Component\n" +
"metadata:\n" +
" name: kvstore\n" +
"spec:\n" +
" type: state.in-memory\n" +
" version: v1\n" +
" metadata:\n" +
" - name: actorStateStore\n" +
" value: 'true'\n"), "/dapr-resources/kvstore.yaml")
.withCopyToContainer(Transferable.of("apiVersion: dapr.io/v1alpha1\n" +
"kind: Component\n" +
"metadata:\n" +
" name: pubsub\n" +
"spec:\n" +
" type: pubsub.in-memory\n" +
" version: v1\n"), "/dapr-resources/pubsub.yaml")
.withCopyToContainer(Transferable.of("apiVersion: dapr.io/v1alpha1\n" +
"kind: Subscription\n" +
"metadata:\n" +
" name: local\n" +
"spec:\n" +
" pubsubname: pubsub\n" +
" topic: topic\n" +
" route: /events\n"), "/dapr-resources/subscription.yaml")
.waitingFor(Wait.forHttp("/v1.0/healthz/outbound")
.forPort(3502)
.forStatusCodeMatching(statusCode -> statusCode >= 200 && statusCode <= 399))
.withLogConsumer(outputFrame -> System.out.println("APP3: " + outputFrame.getUtf8String()));
// TestContainers for each app
private static GenericContainer<?> crossappWorker;
private static GenericContainer<?> app2Worker;
private static GenericContainer<?> app3Worker;
@BeforeAll
public static void setUp() throws Exception {
// Wait for sidecars to be fully initialized & stabilize
Thread.sleep(15000);
// Start crossapp worker (connects to MAIN_WORKFLOW_CONTAINER)
crossappWorker = new GenericContainer<>("openjdk:17-jdk-slim")
.withCopyFileToContainer(MountableFile.forHostPath("target/test-classes"), "/app/classes")
.withCopyFileToContainer(MountableFile.forHostPath("target/dependency"), "/app/libs")
.withWorkingDirectory("/app")
.withCommand("java", "-cp", "/app/classes:/app/libs/*",
"-Ddapr.app.id=crossapp-worker",
"-Ddapr.grpc.endpoint=main-workflow-sidecar:50001",
"-Ddapr.http.endpoint=main-workflow-sidecar:3500",
"io.dapr.it.testcontainers.workflows.crossapp.CrossAppWorker")
.withNetwork(DAPR_NETWORK)
.waitingFor(Wait.forLogMessage(".*CrossAppWorker started.*", 1))
.withLogConsumer(outputFrame -> System.out.println("CrossAppWorker: " + outputFrame.getUtf8String()));
// Start app2 worker (connects to APP2_CONTAINER)
app2Worker = new GenericContainer<>("openjdk:17-jdk-slim")
.withCopyFileToContainer(MountableFile.forHostPath("target/test-classes"), "/app/classes")
.withCopyFileToContainer(MountableFile.forHostPath("target/dependency"), "/app/libs")
.withWorkingDirectory("/app")
.withCommand("java", "-cp", "/app/classes:/app/libs/*",
"-Ddapr.app.id=app2",
"-Ddapr.grpc.endpoint=app2-sidecar:50002",
"-Ddapr.http.endpoint=app2-sidecar:3501",
"io.dapr.it.testcontainers.workflows.crossapp.App2Worker")
.withNetwork(DAPR_NETWORK)
.waitingFor(Wait.forLogMessage(".*App2Worker started.*", 1))
.withLogConsumer(outputFrame -> System.out.println("App2Worker: " + outputFrame.getUtf8String()));
// Start app3 worker (connects to APP3_CONTAINER)
app3Worker = new GenericContainer<>("openjdk:17-jdk-slim")
.withCopyFileToContainer(MountableFile.forHostPath("target/test-classes"), "/app/classes")
.withCopyFileToContainer(MountableFile.forHostPath("target/dependency"), "/app/libs")
.withWorkingDirectory("/app")
.withCommand("java", "-cp", "/app/classes:/app/libs/*",
"-Ddapr.app.id=app3",
"-Ddapr.grpc.endpoint=app3-sidecar:50003",
"-Ddapr.http.endpoint=app3-sidecar:3502",
"io.dapr.it.testcontainers.workflows.crossapp.App3Worker")
.withNetwork(DAPR_NETWORK)
.waitingFor(Wait.forLogMessage(".*App3Worker started.*", 1))
.withLogConsumer(outputFrame -> System.out.println("App3Worker: " + outputFrame.getUtf8String()));
// Start all worker containers
crossappWorker.start();
app2Worker.start();
app3Worker.start();
// Wait for workers to be fully ready and connected
Thread.sleep(5000);
}
@AfterAll
public static void tearDown() throws Exception {
// Clean up worker containers
if (crossappWorker != null) {
crossappWorker.stop();
}
if (app2Worker != null) {
app2Worker.stop();
}
if (app3Worker != null) {
app3Worker.stop();
}
}
/**
* Verifies that all Dapr sidecars are healthy and ready to accept connections.
* This helps prevent the "sidecar unavailable" errors we were seeing.
*/
private void verifySidecarsReady() throws Exception {
// Main container uses ports (3500, 50001)
String mainHealthUrl = "http://localhost:" + MAIN_WORKFLOW_CONTAINER.getMappedPort(3500) + "/v1.0/healthz/outbound";
waitForHealthEndpoint(mainHealthUrl, "Main workflow sidecar");
// App2 uses custom ports (3501, 50002)
String app2HealthUrl = "http://localhost:" + APP2_CONTAINER.getMappedPort(3501) + "/v1.0/healthz/outbound";
waitForHealthEndpoint(app2HealthUrl, "App2 sidecar");
// App3 uses custom ports (3502, 50003)
String app3HealthUrl = "http://localhost:" + APP3_CONTAINER.getMappedPort(3502) + "/v1.0/healthz/outbound";
waitForHealthEndpoint(app3HealthUrl, "App3 sidecar");
}
/**
* Waits for a health endpoint to return a successful response.
*/
private void waitForHealthEndpoint(String healthUrl, String sidecarName) throws Exception {
int maxAttempts = 30; // 30s max
int attempt = 0;
while (attempt < maxAttempts) {
try {
java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient();
java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(healthUrl))
.GET()
.build();
java.net.http.HttpResponse<String> response = client.send(request,
java.net.http.HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 400) {
System.out.println(sidecarName + " is healthy and ready");
return;
}
} catch (Exception e) {
// Ignore connection errors bc they're expected while sidecar is starting
}
attempt++;
Thread.sleep(1000); // Wait 1s before retry
}
throw new RuntimeException(sidecarName + " failed to become healthy within " + maxAttempts + " seconds");
}
@Test
public void testCrossAppWorkflow() throws Exception {
verifySidecarsReady();
String input = "Hello World";
String expectedOutput = "HELLO WORLD [TRANSFORMED BY APP2] [FINALIZED BY APP3]";
// Create workflow client connected to the main workflow orchestrator
// Use the same endpoint configuration that the workers use
// The workers use host.testcontainers.internal:50001, so we need to use the mapped port
String grpcEndpoint = "localhost:" + MAIN_WORKFLOW_CONTAINER.getMappedPort(50001);
String httpEndpoint = "localhost:" + MAIN_WORKFLOW_CONTAINER.getMappedPort(3500);
System.setProperty("dapr.grpc.endpoint", grpcEndpoint);
System.setProperty("dapr.http.endpoint", httpEndpoint);
Map<String, String> propertyOverrides = Map.of(
"dapr.grpc.endpoint", grpcEndpoint,
"dapr.http.endpoint", httpEndpoint
);
Properties clientProperties = new Properties(propertyOverrides);
DaprWorkflowClient workflowClient = new DaprWorkflowClient(clientProperties);
try {
String instanceId = workflowClient.scheduleNewWorkflow(CrossAppWorkflow.class, input);
assertNotNull(instanceId, "Workflow instance ID should not be null");
workflowClient.waitForInstanceStart(instanceId, Duration.ofSeconds(30), false);
Duration timeout = Duration.ofMinutes(2);
WorkflowInstanceStatus workflowStatus = workflowClient.waitForInstanceCompletion(instanceId, timeout, true);
assertNotNull(workflowStatus, "Workflow status should not be null");
assertEquals(WorkflowRuntimeStatus.COMPLETED, workflowStatus.getRuntimeStatus(),
"Workflow should complete successfully");
String workflowOutput = workflowStatus.readOutputAs(String.class);
assertEquals(expectedOutput, workflowOutput, "Workflow output should match expected result");
} finally {
workflowClient.close();
}
}
}