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

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

commit df8fcbc9820e7a4392487fddb8fbc8a14c22e2cc
Author: Lukas Finster <[email protected]>
AuthorDate: Thu Jun 18 14:54:06 2026 +0200

    Improved: Added basic JUnit test for relevant rest-api classes and
    integration test for rest-api services (OFBIZ-13443)
---
 framework/rest-api/ofbiz-component.xml             |   2 -
 .../apache/ofbiz/ws/rs/test/RestServicesTests.java | 213 +++++++++++++++++++++
 .../ofbiz/ws/rs/core/OFBizApiConfigTest.java       | 107 +++++++++++
 .../ofbiz/ws/rs/core/ResponseStatusTest.java       |  70 +++++++
 .../ws/rs/listener/ApiContextListenerTest.java     |  72 +++++++
 .../ofbiz/ws/rs/model/ModelApiReaderTest.java      | 174 +++++++++++++++++
 .../ws/rs/openapi/OFBizOpenApiReaderTest.java      | 185 ++++++++++++++++++
 .../ws/rs/openapi/OFBizResourceScannerTest.java    |  96 ++++++++++
 .../ws/rs/process/RestRequestHandlerTest.java      | 168 ++++++++++++++++
 .../ofbiz/ws/rs/resources/ApiRootResourceTest.java |  80 ++++++++
 .../ofbiz/ws/rs/resources/OpenApiResourceTest.java |  75 ++++++++
 .../apache/ofbiz/ws/rs/util/RestApiUtilTest.java   | 159 +++++++++++++++
 framework/rest-api/testdef/rest-apiTests.xml       |   3 +
 13 files changed, 1402 insertions(+), 2 deletions(-)

diff --git a/framework/rest-api/ofbiz-component.xml 
b/framework/rest-api/ofbiz-component.xml
index ff6785703b..d256e386dd 100644
--- a/framework/rest-api/ofbiz-component.xml
+++ b/framework/rest-api/ofbiz-component.xml
@@ -30,9 +30,7 @@ under the License.
     <!-- service resources: model(s), eca(s) and group definitions -->
     <service-resource type="model" loader="main" 
location="servicedef/services.xml"/>
 
-    <!--
     <test-suite loader="main" location="testdef/rest-apiTests.xml"/>
-    -->
 
     <!-- web applications; will be mounted when using the embedded container 
-->
     <webapp name="rest-api"
diff --git 
a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/test/RestServicesTests.java
 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/test/RestServicesTests.java
