This is an automated email from the ASF dual-hosted git repository.

ashishvijaywargiya pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 39fb3d0984 Add batch test-run REST endpoint (#1701)
39fb3d0984 is described below

commit 39fb3d09848514e990f34e6b0b77538f23eb5b8c
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Sun Aug 23 19:20:50 2026 +0530

    Add batch test-run REST endpoint (#1701)
---
 framework/testtools/api/testruns.rest.xml          |   9 +
 framework/testtools/servicedef/services.xml        |  35 +-
 .../apache/ofbiz/testtools/BatchRunServices.java   | 276 ++++++++
 .../apache/ofbiz/testtools/BatchRunTracker.java    |  61 ++
 .../apache/ofbiz/testtools/TestRunServices.java    |  49 +-
 .../ofbiz/testtools/BatchRunServicesTest.java      | 711 +++++++++++++++++++++
 .../ofbiz/testtools/BatchRunTrackerTest.java       |  74 +++
 .../ofbiz/testtools/TestRunServicesTest.java       |  73 +++
 8 files changed, 1282 insertions(+), 6 deletions(-)

diff --git a/framework/testtools/api/testruns.rest.xml 
b/framework/testtools/api/testruns.rest.xml
index be2ed9e30b..a5e3be2e5e 100644
--- a/framework/testtools/api/testruns.rest.xml
+++ b/framework/testtools/api/testruns.rest.xml
@@ -41,5 +41,14 @@ under the License.
         <operation verb="get" description="Get a test run's status" 
path="{runId}">
             <service name="getTestRunStatus"/>
         </operation>
+
+        <operation verb="post" description="Trigger a batch test-suite run 
across multiple components"
+                   path="batch" consumes="application/json">
+            <service name="runBatchTestSuite"/>
+        </operation>
+
+        <operation verb="get" description="Get a batch run's status" 
path="batch/{batchId}">
+            <service name="getBatchTestRunStatus"/>
+        </operation>
     </resource>
 </api>
diff --git a/framework/testtools/servicedef/services.xml 
b/framework/testtools/servicedef/services.xml
index 3435c2c0cb..894ea27c21 100644
--- a/framework/testtools/servicedef/services.xml
+++ b/framework/testtools/servicedef/services.xml
@@ -78,9 +78,42 @@ under the License.
             Exposed directly via the generic, framework-owned 
framework/testtools/api/testruns.rest.xml
             endpoint (GET /rest/testtools/testruns/{runId}) - same caution as 
runTestSuite above applies to
             any component-owned *.rest.xml.</description>
-        <attribute name="runId" type="String" mode="IN" optional="false"/>
+        <attribute name="runId" type="String" mode="INOUT" optional="false"/>
         <attribute name="status" type="String" mode="OUT" optional="true"/>
         <attribute name="componentName" type="String" mode="OUT" 
optional="true"/>
         <attribute name="resultSummary" type="Map" mode="OUT" optional="true"/>
     </service>
+
+    <service name="runBatchTestSuite" engine="java" auth="true"
+             location="org.apache.ofbiz.testtools.BatchRunServices" 
invoke="runBatchTestSuite">
+        <description>Fans out a full-suite runTestSuite call to multiple 
components in one call, tracked
+            under one batchId; poll getBatchTestRunStatus for the aggregate 
and per-component results.
+            With no components list, auto-discovers every component that has a 
testdef and has both the
+            global test.api.enabled and its own 
test.api.enabled.&lt;componentName&gt; on - if none
+            qualify, the call is rejected rather than returning a batchId with 
nothing queued. With an
+            explicit components list, an empty list is rejected outright, and 
every named component must
+            itself be known, have a testdef, and be enabled, or the whole call 
is rejected up front naming
+            which entries were invalid - no batch is ever queued from an 
invalid or empty list. A
+            component that passes this upfront check but still fails its 
actual test run (e.g. a testdef
+            that resolves to zero tests) is omitted from the batch rather than 
failing the whole call, the
+            same way an auto-discovered component would be. Each queued 
component always runs its whole
+            suite (no per-component suiteName/testCaseName/testMethodName 
scoping in this endpoint).
+            Exposed directly via the generic, framework-owned 
framework/testtools/api/testruns.rest.xml
+            endpoint (POST /rest/testtools/testruns/batch).</description>
+        <attribute name="components" type="List" mode="IN" optional="true"/>
+        <attribute name="batchId" type="String" mode="OUT" optional="true"/>
+    </service>
+
+    <service name="getBatchTestRunStatus" engine="java" auth="true"
+             location="org.apache.ofbiz.testtools.BatchRunServices" 
invoke="getBatchTestRunStatus">
+        <description>Reads a runBatchTestSuite-triggered batch's aggregate 
status (QUEUED/RUNNING/PASSED/
+            FAILED/ERROR, derived live from its children's own tracked 
statuses) plus each component's own
+            runId/status/resultSummary. Exposed directly via the generic, 
framework-owned
+            framework/testtools/api/testruns.rest.xml endpoint
+            (GET /rest/testtools/testruns/batch/{batchId}).</description>
+        <attribute name="batchId" type="String" mode="IN" optional="false"/>
+        <attribute name="status" type="String" mode="OUT" optional="true"/>
+        <attribute name="summary" type="Map" mode="OUT" optional="true"/>
+        <attribute name="components" type="List" mode="OUT" optional="true"/>
+    </service>
 </services>
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/BatchRunServices.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/BatchRunServices.java
new file mode 100644
index 0000000000..d54b2587c4
--- /dev/null
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/BatchRunServices.java
@@ -0,0 +1,276 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 org.apache.ofbiz.testtools;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import org.apache.ofbiz.base.component.ComponentConfig;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilGenerics;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.ServiceUtil;
+
+/**
+ * REST-triggered batch test execution: runBatchTestSuite fans a full-suite
+ * {@link TestRunServices#runTestSuite} call out to multiple components in one 
call, tracked under
+ * one batchId; getBatchTestRunStatus polls the aggregate. Layered entirely on 
top of
+ * TestRunServices/TestRunTracker - no change to the existing single-component
+ * runTestSuite/getTestRunStatus contract.
+ *
+ * <p>Two modes: with no {@code components} list, every component with a 
testdef where both the
+ * global {@code test.api.enabled} and its own {@code 
test.api.enabled.<componentName>} (default
+ * {@code true}) resolve {@code true} is queued (see {@link 
#discoverEligibleComponents}); with an
+ * explicit {@code components} list, an empty list is rejected outright, and 
every named component
+ * must itself pass that same check or the whole call is rejected before 
anything is queued - no
+ * batch is ever queued from an invalid or empty list. This upfront rejection 
is distinct from a
+ * component that passes it but still fails its actual {@link 
TestRunServices#runTestSuite} call
+ * (e.g. a testdef that resolves to zero tests): that component is simply 
omitted from the batch,
+ * the same as an auto-discovered one would be - see {@link 
#runBatchTestSuite}.
+ *
+ * <p>Each queued component always runs its whole suite - {@code runTestSuite} 
is called with only
+ * {@code componentName} set, no {@code suiteName}, which {@link 
JunitSuiteWrapper} already treats
+ * as "every testdef suite in this component" (see its {@code suiteName != 
null} filter). No
+ * per-component {@code testCaseName}/{@code testMethodName}/{@code 
testParams} scoping in this
+ * endpoint.
+ *
+ * <p>{@code runBatchTestSuite} fans a component list out with a plain 
sequential loop, each
+ * iteration calling {@code runTestSuite} synchronously before moving to the 
next - per the "no new
+ * concurrency" constraint, nothing here runs in parallel or off the caller's 
thread. Each
+ * {@code runTestSuite} call itself constructs a fresh test {@code 
Delegator}/{@code
+ * LocalDispatcher} and re-runs startup services (see {@link TestRunServices}' 
own javadoc), so a
+ * large auto-discovered component set can make the POST block for a while 
before a {@code batchId}
+ * comes back - this is a known characteristic of the current POC-level 
implementation, not a bug.
+ *
+ * <p>Exposed directly via the generic, framework-owned
+ * {@code framework/testtools/api/testruns.rest.xml} endpoint
+ * ({@code POST /rest/testtools/testruns/batch}, {@code GET 
/rest/testtools/testruns/batch/{batchId}}).
+ * See {@link TestRunServices}' javadoc for the same "do not expose from a 
component's own
+ * *.rest.xml" caution - it applies here identically.
+ */
+public final class BatchRunServices {
+
+    private static final String MODULE = BatchRunServices.class.getName();
+    private static final String TESTEXEC_PERMISSION = "TESTEXEC_ADMIN";
+
+    static final BatchRunTracker BATCH_TRACKER = new BatchRunTracker();
+
+    private BatchRunServices() {
+    }
+
+    public static Map<String, Object> runBatchTestSuite(DispatchContext dctx, 
Map<String, ?> context) {
+        GenericValue userLogin = (GenericValue) context.get("userLogin");
+        String userLoginId = userLogin == null ? "unknown" : 
userLogin.getString("userLoginId");
+
+        if (!dctx.getSecurity().hasPermission(TESTEXEC_PERMISSION, userLogin)) 
{
+            Debug.logWarning("runBatchTestSuite: DENIED for user '" + 
userLoginId + "' - missing "
+                    + TESTEXEC_PERMISSION, MODULE);
+            return ServiceUtil.returnError("You do not have permission to 
trigger test runs (" + TESTEXEC_PERMISSION + ")");
+        }
+
+        List<String> requestedComponents = 
UtilGenerics.cast(context.get("components"));
+        List<String> componentNames;
+        if (requestedComponents == null) {
+            componentNames = discoverEligibleComponents(dctx.getDelegator());
+            if (componentNames.isEmpty()) {
+                Debug.logWarning("runBatchTestSuite: rejected for user '" + 
userLoginId
+                        + "' - no components are eligible", MODULE);
+                return ServiceUtil.returnError("No components are eligible for 
a batch test run - none have "
+                        + "the test execution API enabled, or none have a 
testdef.");
+            }
+        } else if (requestedComponents.isEmpty()) {
+            Debug.logWarning("runBatchTestSuite: rejected for user '" + 
userLoginId
+                    + "' - components list was explicitly empty", MODULE);
+            return ServiceUtil.returnError("The components list cannot be 
empty - omit the field entirely to "
+                    + "auto-discover eligible components, or name at least one 
component.");
+        } else {
+            List<String> invalid = 
validateRequestedComponents(requestedComponents, dctx.getDelegator());
+            if (!invalid.isEmpty()) {
+                Debug.logWarning("runBatchTestSuite: rejected for user '" + 
userLoginId + "' - invalid components: "
+                        + invalid, MODULE);
+                return ServiceUtil.returnError("The following components 
cannot be included in this batch run: "
+                        + String.join("; ", invalid));
+            }
+            componentNames = requestedComponents.stream().distinct().toList();
+        }
+
+        String batchId = UUID.randomUUID().toString();
+        List<BatchRunTracker.BatchChildRef> children = new ArrayList<>();
+        for (String componentName : componentNames) {
+            Map<String, Object> childContext = new LinkedHashMap<>();
+            childContext.put("componentName", componentName);
+            childContext.put("userLogin", userLogin);
+            Map<String, Object> result = TestRunServices.runTestSuite(dctx, 
childContext);
+            if (ServiceUtil.isError(result)) {
+                // Silently omitted, matching the "auto-discovered component 
has no testdef tests"
+                // edge case this same rule already covers - the only 
realistic ways an
+                // already-eligibility-checked component can still fail here 
are a genuinely empty
+                // testdef (registered but resolves to zero tests) or a live 
config change racing
+                // between this batch's own eligibility check above and this 
call, both of which
+                // mean there is no runId to show for this component either 
way.
+                Debug.logWarning("runBatchTestSuite: batchId=" + batchId + " 
skipped component '" + componentName
+                        + "' - " + ServiceUtil.getErrorMessage(result), 
MODULE);
+                continue;
+            }
+            children.add(new BatchRunTracker.BatchChildRef(componentName, 
(String) result.get("runId")));
+        }
+        if (children.isEmpty()) {
+            Debug.logWarning("runBatchTestSuite: rejected for user '" + 
userLoginId
+                    + "' - every requested component's runTestSuite call 
failed, batchId=" + batchId, MODULE);
+            return ServiceUtil.returnError("No components were successfully 
queued for this batch - every "
+                    + "requested component's runTestSuite call failed.");
+        }
+        BATCH_TRACKER.register(batchId, children);
+
+        List<String> queuedComponentNames = 
children.stream().map(BatchRunTracker.BatchChildRef::componentName).toList();
+        Debug.logInfo("runBatchTestSuite: STARTED batchId=" + batchId + " 
user='" + userLoginId + "' components="
+                + queuedComponentNames, MODULE);
+
+        Map<String, Object> response = ServiceUtil.returnSuccess();
+        response.put("batchId", batchId);
+        return response;
+    }
+
+    public static Map<String, Object> getBatchTestRunStatus(DispatchContext 
dctx, Map<String, ?> context) {
+        GenericValue userLogin = (GenericValue) context.get("userLogin");
+        String userLoginId = userLogin == null ? "unknown" : 
userLogin.getString("userLoginId");
+        if (!dctx.getSecurity().hasPermission(TESTEXEC_PERMISSION, userLogin)) 
{
+            Debug.logWarning("getBatchTestRunStatus: DENIED for user '" + 
userLoginId + "' - missing "
+                    + TESTEXEC_PERMISSION, MODULE);
+            return ServiceUtil.returnError("You do not have permission to view 
test run status (" + TESTEXEC_PERMISSION + ")");
+        }
+
+        String batchId = (String) context.get("batchId");
+        List<BatchRunTracker.BatchChildRef> children = 
BATCH_TRACKER.get(batchId);
+        if (children == null) {
+            return ServiceUtil.returnError("No such batchId: " + batchId);
+        }
+
+        List<Map<String, Object>> componentResults = new ArrayList<>();
+        int passed = 0;
+        int failed = 0;
+        int running = 0;
+        int queued = 0;
+        int errored = 0;
+        for (BatchRunTracker.BatchChildRef child : children) {
+            // TestRunServices.TRACKER always has an entry for this runId by 
construction: it was
+            // registered synchronously (TestRunServices.runTestSuite's own 
TRACKER.register call)
+            // before that call ever returned the runId this child was built 
from, and nothing ever
+            // removes an entry from that tracker.
+            TestRunRecord record = TestRunServices.TRACKER.get(child.runId());
+            componentResults.add(TestRunServices.describeRun(record));
+            switch (record.status()) {
+            case PASSED -> passed++;
+            case FAILED -> failed++;
+            case ERROR -> errored++;
+            case RUNNING -> running++;
+            case QUEUED -> queued++;
+            }
+        }
+
+        String batchStatus;
+        if (errored > 0) {
+            batchStatus = "ERROR";
+        } else if (failed > 0) {
+            batchStatus = "FAILED";
+        } else if (running > 0 || queued > 0) {
+            batchStatus = "RUNNING";
+        } else {
+            batchStatus = "PASSED";
+        }
+
+        Map<String, Object> summary = new LinkedHashMap<>();
+        summary.put("total", children.size());
+        summary.put("passed", passed);
+        summary.put("failed", failed);
+        summary.put("running", running);
+        summary.put("queued", queued);
+        summary.put("error", errored);
+
+        Map<String, Object> result = ServiceUtil.returnSuccess();
+        result.put("status", batchStatus);
+        result.put("summary", summary);
+        result.put("components", componentResults);
+        return result;
+    }
+
+    /**
+     * Every component with a testdef where both the global {@code 
test.api.enabled} and its own
+     * {@code test.api.enabled.<componentName>} (default {@code true}) resolve 
{@code true} -
+     * short-circuits to an empty list without even consulting {@link 
ComponentConfig} when the
+     * global flag alone is off.
+     * @param delegator the calling request's Delegator, for the same 
live-overridable property
+     *     read {@link TestRunServices#readStringProperty} already gives a 
single-component call
+     * @return every eligible component's name, in {@link ComponentConfig}'s 
own declared order,
+     *     each name appearing at most once even if it registers more than one 
testdef file
+     */
+    static List<String> discoverEligibleComponents(Delegator delegator) {
+        if (!TestRunServices.isTestApiGloballyEnabled(delegator)) {
+            return List.of();
+        }
+        return ComponentConfig.getAllTestSuiteInfos(null).stream()
+                .map(info -> info.getComponentConfig().getComponentName())
+                .distinct()
+                .filter(name -> 
TestRunServices.isTestApiEnabledForComponent(delegator, name))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * Validates every caller-named component up front, before any of them is 
queued - a bad entry
+     * must reject the whole call rather than silently running only the good 
ones (see this class's
+     * javadoc).
+     * @param requested the caller-supplied {@code components} list, as given 
(not yet validated)
+     * @param delegator the calling request's Delegator
+     * @return one human-readable reason string per invalid entry, empty if 
every entry is valid
+     */
+    static List<String> validateRequestedComponents(List<String> requested, 
Delegator delegator) {
+        boolean apiEnabled = 
TestRunServices.isTestApiGloballyEnabled(delegator);
+        List<String> invalid = new ArrayList<>();
+        for (String name : requested) {
+            if (UtilValidate.isEmpty(name)) {
+                invalid.add(name + " (blank component name)");
+                continue;
+            }
+            if (!apiEnabled) {
+                invalid.add(name + " (test execution API is disabled)");
+                continue;
+            }
+            if (!Boolean.TRUE.equals(ComponentConfig.componentExists(name))) {
+                invalid.add(name + " (unknown component)");
+                continue;
+            }
+            if (ComponentConfig.getAllTestSuiteInfos(name).isEmpty()) {
+                invalid.add(name + " (no testdef found)");
+                continue;
+            }
+            boolean componentEnabled = 
TestRunServices.isTestApiEnabledForComponent(delegator, name);
+            if (!componentEnabled) {
+                invalid.add(name + " (test execution API is disabled for this 
component)");
+            }
+        }
+        return invalid;
+    }
+}
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/BatchRunTracker.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/BatchRunTracker.java
new file mode 100644
index 0000000000..f7d910a488
--- /dev/null
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/BatchRunTracker.java
@@ -0,0 +1,61 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 org.apache.ofbiz.testtools;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory registry of runBatchTestSuite-triggered batches, for 
getBatchTestRunStatus polling.
+ * Deliberately not persisted - does not survive a server restart, same as 
TestRunTracker. Unlike
+ * TestRunTracker, a batch's child list never changes after registration: 
runBatchTestSuite always
+ * finishes fanning every child run out (each already registered in 
TestRunServices' own TRACKER)
+ * before it ever hands a batchId back to a caller, so there is nothing left 
to mutate here - a
+ * batch's aggregate status is instead computed live, at read time, from each 
child's own tracked
+ * TestRunRecord (see BatchRunServices.getBatchTestRunStatus).
+ *
+ * <p>Like TestRunTracker's own map, nothing ever removes an entry here either 
- every batch ever
+ * triggered stays resident in memory for the life of the server process. This 
is a much smaller
+ * per-entry footprint than the ServiceDispatcher/Delegator leak 
TestRunServices' own javadoc
+ * documents at length (each entry here is just a componentName/runId pair 
list, not a live
+ * dispatcher), but it is still unbounded growth with no TTL, cap, or purge 
mechanism - a known
+ * characteristic of this POC-level implementation, not a bug.
+ */
+final class BatchRunTracker {
+
+    /**
+     * One component queued into a batch run, and the runId TestRunServices' 
own TRACKER tracks it
+     * under.
+     * @param componentName the component this child run belongs to
+     * @param runId the same runId TestRunServices.TRACKER.get(runId) resolves
+     */
+    record BatchChildRef(String componentName, String runId) {
+    }
+
+    private final Map<String, List<BatchChildRef>> records = new 
ConcurrentHashMap<>();
+
+    void register(String batchId, List<BatchChildRef> children) {
+        records.put(batchId, List.copyOf(children));
+    }
+
+    List<BatchChildRef> get(String batchId) {
+        return records.get(batchId);
+    }
+}
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
index d0d48f6cec..ef8a2c78f4 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
@@ -160,7 +160,7 @@ public final class TestRunServices {
             return ServiceUtil.returnError("You do not have permission to 
trigger test runs (" + TESTEXEC_PERMISSION + ")");
         }
 
-        boolean apiEnabled = 
"true".equalsIgnoreCase(readStringProperty(dctx.getDelegator(), 
"test.api.enabled", "false"));
+        boolean apiEnabled = isTestApiGloballyEnabled(dctx.getDelegator());
         if (!apiEnabled) {
             Debug.logWarning("runTestSuite: rejected for user '" + userLoginId 
+ "', suite '" + suiteName + "'"
                     + " - test.api.enabled is false", MODULE);
@@ -189,8 +189,7 @@ public final class TestRunServices {
         // non-blank by the guard above, so this always runs now - there is no 
longer an unscoped,
         // no-componentName path through this method to skip it for.
         // See 
plugins/supporting-docs/specs/2026-08-21-per-component-test-api-toggle-design.md.
-        boolean componentEnabled = "true".equalsIgnoreCase(
-                readStringProperty(dctx.getDelegator(), "test.api.enabled." + 
componentName, "true"));
+        boolean componentEnabled = 
isTestApiEnabledForComponent(dctx.getDelegator(), componentName);
         if (!componentEnabled) {
             Debug.logWarning("runTestSuite: rejected for user '" + userLoginId 
+ "', suite '" + suiteName + "'"
                     + " - test.api.enabled." + componentName + " is false", 
MODULE);
@@ -475,8 +474,24 @@ public final class TestRunServices {
         }
 
         Map<String, Object> result = ServiceUtil.returnSuccess();
-        result.put("status", record.status().name());
+        result.putAll(describeRun(record));
+        return result;
+    }
+
+    /**
+     * Assembles one tracked run's runId/componentName/status/resultSummary 
into the exact shape
+     * getTestRunStatus returns for it - reused as-is by 
BatchRunServices.getBatchTestRunStatus so a
+     * batch's per-component entry is indistinguishable from what GET 
/testruns/{runId} would return
+     * for that same runId. resultSummary merges the tracked resultSummary map 
(if any) with an
+     * errorMessage key (if any), same as getTestRunStatus always did before 
this was extracted.
+     * @param record the tracked run to describe - never null for either 
caller (TRACKER.register
+     *     always creates one before any runId is handed to a caller, and 
nothing ever removes one)
+     */
+    static Map<String, Object> describeRun(TestRunRecord record) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("runId", record.runId());
         result.put("componentName", record.componentName());
+        result.put("status", record.status().name());
         Map<String, Object> resultSummary = new LinkedHashMap<>();
         if (record.resultSummary() != null) {
             resultSummary.putAll(record.resultSummary());
@@ -488,7 +503,7 @@ public final class TestRunServices {
         return result;
     }
 
-    private static String readStringProperty(Delegator delegator, String 
propertyName, String defaultValue) {
+    static String readStringProperty(Delegator delegator, String propertyName, 
String defaultValue) {
         try {
             String value = delegator == null
                     ? UtilProperties.getPropertyValue(RESOURCE, propertyName, 
defaultValue)
@@ -500,4 +515,28 @@ public final class TestRunServices {
             return defaultValue;
         }
     }
+
+    /**
+     * Whether the test execution API is enabled at all, server-wide - the 
same {@code
+     * test.api.enabled} flag {@link #runTestSuite}, {@code 
BatchRunServices.discoverEligibleComponents},
+     * and {@code BatchRunServices.validateRequestedComponents} all gate on 
identically.
+     * @param delegator the calling request's Delegator, for the 
live-overridable property read
+     * @return {@code true} only when the property resolves to the literal 
string {@code "true"}
+     */
+    static boolean isTestApiGloballyEnabled(Delegator delegator) {
+        return "true".equalsIgnoreCase(readStringProperty(delegator, 
"test.api.enabled", "false"));
+    }
+
+    /**
+     * Whether one component's own {@code test.api.enabled.<componentName>} 
override allows the test
+     * execution API for it - defaults to enabled ({@code true}) when unset. 
Only meaningful once
+     * {@link #isTestApiGloballyEnabled} is already {@code true}; callers are 
expected to check the
+     * global flag first, matching {@link #runTestSuite}'s own order.
+     * @param delegator the calling request's Delegator, for the 
live-overridable property read
+     * @param componentName the component to check
+     * @return {@code true} unless the property is explicitly set to something 
other than {@code "true"}
+     */
+    static boolean isTestApiEnabledForComponent(Delegator delegator, String 
componentName) {
+        return "true".equalsIgnoreCase(readStringProperty(delegator, 
"test.api.enabled." + componentName, "true"));
+    }
 }
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/BatchRunServicesTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/BatchRunServicesTest.java
new file mode 100644
index 0000000000..433e70b42a
--- /dev/null
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/BatchRunServicesTest.java
@@ -0,0 +1,711 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 org.apache.ofbiz.testtools;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.ofbiz.base.component.ComponentConfig;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityUtilProperties;
+import org.apache.ofbiz.security.Security;
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.ServiceUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.when;
+
+class BatchRunServicesTest {
+
+    private static ComponentConfig.TestSuiteInfo testSuiteInfoFor(String 
componentName) {
+        ComponentConfig componentConfig = mock(ComponentConfig.class);
+        when(componentConfig.getComponentName()).thenReturn(componentName);
+        ComponentConfig.TestSuiteInfo testSuiteInfo = 
mock(ComponentConfig.TestSuiteInfo.class);
+        when(testSuiteInfo.getComponentConfig()).thenReturn(componentConfig);
+        return testSuiteInfo;
+    }
+
+    @Test
+    void 
discoverEligibleComponentsReturnsEveryComponentWithATestdefWhenBothFlagsAreOn() 
{
+        Delegator delegator = mock(Delegator.class);
+
+        var example = testSuiteInfoFor("example");
+        var party = testSuiteInfoFor("party");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos(null))
+                    .thenReturn(List.of(example, party));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.example", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.party", 
delegator))
+                    .thenReturn("true");
+
+            List<String> discovered = 
BatchRunServices.discoverEligibleComponents(delegator);
+
+            assertThat(discovered, is(List.of("example", "party")));
+        }
+    }
+
+    @Test
+    void discoverEligibleComponentsExcludesAComponentWithItsOwnFlagOff() {
+        Delegator delegator = mock(Delegator.class);
+
+        var example = testSuiteInfoFor("example");
+        var party = testSuiteInfoFor("party");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos(null))
+                    .thenReturn(List.of(example, party));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.example", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.party", 
delegator))
+                    .thenReturn("false");
+
+            List<String> discovered = 
BatchRunServices.discoverEligibleComponents(delegator);
+
+            assertThat(discovered, is(List.of("example")));
+        }
+    }
+
+    @Test
+    void 
discoverEligibleComponentsDeduplicatesMultipleTestSuitesInOneComponent() {
+        Delegator delegator = mock(Delegator.class);
+
+        var accounting1 = testSuiteInfoFor("accounting");
+        var accounting2 = testSuiteInfoFor("accounting");
+        var accounting3 = testSuiteInfoFor("accounting");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos(null))
+                    .thenReturn(List.of(accounting1, accounting2, 
accounting3));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", 
"test.api.enabled.accounting", delegator))
+                    .thenReturn("true");
+
+            List<String> discovered = 
BatchRunServices.discoverEligibleComponents(delegator);
+
+            assertThat(discovered, is(List.of("accounting")));
+        }
+    }
+
+    @Test
+    void discoverEligibleComponentsReturnsEmptyListWhenGlobalFlagIsOff() {
+        Delegator delegator = mock(Delegator.class);
+
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("false");
+
+            List<String> discovered = 
BatchRunServices.discoverEligibleComponents(delegator);
+
+            assertThat(discovered, is(List.of()));
+            componentConfig.verifyNoInteractions();
+        }
+    }
+
+    @Test
+    void validateRequestedComponentsReturnsEmptyListWhenAllAreValid() {
+        Delegator delegator = mock(Delegator.class);
+        var party = testSuiteInfoFor("party");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("party")).thenReturn(true);
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos("party"))
+                    .thenReturn(List.of(party));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.party", 
delegator))
+                    .thenReturn("true");
+
+            List<String> invalid = 
BatchRunServices.validateRequestedComponents(List.of("party"), delegator);
+
+            assertThat(invalid, is(List.of()));
+        }
+    }
+
+    @Test
+    void validateRequestedComponentsFlagsAnUnknownComponent() {
+        Delegator delegator = mock(Delegator.class);
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("nosuch")).thenReturn(false);
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+
+            List<String> invalid = 
BatchRunServices.validateRequestedComponents(List.of("nosuch"), delegator);
+
+            assertThat(invalid, contains("nosuch (unknown component)"));
+        }
+    }
+
+    @Test
+    void validateRequestedComponentsFlagsAComponentWithNoTestdef() {
+        Delegator delegator = mock(Delegator.class);
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("birt")).thenReturn(true);
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos("birt")).thenReturn(List.of());
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+
+            List<String> invalid = 
BatchRunServices.validateRequestedComponents(List.of("birt"), delegator);
+
+            assertThat(invalid, contains("birt (no testdef found)"));
+        }
+    }
+
+    @Test
+    void validateRequestedComponentsFlagsADisabledComponent() {
+        Delegator delegator = mock(Delegator.class);
+        var accounting = testSuiteInfoFor("accounting");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("accounting")).thenReturn(true);
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos("accounting"))
+                    .thenReturn(List.of(accounting));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", 
"test.api.enabled.accounting", delegator))
+                    .thenReturn("false");
+
+            List<String> invalid = 
BatchRunServices.validateRequestedComponents(List.of("accounting"), delegator);
+
+            assertThat(invalid, contains("accounting (test execution API is 
disabled for this component)"));
+        }
+    }
+
+    @Test
+    void 
validateRequestedComponentsFlagsABlankEntryWithoutCallingComponentConfig() {
+        Delegator delegator = mock(Delegator.class);
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+
+            List<String> invalid = 
BatchRunServices.validateRequestedComponents(List.of(""), delegator);
+
+            assertThat(invalid, contains(" (blank component name)"));
+            componentConfig.verifyNoInteractions();
+        }
+    }
+
+    @Test
+    void validateRequestedComponentsFlagsEveryEntryWhenGlobalFlagIsOff() {
+        Delegator delegator = mock(Delegator.class);
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("false");
+
+            List<String> invalid = 
BatchRunServices.validateRequestedComponents(List.of("party"), delegator);
+
+            assertThat(invalid, contains("party (test execution API is 
disabled)"));
+            componentConfig.verifyNoInteractions();
+        }
+    }
+
+    @Test
+    void runBatchTestSuiteReturnsErrorWhenPermissionDenied() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("nobody");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(false);
+
+        Map<String, Object> result = BatchRunServices.runBatchTestSuite(dctx, 
Map.of("userLogin", userLogin));
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("batchId"), nullValue());
+    }
+
+    @Test
+    void runBatchTestSuiteRejectsWhenNoComponentsAreEligible() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        try (MockedStatic<EntityUtilProperties> entityUtilProperties =
+                Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("false");
+
+            Map<String, Object> result = 
BatchRunServices.runBatchTestSuite(dctx, Map.of("userLogin", userLogin));
+
+            assertThat(result.get("responseMessage"), is("error"));
+            assertThat((String) result.get("errorMessage"), containsString("No 
components are eligible"));
+            assertThat(result.get("batchId"), nullValue());
+        }
+    }
+
+    @Test
+    void runBatchTestSuiteRejectsAnExplicitlyEmptyComponentsList() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        Map<String, Object> result = BatchRunServices.runBatchTestSuite(dctx,
+                Map.of("userLogin", userLogin, "components", List.of()));
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat((String) result.get("errorMessage"), containsString("cannot 
be empty"));
+        assertThat(result.get("batchId"), nullValue());
+    }
+
+    @Test
+    void runBatchTestSuiteRejectsWholeBatchWhenAnExplicitComponentIsInvalid() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("nosuch")).thenReturn(false);
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+
+            Map<String, Object> result = 
BatchRunServices.runBatchTestSuite(dctx,
+                    Map.of("userLogin", userLogin, "components", 
List.of("nosuch")));
+
+            assertThat(result.get("responseMessage"), is("error"));
+            assertThat((String) result.get("errorMessage"), 
containsString("nosuch (unknown component)"));
+            assertThat(result.get("batchId"), nullValue());
+        }
+    }
+
+    private static Map<String, Object> successResult(String runId) {
+        Map<String, Object> result = ServiceUtil.returnSuccess();
+        result.put("runId", runId);
+        return result;
+    }
+
+    private static Map<String, Object> errorResult(String message) {
+        return ServiceUtil.returnError(message);
+    }
+
+    @Test
+    void runBatchTestSuiteTracksASuccessfullyQueuedComponent() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        var exampleSuiteInfo = testSuiteInfoFor("example");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS);
+                MockedStatic<TestRunServices> testRunServices =
+                        Mockito.mockStatic(TestRunServices.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("example")).thenReturn(true);
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos("example"))
+                    .thenReturn(List.of(exampleSuiteInfo));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.example", 
delegator))
+                    .thenReturn("true");
+            testRunServices.when(() -> TestRunServices.runTestSuite(eq(dctx), 
Mockito.<Map<String, ?>>any()))
+                    .thenReturn(successResult("run-example"));
+
+            Map<String, Object> result = 
BatchRunServices.runBatchTestSuite(dctx,
+                    Map.of("userLogin", userLogin, "components", 
List.of("example")));
+
+            assertThat(result.get("responseMessage"), is("success"));
+            String batchId = (String) result.get("batchId");
+            assertThat(batchId, notNullValue());
+            assertThat(BatchRunServices.BATCH_TRACKER.get(batchId),
+                    is(List.of(new BatchRunTracker.BatchChildRef("example", 
"run-example"))));
+        }
+    }
+
+    @Test
+    void 
runBatchTestSuiteRejectsTheWholeBatchWhenEveryComponentsRunTestSuiteCallErrors()
 {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        var flakySuiteInfo = testSuiteInfoFor("flaky");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS);
