DefaultArtifactResolverTest.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.maven.impl;

import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;

import org.apache.maven.api.ArtifactCoordinates;
import org.apache.maven.api.DownloadedArtifact;
import org.apache.maven.api.Repository;
import org.apache.maven.api.services.ArtifactResolverRequest;
import org.apache.maven.api.services.ArtifactResolverResult;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.eclipse.aether.repository.ArtifactRepository;
import org.eclipse.aether.repository.LocalRepository;
import org.eclipse.aether.resolution.ArtifactRequest;
import org.eclipse.aether.resolution.ArtifactResult;
import org.eclipse.aether.transfer.ArtifactNotFoundException;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
 * Tests for {@link DefaultArtifactResolver}, specifically the {@code toResult()} conversion
 * that maps Aether's {@link ArtifactResult} to the Maven API's {@link ArtifactResolverResult}.
 */
class DefaultArtifactResolverTest {

    @SuppressWarnings("deprecation")
    private LocalRepository newLocalRepository(String basedir) {
        return new LocalRepository(basedir);
    }

    /**
     * Verifies that {@link ArtifactResult#NO_REPOSITORY} entries in the mapped exceptions
     * do not leak into the Maven API as {@code null} keys. This is the root cause of
     * <a href="https://github.com/apache/maven/issues/12531">#12531</a>: plugins that walk
     * the dependency tree threw {@code IllegalArgumentException} because
     * {@code AbstractSession.getRepository()} did not handle the {@code NoRepository} sentinel.
     */
    @Test
    void toResultFiltersNoRepositoryFromMappedExceptions() {
        // Set up mocks ��� InternalSession mock IS-A Session, so InternalSession.from() cast works
        InternalSession session = mock(InternalSession.class);
        ArtifactResolverRequest request = mock(ArtifactResolverRequest.class);
        when(request.getSession()).thenReturn(session);

        DefaultArtifact aetherArtifact = new DefaultArtifact("g:a:1.0");
        ArtifactCoordinates coordinates = mock(ArtifactCoordinates.class);
        org.apache.maven.api.Artifact mavenArtifact = mock(org.apache.maven.api.Artifact.class);
        when(mavenArtifact.toCoordinates()).thenReturn(coordinates);
        doReturn(mavenArtifact).when(session).getArtifact(any(org.eclipse.aether.artifact.Artifact.class));

        // NO_REPOSITORY should map to Optional.empty()
        doReturn(Optional.empty()).when(session).getRepository(eq(ArtifactResult.NO_REPOSITORY));

        // A real local repository should map to a proper Repository
        LocalRepository localRepo = newLocalRepository("/tmp/repo");
        Repository mavenLocalRepo = mock(Repository.class);
        doReturn(Optional.of(mavenLocalRepo)).when(session).getRepository(eq((ArtifactRepository) localRepo));

        // Create an ArtifactResult with exceptions under both NO_REPOSITORY and a real repository
        ArtifactRequest artRequest = new ArtifactRequest();
        artRequest.setArtifact(aetherArtifact);
        ArtifactResult aetherResult = new ArtifactResult(artRequest);
        aetherResult.addException(
                ArtifactResult.NO_REPOSITORY, new ArtifactNotFoundException(aetherArtifact, (String) null));
        aetherResult.addException(localRepo, new ArtifactNotFoundException(aetherArtifact, (String) null));

        // Convert
        DefaultArtifactResolver resolver = new DefaultArtifactResolver();
        DefaultArtifactResolver.ResolverResult resolverResult =
                new DefaultArtifactResolver.ResolverResult(null, aetherResult);
        ArtifactResolverResult result = resolver.toResult(request, Stream.of(resolverResult));

        // Verify the result
        ArtifactResolverResult.ResultItem item = result.getResult(coordinates);

        // The exceptions map should NOT contain a null key (Map.copyOf() guarantees this ���
        // containsKey(null) throws NPE on immutable maps, so verify via keySet instead)
        Map<Repository, List<Exception>> exceptions = item.getExceptions();
        assertTrue(
                exceptions.keySet().stream().noneMatch(k -> k == null),
                "Exceptions map should not contain null key from NO_REPOSITORY");

        // The real repository's exceptions should still be present
        assertTrue(exceptions.containsKey(mavenLocalRepo), "Exceptions map should contain the real repository");
        assertEquals(1, exceptions.get(mavenLocalRepo).size());

        // isMissing() should still return true (it considers ALL exceptions, including NO_REPOSITORY ones)
        assertTrue(item.isMissing(), "isMissing() should consider exceptions from NO_REPOSITORY");
        assertFalse(item.isResolved());
        assertNull(item.getRepository());
    }

