Add more unit tests for gradle resolver (#1457)

diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/BUILD.bazel b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/BUILD.bazel
index 7a996ef..ee231e6 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/BUILD.bazel
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/BUILD.bazel
@@ -5,10 +5,12 @@
     name = "GradleResolverTest",
     timeout = "eternal",
     srcs = [
+        "GradleModuleMetadataHelper.java",
         "GradleResolverTest.java",
     ],
     data = [
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/data:gradle_build_templates",
+        "//tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures",
     ],
     env = {
         "RJE_VERBOSE": "true",
@@ -27,6 +29,10 @@
             "junit:junit",
             repository_name = "regression_testing_coursier",
         ),
+        artifact(
+            "com.google.guava:guava",
+            repository_name = "rules_jvm_external_deps",
+        ),
         "@bazel_tools//tools/java/runfiles",
     ],
 )
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleModuleMetadataHelper.java b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleModuleMetadataHelper.java
new file mode 100644
index 0000000..971d3dd
--- /dev/null
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleModuleMetadataHelper.java
@@ -0,0 +1,127 @@
+// Copyright 2025 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver.gradle;
+
+import com.github.bazelbuild.rules_jvm_external.Coordinates;
+import com.github.bazelbuild.rules_jvm_external.resolver.MavenRepo;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Objects;
+import javax.xml.stream.XMLEventFactory;
+import javax.xml.stream.XMLEventReader;
+import javax.xml.stream.XMLEventWriter;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.events.XMLEvent;
+
+/**
+ * Helper class to manage gradle module metadata within ephemeral local maven repos for tests
+ * related to the gradle resolver
+ */
+public class GradleModuleMetadataHelper {
+  private final MavenRepo mavenRepo;
+  private final String GRADLE_POM_MARKER = "do_not_remove: published-with-gradle-metadata";
+
+  public GradleModuleMetadataHelper(MavenRepo mavenRepo) throws IOException {
+    Objects.requireNonNull(mavenRepo);
+    this.mavenRepo = mavenRepo;
+  }
+
+  public void addToMavenRepo(Coordinates coordinates, String moduleMetadata)
+      throws IOException, XMLStreamException {
+    // Add this to the maven repo with a POM file
+    this.mavenRepo.add(coordinates);
+
+    Path dir = this.mavenRepo.getPath().resolve(coordinates.toRepoPath()).getParent();
+
+    Path moduleFile =
+        dir.resolve(coordinates.getArtifactId() + "-" + coordinates.getVersion() + ".module");
+    Files.write(moduleFile, moduleMetadata.getBytes());
+
+    Path pomFile =
+        dir.resolve(coordinates.getArtifactId() + "-" + coordinates.getVersion() + ".pom");
+
+    injectGradleMarkerInPom(pomFile);
+  }
+
+  private void injectGradleMarkerInPom(Path pomPath) throws IOException, XMLStreamException {
+    // To resolve artifacts with gradle metadata, gradle looks for a marker comment in pom.xml
+    // e.g https://repo1.maven.org/maven2/com/squareup/okio/okio/3.6.0/okio-3.6.0.pom
+    // so we insert them for the same reason for the pom.xml in these test cases
+
+    String content = Files.readString(pomPath, StandardCharsets.UTF_8);
+    if (content.contains(GRADLE_POM_MARKER)) {
+      return; // already has marker, nothing to do
+    }
+
+    XMLInputFactory inFactory = XMLInputFactory.newInstance();
+    XMLOutputFactory outFactory = XMLOutputFactory.newInstance();
+    XMLEventFactory eventFactory = XMLEventFactory.newInstance();
+
+    // Use a temp buffer to hold rewritten XML
+    StringWriter buffer = new StringWriter();
+    try (Reader in = Files.newBufferedReader(pomPath, StandardCharsets.UTF_8)) {
+      XMLEventReader r = inFactory.createXMLEventReader(in);
+      XMLEventWriter w = outFactory.createXMLEventWriter(buffer);
+
+      boolean injected = false;
+      while (r.hasNext()) {
+        XMLEvent e = r.nextEvent();
+        w.add(e);
+
+        if (!injected
+            && e.isStartElement()
+            && e.asStartElement().getName().getLocalPart().equals("project")) {
+          w.add(eventFactory.createCharacters("\n"));
+          w.add(
+              eventFactory.createComment(
+                  "  This module was also published with a richer model, Gradle metadata,   "));
+          w.add(eventFactory.createCharacters("\n"));
+          w.add(
+              eventFactory.createComment(
+                  "  which should be used instead. Do not delete the following line which   "));
+          w.add(eventFactory.createCharacters("\n"));
+          w.add(
+              eventFactory.createComment(
+                  "  is to indicate to Gradle or any Gradle module metadata file consumer   "));
+          w.add(eventFactory.createCharacters("\n"));
+          w.add(
+              eventFactory.createComment(
+                  "  that they should prefer consuming it instead.                          "));
+          w.add(eventFactory.createCharacters("\n"));
+          w.add(
+              eventFactory.createComment("  " + GRADLE_POM_MARKER + "                           "));
+          w.add(eventFactory.createCharacters("\n"));
+          injected = true;
+        }
+      }
+      w.close();
+      r.close();
+    }
+
+    Files.writeString(
+        pomPath,
+        buffer.toString(),
+        StandardCharsets.UTF_8,
+        StandardOpenOption.TRUNCATE_EXISTING,
+        StandardOpenOption.WRITE);
+  }
+}
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolverTest.java b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolverTest.java
index ec741c7..783cb3e 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolverTest.java
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/GradleResolverTest.java
@@ -14,16 +14,119 @@
 
 package com.github.bazelbuild.rules_jvm_external.resolver.gradle;
 