+                MockedStatic<TestRunServices> testRunServices =
+                        Mockito.mockStatic(TestRunServices.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("flaky")).thenReturn(true);
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos("flaky"))
+                    .thenReturn(List.of(flakySuiteInfo));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.flaky", 
delegator))
+                    .thenReturn("true");
+            testRunServices.when(() -> TestRunServices.runTestSuite(eq(dctx), 
Mockito.<Map<String, ?>>any()))
+                    .thenReturn(errorResult("No tests found"));
+
+            Map<String, Object> result = 
BatchRunServices.runBatchTestSuite(dctx,
+                    Map.of("userLogin", userLogin, "components", 
List.of("flaky")));
+
+            assertThat(result.get("responseMessage"), is("error"));
+            assertThat(result.get("batchId"), nullValue());
+        }
+    }
+
+    @Test
+    void 
runBatchTestSuiteOmitsAFailingComponentButKeepsGoingWhenAtLeastOneSucceeds() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        var exampleSuiteInfo = testSuiteInfoFor("example");
+        var flakySuiteInfo = testSuiteInfoFor("flaky");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS);
+                MockedStatic<TestRunServices> testRunServices =
+                        Mockito.mockStatic(TestRunServices.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("example")).thenReturn(true);
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos("example"))
+                    .thenReturn(List.of(exampleSuiteInfo));
+            componentConfig.when(() -> 
ComponentConfig.componentExists("flaky")).thenReturn(true);
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos("flaky"))
+                    .thenReturn(List.of(flakySuiteInfo));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.example", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.flaky", 
delegator))
+                    .thenReturn("true");
+            testRunServices.when(() -> TestRunServices.runTestSuite(eq(dctx), 
argThat(ctx ->
+                    "example".equals(((Map<?, ?>) ctx).get("componentName")))))
+                    .thenReturn(successResult("run-example"));
+            testRunServices.when(() -> TestRunServices.runTestSuite(eq(dctx), 
argThat(ctx ->
+                    "flaky".equals(((Map<?, ?>) ctx).get("componentName")))))
+                    .thenReturn(errorResult("No tests found"));
+
+            Map<String, Object> result = 
BatchRunServices.runBatchTestSuite(dctx,
+                    Map.of("userLogin", userLogin, "components", 
List.of("example", "flaky")));
+
+            assertThat(result.get("responseMessage"), is("success"));
+            String batchId = (String) result.get("batchId");
+            assertThat(BatchRunServices.BATCH_TRACKER.get(batchId),
+                    is(List.of(new BatchRunTracker.BatchChildRef("example", 
"run-example"))));
+        }
+    }
+
+    @Test
+    void runBatchTestSuiteDedupesDuplicateComponentNamesInAnExplicitList() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        var partySuiteInfo = testSuiteInfoFor("party");
+        try (MockedStatic<ComponentConfig> componentConfig = 
Mockito.mockStatic(ComponentConfig.class);
+                MockedStatic<EntityUtilProperties> entityUtilProperties =
+                        Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS);
+                MockedStatic<TestRunServices> testRunServices =
+                        Mockito.mockStatic(TestRunServices.class, 
Mockito.CALLS_REAL_METHODS)) {
+            componentConfig.when(() -> 
ComponentConfig.componentExists("party")).thenReturn(true);
+            componentConfig.when(() -> 
ComponentConfig.getAllTestSuiteInfos("party"))
+                    .thenReturn(List.of(partySuiteInfo));
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.party", 
delegator))
+                    .thenReturn("true");
+            testRunServices.when(() -> TestRunServices.runTestSuite(eq(dctx), 
Mockito.<Map<String, ?>>any()))
+                    .thenReturn(successResult("run-party"));
+
+            Map<String, Object> result = 
BatchRunServices.runBatchTestSuite(dctx,
+                    Map.of("userLogin", userLogin, "components", 
List.of("party", "party")));
+
+            assertThat(result.get("responseMessage"), is("success"));
+            String batchId = (String) result.get("batchId");
+            assertThat(BatchRunServices.BATCH_TRACKER.get(batchId),
+                    is(List.of(new BatchRunTracker.BatchChildRef("party", 
"run-party"))));
+            testRunServices.verify(() -> 
TestRunServices.runTestSuite(eq(dctx), Mockito.<Map<String, ?>>any()),
+                    times(1));
+        }
+    }
+
+    @Test
+    void getBatchTestRunStatusReturnsErrorWhenPermissionDenied() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("nobody");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(false);
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-1", "userLogin", userLogin));
+
+        assertThat(result.get("responseMessage"), is("error"));
+    }
+
+    @Test
+    void getBatchTestRunStatusReturnsErrorForAnUnknownBatchId() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "no-such-batch", "userLogin", userLogin));
+
+        assertThat(result.get("responseMessage"), is("error"));
+    }
+
+    @Test
+    void getBatchTestRunStatusIsRunningWhileAnyChildIsQueuedOrRunning() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-passed", "example-tests", 
"example", "admin", Map.of());
+        TestRunServices.TRACKER.markRunning("run-passed");
+        TestRunServices.TRACKER.markPassed("run-passed", Map.of("total", 1, 
"passed", 1, "failed", 0));
+        TestRunServices.TRACKER.register("run-running", "party-tests", 
"party", "admin", Map.of());
+        TestRunServices.TRACKER.markRunning("run-running");
+        BatchRunServices.BATCH_TRACKER.register("batch-running", List.of(
+                new BatchRunTracker.BatchChildRef("example", "run-passed"),
+                new BatchRunTracker.BatchChildRef("party", "run-running")));
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-running", "userLogin", userLogin));
+
+        assertThat(result.get("status"), is("RUNNING"));
+        Map<?, ?> summary = (Map<?, ?>) result.get("summary");
+        assertThat(summary.get("total"), is(2));
+        assertThat(summary.get("passed"), is(1));
+        assertThat(summary.get("running"), is(1));
+    }
+
+    @Test
+    void getBatchTestRunStatusIsPassedWhenEveryChildPassed() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-a", "example-tests", "example", 
"admin", Map.of());
+        TestRunServices.TRACKER.markPassed("run-a", Map.of("total", 1, 
"passed", 1, "failed", 0));
+        BatchRunServices.BATCH_TRACKER.register("batch-passed",
+                List.of(new BatchRunTracker.BatchChildRef("example", 
"run-a")));
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-passed", "userLogin", userLogin));
+
+        assertThat(result.get("status"), is("PASSED"));
+    }
+
+    @Test
+    void getBatchTestRunStatusIsFailedWhenAnyChildFailed() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-a2", "example-tests", "example", 
"admin", Map.of());
+        TestRunServices.TRACKER.markPassed("run-a2", Map.of("total", 1, 
"passed", 1, "failed", 0));
+        TestRunServices.TRACKER.register("run-b2", "party-tests", "party", 
"admin", Map.of());
+        TestRunServices.TRACKER.markFailed("run-b2", Map.of("total", 1, 
"passed", 0, "failed", 1));
+        BatchRunServices.BATCH_TRACKER.register("batch-failed", List.of(
+                new BatchRunTracker.BatchChildRef("example", "run-a2"),
+                new BatchRunTracker.BatchChildRef("party", "run-b2")));
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-failed", "userLogin", userLogin));
+
+        assertThat(result.get("status"), is("FAILED"));
+    }
+
+    @Test
+    void getBatchTestRunStatusIsErrorWhenAnyChildErrored() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-a3", "example-tests", "example", 
"admin", Map.of());
+        TestRunServices.TRACKER.markPassed("run-a3", Map.of("total", 1, 
"passed", 1, "failed", 0));
+        TestRunServices.TRACKER.register("run-b3", "party-tests", "party", 
"admin", Map.of());
+        TestRunServices.TRACKER.markError("run-b3", new 
RuntimeException("suite blew up"));
+        BatchRunServices.BATCH_TRACKER.register("batch-error", List.of(
+                new BatchRunTracker.BatchChildRef("example", "run-a3"),
+                new BatchRunTracker.BatchChildRef("party", "run-b3")));
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-error", "userLogin", userLogin));
+
+        assertThat(result.get("status"), is("ERROR"));
+    }
+
+    @Test
+    void getBatchTestRunStatusIncludesPerComponentResults() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-a4", "example-tests", "example", 
"admin", Map.of());
+        TestRunServices.TRACKER.markPassed("run-a4", Map.of("total", 1, 
"passed", 1, "failed", 0));
+        BatchRunServices.BATCH_TRACKER.register("batch-components",
+                List.of(new BatchRunTracker.BatchChildRef("example", 
"run-a4")));
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-components", "userLogin", userLogin));
+
+        List<?> components = (List<?>) result.get("components");
+        assertThat(components.size(), is(1));
+        Map<?, ?> componentResult = (Map<?, ?>) components.get(0);
+        assertThat(componentResult.get("componentName"), is("example"));
+        assertThat(componentResult.get("runId"), is("run-a4"));
+        assertThat(componentResult.get("status"), is("PASSED"));
+    }
+
+    @Test
+    void getBatchTestRunStatusPrefersErrorOverFailedWhenBothArePresent() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-p1-err", "example-tests", 
"example", "admin", Map.of());
+        TestRunServices.TRACKER.markError("run-p1-err", new 
RuntimeException("example error"));
+        TestRunServices.TRACKER.register("run-p1-fail", "party-tests", 
"party", "admin", Map.of());
+        TestRunServices.TRACKER.markFailed("run-p1-fail", Map.of("total", 1, 
"passed", 0, "failed", 1));
+        BatchRunServices.BATCH_TRACKER.register("batch-p1", List.of(
+                new BatchRunTracker.BatchChildRef("example", "run-p1-err"),
+                new BatchRunTracker.BatchChildRef("party", "run-p1-fail")));
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-p1", "userLogin", userLogin));
+
+        assertThat(result.get("status"), is("ERROR"));
+    }
+
+    @Test
+    void getBatchTestRunStatusPrefersFailedOverRunningWhenBothArePresent() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-p2-fail", "example-tests", 
"example", "admin", Map.of());
+        TestRunServices.TRACKER.markFailed("run-p2-fail", Map.of("total", 1, 
"passed", 0, "failed", 1));
+        TestRunServices.TRACKER.register("run-p2-run", "party-tests", "party", 
"admin", Map.of());
+        TestRunServices.TRACKER.markRunning("run-p2-run");
+        BatchRunServices.BATCH_TRACKER.register("batch-p2", List.of(
+                new BatchRunTracker.BatchChildRef("example", "run-p2-fail"),
+                new BatchRunTracker.BatchChildRef("party", "run-p2-run")));
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-p2", "userLogin", userLogin));
+
+        assertThat(result.get("status"), is("FAILED"));
+    }
+
+    @Test
+    void 
getBatchTestRunStatusIsRunningForAGenuinelyQueuedChildThatNeverStarted() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-p3-queued", "example-tests", 
"example", "admin", Map.of());
+        BatchRunServices.BATCH_TRACKER.register("batch-p3",
+                List.of(new BatchRunTracker.BatchChildRef("example", 
"run-p3-queued")));
+
+        Map<String, Object> result = 
BatchRunServices.getBatchTestRunStatus(dctx,
+                Map.of("batchId", "batch-p3", "userLogin", userLogin));
+
+        assertThat(result.get("status"), is("RUNNING"));
+        Map<?, ?> summary = (Map<?, ?>) result.get("summary");
+        assertThat(summary.get("queued"), is(1));
+    }
+}
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/BatchRunTrackerTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/BatchRunTrackerTest.java
new file mode 100644
index 0000000000..9e76860cdd
--- /dev/null
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/BatchRunTrackerTest.java
@@ -0,0 +1,74 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 org.apache.ofbiz.testtools;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class BatchRunTrackerTest {
+
+    @Test
+    void registerMakesTheChildListRetrievableByBatchId() {
+        BatchRunTracker tracker = new BatchRunTracker();
+        List<BatchRunTracker.BatchChildRef> children = List.of(
+                new BatchRunTracker.BatchChildRef("example", "run-1"),
+                new BatchRunTracker.BatchChildRef("party", "run-2"));
+
+        tracker.register("batch-1", children);
+
+        assertThat(tracker.get("batch-1"), is(children));
+    }
+
+    @Test
+    void getReturnsNullForAnUnknownBatchId() {
+        BatchRunTracker tracker = new BatchRunTracker();
+
+        assertThat(tracker.get("no-such-batch"), nullValue());
+    }
+
+    @Test
+    void registerDefensivelyCopiesTheCallersChildList() {
+        BatchRunTracker tracker = new BatchRunTracker();
+        List<BatchRunTracker.BatchChildRef> children = new ArrayList<>();
+        children.add(new BatchRunTracker.BatchChildRef("example", "run-1"));
+
+        tracker.register("batch-1", children);
+        children.add(new BatchRunTracker.BatchChildRef("party", "run-2"));
+
+        assertThat(tracker.get("batch-1"), is(List.of(new 
BatchRunTracker.BatchChildRef("example", "run-1"))));
+    }
+
+    @Test
+    void registerRejectsFurtherMutationOfTheStoredList() {
+        BatchRunTracker tracker = new BatchRunTracker();
+        tracker.register("batch-1", List.of(new 
BatchRunTracker.BatchChildRef("example", "run-1")));
+
+        List<BatchRunTracker.BatchChildRef> stored = tracker.get("batch-1");
+
+        assertThrows(UnsupportedOperationException.class, () ->
+                stored.add(new BatchRunTracker.BatchChildRef("party", 
"run-2")));
+    }
+}
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
index 46eb5b05ba..c2569601cf 100644
--- 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
@@ -271,4 +271,77 @@ class TestRunServicesTest {
             assertThat(result.get("runId"), nullValue());
         }
     }