    /**
     * Verifies that {@code isMissing()} returns {@code false} when a NO_REPOSITORY exception
     * is NOT an {@link ArtifactNotFoundException}. Even though the NO_REPOSITORY exception
     * is filtered from the mapped exceptions map, it must still be checked by {@code isMissing()}.
     */
    @Test
    void isMissingReturnsFalseForNonNotFoundExceptionUnderNoRepository() {
        InternalSession session = mock(InternalSession.class);
        ArtifactResolverRequest request = mock(ArtifactResolverRequest.class);
        when(request.getSession()).thenReturn(session);

        DefaultArtifact aetherArtifact = new DefaultArtifact("g:a:1.0");
        ArtifactCoordinates coordinates = mock(ArtifactCoordinates.class);
        org.apache.maven.api.Artifact mavenArtifact = mock(org.apache.maven.api.Artifact.class);
        when(mavenArtifact.toCoordinates()).thenReturn(coordinates);
        doReturn(mavenArtifact).when(session).getArtifact(any(org.eclipse.aether.artifact.Artifact.class));
        doReturn(Optional.empty()).when(session).getRepository(eq(ArtifactResult.NO_REPOSITORY));

        // Create an ArtifactResult with a RuntimeException under NO_REPOSITORY
        ArtifactRequest artRequest = new ArtifactRequest();
        artRequest.setArtifact(aetherArtifact);
        ArtifactResult aetherResult = new ArtifactResult(artRequest);
        aetherResult.addException(ArtifactResult.NO_REPOSITORY, new RuntimeException("some error"));

        DefaultArtifactResolver resolver = new DefaultArtifactResolver();
        DefaultArtifactResolver.ResolverResult resolverResult =
                new DefaultArtifactResolver.ResolverResult(null, aetherResult);
        ArtifactResolverResult result = resolver.toResult(request, Stream.of(resolverResult));

        ArtifactResolverResult.ResultItem item = result.getResult(coordinates);

        // The exceptions map should be empty (NO_REPOSITORY filtered out)
        assertTrue(item.getExceptions().isEmpty(), "Exceptions map should be empty after filtering NO_REPOSITORY");

        // isMissing() should return false because the exception is NOT ArtifactNotFoundException
        assertFalse(item.isMissing(), "isMissing() should return false for non-ArtifactNotFoundException");
    }

