diff --git a/AzureSearchEmulator.IntegrationTests/EmptyIndexIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/EmptyIndexIntegrationTests.cs new file mode 100644 index 0000000..49dc890 --- /dev/null +++ b/AzureSearchEmulator.IntegrationTests/EmptyIndexIntegrationTests.cs @@ -0,0 +1,42 @@ +using Azure.Search.Documents.Indexes.Models; +using Azure.Search.Documents.Models; +using Xunit; + +namespace AzureSearchEmulator.IntegrationTests; + +/// +/// An index that exists but has never had a document written answers queries with nothing, +/// as in Azure, instead of failing because its Lucene directory has not been created yet. +/// +public class EmptyIndexIntegrationTests(EmulatorFactory factory) + : IClassFixture +{ + [Fact] + public async Task NeverWrittenIndex_SearchesAndGetsReturnNothing() + { + var indexName = "empty-" + Guid.NewGuid().ToString("N")[..8]; + var cancellationToken = TestContext.Current.CancellationToken; + + await factory.CreateSearchIndexClient().CreateIndexAsync( + new SearchIndex(indexName) + { + Fields = + { + new SearchField("id", SearchFieldDataType.String) { IsKey = true }, + new SearchField("name", SearchFieldDataType.String) { IsSearchable = true }, + } + }, + cancellationToken); + + var client = factory.CreateSearchClient(indexName); + + var results = await client.SearchAsync("x", new Azure.Search.Documents.SearchOptions { IncludeTotalCount = true }, cancellationToken); + Assert.Equal(0, results.Value.TotalCount); + + Assert.Equal(0, (await client.GetDocumentCountAsync(cancellationToken)).Value); + + var ex = await Assert.ThrowsAsync( + async () => await client.GetDocumentAsync("1", cancellationToken: cancellationToken)); + Assert.Equal(404, ex.Status); + } +} diff --git a/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs index ba49908..7293d58 100644 --- a/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs +++ b/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs @@ -176,50 +176,6 @@ public async Task DocumentLookupWithUnknownSelectField_IsRejectedAsBadRequest() await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken); } - /// - /// 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. - /// - /// The generic message is the point: the underlying exception names the emulator's own - /// filesystem paths, and those belong in the log rather than the response body. - /// - [Fact] - public async Task UnexpectedFailure_ReturnsEnvelopeWithoutInternalDetail() - { - const string indexName = $"{IndexName}-unexpected"; - var indexClient = factory.CreateSearchIndexClient(); - await indexClient.CreateIndexAsync(BuildIndex(indexName), TestContext.Current.CancellationToken); - - var http = factory.CreateHttpClient(); - var response = await http.GetAsync( - $"/indexes/{indexName}/docs?search=*", - TestContext.Current.CancellationToken); - - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - // Guard the premise: if this stops being a server fault, the assertions below are no - // longer testing what they claim to. - Assert.True(response.StatusCode == HttpStatusCode.InternalServerError, - $"This test needs a query that faults unexpectedly; got {(int)response.StatusCode}. Body: {body}"); - - using var document = JsonDocument.Parse(body); - var message = document.RootElement.GetProperty("error").GetProperty("message").GetString(); - - Assert.Equal("An unexpected error occurred.", message); - Assert.DoesNotContain("/app", body, StringComparison.Ordinal); - Assert.DoesNotContain("Exception", body, StringComparison.Ordinal); - - await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken); - } - /// /// What the consumer sees: the SDK reads the message out of the envelope instead of /// falling back to its own "Service request failed." diff --git a/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs b/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs index 2d049f5..a5704ff 100644 --- a/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs +++ b/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs @@ -1097,10 +1097,30 @@ private static JsonObject ConvertSearchDoc( } } - private IndexSearcher GetSearcher(SearchIndex index) + /// + /// Stands in for the index directory of an index that has never had a document written: the + /// directory is created on first write, so until then there is nothing to open, and Azure + /// answers such an index with an empty result rather than an error. + /// + private static readonly Lazy EmptyReader = new(() => { - var reader = indexReaderFactory.GetIndexReader(index.Name); + var directory = new Lucene.Net.Store.RAMDirectory(); + var config = new IndexWriterConfig(Lucene.Net.Util.LuceneVersion.LUCENE_48, new Lucene.Net.Analysis.Core.KeywordAnalyzer()); + + new IndexWriter(directory, config).Dispose(); - return new IndexSearcher(reader); + return DirectoryReader.Open(directory); + }); + + private IndexSearcher GetSearcher(SearchIndex index) + { + try + { + return new IndexSearcher(indexReaderFactory.GetIndexReader(index.Name)); + } + catch (DirectoryNotFoundException) + { + return new IndexSearcher(EmptyReader.Value); + } } }