diff --git a/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs index f831630..3241f37 100644 --- a/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs +++ b/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs @@ -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("*", 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( + () => searchClient.GetDocumentAsync("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() { diff --git a/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs index ba49908..d85f5a5 100644 --- a/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs +++ b/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs @@ -48,11 +48,6 @@ private static SearchIndex BuildIndex(string indexName) => /// /// Creates the index and indexes a document. /// - /// - /// 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. - /// private async Task SetUpAsync(string indexName) { var indexClient = factory.CreateSearchIndexClient(); @@ -180,15 +175,14 @@ public async Task DocumentLookupWithUnknownSelectField_IsRejectedAsBadRequest() /// An unexpected fault still answers in the envelope, and without leaking internals. /// /// - /// 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. /// [Fact] @@ -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); diff --git a/AzureSearchEmulator.UnitTests/EmptyIndexTests.cs b/AzureSearchEmulator.UnitTests/EmptyIndexTests.cs new file mode 100644 index 0000000..4c697df --- /dev/null +++ b/AzureSearchEmulator.UnitTests/EmptyIndexTests.cs @@ -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; + +/// +/// 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. +/// +/// +/// Uses the real 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. +/// +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); + } + + /// + /// A volume can hold a folder that Lucene created but never committed to. + /// + [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); + } + + /// + /// The empty answer must not be kept: once a document is written, reads see it. + /// + [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()); + 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; + } + } +} diff --git a/AzureSearchEmulator/SearchData/LuceneDirectoryReaderFactory.cs b/AzureSearchEmulator/SearchData/LuceneDirectoryReaderFactory.cs index 38cabb2..8726127 100644 --- a/AzureSearchEmulator/SearchData/LuceneDirectoryReaderFactory.cs +++ b/AzureSearchEmulator/SearchData/LuceneDirectoryReaderFactory.cs @@ -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; diff --git a/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs b/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs index 08ffea1..f0be510 100644 --- a/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs +++ b/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs @@ -48,17 +48,9 @@ public class LuceneNetIndexSearcher( public Task 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 Search(SearchIndex index, SearchRequest request) diff --git a/DebugClient/Program.cs b/DebugClient/Program.cs index 6ad5fc1..0912c2f 100644 --- a/DebugClient/Program.cs +++ b/DebugClient/Program.cs @@ -270,8 +270,46 @@ Console.WriteLine($" ✗ Error: {ex.GetType().Name}: {ex.Message}\n"); } - // Test 11: Delete Index - Console.WriteLine("11. Deleting index..."); + // Test 11: Search an index that has no documents + Console.WriteLine("11. 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("*", 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 12: Delete Index + Console.WriteLine("12. Deleting index..."); try { await indexClient.DeleteIndexAsync(indexName);