diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/AbstractResourceFinder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/AbstractResourceFinder.java index 32c0ce0c3..e2740e174 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/AbstractResourceFinder.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/resourceFinder/AbstractResourceFinder.java @@ -14,8 +14,9 @@ package org.eclipse.lemminx.customservice.synapse.resourceFinder; +import org.eclipse.lemminx.customservice.synapse.connectors.ConnectorHolder; +import org.eclipse.lemminx.customservice.synapse.connectors.entity.Connector; import org.eclipse.lemminx.customservice.synapse.dependency.tree.ArtifactType; -import org.eclipse.lemminx.customservice.synapse.parser.Node; import org.eclipse.lemminx.customservice.synapse.parser.OverviewPageDetailsResponse; import org.eclipse.lemminx.customservice.synapse.parser.pom.PomParser; import org.eclipse.lemminx.customservice.synapse.resourceFinder.pojo.ArtifactResource; @@ -37,12 +38,18 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.logging.Logger; +import java.util.stream.Collectors; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -56,6 +63,34 @@ public abstract class AbstractResourceFinder { protected static final String ARTIFACTS = "ARTIFACTS"; protected static final String REGISTRY = "REGISTRY"; protected static final String LOCAL_ENTRY = "LOCAL_ENTRY"; + + /** + * Registry key for the auto-generated {@code config.properties} file. This entry is present + * in every project and is excluded from dependency conflict detection. + */ + private static final String CONFIG_PROPERTIES_REGISTRY_KEY = "resources:conf/config.properties"; + + /** + * Registry keys for the auto-generated {@code artifact.xml} skeleton files. These are Maven + * artifact descriptor files generated in every project and are excluded from conflict detection. + */ + private static final Set ARTIFACT_XML_REGISTRY_KEYS = Set.of( + "resources:artifact.xml", + "resources:registry/artifact.xml" + ); + + /** + * Registry key prefix for connector zip files stored under the {@code resources/connectors} + * directory of a project. + */ + private static final String CONNECTOR_REGISTRY_KEY_PREFIX = "resources:connectors/"; + + /** + * Name prefix of the built-in HTTP connector. The HTTP connector is bundled with every + * project, so any connector whose name starts with this prefix is excluded from dependency + * conflict detection. + */ + private static final String HTTP_CONNECTOR_PREFIX = "mi-connector-http"; protected static final List resourceFromRegistryOnly = List.of("dataMapper", "js", "json", "smooksConfig", "wsdl", "ws_policy", "xsd", "xsl", "xslt", "yaml", "registry", "unitTestRegistry", "schema", "swagger"); @@ -92,16 +127,19 @@ public abstract class AbstractResourceFinder { *

* This method initializes the dependent resources map and attempts to locate * the dependencies directory for the specified project. If found, it iterates - * through each dependent project, finds resources of each type, and merges them - * into the dependent resources map. + * through each dependent project, checks for resource conflicts against already-loaded + * resources, and merges non-conflicting resources into the dependent resources map. + *

+ * If a dependent project has conflicting artifacts, it is skipped, its directories are + * cleaned up, and a structured error message is returned listing the conflicting + * dependencies and artifacts. * * @param projectPath the absolute path to the project whose dependencies are to be loaded - * @throws RuntimeException if an I/O error occurs while accessing the dependencies + * @return a status message; either a success message or a structured conflict report */ public String loadDependentResources(String projectPath) { dependentResourcesMap = new HashMap<>(); - String projectName = new File(projectPath).getName(); Path projectDependencyDir = findProjectDependencyDir(projectPath); if (projectDependencyDir == null) { LOGGER.warning("No project dependency directory found for project: " + projectPath); @@ -114,70 +152,235 @@ public String loadDependentResources(String projectPath) { return "No dependent integration projects found"; } - Map> duplicates = new HashMap<>(); - // Used to identify any duplicate artifacts across projects - Map> artifactNameToProjects = new HashMap<>(); + Set existingResourceNames = new HashSet<>(); + collectResourceNames(findAllResources(projectPath), existingResourceNames); + Set existingConnectorArtifactIds = collectMainProjectConnectorArtifactIds(); + Set loadedDepConnectorCoreNames = new HashSet<>(); - // Collect main project artifacts - Map allResources = findAllResources(projectPath); - for (String type : allResources.keySet()) { - ResourceResponse mainResources = allResources.get(type); - addArtifactNamesToProjects(mainResources, projectName, artifactNameToProjects); - } + List dependencyConflicts = new ArrayList<>(); + Path downloadDirectory = projectDependencyDir.resolve(Constant.DOWNLOADED); try (var dependentProjects = list(extractedDir)) { - // Iterate over each dependent project directory + LOGGER.info("Loading dependent resources from directory: " + extractedDir); OverviewPageDetailsResponse parentProjectDetails = new OverviewPageDetailsResponse(); PomParser.getPomDetails(projectPath, parentProjectDetails); - for (Path dependentProject : dependentProjects.toArray(Path[]::new)) { - if (isDirectory(dependentProject)) { - String projectNameDep = dependentProject.getFileName().toString(); - OverviewPageDetailsResponse pomDetailsResponse = new OverviewPageDetailsResponse(); - PomParser.getPomDetails(dependentProject.toString(), pomDetailsResponse); - // For each resource type, find resources from the dependent project - Map dependentProjectAllResources = findAllResources(dependentProject.toString()); - for (String type : dependentProjectAllResources.keySet()) { - ResourceResponse resources = dependentProjectAllResources.get(type); - Node isVersionedDeployment = parentProjectDetails.getBuildDetails().getVersionedDeployment(); - if (isVersionedDeployment != null && Boolean.parseBoolean(isVersionedDeployment.getValue()) - && resources != null) { - // Append project details(group ID and artifact ID) to synapse artifacts - if (resources.getResources() != null) { - resources.getResources().forEach(resource -> { - resource.setName(getFullyQualifiedName(pomDetailsResponse, resource)); - }); - } - // Append project details(group ID and artifact ID) to registry artifacts - if (resources.getRegistryResources() != null) { - resources.getRegistryResources().forEach(resource -> { - ((RegistryResource) resource) - .setRegistryKey(getFullyQualifiedNameForRegistryArtifact(pomDetailsResponse, (RegistryResource) resource)); - }); - } - } - dependentResourcesMap.computeIfAbsent(type, k -> new ResourceResponse()); - mergeResourceResponses(dependentResourcesMap.get(type), resources); - addArtifactNamesToProjects(resources, projectNameDep, artifactNameToProjects); - } + boolean isVersionedDeployment = isVersionedDeploymentEnabled(parentProjectDetails); + LOGGER.info("Loading dependent resources for project: " + projectPath + + " (versionedDeployment=" + isVersionedDeployment + ")"); + + for (Path dependentProject : sortedByAddedTime(dependentProjects)) { + LOGGER.info("Processing dependent project: " + dependentProject); + OverviewPageDetailsResponse pomDetails = new OverviewPageDetailsResponse(); + PomParser.getPomDetails(dependentProject.toString(), pomDetails); + Map depResources = findAllResources(dependentProject.toString()); + + // Extract connector zip base names before versioned deployment renames registry keys. + // Versioned deployment prefixes connector zip keys with groupId__artifactId__, which + // would corrupt the base name and prevent conflict detection. + Set depResourceNamesRaw = collectDepResourceNames(depResources); + Set depConnectorZipBaseNames = extractConnectorZipBaseNames(depResourceNamesRaw); + + if (isVersionedDeployment) { + applyVersionedDeploymentToResources(depResources, pomDetails); + } + + Set depResourceNames = collectDepResourceNames(depResources); + excludeNonConflictableEntries(depResourceNames); + + Set conflictingArtifacts = detectArtifactConflicts(depResourceNames, existingResourceNames); + Set conflictingConnectors = detectConnectorConflicts(depConnectorZipBaseNames, + existingConnectorArtifactIds, loadedDepConnectorCoreNames); + + if (!conflictingArtifacts.isEmpty() || !conflictingConnectors.isEmpty()) { + String groupId = Utils.getNodeValue(pomDetails.getBuildDetails().getAdvanceDetails().getProjectGroupId()); + String artifactId = Utils.getNodeValue(pomDetails.getBuildDetails().getAdvanceDetails().getProjectArtifactId()); + String version = Utils.getNodeValue(pomDetails.getPrimaryDetails().getProjectVersion()); + LOGGER.warning("Conflict detected in dependent project: " + dependentProject + + " — conflicting artifacts: " + conflictingArtifacts + + ", conflicting connectors: " + conflictingConnectors); + dependencyConflicts.add(new DependencyConflict(groupId, artifactId, version, + new ArrayList<>(conflictingArtifacts), new ArrayList<>(conflictingConnectors))); + cleanupConflictingDependency(dependentProject, downloadDirectory, groupId, artifactId, version); + } else { + LOGGER.info("No conflicts detected. Merging " + depResources.size() + " resource type(s) from dependent project: " + + dependentProject); + mergeDepResources(depResources); + existingResourceNames.addAll(depResourceNames); + depConnectorZipBaseNames.stream() + .filter(n -> !n.startsWith(HTTP_CONNECTOR_PREFIX)) + .map(Utils::stripConnectorVersion) + .forEach(loadedDepConnectorCoreNames::add); } } } catch (IOException e) { return "Error loading dependent resources: " + e.getMessage(); } - // Find duplicated artifacts - for (Map.Entry> entry : artifactNameToProjects.entrySet()) { - if (entry.getValue().size() > 1) { - duplicates.put(entry.getKey(), entry.getValue()); + if (!dependencyConflicts.isEmpty()) { + String conflictMsg = generateConflictMessage(dependencyConflicts); + LOGGER.warning(conflictMsg); + return conflictMsg; + } + return "Success: Dependent resources loaded successfully for project: " + projectPath; + } + + /** + * Returns the artifact IDs of connectors that belong to the main project (i.e., loaded from + * the project's own connector directory or the downloaded directory). Using artifact ID + * (derived from the folder/zip name) rather than the display name avoids false negatives + * caused by differing casing between the component name in {@code connector.xml} and the + * zip file name. Connectors without a resolvable artifact ID are excluded. Connectors from + * a dependency project loaded in a previous run are also excluded so they do not seed false + * conflicts when {@code loadDependentResources} is called again. + */ + private Set collectMainProjectConnectorArtifactIds() { + + return ConnectorHolder.getInstance().getConnectors().stream() + .filter(Connector::isFromProject) + .map(Connector::getArtifactId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + /** + * Returns {@code true} when the main project's pom.xml has {@code versionedDeployment=true}, + * which causes dep resource names to be prefixed with {@code groupId__artifactId__} before + * conflict checking. + */ + private boolean isVersionedDeploymentEnabled(OverviewPageDetailsResponse pomDetails) { + + return pomDetails.getBuildDetails().getVersionedDeployment() != null + && Boolean.parseBoolean(pomDetails.getBuildDetails().getVersionedDeployment().getValue()); + } + + /** + * Sorts dep directories from the given stream oldest-first by last-modified time so that the + * dependency the user added first always takes priority in conflict detection. A name-based + * secondary sort makes the order deterministic when two directories have the same timestamp + * (common in fast test environments). + */ + private Path[] sortedByAddedTime(java.util.stream.Stream stream) { + + return stream + .filter(Files::isDirectory) + .sorted(Comparator.comparingLong((Path p) -> { + try { + return Files.getLastModifiedTime(p).toMillis(); + } catch (IOException e) { + return Long.MAX_VALUE; + } + }).thenComparing(p -> p.getFileName().toString())) + .toArray(Path[]::new); + } + + /** + * Collects all artifact names and registry resource keys from the dep's resource map into a + * mutable set, then removes the {@code resources:conf/config.properties} key which is + * auto-generated in every project and must never trigger a conflict. + */ + private Set collectDepResourceNames(Map depResources) { + + Set names = new HashSet<>(); + collectResourceNames(depResources, names); + names.remove(CONFIG_PROPERTIES_REGISTRY_KEY); + return names; + } + + /** + * Scans {@code resourceNames} for {@code resources:connectors/*.zip} registry keys and + * returns their base names (filename without the {@code .zip} extension). These entries are + * kept in {@code resourceNames} by this method; call {@link #excludeNonConflictableEntries} afterwards + * to strip them from the artifact namespace. + */ + private Set extractConnectorZipBaseNames(Set resourceNames) { + + Set baseNames = new HashSet<>(); + for (String name : resourceNames) { + if (name.startsWith(CONNECTOR_REGISTRY_KEY_PREFIX) && name.endsWith(Constant.ZIP_EXTENSION)) { + String fileName = name.substring(CONNECTOR_REGISTRY_KEY_PREFIX.length()); + baseNames.add(fileName.substring(0, fileName.lastIndexOf(Constant.DOT))); } } + return baseNames; + } + + /** + * Removes entries from {@code resourceNames} that should never participate in conflict + * detection — either because they are auto-generated per project or because they are + * handled separately through connector conflict detection: + *

+ */ + private void excludeNonConflictableEntries(Set resourceNames) { - if (!duplicates.isEmpty()) { - String duplicateMsg = generateDuplicateArtifactMessage(duplicates); - LOGGER.warning(duplicateMsg); - return duplicateMsg; + resourceNames.removeIf(n -> ARTIFACT_XML_REGISTRY_KEYS.contains(n) || n.startsWith(CONNECTOR_REGISTRY_KEY_PREFIX)); + } + + /** + * Returns the intersection of {@code depResourceNames} and {@code existingResourceNames} — + * i.e., the artifact/registry names that conflict with already-loaded resources. + */ + private Set detectArtifactConflicts(Set depResourceNames, Set existingResourceNames) { + + Set conflicting = new HashSet<>(depResourceNames); + conflicting.retainAll(existingResourceNames); + return conflicting; + } + + /** + * Identifies connectors in the given dependency that conflict with connectors already present + * in the main project or in a previously accepted dependency in this run. + *

+ * A conflict is raised when the same connector (ignoring version) is found in more than one + * place. Version comparison is intentionally skipped: {@code mi-connector-email-1.0.14} and + * {@code mi-connector-email-2.0.1} are treated as the same connector because loading two + * different versions of a connector simultaneously is not supported. + *

+ * The built-in HTTP connector ({@value #HTTP_CONNECTOR_PREFIX}) is always excluded from + * conflict detection because it is bundled with every project. + * + * @param depConnectorZipBaseNames zip base names (without {@code .zip}) from the dependency + * being evaluated + * @param existingConnectorArtifactIds artifact IDs of connectors loaded by the main project + * (from {@link ConnectorHolder}), derived from the zip + * folder name with version stripped + * @param loadedDepConnectorCoreNames version-stripped connector artifact IDs already accepted + * from earlier dependencies in this run + * @return the subset of {@code depConnectorZipBaseNames} that conflict + */ + private Set detectConnectorConflicts(Set depConnectorZipBaseNames, + Set existingConnectorArtifactIds, + Set loadedDepConnectorCoreNames) { + + Set conflicting = new HashSet<>(); + for (String zipBaseName : depConnectorZipBaseNames) { + if (zipBaseName.startsWith(HTTP_CONNECTOR_PREFIX)) { + continue; + } + String coreName = Utils.stripConnectorVersion(zipBaseName); + boolean conflictsWithHolder = existingConnectorArtifactIds.contains(coreName); + boolean conflictsWithCurrentRun = loadedDepConnectorCoreNames.contains(coreName); + if (conflictsWithHolder || conflictsWithCurrentRun) { + conflicting.add(zipBaseName); + } + } + return conflicting; + } + + /** + * Merges all resource responses from {@code depResources} into {@link #dependentResourcesMap}, + * creating an entry for each resource type if one does not already exist. + */ + private void mergeDepResources(Map depResources) { + + for (String type : depResources.keySet()) { + dependentResourcesMap.computeIfAbsent(type, k -> new ResourceResponse()); + mergeResourceResponses(dependentResourcesMap.get(type), depResources.get(type)); } - return "Success: Dependent resources loaded successfully for project: " + projectPath; } public Map getDependentResourcesMap() { @@ -222,10 +425,20 @@ private String getFullyQualifiedNameForRegistryArtifact(OverviewPageDetailsRespo * @param projectPath the absolute path to the project * @return the dependency directory as a Path if it exists, or null if not found */ + /** + * Returns the user home directory used to locate the integration-project-dependencies folder. + * Overridable so that tests can redirect to a temporary directory without mutating the global + * {@code user.home} system property. + */ + protected String getUserHome() { + + return System.getProperty(Constant.USER_HOME); + } + private Path findProjectDependencyDir(String projectPath) { Path dependenciesDir = Path.of( - System.getProperty(Constant.USER_HOME), + getUserHome(), Constant.WSO2_MI, Constant.INTEGRATION_PROJECT_DEPENDENCIES ); @@ -239,43 +452,166 @@ private Path findProjectDependencyDir(String projectPath) { } /** - * Adds artifact names from the given ResourceResponse to the artifactNameToProjects map, - * associating each artifact name with the dependent project name. + * Applies versioned deployment naming (groupId__artifactId__name) to all resources in the map. + * + * @param allResources the resource map to update in place + * @param pomDetailsResponse POM details of the dependent project supplying the prefix + */ + private void applyVersionedDeploymentToResources(Map allResources, + OverviewPageDetailsResponse pomDetailsResponse) { + + for (ResourceResponse resources : allResources.values()) { + if (resources == null) { + continue; + } + if (resources.getResources() != null) { + resources.getResources().forEach(resource -> + resource.setName(getFullyQualifiedName(pomDetailsResponse, resource))); + } + if (resources.getRegistryResources() != null) { + resources.getRegistryResources().forEach(resource -> + ((RegistryResource) resource).setRegistryKey( + getFullyQualifiedNameForRegistryArtifact(pomDetailsResponse, (RegistryResource) resource))); + } + } + } + + /** + * Collects all synapse artifact names and registry resource keys from the given resource map + * into the provided set. * - * @param resources the ResourceResponse containing resources to process - * @param projectNameDep the name of the dependent project - * @param artifactNameToProjects the map to update with artifact names and their associated projects + * @param allResources the resource map to collect names from + * @param names the set to populate with artifact names and registry keys */ - private void addArtifactNamesToProjects(ResourceResponse resources, String projectNameDep, - Map> artifactNameToProjects) { - - if (resources != null && resources.getResources() != null) { - for (Resource resource : resources.getResources()) { - String name = resource.getName(); - if (name != null) { - artifactNameToProjects.computeIfAbsent(name, k -> new ArrayList<>()).add(projectNameDep); + private void collectResourceNames(Map allResources, Set names) { + + for (ResourceResponse resources : allResources.values()) { + if (resources == null) { + continue; + } + if (resources.getResources() != null) { + for (Resource resource : resources.getResources()) { + if (resource.getName() != null) { + names.add(resource.getName()); + } + } + } + if (resources.getRegistryResources() != null) { + for (Resource resource : resources.getRegistryResources()) { + if (resource instanceof RegistryResource) { + String key = ((RegistryResource) resource).getRegistryKey(); + if (key != null) { + names.add(key); + } + } } } } } /** - * Generates a detailed message describing artifacts that were found among multiple dependent projects. + * Deletes the extracted dependent project directory and its corresponding downloaded archive + * ({@code .car} or {@code .zip}) from the Downloaded directory. * - * @param duplicates a map where the key is the artifact name and the value is a list of project names containing the artifact - * @return a formatted string message listing duplicate artifacts and their locations + * @param dependentProjectPath path to the extracted dependency directory to remove + * @param downloadDirectory path to the Downloaded directory + * @param groupId Maven groupId of the dependency + * @param artifactId Maven artifactId of the dependency + * @param version Maven version of the dependency */ - private String generateDuplicateArtifactMessage(Map> duplicates) { + private void cleanupConflictingDependency(Path dependentProjectPath, Path downloadDirectory, + String groupId, String artifactId, String version) { + + try { + Utils.deleteDirectory(dependentProjectPath); + } catch (IOException e) { + LOGGER.warning("Failed to delete dependency directory: " + dependentProjectPath + " - " + e.getMessage()); + } - StringBuilder duplicateMsg = new StringBuilder(); - duplicateMsg.append("DUPLICATE ARTIFACTS\n\n"); - for (Map.Entry> entry : duplicates.entrySet()) { - duplicateMsg.append("Artifact: '").append(entry.getKey()) - .append("' found in: ").append(entry.getValue()).append("\n"); + if (exists(downloadDirectory) && isDirectory(downloadDirectory)) { + String baseName = groupId + Constant.HYPHEN + artifactId + Constant.HYPHEN + version; + try { + Files.deleteIfExists(downloadDirectory.resolve(baseName + Constant.CAR_EXTENSION)); + Files.deleteIfExists(downloadDirectory.resolve(baseName + Constant.ZIP_EXTENSION)); + } catch (IOException e) { + LOGGER.warning("Failed to delete downloaded file for dependency: " + baseName + " - " + e.getMessage()); + } + } + } + + /** + * Generates a structured JSON conflict report listing each conflicting dependency with its Maven + * coordinates and the specific artifact names that caused the conflict. + * + * @param conflicts list of {@link DependencyConflict} entries to report + * @return formatted conflict message string + */ + private String generateConflictMessage(List conflicts) { + + StringBuilder msg = new StringBuilder(); + msg.append("CONFLICTING ARTIFACTS\n\n"); + msg.append("{\n"); + msg.append(" \"conflictingDependencies\": [\n"); + for (int i = 0; i < conflicts.size(); i++) { + DependencyConflict conflict = conflicts.get(i); + msg.append(" {\n"); + msg.append(" \"dependency\": {"); + msg.append("\"groupId\": \"").append(conflict.groupId).append("\", "); + msg.append("\"artifactId\": \"").append(conflict.artifactId).append("\", "); + msg.append("\"version\": \"").append(conflict.version).append("\""); + msg.append("},\n"); + msg.append(" \"conflictingArtifacts\": ["); + List artifacts = conflict.conflictingArtifacts.stream().sorted().collect(Collectors.toList()); + for (int j = 0; j < artifacts.size(); j++) { + msg.append("\"").append(artifacts.get(j)).append("\""); + if (j < artifacts.size() - 1) { + msg.append(", "); + } + } + msg.append("],\n"); + msg.append(" \"conflictingConnectors\": ["); + List connectors = conflict.conflictingConnectors.stream().sorted().collect(Collectors.toList()); + for (int j = 0; j < connectors.size(); j++) { + msg.append("\"").append(connectors.get(j)).append("\""); + if (j < connectors.size() - 1) { + msg.append(", "); + } + } + msg.append("]\n"); + msg.append(" }"); + if (i < conflicts.size() - 1) { + msg.append(","); + } + msg.append("\n"); + } + msg.append(" ]\n"); + msg.append("}\n\n"); + msg.append("The above dependencies have conflicting artifacts with the current project or other dependent projects.\n"); + msg.append("Please remove them from pom.xml and retry."); + return msg.toString(); + } + + /** + * Holds the Maven coordinates of a conflicting dependency and the list of artifact names + * that caused the conflict. + */ + private static class DependencyConflict { + + final String groupId; + final String artifactId; + final String version; + final List conflictingArtifacts; + final List conflictingConnectors; + + DependencyConflict(String groupId, String artifactId, String version, + List conflictingArtifacts, List conflictingConnectors) { + + this.groupId = groupId; + this.artifactId = artifactId; + this.version = version; + this.conflictingArtifacts = conflictingArtifacts; + this.conflictingConnectors = conflictingConnectors; } - duplicateMsg.append("\n"); - duplicateMsg.append("Please avoid having artifacts with the same name and continue.\n"); - return duplicateMsg.toString(); } /** diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java index f35fade06..7c0feb180 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/customservice/synapse/utils/Utils.java @@ -525,6 +525,35 @@ public static String pluralToSingular(String name) { return name; } + /** + * Returns the value of the given synapse parser Node, or an empty string if the node or its + * value is null. + * + * @param node the Node to read + * @return the node's value, or {@code ""} + */ + public static String getNodeValue(org.eclipse.lemminx.customservice.synapse.parser.Node node) { + + return (node != null && node.getValue() != null) ? node.getValue() : ""; + } + + /** + * Strips the trailing version segment from a connector zip base name so that connectors with + * different versions are treated as the same connector. + *

+ * For example, {@code "mi-connector-salesforce-1.0.0"} and {@code "mi-connector-salesforce-2.0.0"} + * both return {@code "mi-connector-salesforce"}. + * If the base name contains no hyphen the original value is returned unchanged. + * + * @param zipBaseName connector zip base name without the {@code .zip} extension + * @return the connector name with the last {@code -version} segment removed + */ + public static String stripConnectorVersion(String zipBaseName) { + + int lastHyphen = zipBaseName.lastIndexOf(Constant.HYPHEN); + return lastHyphen > 0 ? zipBaseName.substring(0, lastHyphen) : zipBaseName; + } + public static DOMNode getChildNodeByName(DOMNode node, String name) { DOMNode foundNode = null; diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/synapse/resource/finder/LoadDependentResourcesTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/synapse/resource/finder/LoadDependentResourcesTest.java new file mode 100644 index 000000000..1a8c4b9ba --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/synapse/resource/finder/LoadDependentResourcesTest.java @@ -0,0 +1,1704 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * WSO2 LLC - support for WSO2 Micro Integrator Configuration + */ + +package org.eclipse.lemminx.synapse.resource.finder; + +import org.eclipse.lemminx.customservice.synapse.connectors.ConnectorHolder; +import org.eclipse.lemminx.customservice.synapse.connectors.entity.Connector; +import org.eclipse.lemminx.customservice.synapse.resourceFinder.AbstractResourceFinder; +import org.eclipse.lemminx.customservice.synapse.resourceFinder.pojo.ArtifactResource; +import org.eclipse.lemminx.customservice.synapse.resourceFinder.pojo.RegistryResource; +import org.eclipse.lemminx.customservice.synapse.resourceFinder.pojo.RequestedResource; +import org.eclipse.lemminx.customservice.synapse.resourceFinder.pojo.Resource; +import org.eclipse.lemminx.customservice.synapse.resourceFinder.pojo.ResourceResponse; +import org.eclipse.lemminx.customservice.synapse.utils.Constant; +import org.eclipse.lemminx.customservice.synapse.utils.Utils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the conflict-detection behavior in + * {@link AbstractResourceFinder#loadDependentResources(String)}. + */ +public class LoadDependentResourcesTest { + + private static final Logger LOGGER = Logger.getLogger(LoadDependentResourcesTest.class.getName()); + + @TempDir + Path tempUserHome; + + private String mainProjectPath; + private TestResourceFinder resourceFinder; + + @BeforeEach + void setUp() throws IOException { + + mainProjectPath = tempUserHome.resolve("main_project").toString(); + Files.createDirectories(Path.of(mainProjectPath)); + createPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + resourceFinder = new TestResourceFinder(); + resourceFinder.setUserHome(tempUserHome.toString()); + ConnectorHolder.getInstance().clearConnectors(); + LOGGER.info("Test setup completed with main project path: " + mainProjectPath); + } + + @AfterEach + void tearDown() { + + ConnectorHolder.getInstance().clearConnectors(); + } + + // ------------------------------------------------------------------------- + // Directory structure helpers + // ------------------------------------------------------------------------- + + /** Creates the base dependency directory (Extracted + Downloaded) for the main project. */ + private Path createDependencyBaseDir() throws IOException { + + String projectName = new File(mainProjectPath).getName(); + String hash = Utils.getHash(mainProjectPath); + Path depDir = tempUserHome + .resolve(Constant.WSO2_MI) + .resolve(Constant.INTEGRATION_PROJECT_DEPENDENCIES) + .resolve(projectName + Constant.UNDERSCORE + hash); + Files.createDirectories(depDir.resolve(Constant.EXTRACTED)); + Files.createDirectories(depDir.resolve(Constant.DOWNLOADED)); + return depDir; + } + + /** + * Creates a dependent project directory under {@code Extracted/} and writes a minimal pom.xml. + * + * @return the path to the created project directory + */ + private Path createDependentProject(Path depBaseDir, String dirName, + String groupId, String artifactId, String version) + throws IOException { + + Path projectPath = depBaseDir.resolve(Constant.EXTRACTED).resolve(dirName); + Files.createDirectories(projectPath); + createPomXml(projectPath, groupId, artifactId, version); + LOGGER.fine("Creating dependent project: " + dirName + " with coordinates " + groupId + ":" + artifactId + ":" + version); + return projectPath; + } + + /** Writes a minimal integration-project pom.xml. */ + private void createPomXml(Path projectDir, String groupId, String artifactId, String version) + throws IOException { + + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " " + groupId + "\n" + + " " + artifactId + "\n" + + " " + version + "\n" + + " pom\n" + + " \n" + + " integration-project\n" + + " \n" + + "\n"; + Files.writeString(projectDir.resolve("pom.xml"), pom); + } + + /** + * Writes a minimal integration-project pom.xml with {@code versionedDeployment=true} so that + * dep resource names are transformed to {@code groupId__artifactId__name} during load. + */ + private void createVersionedPomXml(Path projectDir, String groupId, String artifactId, String version) + throws IOException { + + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " " + groupId + "\n" + + " " + artifactId + "\n" + + " " + version + "\n" + + " pom\n" + + " \n" + + " integration-project\n" + + " true\n" + + " \n" + + "\n"; + Files.writeString(projectDir.resolve("pom.xml"), pom); + } + + /** Creates an empty placeholder file in the Downloaded directory. */ + private Path createDownloadedFile(Path depBaseDir, String groupId, String artifactId, + String version, String extension) throws IOException { + + String fileName = groupId + Constant.HYPHEN + artifactId + Constant.HYPHEN + version + extension; + Path filePath = depBaseDir.resolve(Constant.DOWNLOADED).resolve(fileName); + Files.createFile(filePath); + return filePath; + } + + // ------------------------------------------------------------------------- + // Resource-building helpers + // ------------------------------------------------------------------------- + + /** Builds a resource map with one type entry containing the given artifact names. */ + private Map buildResources(String type, String... names) { + + Map map = new HashMap<>(); + ResourceResponse response = new ResourceResponse(); + List resources = new ArrayList<>(); + for (String name : names) { + ArtifactResource artifact = new ArtifactResource(); + artifact.setName(name); + artifact.setType(type); + resources.add(artifact); + } + response.setResources(resources); + map.put(type, response); + return map; + } + + /** + * Registers a connector in the {@link ConnectorHolder} singleton. The artifact ID is derived + * from {@code zipBaseName} by stripping the trailing {@code -version} segment, matching how + * {@link org.eclipse.lemminx.customservice.synapse.connectors.ConnectorReader} populates it + * in production. Conflict detection uses {@code artifactId} for matching, not the display name. + */ + private void registerConnectorInHolder(String shortName, String zipBaseName) { + + Connector connector = new Connector(); + connector.setName(shortName); + int lastHyphen = zipBaseName.lastIndexOf('-'); + connector.setArtifactId(lastHyphen > 0 ? zipBaseName.substring(0, lastHyphen) : zipBaseName); + connector.setExtractedConnectorPath("/fake/extracted/" + zipBaseName); + connector.setFromProject(true); // simulates a connector from the main project + ConnectorHolder.getInstance().addConnector(connector); + } + + /** + * Builds a resource map whose registry resources contain {@code resources:connectors/{name}.zip} + * entries — the format that {@code findAllResources} returns for connector zips. + */ + private Map buildConnectorZipResources(String... zipBaseNames) { + + List registryResources = new ArrayList<>(); + for (String baseName : zipBaseNames) { + RegistryResource reg = new RegistryResource(); + String key = "resources:connectors/" + baseName + Constant.ZIP_EXTENSION; + reg.setRegistryKey(key); + reg.setRegistryPath(key); + registryResources.add(reg); + } + ResourceResponse response = new ResourceResponse(); + response.setRegistryResources(registryResources); + Map map = new HashMap<>(); + map.put("connector", response); + return map; + } + + /** + * Builds a resource map with one type entry containing registry resources + * (e.g. xslt files). Each {@code registryKey} is the full registry path such + * as {@code gov:/xslt/sample.xslt}. + */ + private Map buildRegistryResources(String type, String... registryKeys) { + + Map map = new HashMap<>(); + ResourceResponse response = new ResourceResponse(); + List registryResources = new ArrayList<>(); + for (String key : registryKeys) { + RegistryResource reg = new RegistryResource(); + reg.setRegistryKey(key); + reg.setRegistryPath(key); + registryResources.add(reg); + } + response.setRegistryResources(registryResources); + map.put(type, response); + return map; + } + + // ------------------------------------------------------------------------- + // Tests: early-exit paths + // ------------------------------------------------------------------------- + + /** + * When no integration-project-dependencies directory exists under USER_HOME/.wso2-mi/ + * for the project, {@code loadDependentResources} returns the "no projects found" message + * without attempting to scan anything. + */ + @Test + void testNoDependencyDirectoryReturnsNoProjectsFound() { + + String result = resourceFinder.loadDependentResources(mainProjectPath); + assertEquals("No dependent integration projects found", result); + } + + /** + * When the dependency base directory exists but the {@code Extracted/} subdirectory has been + * deleted, {@code loadDependentResources} returns the "no projects found" message. + */ + @Test + void testNoExtractedDirectoryReturnsNoProjectsFound() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Files.delete(depBaseDir.resolve(Constant.EXTRACTED)); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + assertEquals("No dependent integration projects found", result); + } + + // ------------------------------------------------------------------------- + // Tests: successful load (no conflicts) + // ------------------------------------------------------------------------- + + /** + * When a dep project has a resource with a different name from the main project's resources, + * no conflict is detected and {@code loadDependentResources} returns a success message. + */ + @Test + void testSuccessfulLoadReturnsSuccessMessage() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "mainSequence")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "depSequence")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), "Expected success message but got: " + result); + } + + /** + * When a dep project loads without conflict, its resources are merged into the + * dependent resources map and are accessible by type. + */ + @Test + void testSuccessfulLoadPopulatesDependentResourcesMap() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "mainSequence")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "depSequence")); + + resourceFinder.loadDependentResources(mainProjectPath); + + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("sequence")); + List sequences = loaded.get("sequence").getResources(); + assertEquals(1, sequences.size()); + assertEquals("depSequence", sequences.get(0).getName()); + } + + /** + * When two dep projects have entirely different resource names, both are loaded without + * conflict and all their resources appear in the dependent resources map. + */ + @Test + void testMultipleNonConflictingDepsAllLoaded() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sequence1")); + resourceFinder.setProjectResources(dep2.toString(), buildResources("sequence", "sequence2")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success")); + Map loaded = resourceFinder.getDependentResourcesMap(); + List sequences = loaded.get("sequence").getResources(); + assertEquals(2, sequences.size()); + List names = new ArrayList<>(); + sequences.forEach(r -> names.add(r.getName())); + assertTrue(names.contains("sequence1")); + assertTrue(names.contains("sequence2")); + } + + /** + * When the {@code Extracted/} directory exists but contains no dep project subdirectories, + * {@code loadDependentResources} returns success with an empty dependent resources map. + */ + @Test + void testEmptyExtractedDirReturnsSuccess() throws IOException { + + createDependencyBaseDir(); // Extracted dir exists but is empty + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success")); + assertTrue(resourceFinder.getDependentResourcesMap().isEmpty()); + } + + // ------------------------------------------------------------------------- + // Tests: conflict with the main project + // ------------------------------------------------------------------------- + + /** + * When a dep project has a resource whose name matches one already in the main project, + * {@code loadDependentResources} returns a message containing "CONFLICTING ARTIFACTS". + */ + @Test + void testConflictWithMainProjectReturnsConflictMessage() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSequence")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSequence")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), "Expected conflict message"); + } + + /** + * The conflict message includes the Maven coordinates (groupId, artifactId, version) of + * each conflicting dependency so that the user can identify which dependency to remove. + */ + @Test + void testConflictMessageContainsDependencyCoordinates() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSequence")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSequence")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("\"groupId\": \"com.example\"")); + assertTrue(result.contains("\"artifactId\": \"dep1\"")); + assertTrue(result.contains("\"version\": \"1.0.0\"")); + } + + /** + * The conflict message's {@code conflictingArtifacts} array lists the names of every + * artifact that caused the conflict, so the user knows which ones to rename or remove. + */ + @Test + void testConflictMessageListsConflictingArtifactNames() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + Map depResources = new HashMap<>(); + depResources.putAll(buildResources("sequence", "sharedSeq")); + depResources.putAll(buildResources("endpoint", "sharedEndpoint")); + + resourceFinder.setProjectResources(mainProjectPath, + mergeResourceMaps(buildResources("sequence", "sharedSeq"), + buildResources("endpoint", "sharedEndpoint"))); + resourceFinder.setProjectResources(dep1.toString(), depResources); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("\"sharedSeq\"")); + assertTrue(result.contains("\"sharedEndpoint\"")); + } + + /** + * A dep project that conflicts with the main project is not merged into the dependent + * resources map — its resources must not be visible to the main project. + */ + @Test + void testConflictingDependencyNotLoadedIntoResourcesMap() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSequence")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSequence")); + + resourceFinder.loadDependentResources(mainProjectPath); + + Map loaded = resourceFinder.getDependentResourcesMap(); + // No resources from dep1 should appear; the map should be empty or lack sequence entries + assertTrue(loaded.isEmpty() || loaded.get("sequence") == null + || loaded.get("sequence").getResources() == null + || loaded.get("sequence").getResources().isEmpty()); + } + + // ------------------------------------------------------------------------- + // Tests: conflict between dependent projects + // ------------------------------------------------------------------------- + + /** + * When two dep projects share a resource name (and neither conflicts with the main project), + * the second one processed (alphabetically) is flagged as conflicting with the first. + */ + @Test + void testConflictBetweenDependentProjectsDetected() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + // dep1 is created first so its directory has an older mtime and is processed first + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSequence")); + resourceFinder.setProjectResources(dep2.toString(), buildResources("sequence", "sharedSequence")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + // dep2 (bdep2) conflicts with dep1 which was loaded first + assertTrue(result.contains("\"artifactId\": \"dep2\"")); + } + + /** + * When two dep projects share a resource name, the first one processed (alphabetically) + * is successfully loaded and its resources are available in the dependent resources map. + */ + @Test + void testFirstDependencyLoadedWhenSecondConflicts() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSequence")); + resourceFinder.setProjectResources(dep2.toString(), buildResources("sequence", "sharedSequence")); + + resourceFinder.loadDependentResources(mainProjectPath); + + // dep1 (adep1) was processed first and should be loaded + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("sequence")); + assertEquals(1, loaded.get("sequence").getResources().size()); + assertEquals("sharedSequence", loaded.get("sequence").getResources().get(0).getName()); + } + + // ------------------------------------------------------------------------- + // Tests: multiple conflicting dependencies + // ------------------------------------------------------------------------- + + /** + * When multiple dep projects each conflict with the main project independently, + * all of them are reported in a single conflict message with their respective coordinates. + */ + @Test + void testMultipleConflictingDependenciesAllReportedInMessage() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "dep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSequence")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSequence")); + resourceFinder.setProjectResources(dep2.toString(), buildResources("sequence", "sharedSequence")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + assertTrue(result.contains("\"artifactId\": \"dep1\"")); + assertTrue(result.contains("\"artifactId\": \"dep2\"")); + assertTrue(result.contains("\"version\": \"1.0.0\"")); + assertTrue(result.contains("\"version\": \"2.0.0\"")); + } + + // ------------------------------------------------------------------------- + // Tests: cleanup of conflicting dependency + // ------------------------------------------------------------------------- + + /** + * When a dep project is flagged as conflicting, its entire extracted directory (including + * all nested files) is recursively deleted from the {@code Extracted/} folder. + */ + @Test + void testConflictCausesExtractedDirectoryDeletion() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + // Add a file inside the dep1 directory to confirm recursive deletion + Files.createFile(dep1.resolve("some_artifact.xml")); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSeq")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSeq")); + + resourceFinder.loadDependentResources(mainProjectPath); + + assertFalse(Files.exists(dep1), "Conflicting dependency's extracted directory should be deleted"); + } + + /** + * When a dep project is flagged as conflicting, the corresponding {@code .car} file + * (matched by groupId-artifactId-version) in the {@code Downloaded/} directory is deleted. + */ + @Test + void testConflictCausesDownloadedCarFileDeletion() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + Path carFile = createDownloadedFile(depBaseDir, "com.example", "dep1", "1.0.0", Constant.CAR_EXTENSION); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSeq")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSeq")); + + resourceFinder.loadDependentResources(mainProjectPath); + + assertFalse(Files.exists(carFile), "Downloaded .car file should be deleted on conflict"); + } + + /** + * When a dep project is flagged as conflicting, the corresponding {@code .zip} file + * (matched by groupId-artifactId-version) in the {@code Downloaded/} directory is deleted. + */ + @Test + void testConflictCausesDownloadedZipFileDeletion() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + Path zipFile = createDownloadedFile(depBaseDir, "com.example", "dep1", "1.0.0", Constant.ZIP_EXTENSION); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSeq")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSeq")); + + resourceFinder.loadDependentResources(mainProjectPath); + + assertFalse(Files.exists(zipFile), "Downloaded .zip file should be deleted on conflict"); + } + + /** + * A dep project that does not conflict is not cleaned up — its extracted directory + * and downloaded archive file remain on disk. + */ + @Test + void testNonConflictingDependencyIsNotCleanedUp() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + Path carFile = createDownloadedFile(depBaseDir, "com.example", "dep1", "1.0.0", Constant.CAR_EXTENSION); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "mainSequence")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "dep1Sequence")); + + resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(Files.exists(dep1), "Non-conflicting dep's extracted dir should not be deleted"); + assertTrue(Files.exists(carFile), "Non-conflicting dep's downloaded file should not be deleted"); + } + + /** + * When one dep conflicts and another does not, only the conflicting dep's files are + * deleted — the non-conflicting dep's extracted directory and downloaded archive survive. + */ + @Test + void testOnlyConflictingDependencyIsCleanedUpNotNonConflicting() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + Path dep2Car = createDownloadedFile(depBaseDir, "com.example", "dep2", "2.0.0", Constant.CAR_EXTENSION); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSeq")); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "dep1UniqueSeq")); + resourceFinder.setProjectResources(dep2.toString(), buildResources("sequence", "sharedSeq")); + + resourceFinder.loadDependentResources(mainProjectPath); + + // dep1 (adep1) is non-conflicting: should still exist + assertTrue(Files.exists(dep1), "Non-conflicting dep1 should not be deleted"); + // dep2 (bdep2) conflicts with main project: should be cleaned up + assertFalse(Files.exists(dep2), "Conflicting dep2 extracted dir should be deleted"); + assertFalse(Files.exists(dep2Car), "Conflicting dep2 downloaded .car should be deleted"); + } + + // ------------------------------------------------------------------------- + // Tests: connector conflict scenarios + // ------------------------------------------------------------------------- + + /** + * When a dep project carries a connector that is not present in the main project's + * ConnectorHolder, no connector conflict is raised and the dep loads successfully. + */ + @Test + void testConnectorFromDepLoadedSuccessfully() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-salesforce-1.0.0")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success")); + } + + /** + * When a dep project includes a connector whose name is already registered in + * ConnectorHolder with {@code fromProject=true} (i.e., the main project owns it), + * a conflict is detected and the conflict message names the dep dependency. + */ + @Test + void testConnectorConflictWithExistingConnectorInHolderReturnsConflictMessage() throws IOException { + + // "salesforce" connector already loaded from the main project + registerConnectorInHolder("salesforce", "mi-connector-salesforce-1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-salesforce-1.0.0")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + assertTrue(result.contains("\"artifactId\": \"dep1\"")); + } + + /** + * When a connector conflict is detected, the conflict message's {@code conflictingConnectors} + * array contains the zip base name of the conflicting connector (without the {@code .zip} + * extension), so the user knows exactly which connector to address. + */ + @Test + void testConnectorConflictMessageListsConflictingConnectorZipName() throws IOException { + + registerConnectorInHolder("salesforce", "mi-connector-salesforce-1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-salesforce-1.0.0")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("\"conflictingConnectors\"")); + assertTrue(result.contains("\"mi-connector-salesforce-1.0.0\"")); + } + + /** + * A connector conflict alone — with no overlapping artifact names — is sufficient to + * trigger a conflict report. In this case {@code conflictingArtifacts} is empty and + * only {@code conflictingConnectors} contains entries. + */ + @Test + void testConnectorOnlyConflictWithNoArtifactConflictStillTriggersReport() throws IOException { + + // Artifacts are different — only the connector clashes + registerConnectorInHolder("salesforce", "mi-connector-salesforce-1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "mainSequence")); + resourceFinder.setProjectResources(dep1.toString(), + mergeResourceMaps(buildResources("sequence", "depSequence"), + buildConnectorZipResources("mi-connector-salesforce-1.0.0"))); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + assertTrue(result.contains("\"mi-connector-salesforce-1.0.0\"")); + // No artifact conflict — the artifact list must be empty + assertTrue(result.contains("\"conflictingArtifacts\": []")); + } + + /** + * When two dep projects include the same connector (ConnectorHolder is initially empty), + * the second dep processed is flagged as conflicting with the first. + */ + @Test + void testConnectorConflictBetweenDepProjectsDetected() throws IOException { + + // ConnectorHolder is empty — both deps are new + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-salesforce-1.0.0")); + resourceFinder.setProjectResources(dep2.toString(), + buildConnectorZipResources("mi-connector-salesforce-1.0.0")); // same connector → conflict + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + // dep2 (bdep2) conflicts with dep1 processed first + assertTrue(result.contains("\"artifactId\": \"dep2\"")); + } + + /** + * When two dep projects include the same connector, the first one processed (alphabetically) + * is loaded successfully and its artifact resources appear in the dependent resources map. + */ + @Test + void testConnectorConflictBetweenDepProjectsFirstDepStillLoaded() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + mergeResourceMaps(buildResources("sequence", "dep1Sequence"), + buildConnectorZipResources("mi-connector-salesforce-1.0.0"))); + resourceFinder.setProjectResources(dep2.toString(), + mergeResourceMaps(buildResources("sequence", "dep2Sequence"), + buildConnectorZipResources("mi-connector-salesforce-1.0.0"))); + + resourceFinder.loadDependentResources(mainProjectPath); + + // dep1 was processed without conflict and must be in the resources map + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("sequence")); + assertEquals(1, loaded.get("sequence").getResources().size()); + assertEquals("dep1Sequence", loaded.get("sequence").getResources().get(0).getName()); + } + + /** + * When the main project has a different connector and the dep has a non-overlapping one, + * no conflict is raised; the dep's extracted directory and downloaded file are not deleted. + */ + @Test + void testNonConflictingConnectorDepIsNotCleanedUp() throws IOException { + + // Holder has "salesforce"; dep has "googlepubsub" — no overlap + registerConnectorInHolder("salesforce", "mi-connector-salesforce-1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + Path carFile = createDownloadedFile(depBaseDir, "com.example", "dep1", "1.0.0", Constant.CAR_EXTENSION); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-googlepubsub-1.0.0")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success")); + assertTrue(Files.exists(dep1), "Non-conflicting dep extracted dir should not be deleted"); + assertTrue(Files.exists(carFile), "Non-conflicting dep downloaded file should not be deleted"); + } + + /** + * When a dep project has both an artifact conflict (same sequence name) and a connector + * conflict, both are reported in the conflict message — one in {@code conflictingArtifacts} + * and one in {@code conflictingConnectors}. + */ + @Test + void testMixedArtifactAndConnectorConflictBothReportedInMessage() throws IOException { + + registerConnectorInHolder("salesforce", "mi-connector-salesforce-1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, buildResources("sequence", "sharedSequence")); + resourceFinder.setProjectResources(dep1.toString(), + mergeResourceMaps(buildResources("sequence", "sharedSequence"), + buildConnectorZipResources("mi-connector-salesforce-1.0.0"))); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("\"sharedSequence\"")); + assertTrue(result.contains("\"mi-connector-salesforce-1.0.0\"")); + } + + /** + * {@code mi-connector-http} is a built-in connector bundled with every MI project. + * It is always skipped during conflict detection — even when it is already registered + * in ConnectorHolder — so no conflict is raised. + */ + @Test + void testHttpConnectorInDepIsAlwaysSkippedNoConflict() throws IOException { + + // Even if http is registered in the holder, a dep carrying it must not be flagged + registerConnectorInHolder("http", "mi-connector-http-0.1.14"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-http-0.1.14")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), + "mi-connector-http must never trigger a conflict, but got: " + result); + } + + /** + * The main project has a connector whose component name (in {@code connector.xml}) uses different + * case than the zip file name — e.g. component name {@code CSV} vs zip name {@code mi-module-csv-1.0.0} — the + * conflict check must still fire. + *

+ * Detection matches on artifact ID (derived from the zip/folder name) rather than the + * display name, so casing differences in {@code connector.xml} do not cause conflicts to + * be missed. + */ + @Test + void testConnectorConflictDetectedWhenComponentNameCasingDiffersFromZipName() throws IOException { + + // The CSV module connector's component name attribute in connector.xml is "CSV" (uppercase), + // while the zip file is named mi-module-csv-1.0.0.zip (lowercase). Conflict detection + // now uses the artifact ID (mi-module-csv) derived from the zip name, not the component name. + registerConnectorInHolder("CSV", "mi-module-csv-1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-module-csv-1.0.0")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), + "Connector conflict must be detected regardless of component name casing, but got: " + result); + assertTrue(result.contains("\"mi-module-csv-1.0.0\"")); + } + + /** + * When a dep includes both {@code mi-connector-http} (always skipped) and another + * connector that has no conflict, the dep loads successfully — the http connector + * does not block loading. + */ + @Test + void testHttpConnectorInDepDoesNotBlockLoadingWhenOtherConnectorAlsoPresent() throws IOException { + + // dep has both http (skipped) and a unique salesforce connector — should load fine + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-http-0.1.14", "mi-connector-salesforce-1.0.0")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success")); + } + + /** + * {@code resources:conf/config.properties} is auto-generated in every integration project + * and is explicitly excluded from conflict detection. When both the main project and a dep + * carry this key, no conflict is raised. + */ + @Test + void testConfigPropertiesRegistryKeyIsSkippedAndDoesNotCauseConflict() throws IOException { + + // Both main project and dep carry resources:conf/config.properties — must not conflict + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, + buildRegistryResources("registry", "resources:conf/config.properties")); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("registry", "resources:conf/config.properties")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), + "resources:conf/config.properties must never trigger a conflict, but got: " + result); + } + + /** + * Even though {@code resources:conf/config.properties} is shared and skipped, other + * registry keys that genuinely conflict (e.g., {@code gov:/xslt/sample.xslt}) are still + * detected and reported. The skipped key must not appear in the conflict report. + */ + @Test + void testConfigPropertiesSkippedButOtherConflictingKeyStillDetected() throws IOException { + + // config.properties is shared (skipped), but sample.xslt also conflicts — must still report + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + Map mainResources = mergeResourceMaps( + buildRegistryResources("registry", "resources:conf/config.properties"), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + Map depResources = mergeResourceMaps( + buildRegistryResources("registry", "resources:conf/config.properties"), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + + resourceFinder.setProjectResources(mainProjectPath, mainResources); + resourceFinder.setProjectResources(dep1.toString(), depResources); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + assertTrue(result.contains("\"gov:/xslt/sample.xslt\"")); + assertFalse(result.contains("\"resources:conf/config.properties\""), + "config.properties must not appear in the conflict report"); + } + + /** + * {@code resources:artifact.xml} is a project-skeleton file present in every integration + * project. It is filtered from conflict detection, so two dep projects both carrying it + * do not conflict. + */ + @Test + void testArtifactXmlSkippedAndDoesNotCauseConflict() throws IOException { + + // Both deps carry resources:artifact.xml — must not conflict + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("registry", "resources:artifact.xml")); + resourceFinder.setProjectResources(dep2.toString(), + buildRegistryResources("registry", "resources:artifact.xml")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), + "resources:artifact.xml must never trigger a conflict, but got: " + result); + } + + /** + * {@code resources:registry/artifact.xml} is a project-skeleton file present in every + * integration project. It is filtered from conflict detection, so two dep projects both + * carrying it do not conflict. + */ + @Test + void testRegistryArtifactXmlSkippedAndDoesNotCauseConflict() throws IOException { + + // Both deps carry resources:registry/artifact.xml — must not conflict + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("registry", "resources:registry/artifact.xml")); + resourceFinder.setProjectResources(dep2.toString(), + buildRegistryResources("registry", "resources:registry/artifact.xml")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), + "resources:registry/artifact.xml must never trigger a conflict, but got: " + result); + } + + /** + * After dep1 loads successfully, {@code resources:artifact.xml} must NOT be added to the + * tracked resource names. If it were, dep2 carrying the same skeleton file would falsely + * conflict with dep1. Both deps must load fine. + */ + @Test + void testArtifactXmlNotAddedToExistingResourceNamesAfterSuccessfulLoad() throws IOException { + + // dep1 loads with artifact.xml; dep2 has the same artifact.xml plus a unique real resource. + // artifact.xml must not be added to existingResourceNames, so dep2 loads fine. + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + mergeResourceMaps(buildRegistryResources("registry", "resources:artifact.xml"), + buildResources("sequence", "dep1Sequence"))); + resourceFinder.setProjectResources(dep2.toString(), + mergeResourceMaps(buildRegistryResources("registry", "resources:artifact.xml"), + buildResources("sequence", "dep2Sequence"))); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success")); + Map loaded = resourceFinder.getDependentResourcesMap(); + assertEquals(2, loaded.get("sequence").getResources().size()); + } + + /** + * A user-created registry resource whose key happens to end with {@code artifact.xml} + * (e.g. {@code gov:/config/artifact.xml}) is a legitimate user resource and must NOT be + * silently excluded from conflict detection. Only the two exact project-skeleton keys + * ({@code resources:artifact.xml} and {@code resources:registry/artifact.xml}) are excluded. + *

+ * This verifies the fix for the overly-broad {@code endsWith("artifact.xml")} check that was + * replaced with exact-key matching via {@code ARTIFACT_XML_REGISTRY_KEYS}. + */ + @Test + void testUserRegistryResourceEndingWithArtifactXmlIsDetectedAsConflict() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + // Main project already has a user-created registry resource whose key ends with artifact.xml + resourceFinder.setProjectResources(mainProjectPath, + buildRegistryResources("registry", "gov:/config/artifact.xml")); + // dep1 also has the same user-created key → should be detected as a conflict + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("registry", "gov:/config/artifact.xml")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), + "A user registry resource ending with artifact.xml must trigger a conflict, but got: " + result); + assertTrue(result.contains("\"gov:/config/artifact.xml\"")); + } + + /** + * The conflict message must list conflicting artifact names in alphabetical order regardless + * of the iteration order of the underlying {@link java.util.HashSet}. Stable ordering makes + * the output reproducible across runs and easier to reason about in logs and error messages. + */ + @Test + void testConflictMessageListsArtifactsInSortedOrder() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + // Main project has several resources whose names sort as: alphaSeq < betaSeq < gammaSeq + resourceFinder.setProjectResources(mainProjectPath, + buildResources("sequence", "gammaSeq", "alphaSeq", "betaSeq")); + // dep1 conflicts with all three + resourceFinder.setProjectResources(dep1.toString(), + buildResources("sequence", "betaSeq", "gammaSeq", "alphaSeq")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + // All three must appear in the message + assertTrue(result.contains("\"alphaSeq\"")); + assertTrue(result.contains("\"betaSeq\"")); + assertTrue(result.contains("\"gammaSeq\"")); + // Alphabetical order: alphaSeq before betaSeq before gammaSeq + assertTrue(result.indexOf("\"alphaSeq\"") < result.indexOf("\"betaSeq\""), + "alphaSeq must appear before betaSeq in the conflict message"); + assertTrue(result.indexOf("\"betaSeq\"") < result.indexOf("\"gammaSeq\""), + "betaSeq must appear before gammaSeq in the conflict message"); + } + + /** + * Connector zip registry keys ({@code resources:connectors/*.zip}) are extracted for + * connector-specific conflict checking and then removed from the artifact namespace. + * Two deps carrying the same connector zip key (for the http connector which is always + * skipped) must not trigger a conflict. + */ + @Test + void testConnectorZipInResourcesRegistryKeySkippedAndDoesNotCauseConflict() throws IOException { + + // Both deps carry resources:connectors/mi-connector-http-0.1.14.zip as a registry resource key + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("registry", "resources:connectors/mi-connector-http-0.1.14.zip")); + resourceFinder.setProjectResources(dep2.toString(), + buildRegistryResources("registry", "resources:connectors/mi-connector-http-0.1.14.zip")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), + "resources:connectors/* must never trigger a conflict, but got: " + result); + } + + /** + * Connector conflict detection is version-agnostic: even if dep1 has + * {@code mi-connector-salesforce-1.0.0} and dep2 has {@code mi-connector-salesforce-2.0.0}, + * the same connector core name ({@code mi-connector-salesforce}) is detected and a conflict + * is raised for dep2. + */ + @Test + void testConnectorVersionDifferenceStillRaisesConflict() throws IOException { + + // dep1 has salesforce 1.0.0, dep2 has salesforce 2.0.0 — different versions, same connector + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-salesforce-1.0.0")); + resourceFinder.setProjectResources(dep2.toString(), + buildConnectorZipResources("mi-connector-salesforce-2.0.0")); // different version, same connector + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), + "Different versions of the same connector must still be flagged as a conflict"); + assertTrue(result.contains("\"artifactId\": \"dep2\"")); + assertTrue(result.contains("\"mi-connector-salesforce-2.0.0\"")); + } + + /** + * Reproduces the "second dependency added" false-positive scenario: after dep1 was + * successfully loaded, its connector is registered in ConnectorHolder with + * {@code fromProject=false}. When {@code loadDependentResources} is called again to load + * dep2, dep1 must not be flagged as conflicting against its own connector already in the + * holder — only connectors with {@code fromProject=true} seed the initial conflict set. + */ + @Test + void testPreviouslyLoadedDepConnectorDoesNotConflictWithItselfOnSubsequentLoad() throws IOException { + + // Simulate the second-dependency-added scenario: + // dep1 was successfully loaded in a prior run. Its connector was registered in ConnectorHolder + // by the subsequent updateConnectors() call, but marked as NOT fromProject (it came from a dep). + // When loadDependentResources is called again (because dep2 was added), dep1 must not be + // flagged as conflicting against its own connector that is already in ConnectorHolder. + registerConnectorInHolder("salesforce", "mi-connector-salesforce-1.0.0"); + // Simulate that the connector came from a dependency (not the main project) + ConnectorHolder.getInstance().getConnectors().get(0).setFromProject(false); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + mergeResourceMaps(buildResources("sequence", "dep1Sequence"), + buildConnectorZipResources("mi-connector-salesforce-1.0.0"))); // dep1's connector is in holder + resourceFinder.setProjectResources(dep2.toString(), + mergeResourceMaps(buildResources("sequence", "dep2Sequence"), + buildConnectorZipResources("mi-connector-googlepubsub-1.0.0"))); // dep2 has a different connector + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), + "dep1 must not conflict against its own connector already in ConnectorHolder: " + result); + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("sequence")); + assertEquals(2, loaded.get("sequence").getResources().size()); + } + + /** + * Regression test for the "first-added dep is wrongly flagged" bug. + *

+ * Scenario: "NoIssue" dep (email-connector 2.0.1) is added first. Later "LowEmailConnector" + * dep (email-connector 1.0.14) is added. On the second {@code loadDependentResources} call + * both deps are present; "LowEmailConnector" sorts alphabetically before "NoIssue" (L < N), + * so without creation-time ordering the newer dep would be processed first and "NoIssue" + * would be falsely flagged as conflicting. + *

+ * With the oldest-first ordering fix, "NoIssue"'s directory (created earlier) is processed + * first and wins; "LowEmailConnector" is the one flagged as conflicting. + */ + @Test + void testFirstAddedDepWinsWhenAlphabeticallyLaterDepHasSameConnector() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + // "NoIssue" is created first — simulates it being added to the project earlier. + // Its directory name starts with 'N', which sorts AFTER 'L' alphabetically, so + // without timestamp-based ordering it would incorrectly be processed last. + Path noIssue = createDependentProject(depBaseDir, "NoIssue", "com.example", "NoIssue", "1.0.0"); + // Back-date noIssue's mtime so the ordering is deterministic even when both dirs + // are created within the same millisecond (common in fast CI environments). + Files.setLastModifiedTime(noIssue, FileTime.fromMillis(System.currentTimeMillis() - 5000)); + // "LowEmailConnector" is created second — simulates it being added later. + // Its directory name starts with 'L', which sorts BEFORE 'N' alphabetically. + Path lowEmail = createDependentProject(depBaseDir, "LowEmailConnector", "com.example", "LowEmailConnector", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(noIssue.toString(), + buildConnectorZipResources("mi-connector-email-2.0.1")); + resourceFinder.setProjectResources(lowEmail.toString(), + buildConnectorZipResources("mi-connector-email-1.0.14")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), + "A connector conflict must be reported"); + // "LowEmailConnector" (added second) must be the one that is rejected + assertTrue(result.contains("\"artifactId\": \"LowEmailConnector\""), + "LowEmailConnector (added later) must be the conflicting dep, but got: " + result); + assertFalse(result.contains("\"artifactId\": \"NoIssue\""), + "NoIssue (added first) must not be flagged as conflicting"); + // NoIssue's connector must be accessible in the loaded resources + assertTrue(Files.exists(noIssue), "NoIssue dep directory must not be deleted"); + assertFalse(Files.exists(lowEmail), "LowEmailConnector dep directory must be deleted"); + } + + // ------------------------------------------------------------------------- + // Tests: registry artifact (xslt) scenarios + // ------------------------------------------------------------------------- + + /** + * A dep project with an xslt registry resource (e.g., {@code gov:/xslt/sample.xslt}) that + * does not conflict with any existing resource loads successfully; the resource is available + * in the dependent resources map under its full registry key. + */ + @Test + void testRegistryResourceLoadedSuccessfully() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success")); + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("xslt")); + List registryResources = loaded.get("xslt").getRegistryResources(); + assertEquals(1, registryResources.size()); + assertEquals("gov:/xslt/sample.xslt", ((RegistryResource) registryResources.get(0)).getRegistryKey()); + } + + /** + * When a dep project has a registry resource with the same key as the main project + * (e.g., both have {@code gov:/xslt/sample.xslt}), a conflict is detected and the + * message includes the registry key and the dep's coordinates. + */ + @Test + void testRegistryResourceKeyConflictWithMainProjectReturnsConflictMessage() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + assertTrue(result.contains("\"gov:/xslt/sample.xslt\"")); + assertTrue(result.contains("\"artifactId\": \"dep1\"")); + } + + /** + * When two dep projects have the same registry resource key (e.g., both have + * {@code gov:/xslt/sample.xslt}), the second dep conflicts with the first. + * The first dep is loaded and its registry resource is accessible in the map. + */ + @Test + void testRegistryResourceKeyConflictBetweenDependentProjects() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + resourceFinder.setProjectResources(dep2.toString(), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + // dep2 (bdep2) conflicts with dep1 which was loaded first + assertTrue(result.contains("\"artifactId\": \"dep2\"")); + // dep1 (adep1) should still be loaded + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("xslt")); + assertEquals(1, loaded.get("xslt").getRegistryResources().size()); + } + + /** + * When a dep has both an artifact conflict (a shared sequence name) and a registry resource + * conflict (a shared xslt key), both are listed in the {@code conflictingArtifacts} array + * of the conflict message. + */ + @Test + void testMixedArtifactAndRegistryConflictBothReportedInMessage() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + Map mainResources = mergeResourceMaps( + buildResources("sequence", "sharedSequence"), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + Map depResources = mergeResourceMaps( + buildResources("sequence", "sharedSequence"), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + + resourceFinder.setProjectResources(mainProjectPath, mainResources); + resourceFinder.setProjectResources(dep1.toString(), depResources); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS")); + assertTrue(result.contains("\"sharedSequence\"")); + assertTrue(result.contains("\"gov:/xslt/sample.xslt\"")); + } + + /** + * A dep whose registry resource key ({@code gov:/xslt/sample.xslt}) differs from the + * main project's key ({@code gov:/xslt/other.xslt}) loads successfully; its extracted + * directory and downloaded archive are not cleaned up. + */ + @Test + void testNonConflictingRegistryResourceNotCleanedUp() throws IOException { + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + Path carFile = createDownloadedFile(depBaseDir, "com.example", "dep1", "1.0.0", Constant.CAR_EXTENSION); + + resourceFinder.setProjectResources(mainProjectPath, + buildRegistryResources("xslt", "gov:/xslt/other.xslt")); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success")); + assertTrue(Files.exists(dep1), "Non-conflicting dep's extracted dir should not be deleted"); + assertTrue(Files.exists(carFile), "Non-conflicting dep's downloaded file should not be deleted"); + } + + // ------------------------------------------------------------------------- + // Tests: versioned deployment scenarios + // ------------------------------------------------------------------------- + + /** + * With versioned deployment enabled in the main project, each dep resource name is + * prefixed with {@code groupId__artifactId__} before conflict checking. Two dep projects + * that share the same base resource name do NOT conflict because their fully-qualified + * names differ ({@code com.example__dep1__sharedSeq} vs {@code com.example__dep2__sharedSeq}). + */ + @Test + void testVersionedDeploymentSameArtifactNameInTwoDepsDoesNotConflict() throws IOException { + + // Overwrite main project pom with versionedDeployment=true + createVersionedPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSeq")); + resourceFinder.setProjectResources(dep2.toString(), buildResources("sequence", "sharedSeq")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), + "Same base name in two deps must not conflict under versioned deployment, but got: " + result); + Map loaded = resourceFinder.getDependentResourcesMap(); + // Both sequences must be present (under their FQNs) + assertNotNull(loaded.get("sequence")); + assertEquals(2, loaded.get("sequence").getResources().size()); + } + + /** + * With versioned deployment enabled, a dep resource is loaded into the dependent resources + * map under its fully-qualified name ({@code groupId__artifactId__name}) rather than its + * original name. The original name must not appear in the map. + */ + @Test + void testVersionedDeploymentResourceLoadedUnderFQN() throws IOException { + + createVersionedPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "mySequence")); + + resourceFinder.loadDependentResources(mainProjectPath); + + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("sequence")); + List sequences = loaded.get("sequence").getResources(); + assertEquals(1, sequences.size()); + // Resource is stored under its FQN, not the original name + assertEquals("com.example__dep1__mySequence", sequences.get(0).getName()); + } + + /** + * With versioned deployment enabled, a conflict is raised when the dep's fully-qualified + * resource name ({@code groupId__artifactId__name}) matches a name already present in the + * main project. This covers the case where the main project explicitly uses the FQN. + */ + @Test + void testVersionedDeploymentConflictWhenFQNMatchesExistingResource() throws IOException { + + createVersionedPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + // Main project already has a resource whose name equals the FQN of dep1's resource + resourceFinder.setProjectResources(mainProjectPath, + buildResources("sequence", "com.example__dep1__sharedSeq")); + // dep1's "sharedSeq" transforms to "com.example__dep1__sharedSeq" → conflicts + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSeq")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), + "FQN match must trigger a conflict under versioned deployment, but got: " + result); + assertTrue(result.contains("\"com.example__dep1__sharedSeq\"")); + } + + /** + * With versioned deployment enabled, registry resource keys are also transformed to include + * the fully-qualified name ({@code path/groupId__artifactId__file.xslt}). Two dep projects + * with the same base registry file ({@code gov:/xslt/sample.xslt}) do NOT conflict because + * their transformed keys are distinct. + */ + @Test + void testVersionedDeploymentSameRegistryKeyInTwoDepsDoesNotConflict() throws IOException { + + createVersionedPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "adep1", "com.example", "dep1", "1.0.0"); + Path dep2 = createDependentProject(depBaseDir, "bdep2", "com.example", "dep2", "2.0.0"); + + // Both deps have the same xslt file — with versioned deployment they transform to different FQNs + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + resourceFinder.setProjectResources(dep2.toString(), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.startsWith("Success"), + "Same registry key in two deps must not conflict under versioned deployment, but got: " + result); + Map loaded = resourceFinder.getDependentResourcesMap(); + // Both registry resources must be present (under their FQNs) + assertNotNull(loaded.get("xslt")); + assertEquals(2, loaded.get("xslt").getRegistryResources().size()); + } + + /** + * With versioned deployment enabled in the main project, two dep projects that share the same + * Maven coordinates ({@code groupId} and {@code artifactId}) and the same resource name + * produce identical fully-qualified names ({@code groupId__artifactId__name}) after + * transformation. The dep added first is accepted; the dep added second is flagged as + * conflicting and its directories are cleaned up. + */ + @Test + void testVersionedDeploymentConflictWhenTwoDepsProduceSameFQN() throws IOException { + + createVersionedPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + // dep1 is created first (older mtime) so it takes priority + Path dep1 = createDependentProject(depBaseDir, "dep1-v1", "com.example", "shared-lib", "1.0.0"); + Files.setLastModifiedTime(dep1, FileTime.fromMillis(System.currentTimeMillis() - 5000)); + // dep2 is created later with the same groupId+artifactId — same FQN after transformation + Path dep2 = createDependentProject(depBaseDir, "dep1-v2", "com.example", "shared-lib", "2.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), buildResources("sequence", "sharedSeq")); + resourceFinder.setProjectResources(dep2.toString(), buildResources("sequence", "sharedSeq")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), + "Two deps with same groupId+artifactId+name must conflict under versioned deployment, but got: " + result); + // dep2 (added later) is the conflicting one + assertTrue(result.contains("\"artifactId\": \"shared-lib\""), + "Conflict message should name the shared-lib artifactId"); + assertTrue(result.contains("\"version\": \"2.0.0\""), + "dep2 (version 2.0.0, added later) should be flagged as conflicting"); + assertFalse(result.contains("\"version\": \"1.0.0\""), + "dep1 (version 1.0.0, added first) should NOT be flagged"); + // dep1 directory must survive; dep2 must be cleaned up + assertTrue(Files.exists(dep1), "dep1 (added first) must not be deleted"); + assertFalse(Files.exists(dep2), "dep2 (added later) must be cleaned up"); + // dep1's resource is loaded under its FQN + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("sequence")); + List sequences = loaded.get("sequence").getResources(); + assertEquals(1, sequences.size()); + assertEquals("com.example__shared-lib__sharedSeq", sequences.get(0).getName()); + } + + /** + * With versioned deployment enabled, the registry resource key of a dep is transformed + * to {@code path/groupId__artifactId__filename}. This FQN is what is stored in the + * dependent resources map; the original key must not appear. + */ + @Test + void testVersionedDeploymentRegistryResourceLoadedUnderFQN() throws IOException { + + createVersionedPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildRegistryResources("xslt", "gov:/xslt/sample.xslt")); + + resourceFinder.loadDependentResources(mainProjectPath); + + Map loaded = resourceFinder.getDependentResourcesMap(); + assertNotNull(loaded.get("xslt")); + List registryResources = loaded.get("xslt").getRegistryResources(); + assertEquals(1, registryResources.size()); + // Registry key must be the FQN, not the original path + assertEquals("gov:/xslt/com.example__dep1__sample.xslt", + ((RegistryResource) registryResources.get(0)).getRegistryKey()); + } + + /** + * Regression: with versioned deployment enabled, connector zip registry keys were being + * renamed to {@code resources:connectors/groupId__artifactId__connector.zip} by + * {@code applyVersionedDeploymentToResources} before connector extraction ran. The FQN + * prefix caused {@code extractConnectorZipBaseNames} to extract + * {@code com.example__dep1__mi-connector-email-1.0.0} instead of + * {@code mi-connector-email-1.0.0}, which never matched any known connector artifact ID, + * silently missing the conflict. + *

+ * Connector zip registry keys must be extracted from the original resource map before + * the versioned-deployment renaming is applied. + */ + @Test + void testVersionedDeploymentConnectorConflictWithMainProjectConnector() throws IOException { + + createVersionedPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + // Email connector already loaded from the main project + registerConnectorInHolder("email", "mi-connector-email-2.0.1"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-email-1.0.0")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), + "Connector conflict must be detected under versioned deployment, but got: " + result); + assertTrue(result.contains("\"mi-connector-email-1.0.0\"")); + assertFalse(Files.exists(dep1), "Conflicting dep directory must be cleaned up"); + } + + /** + * With versioned deployment enabled, two dep projects carrying the same connector + * must still be detected as conflicting. The second dep's connector zip key would be renamed + * by versioned-deployment transformation, breaking the core-name comparison used to detect + * duplicate connectors across deps in the same run. + */ + @Test + void testVersionedDeploymentConnectorConflictBetweenTwoDeps() throws IOException { + + createVersionedPomXml(Path.of(mainProjectPath), "com.example", "main-project", "1.0.0"); + + Path depBaseDir = createDependencyBaseDir(); + Path dep1 = createDependentProject(depBaseDir, "dep1", "com.example", "dep1", "1.0.0"); + Files.setLastModifiedTime(dep1, FileTime.fromMillis(System.currentTimeMillis() - 5000)); + Path dep2 = createDependentProject(depBaseDir, "dep2", "com.example", "dep2", "1.0.0"); + + resourceFinder.setProjectResources(mainProjectPath, new HashMap<>()); + // Both deps carry the same connector (different versions → same core name) + resourceFinder.setProjectResources(dep1.toString(), + buildConnectorZipResources("mi-connector-email-2.0.1")); + resourceFinder.setProjectResources(dep2.toString(), + buildConnectorZipResources("mi-connector-email-1.0.14")); + + String result = resourceFinder.loadDependentResources(mainProjectPath); + + assertTrue(result.contains("CONFLICTING ARTIFACTS"), + "Same connector in two deps must conflict under versioned deployment, but got: " + result); + assertTrue(result.contains("\"artifactId\": \"dep2\""), + "dep2 (added later) must be flagged as the conflicting dependency"); + assertFalse(result.contains("\"artifactId\": \"dep1\""), + "dep1 (added first) must not be flagged"); + assertTrue(Files.exists(dep1), "dep1 (added first) must not be deleted"); + assertFalse(Files.exists(dep2), "dep2 (conflicting) must be cleaned up"); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + @SafeVarargs + private Map mergeResourceMaps(Map... maps) { + + Map merged = new HashMap<>(); + for (Map map : maps) { + merged.putAll(map); + } + return merged; + } + + // ------------------------------------------------------------------------- + // Concrete test implementation of AbstractResourceFinder + // ------------------------------------------------------------------------- + + /** + * Minimal concrete subclass of {@link AbstractResourceFinder} that returns pre-configured + * resource maps per project path, avoiding the need for real XML artifact files on disk. + * The user home directory is injected via {@link #setUserHome} so tests never need to + * mutate the global {@code user.home} system property. + */ + private static class TestResourceFinder extends AbstractResourceFinder { + + private String userHome; + private final Map> projectResources = new HashMap<>(); + + void setUserHome(String userHome) { + + this.userHome = userHome; + } + + @Override + protected String getUserHome() { + + return userHome; + } + + void setProjectResources(String projectPath, Map resources) { + + projectResources.put(projectPath, resources); + } + + @Override + public Map findAllResources(String projectPath) { + + return projectResources.getOrDefault(projectPath, new HashMap<>()); + } + + @Override + protected ResourceResponse findResources(String projectPath, List types) { + + return null; + } + + @Override + protected String getArtifactFolder(String type) { + + return type + "s"; + } + } +}