Skip to content

Commit e37915d

Browse files
committed
Add SearchSideBySideService to do the two searches (#7176)
Progress on #7152
1 parent 04a844f commit e37915d

6 files changed

Lines changed: 350 additions & 0 deletions

File tree

src/NuGetGallery/App_Start/DefaultDependenciesModule.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,12 +744,23 @@ private static void ConfigureSearch(
744744
builder.RegisterType<LuceneSearchService>()
745745
.AsSelf()
746746
.As<ISearchService>()
747+
.Keyed<ISearchService>(BindingKeys.PreviewSearchClient)
747748
.InstancePerLifetimeScope();
748749
builder.RegisterType<LuceneIndexingService>()
749750
.AsSelf()
750751
.As<IIndexingService>()
751752
.InstancePerLifetimeScope();
752753
}
754+
755+
builder
756+
.Register(c => new SearchSideBySideService(
757+
c.Resolve<ISearchService>(),
758+
c.ResolveKeyed<ISearchService>(BindingKeys.PreviewSearchClient),
759+
c.Resolve<ITelemetryService>(),
760+
c.Resolve<IMessageService>(),
761+
c.Resolve<IMessageServiceConfiguration>()))
762+
.As<ISearchSideBySideService>()
763+
.InstancePerLifetimeScope();
753764
}
754765