new file mode 100644
index 0000000000..c325fc1434
--- /dev/null
+++ 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/test/RestServicesTests.java
@@ -0,0 +1,213 @@
+/*******************************************************************************
+ * 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.ws.rs.test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.service.ServiceUtil;
+import org.apache.ofbiz.service.testtools.OFBizTestCase;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class RestServicesTests extends OFBizTestCase {
+
+    public RestServicesTests(String name) {
+        super(name);
+    }
+
+    /**
+     * Verifies that the {@code generateAuthTokenService} returns a success 
response
+     * when called with a valid {@code userLogin} in the service context.
+     *
+     * <p>This is the happy-path test — it confirms the service completes 
without
+     * error and that {@link ServiceUtil#isSuccess(Map)} returns {@code 
true}.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails 
unexpectedly
+     */
+    public void testGenerateAuthTokenReturnsSuccess() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", 
UtilMisc.toMap("userLoginId", "admin"), false);
+        assertNotNull("admin userLogin must exist in demo data", userLogin);
+
+        Map<String, Object> ctx = UtilMisc.toMap("userLogin", (Object) 
userLogin);
+        Map<String, Object> result = 
getDispatcher().runSync("generateAuthTokenService", ctx);
+
+        assertTrue("Service should return success", 
ServiceUtil.isSuccess(result));
+    }
+
+    /**
+     * Verifies that the {@code access_token} output attribute is present and 
non-empty
+     * in the service response.
+     *
+     * <p>A null or empty token would indicate that {@code 
JWTManager.createJwt()}
+     * failed silently or returned an unexpected value.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails 
unexpectedly
+     */
+    public void testGenerateAuthTokenAccessTokenPresent() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", 
UtilMisc.toMap("userLoginId", "admin"), false);
+
+        Map<String, Object> result = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) userLogin));
+
+        String accessToken = (String) result.get("access_token");
+        assertNotNull("access_token should not be null", accessToken);
+        assertFalse("access_token should not be empty", accessToken.isEmpty());
+    }
+
+    /**
+     * Verifies that the {@code token_type} output attribute is exactly {@code 
"Bearer"}.
+     *
+     * <p>REST clients rely on this value to correctly construct the
+     * {@code Authorization: Bearer <token>} header. Any deviation would break
+     * standard OAuth2 / JWT bearer token flows.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails 
unexpectedly
+     */
+    public void testGenerateAuthTokenTokenTypeIsBearer() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", 
UtilMisc.toMap("userLoginId", "admin"), false);
+
+        Map<String, Object> result = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) userLogin));
+
+        assertEquals("token_type should be Bearer", "Bearer", 
result.get("token_type"));
+    }
+
+    /**
+     * Verifies that the {@code expires_in} output attribute is present and 
represents
+     * a valid positive integer (in seconds).
+     *
+     * <p>The value is read from the {@code security.jwt.token.expireTime} 
property
+     * in {@code security.properties}. This test guards against a missing 
property
+     * file entry or a non-numeric value being returned.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails 
unexpectedly,
+     *                   or if {@code expires_in} cannot be parsed as an 
integer
+     */
+    public void testGenerateAuthTokenExpiresInIsValid() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", 
UtilMisc.toMap("userLoginId", "admin"), false);
+
+        Map<String, Object> result = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) userLogin));
+
+        String expiresIn = (String) result.get("expires_in");
+        assertNotNull("expires_in should not be null", expiresIn);
+
+        int expiresCurrent = Integer.parseInt(expiresIn);
+        assertTrue("expires_in should be a positive number", expiresCurrent > 
0);
+
+        int expiresTarget = 1800;
+        assertTrue("expires_in should match the configured amount", 
expiresCurrent == expiresTarget);
+    }
+
+    /**
+     * Verifies that the {@code access_token} is a well-formed JWT by checking
+     * it consists of exactly three Base64-encoded parts separated by dots
+     * ({@code header.payload.signature}).
+     *
+     * <p>This does not validate the cryptographic signature — it confirms that
+     * {@code JWTManager.createJwt()} produced a structurally valid token that
+     * JWT libraries and API clients will be able to parse.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails 
unexpectedly
+     */
+    public void testGenerateAuthTokenTokenIsValidJwtFormat() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", 
UtilMisc.toMap("userLoginId", "admin"), false);
+
+        Map<String, Object> result = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) userLogin));
+
+        String accessToken = (String) result.get("access_token");
+        String[] parts = accessToken.split("\\.");
+        assertEquals("JWT should have 3 parts (header.payload.signature)", 3, 
parts.length);
+    }
+
+    /**
+     * Verifies that two different users receive different tokens.
+     *
+     * <p>Since the {@code userLoginId} is embedded in the JWT payload, tokens
+     * generated for different users must not be identical. Identical tokens 
would
+     * indicate that the {@code userLogin} is not being read from context 
correctly,
+     * allowing one user to authenticate as another.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails 
unexpectedly
+     */
+    public void testGenerateAuthTokenDifferentUsersGetDifferentTokens() throws 
Exception {
+        GenericValue adminLogin = getDelegator().findOne("UserLogin", 
UtilMisc.toMap("userLoginId", "admin"), false);
+        GenericValue systemLogin = getDelegator().findOne("UserLogin", 
UtilMisc.toMap("userLoginId", "system"), false);
+
+        assertNotNull("admin userLogin must exist", adminLogin);
+        assertNotNull("system userLogin must exist", systemLogin);
+
+        Map<String, Object> adminResult = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) adminLogin));
+
+        Map<String, Object> systemResult = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) systemLogin));
+
+        assertFalse(
+                "Different users should receive different tokens",
+                
adminResult.get("access_token").equals(systemResult.get("access_token")));
+    }
+
+    /**
+     * Verifies JWT payload hast correct issuer and userLoginId.
+     *
+     * @throws Exception if the service call or entity lookup fails 
unexpectedly
+     */
+    public void testGenerateAuthTokenIssuerAndUserLoginIdInPayload() throws 
Exception {
+        GenericValue adminLogin = getDelegator().findOne("UserLogin", 
UtilMisc.toMap("userLoginId", "admin"), false);
+        Map<String, Object> adminResult = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) adminLogin));
+        String jwt = (String) adminResult.get("access_token");
+
+        String[] parts = jwt.split("\\.");
+        String payloadSegment = parts[1];
+        byte[] decodedBytes = 
Base64.getUrlDecoder().decode(padBase64(payloadSegment));
+        String jwtDecoded = new String(decodedBytes, StandardCharsets.UTF_8);
+
+        ObjectMapper mapper = new ObjectMapper();
+        JsonNode claims = mapper.readTree(jwtDecoded);
+
+        String iss = claims.path("iss").asText(null);
+        String userLoginId = claims.path("userLoginId").asText(null);
+
+        assertTrue("Issuer is ApacheOFBiz", iss.equals("ApacheOFBiz"));
+        assertTrue("UserLoginId is admin", userLoginId.equals("admin"));
+    }
+
+    private static String padBase64(String input) {
+        int padding = (4 - input.length() % 4) % 4;
+        StringBuilder sb = new StringBuilder(input);
+        for (int i = 0; i < padding; i++) sb.append('=');
+        return sb.toString();
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/OFBizApiConfigTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/OFBizApiConfigTest.java
new file mode 100644
index 0000000000..2e9dd22b26
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/OFBizApiConfigTest.java
@@ -0,0 +1,107 @@
+/*******************************************************************************
+ * 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.ws.rs.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.lang.reflect.Method;
+
+import org.junit.jupiter.api.Test;
+
+class OFBizApiConfigTest {
+
+    @Test
+    void testBuildCleanPathRemovesDuplicateSlashes() throws Exception {
+        OFBizApiConfig config = new OFBizApiConfig();
+
+        Method method = OFBizApiConfig.class.getDeclaredMethod(
+                "buildCleanPath",
+                String[].class);
+        method.setAccessible(true);
+
+        String path = (String) method.invoke(
+                config,
+                (Object) new String[] {
+                        "/party/",
+                        "/customers/",
+                        "/{partyId}/"
+                });
+
+        assertEquals("party/customers/{partyId}", path);
+    }
+
+    @Test
+    void testBuildCleanPathIgnoresNullParts() throws Exception {
+        OFBizApiConfig config = new OFBizApiConfig();
+
+        Method method = OFBizApiConfig.class.getDeclaredMethod(
+                "buildCleanPath",
+                String[].class);
+        method.setAccessible(true);
+
+        String path = (String) method.invoke(
+                config,
+                (Object) new String[] {
+                        null,
+                        "",
+                        " ",
+                        "/party/"
+                });
+
+        assertEquals("party", path);
+    }
+
+    @Test
+    void testBuildCleanPathReturnsEmptyStringWhenNoValidParts() throws 
Exception {
+        OFBizApiConfig config = new OFBizApiConfig();
+
+        Method method = OFBizApiConfig.class.getDeclaredMethod(
+                "buildCleanPath",
+                String[].class);
+        method.setAccessible(true);
+
+        String path = (String) method.invoke(
+                config,
+                (Object) new String[] {
+                        "",
+                        null,
+                        "   "
+                });
+
+        assertEquals("", path);
+    }
+
+    @Test
+    void testBuildCleanPathWithSinglePart() throws Exception {
+        OFBizApiConfig config = new OFBizApiConfig();
+
+        Method method = OFBizApiConfig.class.getDeclaredMethod(
+                "buildCleanPath",
+                String[].class);
+        method.setAccessible(true);
+
+        String path = (String) method.invoke(
+                config,
+                (Object) new String[] {
+                        "/services/"
+                });
+
+        assertEquals("services", path);
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/ResponseStatusTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/ResponseStatusTest.java
new file mode 100644
index 0000000000..8cb1837b30
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/ResponseStatusTest.java
@@ -0,0 +1,70 @@
+/*******************************************************************************
+ * 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.ws.rs.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.apache.ofbiz.ws.rs.core.ResponseStatus.Custom;
+import org.junit.jupiter.api.Test;
+
+import jakarta.ws.rs.core.Response.Status.Family;
+
+class ResponseStatusTest {
+
+    @Test
+    void testUnprocessableEntityHasCorrectStatusCode() {
+        assertEquals(422, Custom.UNPROCESSABLE_ENTITY.getStatusCode());
+    }
+
+    @Test
+    void testUnprocessableEntityHasCorrectReasonPhrase() {
+        assertEquals("Unprocessable Entity", 
Custom.UNPROCESSABLE_ENTITY.getReasonPhrase());
+    }
+
+    @Test
+    void testToStringReturnsReasonPhrase() {
+        assertEquals("Unprocessable Entity", 
Custom.UNPROCESSABLE_ENTITY.toString());
+    }
+
+    @Test
+    void testFamilyIsClientError() {
+        // 422 falls in the 4xx range
+        assertEquals(Family.CLIENT_ERROR, 
Custom.UNPROCESSABLE_ENTITY.getFamily());
+    }
+
+    @Test
+    void testFromStatusCodeReturnsMatchingEnum() {
+        Custom result = Custom.fromStatusCode(422);
+        assertNotNull(result);
+        assertSame(Custom.UNPROCESSABLE_ENTITY, result);
+    }
+
+    @Test
+    void testFromStatusCodeReturnsNullForUnknownStatusCode() {
+        assertNull(Custom.fromStatusCode(999));
+    }
+
+    @Test
+    void testFromStatusCodeReturnsNullForStandardStatusCode() {
+        assertNull(Custom.fromStatusCode(404));
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/listener/ApiContextListenerTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/listener/ApiContextListenerTest.java
new file mode 100644
index 0000000000..a6f301204b
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/listener/ApiContextListenerTest.java
@@ -0,0 +1,72 @@
+/*******************************************************************************
+ * 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.ws.rs.listener;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.DelegatorFactory;
+import org.apache.ofbiz.service.LocalDispatcher;
+import org.apache.ofbiz.service.ServiceContainer;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletContextEvent;
+
+class ApiContextListenerTest {
+
+    @Test
+    void testContextInitializedWiresDelegatorAndDispatcherUnderCorrectKeys() {
+        ServletContext servletContext = mock(ServletContext.class);
+        ServletContextEvent event = mock(ServletContextEvent.class);
+        when(event.getServletContext()).thenReturn(servletContext);
+        
when(servletContext.getInitParameter("entityDelegatorName")).thenReturn("default");
+        
when(servletContext.getInitParameter("localDispatcherName")).thenReturn("api-dispatcher");
+
+        Delegator delegator = mock(Delegator.class);
+        LocalDispatcher dispatcher = mock(LocalDispatcher.class);
+
+        try (MockedStatic<DelegatorFactory> delegatorFactory = 
mockStatic(DelegatorFactory.class);
+                MockedStatic<ServiceContainer> serviceContainer = 
mockStatic(ServiceContainer.class)) {
+            delegatorFactory.when(() -> 
DelegatorFactory.getDelegator("default")).thenReturn(delegator);
+            serviceContainer.when(() -> 
ServiceContainer.getLocalDispatcher("api-dispatcher", 
delegator)).thenReturn(dispatcher);
+
+            new ApiContextListener().contextInitialized(event);
+
+            verify(servletContext).setAttribute("delegator", delegator);
+            verify(servletContext).setAttribute("dispatcher", dispatcher);
+        }
+    }
+
+    @Test
+    void testContextDestroyedRemovesDelegatorAndDispatcherAttributes() {
+        ServletContext servletContext = mock(ServletContext.class);
+        ServletContextEvent event = mock(ServletContextEvent.class);
+        when(event.getServletContext()).thenReturn(servletContext);
+
+        new ApiContextListener().contextDestroyed(event);
+
+        verify(servletContext).removeAttribute("delegator");
+        verify(servletContext).removeAttribute("dispatcher");
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/model/ModelApiReaderTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/model/ModelApiReaderTest.java
new file mode 100644
index 0000000000..5cb0f398da
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/model/ModelApiReaderTest.java
@@ -0,0 +1,174 @@
+/*******************************************************************************
+ * 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.ws.rs.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Integration-style tests for {@link ModelApiReader}.
+ *
+ * <p>These tests deliberately parse real, temporary {@code *.rest.xml} files
+ * rather than mocking {@code UtilXml}. The behavior under test is the
+ * mapping between XML attribute names and {@link ModelApi} /
+ * {@link ModelResource} / {@link ModelOperation} fields, which can only be
+ * verified meaningfully against a real XML document.</p>
+ */
+class ModelApiReaderTest {
+
+    @TempDir
+    private File tempDir;
+
+    private File writeXml(String content) throws IOException {
+        File file = new File(tempDir, "test.rest.xml");
+        Files.writeString(file.toPath(), content);
+        return file;
+    }
+
+    @Test
+    void testGetModelApiParsesTopLevelAttributes() throws IOException {
+        File file = writeXml("""
+                <api displayName="My API" name="myApi" description="desc" 
path="/api" publish="true"/>
+                """);
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+
+        assertEquals("My API", api.getDisplayName());
+        assertEquals("myApi", api.getName());
+        assertEquals("desc", api.getDescription());
+        assertEquals("/api", api.getPath());
+        assertTrue(api.isPublish());
+    }
+
+    @Test
+    void testGetModelApiMissingPublishAttributeDefaultsToFalse() throws 
IOException {
+        // No "publish" attribute at all -> UtilXml.checkEmpty(...) yields "",
+        // and Boolean.parseBoolean("") is false. This locks in that implicit
+        // default so a change in checkEmpty/parseBoolean behavior is caught.
+        File file = writeXml("<api name=\"myApi\"/>");
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+
+        assertFalse(api.isPublish());
+    }
+
+    @Test
+    void testGetModelAPiParsesSingleResourceAttributes() throws IOException {
+        File file = writeXml("""
+                <api name="myApi">
+                <resource name="users" description="user resource" 
displayName="Users"
+                path="/users" publish="true" auth="true"/>
+                </api>
+                """);
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+
+        List<ModelResource> resources = api.getResources();
+        assertEquals(1, resources.size());
+
+        ModelResource users = resources.get(0);
+        assertEquals("users", users.getName());
+        assertEquals("user resource", users.getDescription());
+        assertEquals("Users", users.getDisplayName());
+        assertEquals("/users", users.getPath());
+        assertTrue(users.isPublish());
+        assertTrue(users.isAuth());
+    }
+
+    @Test
+    void testGetModelApiParsesNestedResourcesRecursively() throws IOException {
+        File file = writeXml("""
+                <api name="myApi">
+                <resource name="users" path="/users">
+                <resource name="orders" path="/orders">
+                <resource name="items" path="/items"/>
+                </resource>
+                </resource>
+                </api>
+                """);
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+
+        ModelResource users = api.getResources().get(0);
+        assertEquals("users", users.getName());
+        assertEquals(1, users.getSubResources().size());
+
+        ModelResource orders = users.getSubResources().get(0);
+        assertEquals("orders", orders.getName());
+        assertEquals(1, orders.getSubResources().size());
+
+        ModelResource items = orders.getSubResources().get(0);
+        assertEquals("items", items.getName());
+        assertTrue(items.getSubResources().isEmpty());
+    }
+
+    @Test
+    void testGetModelApiParsesOperationsOnAResource() throws IOException {
+        File file = writeXml("""
+                <api name="myApi">
+                <resource name="orders" path="/orders">
+                <operation path="/{id}" verb="GET" produces="application/json"
+                consumes="application/json" description="Get an order" 
auth="true">
+                <service name="getOrder"/>
+                </operation>
+                </resource>
+                </api>
+                """);
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+        ModelResource orders = api.getResources().get(0);
+
+        List<ModelOperation> operations = orders.getOperations();
+        assertEquals(1, operations.size());
+
+        ModelOperation getOrder = operations.get(0);
+        assertEquals("/{id}", getOrder.getPath());
+        assertEquals("GET", getOrder.getVerb());
+        assertEquals("application/json", getOrder.getProduces());
+        assertEquals("application/json", getOrder.getConsumes());
+        assertEquals("Get an order", getOrder.getDescription());
+        assertEquals("getOrder", getOrder.getService());
+        assertTrue(getOrder.isAuth());
+    }
+
+    @Test
+    void testGetModelApiThrowsRuntimeExceptionForMalformedXml() throws 
IOException {
+        File file = writeXml("<api name=\"broken\"><unclosed>");
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> 
ModelApiReader.getModelApi(file));
+        assertTrue(ex.getMessage().contains("Failed to parse REST API 
definition"));
+    }
+
+    @Test
+    void getModelApiThrowsForNonExistentFile() {
+        File file = new File(tempDir, "does-not-exist.rest.xml");
+
+        assertThrows(RuntimeException.class, () -> 
ModelApiReader.getModelApi(file));
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizOpenApiReaderTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizOpenApiReaderTest.java
new file mode 100644
index 0000000000..c0ff824ac9
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizOpenApiReaderTest.java
@@ -0,0 +1,185 @@
+/*******************************************************************************
+ * 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.ws.rs.openapi;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.LocalDispatcher;
+import org.apache.ofbiz.ws.rs.core.OFBizApiConfig;
+import org.apache.ofbiz.ws.rs.model.ModelApi;
+import org.apache.ofbiz.ws.rs.model.ModelOperation;
+import org.apache.ofbiz.ws.rs.model.ModelResource;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import jakarta.servlet.ServletContext;
+
+class OFBizOpenApiReaderTest {
+
+    private OFBizOpenApiReader reader;
+
+    @BeforeEach
+    void setUp() {
+        reader = new OFBizOpenApiReader();
+    }
+
+    @Test
+    void testOpenApiIsGenerated() throws Exception {
+        OpenAPI openAPI = buildOpenApi();
+        assertNotNull(openAPI);
+    }
+
+    @Test
+    void testPathItemExists() throws Exception {
+        OpenAPI openAPI = buildOpenApi();
+        PathItem pathItem = openAPI.getPaths().get("/api/resource/hello");
+        assertNotNull(pathItem);
+    }
+
+    @Test
+    void testGetOperationExists() throws Exception {
+        PathItem pathItem = getPathItem();
+        Operation getOp = pathItem.getGet();
+        assertNotNull(getOp);
+    }
+
+    @Test
+    void testOperationIdMatchesServiceName() throws Exception {
+        Operation getOp = getGetOperation();
+        assertEquals("testService", getOp.getOperationId());
+    }
+
+    @Test
+    void testOperationSummaryMatchesDescription() throws Exception {
+        Operation getOp = getGetOperation();
+        assertEquals("test operation", getOp.getSummary());
+    }
+
+    /**
+     * Verifies that buildNestedUrl correctly joins segments.
+     */
+    @Test
+    void testBuildNestedUrlShouldNormalizeSegments() {
+        List<String> segments = List.of("/api/", "/resource/", "hello");
+
+        String url = OFBizOpenApiReader.buildNestedUrl(segments);
+
+        assertEquals("/api/resource/hello", url);
+    }
+
+    /**
+     * Verifies that empty segments are ignored.
+     */
+    @Test
+    void buildNestedUrlShouldIgnoreEmptySegments() {
+        List<String> segments = new ArrayList<>();
+        segments.add("");
+        segments.add("/");
+        segments.add("api");
+        segments.add(null);
+        segments.add("test");
+
+        String url = OFBizOpenApiReader.buildNestedUrl(segments);
+
+        assertEquals("/api/test", url);
+    }
+
+    /**
+     * Sets up all mocks (dispatcher, servlet context, static access, API 
model)
+     * and runs reader.read(...), returning the resulting OpenAPI object.
+     */
+    @SuppressWarnings("deprecation")
+    private OpenAPI buildOpenApi() throws Exception {
+        LocalDispatcher dispatcher = mock(LocalDispatcher.class);
+        DispatchContext dctx = mock(DispatchContext.class);
+        when(dispatcher.getDispatchContext()).thenReturn(dctx);
+
+        try (MockedStatic<org.apache.ofbiz.ws.rs.listener.ApiContextListener> 
ctxMock =
+                        
Mockito.mockStatic(org.apache.ofbiz.ws.rs.listener.ApiContextListener.class);
+                MockedStatic<org.apache.ofbiz.webapp.WebAppUtil> webMock =
+                        
Mockito.mockStatic(org.apache.ofbiz.webapp.WebAppUtil.class);
+                MockedStatic<OFBizApiConfig> apiMock = 
Mockito.mockStatic(OFBizApiConfig.class)) {
+
+            ServletContext servletContext = mock(ServletContext.class);
+
+            
ctxMock.when(org.apache.ofbiz.ws.rs.listener.ApiContextListener::getApplicationCntx)
+                    .thenReturn(servletContext);
+
+            webMock.when(() -> 
org.apache.ofbiz.webapp.WebAppUtil.getDispatcher(servletContext))
+                    .thenReturn(dispatcher);
+
+            ModelOperation op = mock(ModelOperation.class);
+            when(op.getPath()).thenReturn("hello");
+            when(op.getVerb()).thenReturn("GET");
+            when(op.getService()).thenReturn("testService");
+            when(op.getDescription()).thenReturn("test operation");
+
+            ModelResource resource = mock(ModelResource.class);
+            when(resource.getPath()).thenReturn("resource");
+            when(resource.getDisplayName()).thenReturn("TestResource");
+            when(resource.getDescription()).thenReturn("desc");
+            when(resource.getOperations()).thenReturn(List.of(op));
+            when(resource.getSubResources()).thenReturn(List.of());
+
+            ModelApi api = mock(ModelApi.class);
+            when(api.isPublish()).thenReturn(true);
+            when(api.getPath()).thenReturn("api");
+            when(api.getResources()).thenReturn(List.of(resource));
+
+            apiMock.when(OFBizApiConfig::getModelApis)
+                    .thenReturn(Map.of("test", api));
+
+            org.apache.ofbiz.service.ModelService service =
+                    mock(org.apache.ofbiz.service.ModelService.class);
+            when(service.getName()).thenReturn("testService");
+            when(service.getInModelParamList()).thenReturn(List.of());
+            when(service.isExport()).thenReturn(true);
+            when(dctx.getModelService("testService")).thenReturn(service);
+
+            return reader.read(Set.of(), Map.of());
+        }
+    }
+
+    private PathItem getPathItem() throws Exception {
+        OpenAPI openAPI = buildOpenApi();
+        PathItem pathItem = openAPI.getPaths().get("/api/resource/hello");
+        assertNotNull(pathItem, "Expected path item for /api/resource/hello");
+        return pathItem;
+    }
+
+    private Operation getGetOperation() throws Exception {
+        Operation getOp = getPathItem().getGet();
+        assertNotNull(getOp, "Expected GET operation to be present");
+        return getOp;
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizResourceScannerTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizResourceScannerTest.java
new file mode 100644
index 0000000000..a40580682c
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizResourceScannerTest.java
@@ -0,0 +1,96 @@
+/*******************************************************************************
+ * 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.ws.rs.openapi;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class OFBizResourceScannerTest {
+
+    private OFBizResourceScanner scanner;
+
+    @BeforeEach
+    private void setUp() {
+        scanner = new OFBizResourceScanner();
+    }
+
+    /**
+     * Verifies that a {@code null} input is treated as ignored,
+     * consistent with {@code UtilValidate.isEmpty} returning {@code true} for 
null.
+     */
+    @Test
+    void testIsIgnoredNull() {
+        assertTrue(scanner.isIgnored(null));
+    }
+
+    /**
+     * Verifies that an empty string is treated as ignored.
+     */
+    @Test
+    void testIsIgnoredEmptyString() {
+        assertTrue(scanner.isIgnored(""));
+    }
+
+    /**
+     * Verifies that the exact class name in {@code IGNORED} is ignored.
+     */
+    @Test
+    void testIsIgnoredExactMatch() {
+        
assertTrue(scanner.isIgnored("org.apache.ofbiz.ws.rs.resources.OFBizServiceResource"));
+    }
+
+    /**
+     * Verifies that a class name starting with an ignored entry is also 
ignored.
+     * Since {@code startsWith} is used, any sub-class or nested path under the
+     * ignored prefix is treated as ignored.
+     */
+    @Test
+    void testIsIgnoredPrefixMatch() {
+        
assertTrue(scanner.isIgnored("org.apache.ofbiz.ws.rs.resources.OFBizServiceResource$InnerClass"));
+    }
+
+    /**
+     * Verifies that a class name not in {@code IGNORED} and not starting with
+     * any ignored entry is not ignored.
+     */
+    @Test
+    void testIsIgnoredNonMatchingClass() {
+        
assertFalse(scanner.isIgnored("org.apache.ofbiz.ws.rs.resources.OFBizOpenApiReader"));
+    }
+
+    /**
+     * Verifies that a partial match that is not a prefix is not ignored.
+     * The ignored entry must match from the start of the string.
+     */
+    @Test
+    void testIsIgnoredPartialNonPrefixMatch() {
+        assertFalse(scanner.isIgnored("com.example.OFBizServiceResource"));
+    }
+
+    /**
+     * Verifies that a completely unrelated class name is not ignored.
+     */
+    @Test
+    void testIsIgnoredUnrelatedClass() {
+        assertFalse(scanner.isIgnored("com.example.SomeOtherResource"));
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/process/RestRequestHandlerTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/process/RestRequestHandlerTest.java
new file mode 100644
index 0000000000..611d17645d
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/process/RestRequestHandlerTest.java
@@ -0,0 +1,168 @@
+/*******************************************************************************
+ * 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.ws.rs.process;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.glassfish.jersey.server.ContainerRequest;
+import org.junit.jupiter.api.Test;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+
+/**
+ * Tests for {@link RestRequestHandler#extract(MultivaluedMap)} and
+ * {@link RestRequestHandler#extractRequestBody(ContainerRequestContext)}.
+ *
+ * <p>{@code RestRequestHandler} is abstract and its {@code @Inject} fields
+ * are not relevant to these two methods, so a minimal concrete subclass is
+ * used purely to obtain an instance. {@code extract} and
+ * {@code extractRequestBody} are exercised directly rather than through
+ * {@code apply(...)}.</p>
+ */
+class RestRequestHandlerTest {
+
+    /**
+     * Minimal concrete subclass solely to instantiate the abstract class
+     * under test. {@code execute} is never invoked by the tests below.
+     */
+    private static final class TestHandler extends RestRequestHandler {
+        @Override
+        protected Response execute(ContainerRequestContext data, Map<String, 
Object> arguments) {
+            return null;
+        }
+    }
+
+    private final TestHandler handler = new TestHandler();
+
+
+    @Test
+    void testExtractSingleValueParameterIsStoredAsPlainString() {
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.putSingle("testParam", "testValue");
+
+        Map<String, Object> result = handler.extract(params);
+
+        assertEquals("testValue", result.get("testParam"));
+    }
+
+    @Test
+    void testExtractMultiValueParameterIsStoredAsList() {
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.add("tag", "testValue");
+        params.add("tag", "testValueTwo");
+
+        Map<String, Object> result = handler.extract(params);
+
+        assertEquals(List.of("testValue", "testValueTwo"), result.get("tag"));
+    }
+
+    @Test
+    void testExtractSkipsKeysWithEmptyValueList() {
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.put("empty", List.of());
+        params.putSingle("present", "value");
+
+        Map<String, Object> result = handler.extract(params);
+
+        assertEquals(1, result.size());
+        assertEquals("value", result.get("present"));
+        assertTrue(!result.containsKey("empty"));
+    }
+
+    @Test
+    void testExtractOfEmptyMapReturnsEmptyMap() {
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+
+        Map<String, Object> result = handler.extract(params);
+
+        assertTrue(result.isEmpty());
+    }
+
+
+    @Test
+    void testExtractRequestBodyReturnsEmptyMapWhenNotAContainerRequest() {
+        ContainerRequestContext requestContext = 
mock(ContainerRequestContext.class);
+
+        Map<String, Object> result = 
handler.extractRequestBody(requestContext);
+
+        assertTrue(result.isEmpty());
+        verifyNoInteractions(requestContext);
+    }
+
+    @Test
+    void testExtractRequestBodyReturnsEmptyMapWhenNoEntity() {
+        ContainerRequest requestContext = mock(ContainerRequest.class);
+        when(requestContext.hasEntity()).thenReturn(false);
+
+        Map<String, Object> result = 
handler.extractRequestBody(requestContext);
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    void testExtractRequestBodyReturnsEmptyMapWhenContentTypeIsNotJson() {
+        ContainerRequest requestContext = mock(ContainerRequest.class);
+        when(requestContext.hasEntity()).thenReturn(true);
+        
when(requestContext.getMediaType()).thenReturn(MediaType.TEXT_PLAIN_TYPE);
+
+        Map<String, Object> result = 
handler.extractRequestBody(requestContext);
+
+        assertTrue(result.isEmpty());
+        verify(requestContext, 
org.mockito.Mockito.never()).readEntity(eq(Map.class));
+    }
+
+    @Test
+    void testExtractRequestBodyReturnsEmptyMapWhenJsonEntityIsNull() {
+        ContainerRequest requestContext = mock(ContainerRequest.class);
+        when(requestContext.hasEntity()).thenReturn(true);
+        
when(requestContext.getMediaType()).thenReturn(MediaType.APPLICATION_JSON_TYPE);
+        when(requestContext.readEntity(eq(Map.class))).thenReturn(null);
+
+        Map<String, Object> result = 
handler.extractRequestBody(requestContext);
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    void testExtractRequestBodyReturnsParsedMapForJsonEntity() {
+        ContainerRequest requestContext = mock(ContainerRequest.class);
+        when(requestContext.hasEntity()).thenReturn(true);
+        
when(requestContext.getMediaType()).thenReturn(MediaType.APPLICATION_JSON_TYPE);
+        when(requestContext.readEntity(eq(Map.class))).thenReturn(Map.of("id", 
"123", "active", true));
+
+        Map<String, Object> result = 
handler.extractRequestBody(requestContext);
+
+        assertEquals("123", result.get("id"));
+        assertEquals(true, result.get("active"));
+        verify(requestContext).bufferEntity();
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/ApiRootResourceTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/ApiRootResourceTest.java
new file mode 100644
index 0000000000..8bf48f37cd
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/ApiRootResourceTest.java
@@ -0,0 +1,80 @@
+/*******************************************************************************
+ * 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.ws.rs.resources;
+
+import static org.apache.ofbiz.ws.rs.resources.ApiRootResource.joinUri;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class ApiRootResourceTest {
+
+    @ParameterizedTest
+    @CsvSource({
+        "base, path",
+        "base, /path",
+        "base/, path",
+        "base/, /path"
+    })
+    void testJoinUriBaseAndPath(String base, String path) {
+        assertEquals("base/path", joinUri(base, path));
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "base, /",
+        "base/, /"
+    })
+    void testJoinUriBase(String base, String path) {
+        assertEquals("base/", joinUri(base, path));
+    }
+
+    @Test
+    void testJoinUriPreservePathParams() {
+        assertEquals("base/path.{myParam}", joinUri("base", 
"/path.{myParam}"));
+    }
+
+    @Test
+    void testJoinUriOneArgument() {
+        assertEquals("base", joinUri("base"));
+    }
+
+    @Test
+    void testJoinUriEmpty() {
+        assertEquals("", joinUri());
+    }
+
+    // Current known Limitations, change Tests once these change.
+    // Ensures that a change to these is a concious decision
+
+    //No full normalization with double '/' -> //
+    @Test
+    void testJoinUriDoubleSlash() {
+        assertEquals("base//path", joinUri("base//", "path"));
+    }
+
+    // No fault tolerance for null parts
+    @Test
+    void testNullPartThrowsNullPointerException() {
+        assertThrows(NullPointerException.class, () -> joinUri("base", null));
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/OpenApiResourceTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/OpenApiResourceTest.java
new file mode 100644
index 0000000000..83c4d43ac0
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/OpenApiResourceTest.java
@@ -0,0 +1,75 @@
+/*******************************************************************************
+ * 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.ws.rs.resources;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.ofbiz.ws.rs.openapi.OFBizOpenApiReader;
+import org.apache.ofbiz.ws.rs.openapi.OFBizResourceScanner;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.MockedConstruction;
+import org.mockito.Mockito;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.UriInfo;
+
+class OpenApiResourceTest {
+
+
+    @SuppressWarnings("try")
+    @ParameterizedTest
+    @CsvSource({
+        "json, application/json",
+        "yaml, application/yaml"
+    })
+    void testGetOpenApi(String type, String expectedType) throws Exception {
+
+        // Mock HTTP request context
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getScheme()).thenReturn("http");
+        when(request.getServerName()).thenReturn("localhost");
+        when(request.getServerPort()).thenReturn(8080);
+        when(request.getContextPath()).thenReturn("/ofbiz");
+
+        OpenApiResource resource = new OpenApiResource();
+
+        // inject request via reflection (since field is @Context)
+        java.lang.reflect.Field f = 
OpenApiResource.class.getDeclaredField("request");
+        f.setAccessible(true);
+        f.set(resource, request);
+
+        // Mock heavy Swagger builder
+        try 
(MockedConstruction<org.apache.ofbiz.ws.rs.openapi.OFBizOpenApiReader> 
readerMock =
+                Mockito.mockConstruction(OFBizOpenApiReader.class, (mock, 
context) -> { });
+                MockedConstruction<OFBizResourceScanner> scannerMock = 
Mockito.mockConstruction(OFBizResourceScanner.class)) {
+
+            Response response = resource.getOpenApi(mock(HttpHeaders.class), 
mock(UriInfo.class), type);
+
+            assertEquals(200, response.getStatus());
+            assertEquals(expectedType, response.getMediaType().toString());
+            assertNotNull(response.getEntity());
+        }
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilTest.java
new file mode 100644
index 0000000000..679dc99574
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilTest.java
@@ -0,0 +1,159 @@
+/*******************************************************************************
+ * 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.ws.rs.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.ofbiz.service.ModelService;
+import org.apache.ofbiz.ws.rs.response.Error;
+import org.apache.ofbiz.ws.rs.response.Success;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+
+public final class RestApiUtilTest {
+
+    private RestApiUtilTest() { }
+
+    @Test
+    void testSuccess() {
+        String message = "Success";
+        String data = "some data";
+
+        Response response = RestApiUtil.success(message, data);
+
+        Success expected = new Success(200, "OK", message, data);
+        Success actual = (Success) response.getEntity();
+
+        assertEquals(expected.getStatusCode(), actual.getStatusCode());
+        assertEquals(expected.getStatusDescription(), 
actual.getStatusDescription());
+        assertEquals(expected.getSuccessMessage(), actual.getSuccessMessage());
+        assertEquals(expected.getData(), actual.getData());
+    }
+
+    @Test
+    void testError() {
+        String message = "Error";
+        String reason = "reason for error";
+
+        Response response = RestApiUtil.error(400, reason, message);
+
+        Error expected = new Error(400, reason, message);
+        Error actual = (Error) response.getEntity();
+
+        assertEquals(expected.getAdditionalErrors(), 
actual.getAdditionalErrors());
+        assertEquals(expected.getClass(), actual.getClass());
+        assertEquals(expected.getErrorDesc(), actual.getErrorDesc());
+        assertEquals(expected.getErrorMessage(), actual.getErrorMessage());
+        assertEquals(expected.getStatusCode(), actual.getStatusCode());
+        assertEquals(expected.getStatusDescription(), 
actual.getStatusDescription());
+        assertEquals(expected.getType(), actual.getType());
+    }
+
+    @Test
+    void testResponseBuilder() {
+        String message = "Error";
+        String reason = "reason for error";
+
+        Response response = RestApiUtil.errorBuilder(400, reason, 
message).build();
+
+        assertEquals(400, response.getStatus());
+        assertEquals(MediaType.APPLICATION_JSON, 
response.getMediaType().toString());
+
+        Error expected = (Error) response.getEntity();
+        assertEquals(expected, response.getEntity());
+    }
+
+    @Test
+    void testExtractParams() {
+        MultivaluedMap<String, String> input = new MultivaluedHashMap<>();
+        input.add("name", "John");
+        input.add("role", "admin");
+        input.add("role", "user");
+        input.put("empty", List.of());
+        input.put("null", null);
+
+        Map<String, Object> result = RestApiUtil.extractParams(input);
+
+        // Assert single-value parameter
+        assertEquals("John", result.get("name"));
+        assertTrue(result.get("name") instanceof String);
+
+        // Assert multi-value parameter
+        assertTrue(result.get("role") instanceof List);
+
+        @SuppressWarnings("unchecked")
+        List<String> roles = (List<String>) result.get("role");
+        assertEquals(List.of("admin", "user"), roles);
+
+        // Assert empty parameter is ignored
+        assertFalse(result.containsKey("empty"));
+
+        // Assert null parameter is ignored
+        assertFalse(result.containsKey("null"));
+    }
+
+    @ParameterizedTest
+    @NullSource
+    @ValueSource(strings = { "my/path/without/parameters", ""})
+    void testGetPathParametersReturnEmptyList(String pathInfo) {
+        List<String> pathParameters = RestApiUtil.getPathParameters(pathInfo);
+        assertTrue(pathParameters.isEmpty());
+    }
+
+    @Test
+    void testGetPathParameters() {
+        String pathInfo = 
"my/path/with/parameters/{parameterOne}/middle/and/end/{parameterTwo}";
+
+        List<String> pathParameters = RestApiUtil.getPathParameters(pathInfo);
+
+        assertTrue(pathParameters.contains("parameterOne"));
+        assertTrue(pathParameters.contains("parameterTwo"));
+    }
+
+    @Test
+    void testBuildErrorFromServiceResult() {
+        Map<String, Object> result = new HashMap<>();
+
+        List<String> errors = new LinkedList<>(List.of("errorOne", "errorTwo", 
"errorThree"));
+        result.put(ModelService.ERROR_MESSAGE_LIST, errors);
+
+        Response response =
+                RestApiUtil.buildErrorFromServiceResult("TestService", result, 
Locale.ENGLISH);
+
+        Error error = (Error) response.getEntity();
+
+        assertEquals("errorOne", error.getErrorDesc());
+        assertEquals(List.of("errorTwo", "errorThree"), 
error.getAdditionalErrors());
+    }
+}
diff --git a/framework/rest-api/testdef/rest-apiTests.xml 
b/framework/rest-api/testdef/rest-apiTests.xml
index 48e0630b15..3cb93145f5 100644
--- a/framework/rest-api/testdef/rest-apiTests.xml
+++ b/framework/rest-api/testdef/rest-apiTests.xml
@@ -22,5 +22,8 @@ under the License.
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
             
xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/test-suite.xsd";>
 
+    <test-case case-name="rest-api-services-tests">
+        <junit-test-suite 
class-name="org.apache.ofbiz.ws.rs.test.RestServicesTests"/>
+    </test-case>
 
 </test-suite>
\ No newline at end of file

Reply via email to