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

sergehuber pushed a commit to branch UNOMI-996-migration-mappings
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit dd5d4954a268d0c4aae1d0224a2fde5ac120d913
Author: Serge Huber <[email protected]>
AuthorDate: Tue Oct 6 15:56:16 2026 +0200

    UNOMI-996: harden 4.0 tenant document-id migration and mapping IT
---
 .../test/java/org/apache/unomi/itests/AllITs.java  |   2 +
 .../apache/unomi/itests/CorePersistenceITs.java    |   2 +
 .../itests/migration/Migrate40IndexMappingIT.java  | 143 +++++++++++++++++++++
 tools/shell-commands/pom.xml                       |  24 ++++
 .../shell/migration/utils/MigrationUtils.java      |  99 +++++++++++++-
 .../migrate-4.0.0-01-tenantDocumentIds.groovy      |  26 ++--
 .../initialize_tenant_and_audit_fields.painless    |  10 +-
 .../shell/migration/utils/MigrationUtilsTest.java  |  67 ++++++++++
 8 files changed, 354 insertions(+), 19 deletions(-)

diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 61e39077e..4d377c022 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -18,6 +18,7 @@ package org.apache.unomi.itests;
 
 import org.apache.unomi.itests.migration.Migrate16xToCurrentVersionIT;
 import org.apache.unomi.itests.graphql.*;
+import org.apache.unomi.itests.migration.Migrate40IndexMappingIT;
 import org.apache.unomi.itests.migration.MigrationIT;
 import org.apache.unomi.itests.shell.*;
 import org.junit.runner.RunWith;
