Skip to content
Open
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
27 changes: 27 additions & 0 deletions AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,33 @@ public async Task GetDocumentById_ShouldReturnDocument()
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
}

[Fact]
public async Task EmptyIndex_ShouldAnswerReadsWithoutDocuments()
{
const string indexName = "test-empty-index";
var indexClient = factory.CreateSearchIndexClient();
var searchClient = factory.CreateSearchClient(indexName);

// Arrange - Create the index and write nothing to it
await CreateIndexAsync(indexClient, indexName);

// Act
var results = await searchClient.SearchAsync<Product>("*", new SearchOptions { IncludeTotalCount = true }, TestContext.Current.CancellationToken);
var items = await results.Value.GetResultsAsync().ToListAsync(TestContext.Current.CancellationToken);
var count = await searchClient.GetDocumentCountAsync(TestContext.Current.CancellationToken);
var missing = await Assert.ThrowsAsync<Azure.RequestFailedException>(
() => searchClient.GetDocumentAsync<Product>("1", cancellationToken: TestContext.Current.CancellationToken));

// Assert
Assert.Empty(items);
Assert.Equal(0, results.Value.TotalCount);
Assert.Equal(0, count.Value);
Assert.Equal(404, missing.Status);

// Cleanup
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
}

[Fact]
public async Task SearchDocuments_SimpleQuery_ShouldReturnResults()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,6 @@ private static SearchIndex BuildIndex(string indexName) =>
/// <summary>
/// Creates the index and indexes a document.
/// </summary>
/// <remarks>
/// The document matters: with an empty index the Lucene directory does not exist yet, so a
/// query fails on the missing directory before it ever reaches the filter parser, and the
/// test would assert on the wrong error.
/// </remarks>
private async Task<SearchIndexClient> SetUpAsync(string indexName)
{
var indexClient = factory.CreateSearchIndexClient();
Expand Down Expand Up @@ -180,15 +175,14 @@ public async Task DocumentLookupWithUnknownSelectField_IsRejectedAsBadRequest()
/// An unexpected fault still answers in the envelope, and without leaking internals.
/// </summary>
/// <remarks>
/// Searching an index that has never had a document written to it currently trips a Lucene
/// DirectoryNotFoundException, because the segment directory is only created on first write.
/// That 500 is arguably its own bug — the real service answers an empty result set — so this
/// test deliberately asserts only on the SHAPE of an unexpected failure, not on this query
/// being one. Should the empty-index case be fixed to return results, the assertions below
/// stop applying and the test should be pointed at another unexpected fault rather than
/// being made to keep this 500 alive.
/// A full Lucene query that does not parse currently escapes as a Lucene parse exception,
/// which is not mapped to a status, so it answers 500 where the real service answers 400.
/// That is arguably its own bug, so this test deliberately asserts only on the SHAPE of an
/// unexpected failure, not on this query being one. Should malformed query syntax be
/// fixed to answer 400, the assertions below stop applying and the test should be pointed
/// at another unexpected fault rather than being made to keep this 500 alive.
///
/// The generic message is the point: the underlying exception names the emulator's own
/// The generic message is the point: the underlying exception can name the emulator's own
/// filesystem paths, and those belong in the log rather than the response body.
/// </remarks>
[Fact]
Expand All @@ -200,7 +194,7 @@ public async Task UnexpectedFailure_ReturnsEnvelopeWithoutInternalDetail()

var http = factory.CreateHttpClient();
var response = await http.GetAsync(
$"/indexes/{indexName}/docs?search=*",
$"/indexes/{indexName}/docs?search=%28&queryType=full",
TestContext.Current.CancellationToken);

var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Expand Down
153 changes: 153 additions & 0 deletions AzureSearchEmulator.UnitTests/EmptyIndexTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using System.Text.Json.Nodes;
using AzureSearchEmulator.Indexing;
using AzureSearchEmulator.Models;
using AzureSearchEmulator.SearchData;
using AzureSearchEmulator.Searching;
using Lucene.Net.Store;
using Xunit;
using Directory = Lucene.Net.Store.Directory;

namespace AzureSearchEmulator.UnitTests;

/// <summary>
/// Covers reading an index that exists but has never had a document written to it, which
/// Azure answers with an empty result rather than an error.
/// </summary>
/// <remarks>
/// Uses the real <see cref="LuceneDirectoryReaderFactory"/> over an on-disk directory, because
/// the failure comes from opening a reader on a directory Lucene has never written. An
/// in-memory directory is always readable, so a test built on one would pass without the fix.
/// </remarks>
public sealed class EmptyIndexTests : IDisposable
{
private readonly string _path = Path.Combine(Path.GetTempPath(), "azs-empty-index-" + Guid.NewGuid().ToString("N"));
private readonly SearchIndex _index = LuceneTestHelper.CreateSuggesterProductIndex();
private readonly LuceneDirectoryReaderFactory _readerFactory;
private readonly LuceneNetIndexWriterFactory _writerFactory;
private readonly LuceneNetSearchIndexer _indexer;
private readonly LuceneNetIndexSearcher _searcher;

public EmptyIndexTests()
{
var directoryFactory = new FixedPathDirectoryFactory(_path);

_readerFactory = new LuceneDirectoryReaderFactory(directoryFactory);
_writerFactory = new LuceneNetIndexWriterFactory(directoryFactory);
_indexer = new LuceneNetSearchIndexer(_writerFactory, _readerFactory);
_searcher = new LuceneNetIndexSearcher(_readerFactory);
}

public void Dispose()
{
// Reader and writer before the folder: both hold open handles.
_readerFactory.ClearCachedReader(_index.Name);
_writerFactory.Dispose();

if (System.IO.Directory.Exists(_path))
{
System.IO.Directory.Delete(_path, recursive: true);
}
}

[Fact]
public async Task Search_OnAnIndexWithNoDocuments_ReturnsNoResults()
{
var response = await _searcher.Search(_index, new SearchRequest { Search = "*", Count = true });

Assert.Empty(response.Results);
Assert.Equal(0, response.Count);
}

[Fact]
public async Task Search_ForText_OnAnIndexWithNoDocuments_ReturnsNoResults()
{
var response = await _searcher.Search(_index, new SearchRequest { Search = "laptop", Count = true });

Assert.Empty(response.Results);
Assert.Equal(0, response.Count);
}

[Fact]
public async Task GetDocCount_OnAnIndexWithNoDocuments_ReturnsZero()
{
Assert.Equal(0, await _searcher.GetDocCount(_index));
}

[Fact]
public async Task GetDoc_OnAnIndexWithNoDocuments_ReturnsNull()
{
Assert.Null(await _searcher.GetDoc(_index, "1"));
}

[Fact]
public async Task Suggest_OnAnIndexWithNoDocuments_ReturnsNoResults()
{
var response = await _searcher.Suggest(_index, new SuggestRequest { Search = "lap", SuggesterName = "sg" });

Assert.Empty(response.Results);
}

[Fact]
public async Task Autocomplete_OnAnIndexWithNoDocuments_ReturnsNoResults()
{
var response = await _searcher.Autocomplete(_index,
new AutocompleteRequest { Search = "lap", SuggesterName = "sg" });

Assert.Empty(response.Results);
}

/// <summary>
/// A volume can hold a folder that Lucene created but never committed to.
/// </summary>
[Fact]
public async Task Search_WhenTheIndexFolderExistsButIsEmpty_ReturnsNoResults()
{
System.IO.Directory.CreateDirectory(_path);

var response = await _searcher.Search(_index, new SearchRequest { Search = "*", Count = true });

Assert.Empty(response.Results);
Assert.Equal(0, response.Count);
}

/// <summary>
/// The empty answer must not be kept: once a document is written, reads see it.
/// </summary>
[Fact]
public async Task Reads_AfterTheFirstDocumentIsWritten_SeeIt()
{
// Read first, so the empty answer is the one a stale cache would keep serving.
Assert.Empty((await _searcher.Search(_index, new SearchRequest { Search = "*" })).Results);
Assert.Equal(0, await _searcher.GetDocCount(_index));

_indexer.IndexDocuments(_index,
[
new UploadIndexDocumentAction(new JsonObject
{
["Id"] = "1",
["Name"] = "Laptop Pro 15",
["Description"] = "High-performance laptop",
})
]);

var response = await _searcher.Search(_index, new SearchRequest { Search = "laptop", Count = true });

Assert.Equal(1, response.Count);
Assert.Equal("1", Assert.Single(response.Results)["Id"]!.GetValue<string>());
Assert.Equal(1, await _searcher.GetDocCount(_index));
Assert.NotNull(await _searcher.GetDoc(_index, "1"));
}

private sealed class FixedPathDirectoryFactory(string path) : ILuceneDirectoryFactory
{
private Directory? _directory;

public Directory GetDirectory(string indexName) => _directory ??= new SimpleFSDirectory(path);

public void ClearCachedDirectory(string indexName)
{
_directory?.Dispose();
_directory = null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ public IndexReader RefreshReader(string indexName)
{
var directory = luceneDirectoryFactory.GetDirectory(indexName);

// An index with no documents written yet has no Lucene files, and may not have a
// folder. It is not cached, so the next call looks again and sees the first write.
if (!DirectoryReader.IndexExists(directory))
{
return new MultiReader();
}

var reader = DirectoryReader.Open(directory);

_indexReaders[indexName] = reader;
Expand Down
12 changes: 2 additions & 10 deletions AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,9 @@ public class LuceneNetIndexSearcher(

public Task<int> GetDocCount(SearchIndex index)
{
try
{
var reader = indexReaderFactory.GetIndexReader(index.Name);
var reader = indexReaderFactory.GetIndexReader(index.Name);

return Task.FromResult(reader.NumDocs);
}
catch (DirectoryNotFoundException)
{
// If we've come this far, the index exists, but has no documents, so it's okay to return 0
return Task.FromResult(0);
}
return Task.FromResult(reader.NumDocs);
}

public async Task<SearchResponse> Search(SearchIndex index, SearchRequest request)
Expand Down
42 changes: 40 additions & 2 deletions DebugClient/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,46 @@
Console.WriteLine($" ✗ Error: {ex.GetType().Name}: {ex.Message}\n");
}

// Test 10: Delete Index
Console.WriteLine("10. Deleting index...");
// Test 10: Search an index that has no documents
Console.WriteLine("10. Searching an empty index...");
const string emptyIndexName = "empty-test-index";
try
{
await indexClient.CreateIndexAsync(new SearchIndex(emptyIndexName)
{
Fields =
[
new SearchField("id", SearchFieldDataType.String) { IsKey = true },
new SearchField("name", SearchFieldDataType.String) { IsSearchable = true }
]
});

var emptySearchClient = new SearchClient(new Uri(endpoint), emptyIndexName, credential, options);

// Azure answers a search on an index with no documents with an empty result, not an error.
var results = await emptySearchClient.SearchAsync<object>("*", new SearchOptions { IncludeTotalCount = true });
var items = await results.Value.GetResultsAsync().ToListAsync();

Console.WriteLine($" ✓ Found {items.Count} results, total count {results.Value.TotalCount}\n");
}
catch (Exception ex)
{
Console.WriteLine($" ✗ Error: {ex.GetType().Name}: {ex.Message}\n");
}
finally
{
try
{
await indexClient.DeleteIndexAsync(emptyIndexName);
}
catch (Exception ex)
{
Console.WriteLine($" ✗ Error deleting {emptyIndexName}: {ex.GetType().Name}: {ex.Message}\n");
}
}

// Test 11: Delete Index
Console.WriteLine("11. Deleting index...");
try
{
await indexClient.DeleteIndexAsync(indexName);
Expand Down