+import static org.junit.Assert.assertEquals;
+
+import com.github.bazelbuild.rules_jvm_external.Coordinates;
+import com.github.bazelbuild.rules_jvm_external.resolver.MavenRepo;
 import com.github.bazelbuild.rules_jvm_external.resolver.Resolver;
 import com.github.bazelbuild.rules_jvm_external.resolver.ResolverTestBase;
 import com.github.bazelbuild.rules_jvm_external.resolver.cmd.ResolverConfig;
 import com.github.bazelbuild.rules_jvm_external.resolver.events.EventListener;
 import com.github.bazelbuild.rules_jvm_external.resolver.netrc.Netrc;
+import com.google.common.graph.Graph;
+import com.google.devtools.build.runfiles.AutoBazelRepository;
+import com.google.devtools.build.runfiles.Runfiles;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Set;
+import javax.xml.stream.XMLStreamException;
+import org.junit.Test;
 
+@AutoBazelRepository
 public class GradleResolverTest extends ResolverTestBase {
 
   @Override
   protected Resolver getResolver(Netrc netrc, EventListener listener) {
     return new GradleResolver(netrc, ResolverConfig.DEFAULT_MAX_THREADS, listener);
   }
+
+  @Test
+  public void resolvesSimpleJvmVariant() throws IOException, XMLStreamException {
+    // This test validates gradle can resolve a artifact using only gradle module metadata
+    // In this case, there's a root artifact com.example.sample which points to
+    // com.example.sample-jvm, which satisfies the runtimeClasspath configuration
+    // as it has the JVM variant attributes by default.
+    Coordinates baseCoordinates = new Coordinates("com.example:sample:1.0");
+    Coordinates jvmCoordinates = new Coordinates("com.example:sample-jvm:1.0");
+    MavenRepo mavenRepo = MavenRepo.create();
+    GradleModuleMetadataHelper moduleMetadataHelper = new GradleModuleMetadataHelper(mavenRepo);
+
+    Runfiles runfiles =
+        Runfiles.preload().withSourceRepository(AutoBazelRepository_GradleResolverTest.NAME);
+    Path baseMetadataPath =
+        Paths.get(
+            runfiles.rlocation(
+                "rules_jvm_external/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/simpleJvmVariant/sample-1.0.module"));
+    String baseMetadata = Files.readString(baseMetadataPath);
+    moduleMetadataHelper.addToMavenRepo(baseCoordinates, baseMetadata);
+
+    Path jvmMetadataPath =
+        Paths.get(
+            runfiles.rlocation(
+                "rules_jvm_external/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/simpleJvmVariant/sample-jvm-1.0.module"));
+    String jvmMetadata = Files.readString(jvmMetadataPath);
+    moduleMetadataHelper.addToMavenRepo(jvmCoordinates, jvmMetadata);
+
+    Graph<Coordinates> resolved =
+        resolver
+            .resolve(prepareRequestFor(mavenRepo.getPath().toUri(), baseCoordinates))
+            .getResolution();
+
+    assertEquals(2, resolved.nodes().size());
+    // sample-jvm resolves indirectly through sample using the gradle module metadata redirect
+    assertEquals(Set.of(baseCoordinates, jvmCoordinates), resolved.nodes());
+  }
+
+  @Test
+  public void resolvesJvmButNotAndroidVariant() throws IOException, XMLStreamException {
+    // This test validates a scenario similar to
+    // https://repo1.maven.org/maven2/com/squareup/okhttp3/okhttp/5.1.0/okhttp-5.1.0.module
+    // which supports 2 different coordinates with one base coordinate - one for JVM and android
+    // using variant selection.
+    // Right now, we only resolve the default runtime classpath configuration, so we'll only resolve
+    // the JVM variant
+    // and won't have the android variant
+    Coordinates baseCoordinates = new Coordinates("com.example:sample:1.0");
+    Coordinates jvmCoordinates = new Coordinates("com.example:sample-jvm:1.0");
+    Coordinates androidCoordinates = new Coordinates("com.example:sample-android:1.0");
+    MavenRepo mavenRepo = MavenRepo.create();
+    GradleModuleMetadataHelper moduleMetadataHelper = new GradleModuleMetadataHelper(mavenRepo);
+
+    Runfiles runfiles =
+        Runfiles.preload().withSourceRepository(AutoBazelRepository_GradleResolverTest.NAME);
+    Path baseMetadataPath =
+        Paths.get(
+            runfiles.rlocation(
+                "rules_jvm_external/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-1.0.module"));
+    String baseMetadata = Files.readString(baseMetadataPath);
+    moduleMetadataHelper.addToMavenRepo(baseCoordinates, baseMetadata);
+
+    Path jvmMetadataPath =
+        Paths.get(
+            runfiles.rlocation(
+                "rules_jvm_external/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-jvm-1.0.module"));
+    String jvmMetadata = Files.readString(jvmMetadataPath);
+    moduleMetadataHelper.addToMavenRepo(jvmCoordinates, jvmMetadata);
+
+    Path androidMetadataPath =
+        Paths.get(
+            runfiles.rlocation(
+                "rules_jvm_external/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-android-1.0.module"));
+    String androidMetadata = Files.readString(androidMetadataPath);
+    moduleMetadataHelper.addToMavenRepo(androidCoordinates, androidMetadata);
+
+    Graph<Coordinates> resolved =
+        resolver
+            .resolve(prepareRequestFor(mavenRepo.getPath().toUri(), baseCoordinates))
+            .getResolution();
+
+    // sample-jvm resolves indirectly through sample using the gradle module metadata redirect
+    // but not sample-android as we don't resolve multiple variants currently.
+    assertEquals(2, resolved.nodes().size());
+    // Once we support resolving android variant, this test should be updated to ensure
+    // sample-android is also resolved
+    assertEquals(Set.of(baseCoordinates, jvmCoordinates), resolved.nodes());
+  }
 }
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/BUILD.bazel b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/BUILD.bazel
new file mode 100644
index 0000000..e6b201f
--- /dev/null
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/BUILD.bazel
@@ -0,0 +1,11 @@
+filegroup(
+    name = "fixtures",
+    srcs = [
+        "jvmAndAndroidVariants/sample-1.0.module",
+        "jvmAndAndroidVariants/sample-android-1.0.module",
+        "jvmAndAndroidVariants/sample-jvm-1.0.module",
+        "simpleJvmVariant/sample-1.0.module",
+        "simpleJvmVariant/sample-jvm-1.0.module",
+    ],
+    visibility = ["//tests/com/github/bazelbuild/rules_jvm_external:__subpackages__"],
+)
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-1.0.module b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-1.0.module
new file mode 100644
index 0000000..459f61c
--- /dev/null
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-1.0.module
@@ -0,0 +1,52 @@
+{
+  "formatVersion": "1.1",
+  "component": {
+    "group": "com.example",
+    "module": "sample",
+    "version": "1.0",
+    "attributes": { "org.gradle.status": "release" }
+  },
+  "variants": [
+    {
+      "name": "commonMainMetadataElements",
+      "attributes": {
+        "org.gradle.category": "library",
+        "org.gradle.usage": "kotlin-metadata",
+        "org.jetbrains.kotlin.platform.type": "common"
+      },
+      "files": [
+        { "name": "sample-1.0-metadata.jar", "url": "sample-1.0-metadata.jar" }
+      ]
+    },
+    {
+      "name": "jvmRuntimeElements",
+      "attributes": {
+        "org.gradle.category": "library",
+        "org.gradle.usage": "java-runtime",
+        "org.jetbrains.kotlin.platform.type": "jvm",
+        "org.gradle.libraryelements": "jar"
+      },
+      "available-at": {
+        "group": "com.example",
+        "module": "sample-jvm",
+        "version": "1.0",
+        "url": "../../sample-jvm/1.0/sample-jvm-1.0.module"
+      }
+    },
+    {
+      "name": "androidReleaseRuntimeElements",
+      "attributes": {
+        "org.gradle.category": "library",
+        "org.gradle.usage": "java-runtime",
+        "org.jetbrains.kotlin.platform.type": "androidJvm",
+        "org.gradle.libraryelements": "aar"
+      },
+      "available-at": {
+        "group": "com.example",
+        "module": "sample-android",
+        "version": "1.0",
+        "url": "../../sample-android/1.0/sample-android-1.0.module"
+      }
+    }
+  ]
+}
\ No newline at end of file
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-android-1.0.module b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-android-1.0.module
new file mode 100644
index 0000000..2127966
--- /dev/null
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-android-1.0.module
@@ -0,0 +1,24 @@
+{
+  "formatVersion": "1.1",
+  "component": {
+    "group": "com.example",
+    "module": "sample-android",
+    "version": "1.0",
+    "attributes": { "org.gradle.status": "release" }
+  },
+  "variants": [
+    {
+      "name": "releaseRuntimeElements",
+      "attributes": {
+        "org.gradle.category": "library",
+        "org.gradle.usage": "java-runtime",
+        "org.gradle.libraryelements": "aar",
+        "org.gradle.dependency.bundling": "external",
+        "org.jetbrains.kotlin.platform.type": "androidJvm"
+      },
+      "files": [
+        { "name": "sample-android-1.0.aar", "url": "sample-android-1.0.aar" }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-jvm-1.0.module b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-jvm-1.0.module
new file mode 100644
index 0000000..fe7a290
--- /dev/null
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/jvmAndAndroidVariants/sample-jvm-1.0.module
@@ -0,0 +1,37 @@
+{
+  "formatVersion": "1.1",
+  "component": {
+    "group": "com.example",
+    "module": "sample-jvm",
+    "version": "1.0",
+    "attributes": { "org.gradle.status": "release" }
+  },
+  "variants": [
+    {
+      "name": "apiElements",
+      "attributes": {
+        "org.gradle.category": "library",
+        "org.gradle.usage": "java-api",
+        "org.gradle.libraryelements": "jar",
+        "org.jetbrains.kotlin.platform.type": "jvm"
+      },
+      "files": [
+        { "name": "sample-jvm-1.0.jar", "url": "sample-jvm-1.0.jar" }
+      ]
+    },
+    {
+      "name": "runtimeElements",
+      "attributes": {
+        "org.gradle.category": "library",
+        "org.gradle.usage": "java-runtime",
+        "org.gradle.libraryelements": "jar",
+        "org.gradle.dependency.bundling": "external",
+        "org.gradle.jvm.version": 11,
+        "org.jetbrains.kotlin.platform.type": "jvm"
+      },
+      "files": [
+        { "name": "sample-jvm-1.0.jar", "url": "sample-jvm-1.0.jar" }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/simpleJvmVariant/sample-1.0.module b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/simpleJvmVariant/sample-1.0.module
new file mode 100644
index 0000000..387a96c
--- /dev/null
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/simpleJvmVariant/sample-1.0.module
@@ -0,0 +1,26 @@
+{
+  "formatVersion": "1.1",
+  "component": {
+    "group": "com.example",
+    "module": "sample",
+    "version": "1.0",
+    "attributes": { "org.gradle.status": "release" }
+  },
+  "variants": [
+    {
+      "name": "jvmRuntimeElements",
+      "attributes": {
+       "org.gradle.category": "library",
+       "org.gradle.libraryelements": "jar",
+       "org.gradle.usage": "java-runtime",
+       "org.jetbrains.kotlin.platform.type": "jvm"
+      },
+      "available-at": {
+        "group": "com.example",
+        "module": "sample-jvm",
+        "version": "1.0",
+        "url": "../../sample-jvm/1.0/sample-jvm-1.0.module"
+      }
+    }
+  ]
+}
\ No newline at end of file
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/simpleJvmVariant/sample-jvm-1.0.module b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/simpleJvmVariant/sample-jvm-1.0.module
new file mode 100644
index 0000000..4dbb50d
--- /dev/null
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/gradle/fixtures/simpleJvmVariant/sample-jvm-1.0.module
@@ -0,0 +1,17 @@
+{
+  "formatVersion": "1.1",
+  "component": { "group": "com.example", "module": "sample-jvm", "version": "1.0",
+    "attributes": { "org.gradle.status": "release" } },
+  "variants": [
+    {
+      "name": "runtimeElements",
+      "attributes": {
+        "org.gradle.category": "library",
+        "org.gradle.libraryelements": "jar",
+        "org.gradle.usage": "java-runtime",
+        "org.jetbrains.kotlin.platform.type": "jvm"
+      },
+      "files": [{ "name": "sample-jvm-1.0.jar", "url": "sample-jvm-1.0.jar" }]
+    }
+  ]
+}