@@ -34,6 +35,7 @@ import org.junit.runners.Suite.SuiteClasses;
 @SuiteClasses({
         Migrate16xToCurrentVersionIT.class,
         MigrationIT.class,
+        Migrate40IndexMappingIT.class,
         PersistenceServiceIT.class,
         BasicIT.class,
         ConditionEvaluatorIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java 
b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index 3d19d4538..bf50ac2b2 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -18,6 +18,7 @@ package org.apache.unomi.itests;
 
 import org.apache.unomi.itests.migration.Migrate16xToCurrentVersionIT;
 import org.apache.unomi.itests.graphql.*;
+import org.apache.unomi.itests.migration.Migrate40IndexMappingIT;
 import org.apache.unomi.itests.migration.MigrationIT;
 import org.apache.unomi.itests.shell.*;
 import org.junit.runner.RunWith;
@@ -35,6 +36,7 @@ import org.junit.runners.Suite.SuiteClasses;
 @SuiteClasses({
         Migrate16xToCurrentVersionIT.class,
         MigrationIT.class,
+        Migrate40IndexMappingIT.class,
         PersistenceServiceIT.class,
         BasicIT.class,
         ConditionEvaluatorIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
 
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
new file mode 100644
index 000000000..884b3d634
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
@@ -0,0 +1,143 @@
+/*
+ * 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.unomi.itests.migration;
+
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.unomi.itests.BaseIT;
+import org.apache.unomi.itests.persistence.SearchBackendIT;
+import org.apache.unomi.shell.migration.utils.HttpUtils;
+import org.apache.unomi.shell.migration.utils.MigrationUtils;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.osgi.framework.FrameworkUtil;
+
+import java.util.Arrays;
+
+/**
+ * UNOMI-996: migration must find bundled mappings (including cluster nodes) 
and
+ * copy the mapping of an unknown index instead of stopping.
+ */
+@Category(SearchBackendIT.class)
+public class Migrate40IndexMappingIT extends BaseIT {
+
+    private static final String UNKNOWN_INDEX = "context-unomi996unknown";
+    private static final String DEST_INDEX = "context-unomi996unknown-dest";
+
+    @After
+    public void deleteTestIndices() throws Exception {
+        if (!persistenceCapabilities().httpAdminApi()) {
+            return;
+        }
+        try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+            String es = getSearchEngineBaseUrl();
+            MigrationUtils.deleteIndex(httpClient, es, UNKNOWN_INDEX);
+            MigrationUtils.deleteIndex(httpClient, es, DEST_INDEX);
+        }
+    }
+
+    @Test
+    public void clusterNodeMappingIsBundledWithTheMigrationCommand() throws 
Exception {
+        String mapping = MigrationUtils.extractMappingFromBundles(
+                
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext(), 
"clusterNode.json");
+        Assert.assertTrue("clusterNode mapping should define cpuLoad", 
mapping.contains("cpuLoad"));
+    }
+
+    @Test
+    public void unknownIndexKeepsDocumentsOnTheConfiguredTenant() throws 
Exception {
+        Assume.assumeTrue(
+                "HTTP admin API required to create migration fixture indices 
(provider="
+                        + getPersistenceBackend().providerId() + ")",
+                persistenceCapabilities().httpAdminApi());
+
+        try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+            String es = getSearchEngineBaseUrl();
+            org.osgi.framework.BundleContext migrationBundles =
+                    
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext();
+
+            String sourceSettings = "{"
+                    + 
"\"settings\":{\"number_of_shards\":1,\"number_of_replicas\":0},"
+                    + "\"mappings\":{\"properties\":{"
+                    + "\"itemId\":{\"type\":\"keyword\"},"
+                    + "\"itemType\":{\"type\":\"keyword\"},"
+                    + "\"scope\":{\"type\":\"keyword\"},"
+                    + "\"tenantId\":{\"type\":\"keyword\"}"
+                    + "}}}";
+            HttpUtils.executePutRequest(httpClient, es + "/" + UNKNOWN_INDEX, 
sourceSettings, null);
+            HttpUtils.executePutRequest(httpClient, es + "/" + UNKNOWN_INDEX
+                    + "/_doc/unknown1?refresh=true",
+                    
"{\"itemId\":\"unknown1\",\"itemType\":\"sfdcConfiguration\"}", null);
+            HttpUtils.executePutRequest(httpClient, es + "/" + UNKNOWN_INDEX
+                    + "/_doc/unknown2?refresh=true",
+                    
"{\"itemId\":\"unknown2\",\"itemType\":\"sfdcConfiguration\",\"scope\":\"system\"}",
 null);
+
+            String mapping = MigrationUtils.resolveIndexMapping(
+                    migrationBundles, httpClient, es, UNKNOWN_INDEX, 
"does-not-exist.json");
+            Assert.assertTrue("copied mapping should keep itemType", 
mapping.contains("itemType"));
+
+            Assert.assertEquals("sfdcconfiguration",
+                    MigrationUtils.resolveItemType(UNKNOWN_INDEX, "context",
+                            Arrays.asList("profile", "event", "clusterNode", 
"generic")));
+
+            String destSettings = 
"{\"settings\":{\"number_of_shards\":1,\"number_of_replicas\":0},\"mappings\":"
+                    + mapping + "}";
+            MigrationUtils.createIndex(httpClient, es, DEST_INDEX, 
destSettings);
+
+            String painless = MigrationUtils.getFileWithoutComments(
+                    migrationBundles, 
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless");
+            JSONObject paramsObj = new JSONObject();
+            paramsObj.put("tenantId", TEST_TENANT_ID);
+            paramsObj.put("systemTenantId", "system");
+            paramsObj.put("forceDefaultTenant", true);
+            paramsObj.put("itemType", "generic");
+            paramsObj.put("date", "2026-01-01T00:00:00Z");
+            paramsObj.put("systemItems", new JSONArray());
+
+            JSONObject scriptObj = new JSONObject();
+            scriptObj.put("source", painless);
+            scriptObj.put("lang", "painless");
+            scriptObj.put("params", paramsObj);
+
+            JSONObject reindex = new JSONObject();
+            reindex.put("source", new JSONObject().put("index", 
UNKNOWN_INDEX));
+            reindex.put("dest", new JSONObject().put("index", DEST_INDEX));
+            reindex.put("script", scriptObj);
+            HttpUtils.executePostRequest(httpClient, es + 
"/_reindex?refresh=true&wait_for_completion=true",
+                    reindex.toString(), null);
+
+            String search = HttpUtils.executeGetRequest(httpClient,
+                    es + "/" + DEST_INDEX + "/_search?size=10", null);
+            JSONArray hits = new 
JSONObject(search).getJSONObject("hits").getJSONArray("hits");
+            Assert.assertEquals(2, hits.length());
+            for (int i = 0; i < hits.length(); i++) {
+                JSONObject hit = hits.getJSONObject(i);
+                String id = hit.getString("_id");
+                JSONObject source = hit.getJSONObject("_source");
+                Assert.assertTrue("document id should use the configured 
tenant: " + id,
+                        id.startsWith(TEST_TENANT_ID + "_"));
+                Assert.assertFalse("unknown index documents must not use the 
system tenant: " + id,
+                        id.startsWith("system_"));
+                Assert.assertEquals(TEST_TENANT_ID, 
source.getString("tenantId"));
+                Assert.assertNotEquals("system", source.getString("tenantId"));
+            }
+        }
+    }
+}
diff --git a/tools/shell-commands/pom.xml b/tools/shell-commands/pom.xml
index 9451764d7..4f0aef63b 100644
--- a/tools/shell-commands/pom.xml
+++ b/tools/shell-commands/pom.xml
@@ -158,6 +158,30 @@
 
     <build>
         <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-resources-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>copy-persistence-mappings</id>
+                        <phase>generate-resources</phase>
+                        <goals>
+                            <goal>copy-resources</goal>
+                        </goals>
+                        <configuration>
+                            
<outputDirectory>${project.build.outputDirectory}/META-INF/cxs/mappings</outputDirectory>
+                            <resources>
+                                <resource>
+                                    
<directory>${project.basedir}/../../persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings</directory>
+                                </resource>
+                                <resource>
+                                    
<directory>${project.basedir}/../../extensions/geonames/services/src/main/resources/META-INF/cxs/mappings</directory>
+                                </resource>
+                            </resources>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
             <plugin>
                 <groupId>org.apache.felix</groupId>
                 <artifactId>maven-bundle-plugin</artifactId>
diff --git 
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
 
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
index b80055576..f2d33be07 100644
--- 
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
+++ 
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
@@ -303,6 +303,24 @@ public class MigrationUtils {
     }
 
     public static String extractMappingFromBundles(BundleContext 
bundleContext, String fileName) throws IOException {
+        String mapping = findMappingInBundles(bundleContext, fileName);
+        if (mapping == null) {
+            throw new RuntimeException("no mapping found in bundles for: " + 
fileName);
+        }
+        return mapping;
+    }
+
+    /**
+     * Looks up {@code META-INF/cxs/mappings/{fileName}} in every OSGi bundle.
+     * The migration command bundle ships a copy of the persistence mappings 
so this
+     * works before Unomi persistence features are started.
+     *
+     * @return the mapping JSON, or {@code null} if no bundle contains the file
+     */
+    public static String findMappingInBundles(BundleContext bundleContext, 
String fileName) throws IOException {
+        if (bundleContext == null || fileName == null || fileName.isBlank()) {
+            return null;
+        }
         for (Bundle bundle : bundleContext.getBundles()) {
             Enumeration<URL> predefinedMappings = 
bundle.findEntries("META-INF/cxs/mappings", fileName, true);
             if (predefinedMappings == null) {
@@ -310,11 +328,88 @@ public class MigrationUtils {
             }
             if (predefinedMappings.hasMoreElements()) {
                 URL predefinedMappingURL = predefinedMappings.nextElement();
-                return IOUtils.toString(predefinedMappingURL);
+                return IOUtils.toString(predefinedMappingURL, 
StandardCharsets.UTF_8);
             }
         }
+        return null;
+    }
+
+    /**
+     * Resolves the mapping used to recreate an index during migration.
+     * Prefers a bundled file when present (the 4.0 definition). Otherwise 
copies
+     * the mapping currently stored on the index, so unknown types do not stop 
the run.
+     */
+    public static String resolveIndexMapping(BundleContext bundleContext, 
CloseableHttpClient httpClient,
+                                             String esAddress, String 
indexName, String mappingFileName) throws IOException {
+        if (mappingFileName != null && !mappingFileName.isBlank()) {
+            String bundled = findMappingInBundles(bundleContext, 
mappingFileName);
+            if (bundled != null) {
+                return bundled;
+            }
+        }
+        return extractMappingFromIndex(httpClient, esAddress, indexName);
+    }
+
+    public static String extractMappingFromIndex(CloseableHttpClient 
httpClient, String esAddress, String indexName) throws IOException {
+        if (indexName == null || indexName.isBlank()) {
+            throw new IllegalArgumentException("An index name is required to 
copy its mapping");
+        }
+        String response = HttpUtils.executeGetRequest(httpClient, esAddress + 
"/" + indexName + "/_mapping", null);
+        return mappingsFromIndexResponse(response);
+    }
+
+    /**
+     * Reads the mappings object from a GET {@code /{index}/_mapping} body.
+     */
+    public static String mappingsFromIndexResponse(String responseBody) {
+        if (responseBody == null || responseBody.isBlank()) {
+            throw new IllegalArgumentException("Index mapping response is 
empty");
+        }
+        JSONObject root = new JSONObject(responseBody);
+        if (root.keySet().isEmpty()) {
+            throw new IllegalArgumentException("Index mapping response has no 
indices");
+        }
+        String firstIndex = root.keys().next();
+        JSONObject index = root.getJSONObject(firstIndex);
+        if (!index.has("mappings")) {
+            throw new IllegalArgumentException("Index mapping response has no 
mappings for " + firstIndex);
+        }
+        return index.getJSONObject("mappings").toString();
+    }
 
-        throw new RuntimeException("no mapping found in bundles for: " + 
fileName);
+    /**
+     * Maps an index name such as {@code context-event-000001} or {@code 
context-clusternode}
+     * to an item type. Known types are matched longest-first, ignoring case.
+     * Unknown names keep the suffix after the prefix (without a rollover 
number).
+     */
+    public static String resolveItemType(String indexName, String indexPrefix, 
Collection<String> knownTypes) {
+        if (indexName == null || indexName.isBlank()) {
+            throw new IllegalArgumentException("An index name is required");
+        }
+        if (indexPrefix == null || indexPrefix.isBlank()) {
+            throw new IllegalArgumentException("An index prefix is required");
+        }
+        String lowerIndex = indexName.toLowerCase(Locale.ROOT);
+        String prefix = indexPrefix.toLowerCase(Locale.ROOT) + "-";
+        if (knownTypes != null) {
+            List<String> types = new ArrayList<>();
+            for (String type : knownTypes) {
+                if (type != null && !type.isBlank() && 
!"generic".equals(type)) {
+                    types.add(type);
+                }
+            }
+            types.sort((a, b) -> Integer.compare(b.length(), a.length()));
+            for (String type : types) {
+                if (lowerIndex.startsWith(prefix + 
type.toLowerCase(Locale.ROOT))) {
+                    return type;
+                }
+            }
+        }
+        if (lowerIndex.startsWith(prefix)) {
+            String rest = indexName.substring(indexPrefix.length() + 1);
+            return rest.replaceFirst("-\\d{6}$", "");
+        }
+        return "generic";
     }
 
     public static String buildIndexCreationRequest(String baseIndexSettings, 
String mapping, MigrationContext context, boolean isMonthlyIndex) throws 
IOException {
diff --git 
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
 
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
index 647aae6ac..b8557b50e 100644
--- 
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
+++ 
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
@@ -67,22 +67,18 @@ def indexConfigs = [
                 mapping: "personaSession.json",
                 useRollover: false
         ],
+        "clusterNode": [
+                baseSettings: "requestBody/2.0.0/base_index_mapping.json",
+                mapping: "clusterNode.json",
+                useRollover: false
+        ],
         "generic": [
                 baseSettings: "requestBody/2.0.0/base_index_mapping.json",
-                mapping: null, // Will be determined dynamically from resolved 
item type
+                mapping: null, // Copy the mapping currently stored on the 
index
                 useRollover: false
         ]
 ]
 
-// Helper function to resolve item type from index name
-def resolveItemType = { String indexName ->
-    def type = indexConfigs.find { type, config ->
-        indexName.startsWith("${indexPrefix}-${type}")
-    }
-    return type ? type.key : "generic"
-}
-
-// Helper function to get index configuration
 def getIndexConfig = { String itemType ->
     return indexConfigs[itemType] ?: indexConfigs["generic"]
 }
@@ -122,18 +118,20 @@ context.performMigrationStep("4.0.0-get-all-indices", () 
-> {
         context.printMessage("Processing index: " + indexName)
 
         // Determine item type and get configuration
-        String itemType = resolveItemType(indexName)
+        String itemType = MigrationUtils.resolveItemType(indexName, 
indexPrefix, indexConfigs.keySet())
         def indexConfig = getIndexConfig(itemType)
 
         // Add item type to parameters
         Map<String, Object> params = new HashMap<>(baseParams)
         params.put("itemType", itemType)
+        // Cluster nodes and unknown indices keep site data in the configured 
tenant, never "system"
+        boolean forceDefaultTenant = (itemType == "clusterNode" || 
indexConfig.mapping == null)
+        params.put("forceDefaultTenant", forceDefaultTenant)
 
         // Get base settings and mapping
         String baseSettings = MigrationUtils.resourceAsString(bundleContext, 
indexConfig.baseSettings)
-        String mapping = indexConfig.mapping ?
-                MigrationUtils.extractMappingFromBundles(bundleContext, 
indexConfig.mapping) :
-                MigrationUtils.extractMappingFromBundles(bundleContext, 
"${itemType}.json")
+        String mappingFile = indexConfig.mapping ?: "${itemType}.json"
+        String mapping = MigrationUtils.resolveIndexMapping(bundleContext, 
context.getHttpClient(), esAddress, indexName, mappingFile)
 
         // Build index settings
         String newIndexSettings
diff --git 
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
 
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
index 07b89377b..7b089252d 100644
--- 
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
+++ 
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
@@ -18,8 +18,10 @@
 if (ctx._id != null) {
     // Skip if already has tenant prefix
     if (!ctx._id.startsWith(params.tenantId + '_') && 
!ctx._id.startsWith(params.systemTenantId + '_')) {
-        // For geonames or items with system scope, use system tenant
-        if (ctx._index.endsWith('-geonames') || (ctx._source.scope != null && 
ctx._source.scope == 'system')) {
+        // Cluster nodes and unknown indices always stay in the configured 
(default) tenant
+        if (params.forceDefaultTenant == true) {
+            ctx._id = params.tenantId + '_' + ctx._id;
+        } else if (ctx._index.endsWith('-geonames') || (ctx._source.scope != 
null && ctx._source.scope == 'system')) {
             // Preserve the entire original document ID (it may contain 
underscores, e.g., "dummy_scope")
             // Do NOT split on underscores - this was a bug in the original 
migration that caused itemIds
             // like "dummy_scope" to become "dummy" when the document ID was 
"dummy_scope_scope"
@@ -92,7 +94,9 @@ if (!ctx._index.endsWith('-systemitems') && 
!ctx._index.endsWith('-geonames')) {
 
 // Set tenant ID in the source document based on scope for ALL items
 if (ctx._source.tenantId == null) {
-    if (ctx._index.endsWith('-geonames') || (ctx._source.scope != null && 
ctx._source.scope == 'system')) {
+    if (params.forceDefaultTenant == true) {
+        ctx._source.tenantId = params.tenantId;
+    } else if (ctx._index.endsWith('-geonames') || (ctx._source.scope != null 
&& ctx._source.scope == 'system')) {
         ctx._source.tenantId = params.systemTenantId;
     } else {
         ctx._source.tenantId = params.tenantId;
diff --git 
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
 
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
index 96129f47c..2be643754 100644
--- 
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
+++ 
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
@@ -25,6 +25,11 @@ import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertThrows;
@@ -209,4 +214,66 @@ public class MigrationUtilsTest {
                 () -> MigrationUtils.getFileWithoutComments(bundleContext, 
"missing.painless"));
         assertEquals("Resource not found: missing.painless", ex.getMessage());
     }
+
+    @Test
+    public void 
extractMappingFromBundlesFindsKnownTypeWithoutPersistenceBundle() throws 
Exception {
+        Path mappingFile = Files.createTempFile("clusterNode", ".json");
+        Files.writeString(mappingFile, 
"{\"properties\":{\"cpuLoad\":{\"type\":\"double\"}}}");
+        URL mappingUrl = mappingFile.toUri().toURL();
+        when(bundleContext.getBundles()).thenReturn(new Bundle[]{bundle});
+        when(bundle.findEntries("META-INF/cxs/mappings", "clusterNode.json", 
true))
+                
.thenReturn(Collections.enumeration(Collections.singletonList(mappingUrl)));
+
+        String mapping = 
MigrationUtils.extractMappingFromBundles(bundleContext, "clusterNode.json");
+        assertEquals("{\"properties\":{\"cpuLoad\":{\"type\":\"double\"}}}", 
mapping);
+        Files.deleteIfExists(mappingFile);
+    }
+
+    @Test
+    public void extractMappingFromBundlesThrowsWhenMissing() {
+        when(bundleContext.getBundles()).thenReturn(new Bundle[]{bundle});
+        when(bundle.findEntries("META-INF/cxs/mappings", "generic.json", 
true)).thenReturn(null);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> MigrationUtils.extractMappingFromBundles(bundleContext, 
"generic.json"));
+        assertEquals("no mapping found in bundles for: generic.json", 
ex.getMessage());
+    }
+
+    @Test
+    public void mappingsFromIndexResponseReadsFirstIndex() {
+        String body = 
"{\"context-sfdcconfiguration\":{\"mappings\":{\"properties\":{\"itemId\":{\"type\":\"keyword\"}}}}}";
+        String mappings = MigrationUtils.mappingsFromIndexResponse(body);
+        assertEquals("{\"properties\":{\"itemId\":{\"type\":\"keyword\"}}}", 
mappings);
+    }
+
+    @Test
+    public void mappingsFromIndexResponseRejectsEmptyBody() {
+        assertThrows(IllegalArgumentException.class, () -> 
MigrationUtils.mappingsFromIndexResponse(""));
+        assertThrows(IllegalArgumentException.class, () -> 
MigrationUtils.mappingsFromIndexResponse("{}"));
+    }
+
+    @Test
+    public void resolveItemTypeMatchesClusterNodeIgnoringCase() {
+        Collection<String> types = Arrays.asList("profile", "event", 
"clusterNode", "generic");
+        assertEquals("clusterNode", 
MigrationUtils.resolveItemType("context-clusternode", "context", types));
+        assertEquals("clusterNode", 
MigrationUtils.resolveItemType("context-clusterNode", "context", types));
+    }
+
+    @Test
+    public void resolveItemTypeMatchesRolloverEvent() {
+        Collection<String> types = Arrays.asList("event", "session", 
"generic");
+        assertEquals("event", 
MigrationUtils.resolveItemType("context-event-000001", "context", types));
+    }
+
+    @Test
+    public void resolveItemTypeKeepsUnknownIndexSuffix() {
+        Collection<String> types = Arrays.asList("profile", "event", 
"generic");
+        assertEquals("sfdcconfiguration", 
MigrationUtils.resolveItemType("context-sfdcconfiguration", "context", types));
+    }
+
+    @Test
+    public void resolveItemTypePrefersLongerKnownType() {
+        Collection<String> types = Arrays.asList("profile", "profileAlias");
+        assertEquals("profileAlias", 
MigrationUtils.resolveItemType("context-profilealias", "context", types));
+    }
 } 
\ No newline at end of file

Reply via email to