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
42 changes: 42 additions & 0 deletions AzureSearchEmulator.IntegrationTests/EmptyIndexIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Azure.Search.Documents.Indexes.Models;
using Azure.Search.Documents.Models;
using Xunit;

namespace AzureSearchEmulator.IntegrationTests;

/// <summary>
/// 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.
/// </summary>
public class EmptyIndexIntegrationTests(EmulatorFactory factory)
: IClassFixture<EmulatorFactory>
{
[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<SearchDocument>("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<Azure.RequestFailedException>(
async () => await client.GetDocumentAsync<SearchDocument>("1", cancellationToken: cancellationToken));
Assert.Equal(404, ex.Status);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,50 +176,6 @@ public async Task DocumentLookupWithUnknownSelectField_IsRejectedAsBadRequest()
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
}

/// <summary>
/// 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.
///
/// 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.
/// </remarks>
[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);
}

/// <summary>
/// What the consumer sees: the SDK reads the message out of the envelope instead of
/// falling back to its own "Service request failed."
Expand Down
26 changes: 23 additions & 3 deletions AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1097,10 +1097,30 @@ private static JsonObject ConvertSearchDoc(
}
}

private IndexSearcher GetSearcher(SearchIndex index)
/// <summary>
/// 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.
/// </summary>
private static readonly Lazy<IndexReader> 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);
}
}
}