Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/Aspire.Cli/NuGet/NuGetClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ internal sealed class NuGetClient(
= static (workingDirectory, machineWideSettings) =>
Settings.LoadDefaultSettings(workingDirectory, configFileName: null, machineWideSettings);

/// <summary>
/// Creates the repository used to query a package source, allowing tests to supply in-memory protocol resources.
/// </summary>
internal Func<PackageSource, SourceRepository> SourceRepositoryFactory { get; init; }
= static source => Repository.Factory.GetCoreV3(source);

public Task RestoreAsync(
IReadOnlyList<(string Id, string Version)> packages,
string framework,
Expand Down Expand Up @@ -680,7 +686,7 @@ private async Task<PackageSearchResult<NuGetSearchResult>> SearchSourceAsync(
INuGetLogger nuGetLogger,
CancellationToken cancellationToken)
{
var repository = Repository.Factory.GetCoreV3(source);
var repository = SourceRepositoryFactory(source);
var searchResource = await repository.GetResourceAsync<PackageSearchResource>(cancellationToken).ConfigureAwait(false);
if (searchResource is null)
{
Expand Down
75 changes: 59 additions & 16 deletions tests/Aspire.Cli.Tests/NuGet/NuGetClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
using NuGet.Configuration;
using NuGet.ProjectModel;
using NuGet.Packaging;
using NuGet.Packaging.Core;
using NuGet.Protocol;
using NuGet.Protocol.Core.Types;
using NuGet.Versioning;

namespace Aspire.Cli.Tests.NuGet;

Expand All @@ -26,39 +29,79 @@ public class NuGetClientTests(ITestOutputHelper outputHelper)
[InlineData(4001, true)]
public async Task SearchAsync_BoundsPaginationAndReportsWhenResultsMayBeIncomplete(int packageCount, bool expectLimitReached)
{
// Serve the pages from memory: a local folder feed re-reads every .nupkg for each page, which made
// this test take several minutes per case on macOS and pushed Aspire.Cli.Tests past its CI timeout.
// Real local-feed pagination is covered by SearchAsync_ReturnsAllRequestedPages.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feed = workspace.CreateDirectory("feed");
var ids = Enumerable.Range(0, packageCount).Select(index => $"Paging.Package{index:D4}").ToArray();
var expectedIds = ids.ToHashSet(StringComparer.Ordinal);
foreach (var id in ids)
{
NuGetTestHelper.CreatePackage(feed, id, "1.0.0", new Dictionary<string, string>());
}

var searchResource = new PagedPackageSearchResource(ids);
var client = new NuGetClient(new TestFeatures(), new TestEnvironment(), NullLogger<NuGetClient>.Instance)
{
AmbientSettingsLoader = NuGetTestHelper.LoadSettings,
MachineWideSettingsFactory = () => new TestMachineWideSettings(NullSettings.Instance)
MachineWideSettingsFactory = () => new TestMachineWideSettings(NullSettings.Instance),
SourceRepositoryFactory = source => Repository.CreateSource(
[new Lazy<INuGetResourceProvider>(() => new PagedPackageSearchResourceProvider(searchResource))],
source)
};

foreach (var (take, expectedLimitReached) in new[] { (3500, false), (4000, false), (int.MaxValue, expectLimitReached) })
{
searchResource.Requests.Clear();
var results = await client.SearchAsync(
"Paging.Package", prerelease: true, take,
explicitSources: [feed.FullName], nugetConfigPath: null,
explicitSources: ["https://paging.example/v3/index.json"], nugetConfigPath: null,
workingDirectory: workspace.Path, TestContext.Current.CancellationToken);

// Local feeds limit results before the client sorts them, so the bounded subset
// is not guaranteed to contain the first IDs in lexicographic order.
var packageIds = results.Packages.Select(package => package.Id).ToArray();
Assert.Equal(Math.Min(packageCount, Math.Min(take, 4000)), packageIds.Length);
Assert.Distinct(packageIds);
Assert.All(packageIds, packageId => Assert.Contains(packageId, expectedIds));
Assert.Equal(packageIds.Order(), packageIds);
var expectedCount = Math.Min(packageCount, Math.Min(take, NuGetSearchLimits.MaxResults));
Assert.Equal(ids.Take(expectedCount), results.Packages.Select(package => package.Id));
Assert.Equal(expectedLimitReached, results.LimitReached);
Assert.Empty(results.FailedSources);
Assert.All(searchResource.Requests, request =>
{
Assert.InRange(request.Skip, 0, NuGetSearchLimits.MaxSkip);
Assert.InRange(request.Take, 1, NuGetSearchLimits.PageSize);
});
}
}

private sealed class PagedPackageSearchResource(IReadOnlyList<string> ids) : PackageSearchResource
{
public List<(int Skip, int Take)> Requests { get; } = [];

public override bool SupportsPackageTypeFiltering => false;

public override Task<IEnumerable<IPackageSearchMetadata>> SearchAsync(
string searchTerm,
global::NuGet.Protocol.Core.Types.SearchFilter filters,
int skip,
int take,
global::NuGet.Common.ILogger log,
CancellationToken cancellationToken)
{
lock (Requests)
{
Requests.Add((skip, take));
}

var page = ids
.Where(id => id.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
.Skip(skip)
.Take(take)
.Select(id => PackageSearchMetadataBuilder
.FromIdentity(new PackageIdentity(id, NuGetVersion.Parse("1.0.0")))
.Build())
.ToArray();
return Task.FromResult<IEnumerable<IPackageSearchMetadata>>(page);
}
}

private sealed class PagedPackageSearchResourceProvider(PackageSearchResource resource)
: ResourceProvider(typeof(PackageSearchResource))
{
public override Task<Tuple<bool, INuGetResource?>> TryCreate(SourceRepository source, CancellationToken token)
=> Task.FromResult(new Tuple<bool, INuGetResource?>(true, resource));
}

[Fact]
public async Task SearchAsync_ReturnsAllRequestedPages()
{
Expand Down
Loading