    /**
     * Verifies that a resolved artifact with NO_REPOSITORY exceptions is properly handled.
     */
    @Test
    void resolvedArtifactWithNoRepositoryExceptions() {
        InternalSession session = mock(InternalSession.class);
        ArtifactResolverRequest request = mock(ArtifactResolverRequest.class);
        when(request.getSession()).thenReturn(session);

        Path artifactPath = Path.of("/tmp/artifact.jar");
        org.eclipse.aether.artifact.Artifact aetherArtifact = new DefaultArtifact("g:a:1.0").setPath(artifactPath);
        ArtifactCoordinates coordinates = mock(ArtifactCoordinates.class);
        org.apache.maven.api.Artifact mavenArtifact = mock(org.apache.maven.api.Artifact.class);
        DownloadedArtifact downloadedArtifact = mock(DownloadedArtifact.class);
        when(mavenArtifact.toCoordinates()).thenReturn(coordinates);
        doReturn(mavenArtifact).when(session).getArtifact(any(org.eclipse.aether.artifact.Artifact.class));
        doReturn(downloadedArtifact).when(session).getArtifact(any(Class.class), any());
        doReturn(Optional.empty()).when(session).getRepository(eq(ArtifactResult.NO_REPOSITORY));

        LocalRepository localRepo = newLocalRepository("/tmp/repo");
        Repository mavenLocalRepo = mock(Repository.class);
        doReturn(Optional.of(mavenLocalRepo)).when(session).getRepository(eq((ArtifactRepository) localRepo));

        ArtifactRequest artRequest = new ArtifactRequest();
        artRequest.setArtifact(new DefaultArtifact("g:a:1.0"));
        ArtifactResult aetherResult = new ArtifactResult(artRequest);
        aetherResult.setArtifact(aetherArtifact);
        aetherResult.setRepository(localRepo);
        // Add an exception under NO_REPOSITORY (can happen even on successful resolution)
        aetherResult.addException(
                ArtifactResult.NO_REPOSITORY, new ArtifactNotFoundException(aetherArtifact, (String) null));

        DefaultArtifactResolver resolver = new DefaultArtifactResolver();
        DefaultArtifactResolver.ResolverResult resolverResult =
                new DefaultArtifactResolver.ResolverResult(null, aetherResult);
        ArtifactResolverResult result = resolver.toResult(request, Stream.of(resolverResult));

        ArtifactResolverResult.ResultItem item = result.getResult(coordinates);

        // Should be resolved (has a path)
        assertTrue(item.isResolved());
        // Should NOT be missing (it's resolved)
        assertFalse(item.isMissing());
        // Exceptions map should not have null keys (verify via keySet ��� immutable map throws NPE on containsKey(null))
        assertTrue(item.getExceptions().keySet().stream().noneMatch(k -> k == null));
    }

    /**
     * Verifies that {@code isMissing()} returns {@code false} when there are no exceptions
     * at all, even if the artifact is unresolved. An empty exception list should not trigger
     * vacuous-truth: "all zero exceptions are ArtifactNotFoundException" must not mean missing.
     */
    @Test
    void isMissingReturnsFalseWhenNoExceptions() {
        InternalSession session = mock(InternalSession.class);
        ArtifactResolverRequest request = mock(ArtifactResolverRequest.class);
        when(request.getSession()).thenReturn(session);

        DefaultArtifact aetherArtifact = new DefaultArtifact("g:a:1.0");
        ArtifactCoordinates coordinates = mock(ArtifactCoordinates.class);
        org.apache.maven.api.Artifact mavenArtifact = mock(org.apache.maven.api.Artifact.class);
        when(mavenArtifact.toCoordinates()).thenReturn(coordinates);
        doReturn(mavenArtifact).when(session).getArtifact(any(org.eclipse.aether.artifact.Artifact.class));

        // Create an ArtifactResult with no exceptions and no resolved artifact
        ArtifactRequest artRequest = new ArtifactRequest();
        artRequest.setArtifact(aetherArtifact);
        ArtifactResult aetherResult = new ArtifactResult(artRequest);
        // No exceptions added, artifact not resolved (no path)

        DefaultArtifactResolver resolver = new DefaultArtifactResolver();
        DefaultArtifactResolver.ResolverResult resolverResult =
                new DefaultArtifactResolver.ResolverResult(null, aetherResult);
        ArtifactResolverResult result = resolver.toResult(request, Stream.of(resolverResult));

        ArtifactResolverResult.ResultItem item = result.getResult(coordinates);

        // No exceptions, not resolved
        assertTrue(item.getExceptions().isEmpty());
        assertFalse(item.isResolved());
        // isMissing() must be false ��� no exceptions means we cannot conclude it's "missing"
        assertFalse(item.isMissing(), "isMissing() must not return true on empty exceptions (vacuous truth)");
    }
}