Skip to content

Commit edeb8bf

Browse files
committed
feat: add generic interface tests and fix collection Contains exception handling
- Added comprehensive generic interface tests for both Tavily and Brave connectors - TavilyTextSearchTests: 3 new tests for ITextSearch<TavilyWebPage> interface - BraveTextSearchTests: 3 new tests for ITextSearch<BraveWebPage> interface - Tests cover SearchAsync, GetSearchResultsAsync, and GetTextSearchResultsAsync methods - Added proper mocking with MultipleHttpMessageHandlerStub and test data - Fixed collection Contains exception handling to prevent graceful degradation - Modified ConvertToLegacyOptions in both connectors to re-throw critical exceptions - Collection Contains patterns now properly throw NotSupportedException instead of degrading - Maintains graceful degradation for other unsupported LINQ patterns - Fixed previously failing CollectionContainsFilterThrowsNotSupportedExceptionAsync test - Enhanced C# 14 MemoryExtensions.Contains compatibility - Improved error messages to cover both C# 13- (Enumerable.Contains) and C# 14+ (MemoryExtensions.Contains) - All collection Contains patterns now properly handled regardless of C# language version Test Results: - Tavily: 30 tests passed (including 3 new generic interface tests) - Brave: 24 tests passed (including 3 new generic interface tests) - All collection Contains exception tests now pass correctly
1 parent f9bbccc commit edeb8bf

4 files changed

Lines changed: 345 additions & 72 deletions

File tree

dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

3+
#pragma warning disable CS8602 // Dereference of a possibly null reference - for LINQ expression properties
4+
35
using System;
46
using System.IO;
57
using System.Linq;
@@ -241,6 +243,151 @@ public void Dispose()
241243
GC.SuppressFinalize(this);
242244
}
243245

246+
#region Generic ITextSearch<BraveWebPage> Interface Tests
247+
248+
[Fact]
249+
public async Task GenericSearchAsyncReturnsResultsSuccessfullyAsync()
250+
{
251+
// Arrange
252+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson));
253+
ITextSearch<BraveWebPage> textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
254+
255+
// Act
256+
var searchOptions = new TextSearchOptions<BraveWebPage>
257+
{
258+
Top = 4,
259+
Skip = 0
260+
};
261+
KernelSearchResults<string> result = await textSearch.SearchAsync("What is the Semantic Kernel?", searchOptions);
262+
263+
// Assert - Verify basic generic interface functionality
264+
Assert.NotNull(result);
265+
Assert.NotNull(result.Results);
266+
var resultList = await result.Results.ToListAsync();
267+
Assert.NotEmpty(resultList);
268+
269+
// Verify the request was made correctly
270+
var requestUris = this._messageHandlerStub.RequestUris;
271+
Assert.Single(requestUris);
272+
Assert.NotNull(requestUris[0]);
273+
Assert.Contains("count=4", requestUris[0].AbsoluteUri);
274+
}
275+
276+
[Fact]
277+
public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync()
278+
{
279+
// Arrange
280+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson));
281+
ITextSearch<BraveWebPage> textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
282+
283+
// Act
284+
var searchOptions = new TextSearchOptions<BraveWebPage>
285+
{
286+
Top = 3,
287+
Skip = 0
288+
};
289+
KernelSearchResults<object> result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions);
290+
291+
// Assert - Verify generic interface returns results
292+
Assert.NotNull(result);
293+
Assert.NotNull(result.Results);
294+
var resultList = await result.Results.ToListAsync();
295+
Assert.NotEmpty(resultList);
296+
// Results are still objects (BraveSearchResult) not BraveWebPage since that's just for filtering
297+
298+
// Verify the request was made correctly
299+
var requestUris = this._messageHandlerStub.RequestUris;
300+
Assert.Single(requestUris);
301+
Assert.NotNull(requestUris[0]);
302+
Assert.Contains("count=3", requestUris[0].AbsoluteUri);
303+
}
304+
305+
[Fact]
306+
public async Task GenericGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync()
307+
{
308+
// Arrange
309+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson));
310+
ITextSearch<BraveWebPage> textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
311+
312+
// Act
313+
var searchOptions = new TextSearchOptions<BraveWebPage>
314+
{
315+
Top = 5,
316+
Skip = 0
317+
};
318+
KernelSearchResults<TextSearchResult> result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", searchOptions);
319+
320+
// Assert - Verify generic interface returns TextSearchResult objects
321+
Assert.NotNull(result);
322+
Assert.NotNull(result.Results);
323+
var resultList = await result.Results.ToListAsync();
324+
Assert.NotEmpty(resultList);
325+
Assert.All(resultList, item => Assert.IsType<TextSearchResult>(item));
326+
327+
// Verify the request was made correctly
328+
var requestUris = this._messageHandlerStub.RequestUris;
329+
Assert.Single(requestUris);
330+
Assert.NotNull(requestUris[0]);
331+
Assert.Contains("count=5", requestUris[0].AbsoluteUri);
332+
}
333+
334+
[Fact]
335+
public async Task CollectionContainsFilterThrowsNotSupportedExceptionAsync()
336+
{
337+
// Arrange - Tests both Enumerable.Contains (C# 13-) and MemoryExtensions.Contains (C# 14+)
338+
// The same code array.Contains() resolves differently based on C# language version:
339+
// - C# 13 and earlier: Enumerable.Contains (LINQ extension method)
340+
// - C# 14 and later: MemoryExtensions.Contains (span-based optimization due to "first-class spans")
341+
// Our implementation handles both identically since Brave API has limited query operators
342+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson));
343+
ITextSearch<BraveWebPage> textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
344+
string[] sites = ["microsoft.com", "github.com"];
345+
346+
// Act & Assert - Verify that collection Contains pattern throws clear exception
347+
var searchOptions = new TextSearchOptions<BraveWebPage>
348+
{
349+
Top = 5,
350+
Skip = 0,
351+
Filter = page => sites.Contains(page.Url!.ToString()) // Enumerable.Contains (C# 13-) or MemoryExtensions.Contains (C# 14+)
352+
};
353+
354+
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () =>
355+
{
356+
await textSearch.SearchAsync("test", searchOptions);
357+
});
358+
359+
// Assert - Verify error message explains the limitation clearly
360+
Assert.Contains("Collection Contains filters", exception.Message);
361+
Assert.Contains("not supported", exception.Message);
362+
}
363+
364+
[Fact]
365+
public async Task StringContainsStillWorksWithLINQFiltersAsync()
366+
{
367+
// Arrange - Verify that String.Contains (instance method) still works
368+
// String.Contains is NOT affected by C# 14 "first-class spans" - only arrays are
369+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson));
370+
ITextSearch<BraveWebPage> textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
371+
372+
// Act - String.Contains should continue to work
373+
var searchOptions = new TextSearchOptions<BraveWebPage>
374+
{
375+
Top = 5,
376+
Skip = 0,
377+
Filter = page => page.Title.Contains("Kernel") // String.Contains - instance method
378+
};
379+
KernelSearchResults<string> result = await textSearch.SearchAsync("Semantic Kernel tutorial", searchOptions);
380+
381+
// Assert - Verify String.Contains works correctly
382+
var requestUris = this._messageHandlerStub.RequestUris;
383+
Assert.Single(requestUris);
384+
Assert.NotNull(requestUris[0]);
385+
Assert.Contains("Kernel", requestUris[0].AbsoluteUri);
386+
Assert.Contains("count=5", requestUris[0].AbsoluteUri);
387+
}
388+
389+
#endregion
390+
244391
#region private
245392
private const string WhatIsTheSkResponseJson = "./TestData/brave_what_is_the_semantic_kernel.json";
246393
private const string SiteFilterSkResponseJson = "./TestData/brave_site_filter_what_is_the_semantic_kernel.json";

dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

3+
#pragma warning disable CS8602 // Dereference of a possibly null reference - for LINQ expression properties
4+
35
using System;
46
using System.IO;
57
using System.Linq;
@@ -344,6 +346,156 @@ public void Dispose()
344346
GC.SuppressFinalize(this);
345347
}
346348

349+
#region Generic ITextSearch<TavilyWebPage> Interface Tests
350+
351+
[Fact]
352+
public async Task GenericSearchAsyncReturnsResultsSuccessfullyAsync()
353+
{
354+
// Arrange
355+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson));
356+
ITextSearch<TavilyWebPage> textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
357+
358+
// Act
359+
var searchOptions = new TextSearchOptions<TavilyWebPage>
360+
{
361+
Top = 4,
362+
Skip = 0
363+
};
364+
KernelSearchResults<string> result = await textSearch.SearchAsync("What is the Semantic Kernel?", searchOptions);
365+
366+
// Assert - Verify basic generic interface functionality
367+
Assert.NotNull(result);
368+
Assert.NotNull(result.Results);
369+
var resultList = await result.Results.ToListAsync();
370+
Assert.NotEmpty(resultList);
371+
372+
// Verify the request was made correctly
373+
var requestContents = this._messageHandlerStub.RequestContents;
374+
Assert.Single(requestContents);
375+
Assert.NotNull(requestContents[0]);
376+
var requestBodyJson = Encoding.UTF8.GetString(requestContents[0]!);
377+
Assert.Contains("\"query\"", requestBodyJson);
378+
Assert.Contains("\"max_results\":4", requestBodyJson);
379+
}
380+
381+
[Fact]
382+
public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync()
383+
{
384+
// Arrange
385+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson));
386+
ITextSearch<TavilyWebPage> textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
387+
388+
// Act
389+
var searchOptions = new TextSearchOptions<TavilyWebPage>
390+
{
391+
Top = 3,
392+
Skip = 0
393+
};
394+
KernelSearchResults<object> result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions);
395+
396+
// Assert - Verify generic interface returns results
397+
Assert.NotNull(result);
398+
Assert.NotNull(result.Results);
399+
var resultList = await result.Results.ToListAsync();
400+
Assert.NotEmpty(resultList);
401+
// Results are still objects (TavilySearchResult) not TavilyWebPage since that's just for filtering
402+
403+
// Verify the request was made correctly
404+
var requestContents = this._messageHandlerStub.RequestContents;
405+
Assert.Single(requestContents);
406+
Assert.NotNull(requestContents[0]);
407+
var requestBodyJson = Encoding.UTF8.GetString(requestContents[0]!);
408+
Assert.Contains("\"max_results\":3", requestBodyJson);
409+
}
410+
411+
[Fact]
412+
public async Task GenericGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync()
413+
{
414+
// Arrange
415+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson));
416+
ITextSearch<TavilyWebPage> textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
417+
418+
// Act
419+
var searchOptions = new TextSearchOptions<TavilyWebPage>
420+
{
421+
Top = 5,
422+
Skip = 0
423+
};
424+
KernelSearchResults<TextSearchResult> result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", searchOptions);
425+
426+
// Assert - Verify generic interface returns TextSearchResult objects
427+
Assert.NotNull(result);
428+
Assert.NotNull(result.Results);
429+
var resultList = await result.Results.ToListAsync();
430+
Assert.NotEmpty(resultList);
431+
Assert.All(resultList, item => Assert.IsType<TextSearchResult>(item));
432+
433+
// Verify the request was made correctly
434+
var requestContents = this._messageHandlerStub.RequestContents;
435+
Assert.Single(requestContents);
436+
Assert.NotNull(requestContents[0]);
437+
var requestBodyJson = Encoding.UTF8.GetString(requestContents[0]!);
438+
Assert.Contains("\"max_results\":5", requestBodyJson);
439+
}
440+
441+
[Fact]
442+
public async Task CollectionContainsFilterThrowsNotSupportedExceptionAsync()
443+
{
444+
// Arrange - Tests both Enumerable.Contains (C# 13-) and MemoryExtensions.Contains (C# 14+)
445+
// The same code array.Contains() resolves differently based on C# language version:
446+
// - C# 13 and earlier: Enumerable.Contains (LINQ extension method)
447+
// - C# 14 and later: MemoryExtensions.Contains (span-based optimization due to "first-class spans")
448+
// Our implementation handles both identically since Tavily API has limited query operators
449+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson));
450+
ITextSearch<TavilyWebPage> textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
451+
string[] domains = ["microsoft.com", "github.com"];
452+
453+
// Act & Assert - Verify that collection Contains pattern throws clear exception
454+
var searchOptions = new TextSearchOptions<TavilyWebPage>
455+
{
456+
Top = 5,
457+
Skip = 0,
458+
Filter = page => domains.Contains(page.Url!.ToString()) // Enumerable.Contains (C# 13-) or MemoryExtensions.Contains (C# 14+)
459+
};
460+
461+
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () =>
462+
{
463+
await textSearch.SearchAsync("test", searchOptions);
464+
});
465+
466+
// Assert - Verify error message explains the limitation clearly
467+
Assert.Contains("Collection Contains filters", exception.Message);
468+
Assert.Contains("not supported", exception.Message);
469+
}
470+
471+
[Fact]
472+
public async Task StringContainsStillWorksWithLINQFiltersAsync()
473+
{
474+
// Arrange - Verify that String.Contains (instance method) still works
475+
// String.Contains is NOT affected by C# 14 "first-class spans" - only arrays are
476+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson));
477+
ITextSearch<TavilyWebPage> textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient });
478+
479+
// Act - String.Contains should continue to work
480+
var searchOptions = new TextSearchOptions<TavilyWebPage>
481+
{
482+
Top = 5,
483+
Skip = 0,
484+
Filter = page => page.Title.Contains("Kernel") // String.Contains - instance method
485+
};
486+
KernelSearchResults<string> result = await textSearch.SearchAsync("Semantic Kernel tutorial", searchOptions);
487+
488+
// Assert - Verify String.Contains works correctly
489+
var requestContents = this._messageHandlerStub.RequestContents;
490+
Assert.Single(requestContents);
491+
Assert.NotNull(requestContents[0]);
492+
var requestBodyJson = Encoding.UTF8.GetString(requestContents[0]!);
493+
Assert.Contains("Kernel", requestBodyJson);
494+
Assert.Contains("\"max_results\":5", requestBodyJson);
495+
}
496+
497+
#endregion
498+
347499
#region private
348500
private const string WhatIsTheSKResponseJson = "./TestData/tavily_what_is_the_semantic_kernel.json";
349501
private const string SiteFilterDevBlogsResponseJson = "./TestData/tavily_site_filter_devblogs_microsoft.com.json";

0 commit comments

Comments
 (0)