755766
private static void RegisterSearchService(

src/NuGetGallery/NuGetGallery.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@
502502
<Compile Include="Services\IFeatureFlagService.cs" />
503503
<Compile Include="Services\InvalidLicenseUrlValidationMessage.cs" />
504504
<Compile Include="Services\InvalidUrlEncodingForLicenseUrlValidationMessage.cs" />
505+
<Compile Include="Services\ISearchSideBySideService.cs" />
505506
<Compile Include="Services\ISymbolPackageUploadService.cs" />
506507
<Compile Include="Services\ISymbolPackageFileService.cs" />
507508
<Compile Include="Services\ITyposquattingCheckListCacheService.cs" />
@@ -514,6 +515,7 @@
514515
<Compile Include="Services\PackageDeprecationService.cs" />
515516
<Compile Include="Services\PackageShouldNotBeSignedUserFixableValidationMessage.cs" />
516517
<Compile Include="Services\PlainTextOnlyValidationMessage.cs" />
518+
<Compile Include="Services\SearchSideBySideService.cs" />
517519
<Compile Include="Services\SymbolPackageValidationResult.cs" />
518520
<Compile Include="Services\FlatContainerContentFileMetadataService.cs" />
519521
<Compile Include="Infrastructure\Mail\MarkdownMessageService.cs" />
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Threading.Tasks;
5+
using NuGet.Services.Entities;
6+
7+
namespace NuGetGallery
8+
{
9+
/// <summary>
10+
/// A service which two search queries in parallel and return the results for the purposes of side-by-side
11+
/// comparison.
12+
/// </summary>
13+
public interface ISearchSideBySideService
14+
{
15+
Task<SearchSideBySideViewModel> SearchAsync(string searchTerm, User currentUser);
16+
Task RecordFeedbackAsync(SearchSideBySideViewModel viewModel, string searchUrl);
17+
}
18+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
using NuGet.Services.Entities;
8+
using NuGet.Services.Messaging.Email;
9+
using NuGetGallery.Infrastructure.Mail.Messages;
10+
using NuGetGallery.OData;
11+
12+
namespace NuGetGallery
13+
{
14+
public class SearchSideBySideService : ISearchSideBySideService
15+
{
16+
private readonly ISearchService _oldSearchService;
17+
private readonly ISearchService _newSearchService;
18+
private readonly ITelemetryService _telemetryService;
19+
private readonly IMessageService _messageService;
20+
private readonly IMessageServiceConfiguration _messageServiceConfiguration;
21+
22+
public SearchSideBySideService(
23+
ISearchService oldSearchService,
24+
ISearchService newSearchService,
25+
ITelemetryService telemetryService,
26+
IMessageService messageService,
27+
IMessageServiceConfiguration messageServiceConfiguration)
28+
{
29+
_oldSearchService = oldSearchService ?? throw new ArgumentNullException(nameof(oldSearchService));
30+
_newSearchService = newSearchService ?? throw new ArgumentNullException(nameof(newSearchService));
31+
_telemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService));
32+
_messageService = messageService ?? throw new ArgumentNullException(nameof(messageService));
33+
_messageServiceConfiguration = messageServiceConfiguration ?? throw new ArgumentNullException(nameof(messageServiceConfiguration));
34+
}
35+
36+
public async Task<SearchSideBySideViewModel> SearchAsync(string searchTerm, User currentUser)
37+
{
38+
SearchSideBySideViewModel viewModel;
39+
if (!string.IsNullOrWhiteSpace(searchTerm))
40+
{
41+
searchTerm = searchTerm.Trim();
42+
43+
var oldTask = SearchAsync(_oldSearchService, searchTerm);
44+
var newTask = SearchAsync(_newSearchService, searchTerm);
45+
46+
await Task.WhenAll(oldTask, newTask);
47+
48+
var oldResults = await oldTask;
49+
var newResults = await newTask;
50+
51+
viewModel = new SearchSideBySideViewModel
52+
{
53+
SearchTerm = searchTerm,
54+
OldSuccess = SearchResults.IsSuccessful(oldResults),
55+
OldHits = oldResults.Hits,
56+
OldItems = oldResults.Data.Select(x => new ListPackageItemViewModel(x, currentUser)).ToList(),
57+
NewSuccess = SearchResults.IsSuccessful(newResults),
58+
NewHits = newResults.Hits,
59+
NewItems = newResults.Data.Select(x => new ListPackageItemViewModel(x, currentUser)).ToList(),
60+
};
61+
62+
_telemetryService.TrackSearchSideBySide(
63+
viewModel.SearchTerm,
64+
viewModel.OldSuccess,
65+
viewModel.OldHits,
66+
viewModel.NewSuccess,
67+
viewModel.NewHits);
68+
}
69+
else
70+
{
71+
viewModel = new SearchSideBySideViewModel();
72+
}
73+
74+
viewModel.EmailAddress = currentUser?.EmailAddress;
75+
76+
return viewModel;
77+
}
78+
79+
public async Task RecordFeedbackAsync(SearchSideBySideViewModel viewModel, string searchUrl)
80+
{
81+
_telemetryService.TrackSearchSideBySideFeedback(
82+
Trim(viewModel.SearchTerm),
83+
viewModel.OldHits,
84+
viewModel.NewHits,
85+
Trim(viewModel.BetterSide),
86+
Trim(viewModel.MostRelevantPackage),
87+
Trim(viewModel.ExpectedPackages),
88+
!string.IsNullOrWhiteSpace(viewModel.Comments),
89+
!string.IsNullOrWhiteSpace(viewModel.EmailAddress));
90+
91+
await _messageService.SendMessageAsync(
92+
new SearchSideBySideMessage(_messageServiceConfiguration, viewModel, searchUrl));
93+
}
94+
95+
private static string Trim(string input)
96+
{
97+
return input?.Trim() ?? string.Empty;
98+
}
99+
100+
private async Task<SearchResults> SearchAsync(ISearchService searchService, string searchTerm)
101+
{
102+
await Task.Yield();
103+
104+
var searchFilter = SearchAdaptor.GetSearchFilter(
105+
searchTerm,
106+
page: 1,
107+
includePrerelease: true,
108+
sortOrder: null,
109+
context: SearchFilter.UISearchContext,
110+
semVerLevel: SemVerLevelKey.SemVerLevel2);
111+
112+
searchFilter.Take = 10;
113+
114+
return await searchService.Search(searchFilter);
115+
}
116+
}
117+
}

tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
<Compile Include="Helpers\StreamHelperFacts.cs" />
9191
<Compile Include="Infrastructure\Mail\Messages\SearchSideBySideMessageFacts.cs" />
9292
<Compile Include="Services\PackageDeprecationServiceFacts.cs" />
93+
<Compile Include="Services\SearchSideBySideServiceFacts.cs" />
9394
<Compile Include="Services\StatusServiceFacts.cs" />
9495
<Compile Include="TestData\TestDataResourceUtility.cs" />
9596
<Compile Include="UsernameValidationRegex.cs" />
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
using Moq;
8+
using NuGet.Services.Entities;
9+
using NuGet.Services.Messaging.Email;
10+
using NuGetGallery.Infrastructure.Mail.Messages;
11+
using NuGetGallery.Infrastructure.Search.Models;
12+
using Xunit;
13+
14+
namespace NuGetGallery
15+
{
16+
public class SearchSideBySideServiceFacts
17+
{
18+
public class SearchAsync : Facts
19+
{
20+
[Fact]
21+
public async Task ReturnsNullEmailAddressWithNoCurrentUser()
22+
{
23+
var result = await Target.SearchAsync("json", currentUser: null);
24+
25+
Assert.Null(result.EmailAddress);
26+
}
27+
28+
[Theory]
29+
[InlineData(null)]
30+
[InlineData("")]
31+
[InlineData(" ")]
32+
public async Task ReturnsEmptyModelWithNoSearchTerm(string searchTerm)
33+
{
34+
var result = await Target.SearchAsync(searchTerm, CurrentUser);
35+
36+
Assert.Equal(string.Empty, result.SearchTerm);
37+
Assert.Equal("[email protected]", result.EmailAddress);
38+
39+
OldSearchService.Verify(x => x.Search(It.IsAny<SearchFilter>()), Times.Never);
40+
NewSearchService.Verify(x => x.Search(It.IsAny<SearchFilter>()), Times.Never);
41+
TelemetryService.Verify(
42+
x => x.TrackSearchSideBySide(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<int>()),
43+
Times.Never);
44+
}
45+
46+
[Fact]
47+
public async Task ReturnsSearchResults()
48+
{
49+
var searchTerm = " json ";
50+
51+
var result = await Target.SearchAsync(searchTerm, CurrentUser);
52+
53+
Assert.Equal("json", result.SearchTerm);
54+
Assert.True(result.OldSuccess, "The old search should have succeeded.");
55+
Assert.Equal(OldSearchResults.Hits, result.OldHits);
56+
var oldA = Assert.Single(result.OldItems);
57+
Assert.Equal("1.0.0", oldA.Version);
58+
Assert.True(result.NewSuccess, "The new search should have succeeded.");
59+
Assert.Equal(NewSearchResults.Hits, result.NewHits);
60+
Assert.Equal(2, result.NewItems.Count);
61+
Assert.Equal("2.0.0", result.NewItems[0].Version);
62+
Assert.Equal("3.0.0", result.NewItems[1].Version);
63+
Assert.Equal("[email protected]", result.EmailAddress);
64+
65+
OldSearchService.Verify(x => x.Search(It.IsAny<SearchFilter>()), Times.Once);
66+
NewSearchService.Verify(x => x.Search(It.IsAny<SearchFilter>()), Times.Once);
67+
TelemetryService.Verify(x => x.TrackSearchSideBySide("json", true, 1, true, 2), Times.Once);
68+
69+
AssertSearchFilters(result.SearchTerm);
70+
}
71+
72+
private void AssertSearchFilters(string searchTerm)
73+
{
74+
AssertSearchFilter(OldSearchFilters, searchTerm);
75+
AssertSearchFilter(NewSearchFilters, searchTerm);
76+
}
77+
78+
private void AssertSearchFilter(List<SearchFilter> searchFilters, string searchTerm)
79+
{
80+
var single = Assert.Single(searchFilters);
81+
Assert.Equal(searchTerm, single.SearchTerm);
82+
Assert.True(single.IncludePrerelease);
83+
Assert.Equal(0, single.Skip);
84+
Assert.Equal(10, single.Take);
85+
Assert.Equal(SortOrder.Relevance, single.SortOrder);
86+
Assert.Equal(SemVerLevelKey.SemVerLevel2, single.SemVerLevel);
87+
}
88+
}
89+
90+
public class RecordFeedbackAsync : Facts
91+
{
92+
[Fact]
93+
public async Task RecordsFeedback()
94+
{
95+
var model = new SearchSideBySideViewModel
96+
{
97+
SearchTerm = " json ",
98+
OldHits = 23,
99+
NewHits = 42,
100+
BetterSide = " new ",
101+
MostRelevantPackage = " NuGet.Core ",
102+
ExpectedPackages = " NuGet.Packaging, NuGet.Versioning ",
103+
Comments = " comments ",
104+
EmailAddress = " [email protected] ",
105+
};
106+
var searchUrl = "https://localhost/experiments/search-sxs?q=json";
107+
108+
await Target.RecordFeedbackAsync(model, searchUrl);
109+
110+
TelemetryService.Verify(
111+
x => x.TrackSearchSideBySideFeedback(
112+
"json",
113+
23,
114+
42,
115+
"new",
116+
"NuGet.Core",
117+
"NuGet.Packaging, NuGet.Versioning",
118+
true,
119+
true),
120+
Times.Once);
121+
MessageService.Verify(
122+
x => x.SendMessageAsync(It.IsAny<SearchSideBySideMessage>(), false, false),
123+
Times.Once);
124+
}
125+
}
126+
127+
public abstract class Facts
128+
{
129+
public Facts()
130+
{
131+
OldSearchService = new Mock<ISearchService>();
132+
NewSearchService = new Mock<ISearchService>();
133+
TelemetryService = new Mock<ITelemetryService>();
134+
MessageService = new Mock<IMessageService>();
135+
MessageServiceConfiguration = new Mock<IMessageServiceConfiguration>();
136+
137+
CurrentUser = new User
138+
{
139+
EmailAddress = "[email protected]",
140+
};
141+
OldSearchFilters = new List<SearchFilter>();
142+
OldSearchResults = new SearchResults(
143+
hits: 1,
144+
indexTimestampUtc: null,
145+
data: new[]
146+
{
147+
new Package
148+
{
149+
Version = "1.0.0",
150+
PackageRegistration = new PackageRegistration(),
151+
},
152+
}.AsQueryable());
153+
NewSearchFilters = new List<SearchFilter>();
154+
NewSearchResults = new SearchResults(
155+
hits: 2,
156+
indexTimestampUtc: null,
157+
data: new[]
158+
{
159+
new Package
160+
{
161+
Version = "2.0.0",
162+
PackageRegistration = new PackageRegistration(),
163+
},
164+
new Package
165+
{
166+
Version = "3.0.0",
167+
PackageRegistration = new PackageRegistration(),
168+
},
169+
}.AsQueryable());
170+
171+
OldSearchService
172+
.Setup(x => x.Search(It.IsAny<SearchFilter>()))
173+
.ReturnsAsync(() => OldSearchResults)
174+
.Callback<SearchFilter>(x => OldSearchFilters.Add(x));
175+
NewSearchService
176+
.Setup(x => x.Search(It.IsAny<SearchFilter>()))
177+
.ReturnsAsync(() => NewSearchResults)
178+
.Callback<SearchFilter>(x => NewSearchFilters.Add(x));
179+
180+
Target = new SearchSideBySideService(
181+
OldSearchService.Object,
182+
NewSearchService.Object,
183+
TelemetryService.Object,
184+
MessageService.Object,
185+
MessageServiceConfiguration.Object);
186+
}
187+
188+
public Mock<ISearchService> OldSearchService { get; }
189+
public Mock<ISearchService> NewSearchService { get; }
190+
public Mock<ITelemetryService> TelemetryService { get; }
191+
public Mock<IMessageService> MessageService { get; }
192+
public Mock<IMessageServiceConfiguration> MessageServiceConfiguration { get; }
193+
public User CurrentUser { get; }
194+
public List<SearchFilter> OldSearchFilters { get; }
195+
public SearchResults OldSearchResults { get; }
196+
public List<SearchFilter> NewSearchFilters { get; }
197+
public SearchResults NewSearchResults { get; }
198+
public SearchSideBySideService Target { get; }
199+
}
200+
}
201+
}

0 commit comments

Comments
 (0)