+
+    @Test
+    void describeRunIncludesRunIdComponentNameStatusAndResultSummary() {
+        TestRunRecord record = TestRunRecord.queued("run-9", "example-tests", 
"example", "admin", Map.of())
+                .passed(Map.of("total", 2, "passed", 2, "failed", 0));
+
+        Map<String, Object> described = TestRunServices.describeRun(record);
+
+        assertThat(described.get("runId"), is("run-9"));
+        assertThat(described.get("componentName"), is("example"));
+        assertThat(described.get("status"), is("PASSED"));
+        assertThat(described.get("resultSummary"), is(Map.of("total", 2, 
"passed", 2, "failed", 0)));
+    }
+
+    @Test
+    void describeRunAddsErrorMessageIntoResultSummaryWhenPresent() {
+        TestRunRecord record = TestRunRecord.queued("run-9", "example-tests", 
"example", "admin", Map.of())
+                .error(new RuntimeException("boom"));
+
+        Map<String, Object> described = TestRunServices.describeRun(record);
+
+        assertThat(described.get("status"), is("ERROR"));
+        assertThat(((Map<?, ?>) 
described.get("resultSummary")).get("errorMessage"), is("boom"));
+    }
+
+    @Test
+    void getTestRunStatusIncludesRunId() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("run-id-check", "example-tests", 
"example", "admin", Map.of());
+
+        Map<String, Object> result = TestRunServices.getTestRunStatus(dctx,
+                Map.of("runId", "run-id-check", "userLogin", userLogin));
+
+        assertThat(result.get("runId"), is("run-id-check"));
+    }
+
+    @Test
+    void isTestApiGloballyEnabledReadsTheGlobalFlag() {
+        Delegator delegator = mock(Delegator.class);
+        try (MockedStatic<EntityUtilProperties> entityUtilProperties =
+                Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+
+            assertThat(TestRunServices.isTestApiGloballyEnabled(delegator), 
is(true));
+        }
+    }
+
+    @Test
+    void isTestApiEnabledForComponentDefaultsToTrueWhenUnset() {
+        Delegator delegator = mock(Delegator.class);
+        try (MockedStatic<EntityUtilProperties> entityUtilProperties =
+                Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            assertThat(TestRunServices.isTestApiEnabledForComponent(delegator, 
"example"), is(true));
+        }
+    }
+
+    @Test
+    void isTestApiEnabledForComponentReflectsAnExplicitOverride() {
+        Delegator delegator = mock(Delegator.class);
+        try (MockedStatic<EntityUtilProperties> entityUtilProperties =
+                Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.example", 
delegator))
+                    .thenReturn("false");
+
+            assertThat(TestRunServices.isTestApiEnabledForComponent(delegator, 
"example"), is(false));
+        }
+    }
 }

Reply via email to