Skip to content
Merged
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
39 changes: 39 additions & 0 deletions AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,45 @@ public async Task Search_ServesThePage()
Assert.Contains("href=\"search\" class=\"topnav-link active\"", html);
}

/// <remarks>
/// Like the search page, the index pages render only once their circuit connects (issue #91).
/// They live under <c>/browse</c> because <c>/indexes</c> and <c>/indexes/{name}</c> are the
/// API's own routes.
/// </remarks>
[Theory]
[InlineData("/browse")]
[InlineData("/browse/test-dashboard-any")]
public async Task Indexes_ServesThePage(string path)
{
await factory.WaitUntilServingAsync();
using var client = factory.CreateHttpClient();

var response = await client.GetAsync(path, TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);

var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);

Assert.Contains("href=\"browse\" class=\"topnav-link active\"", html);
}

/// <remarks>
/// The other half of the pair: adding pages about indexes must leave the API's index listing
/// answering with JSON.
/// </remarks>
[Fact]
public async Task IndexesApi_StillAnswersWithJson()
{
await factory.WaitUntilServingAsync();
using var client = factory.CreateHttpClient();

var response = await client.GetAsync("/indexes", TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}

[Fact]
public async Task Dashboard_StylesheetIsServed()
{
Expand Down
87 changes: 87 additions & 0 deletions AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,93 @@ public async Task GetIndexNames_ShouldListCreatedIndex()
}
}

/// <remarks>
/// The listing used to go through OData's serializer, which wrote text weights as an array of
/// empty objects the SDK could not read and left out semantic configurations (found while
/// building the dashboard's index pages, issue #91).
/// </remarks>
[Fact]
public async Task GetIndexes_ReturnsFullDefinitions()
{
const string indexName = "test-get-indexes-full";
var indexClient = factory.CreateSearchIndexClient();

var index = new SearchIndex(indexName)
{
Fields =
{
new SimpleField("id", SearchFieldDataType.String) { IsKey = true },
new SearchableField("name")
},
ScoringProfiles =
{
new ScoringProfile("boost") { TextWeights = new TextWeights(new Dictionary<string, double> { ["name"] = 2 }) }
},
SemanticSearch = new SemanticSearch
{
Configurations =
{
new SemanticConfiguration("default", new SemanticPrioritizedFields { TitleField = new SemanticField("name") })
}
}
};

await indexClient.CreateIndexAsync(index, TestContext.Current.CancellationToken);

try
{
SearchIndex? listed = null;

await foreach (var i in indexClient.GetIndexesAsync(TestContext.Current.CancellationToken))
{
if (i.Name == indexName)
{
listed = i;
}
}

Assert.NotNull(listed);
Assert.Equal(2, listed.Fields.Count);
Assert.Equal(2, listed.ScoringProfiles.Single().TextWeights.Weights["name"]);
Assert.Equal("default", listed.SemanticSearch?.Configurations.Single().Name);
}
finally
{
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
}
}

/// <remarks>
/// The dashboard's index pages show these, so the SDK has to be able to read them (issue #91).
/// </remarks>
[Fact]
public async Task GetIndexStatistics_ReportsDocumentsAndStorage()
{
const string indexName = "test-index-statistics";
var indexClient = factory.CreateSearchIndexClient();
await CreateIndexAsync(indexClient, indexName);

try
{
var empty = (await indexClient.GetIndexStatisticsAsync(indexName, TestContext.Current.CancellationToken)).Value;

Assert.Equal(0, empty.DocumentCount);

var searchClient = factory.CreateSearchClient(indexName);
await UploadDocumentsAsync(searchClient);

var statistics = (await indexClient.GetIndexStatisticsAsync(indexName, TestContext.Current.CancellationToken)).Value;

Assert.Equal(CreateProductDocuments().Count, statistics.DocumentCount);
Assert.True(statistics.StorageSize > empty.StorageSize);
Assert.Equal(0, statistics.VectorIndexSize);
}
finally
{
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
}
}

[Fact]
public async Task UploadDocuments_ShouldSucceed()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,22 +127,17 @@ public async Task GetModifyPut_DoesNotStripUnmodelledProperties()
}

/// <summary>
/// Documents a known gap rather than a guarantee: the collection listing still serializes
/// through OData, which emits only what the EDM model declares, so the properties preserved
/// through the extension bag are absent there.
/// The collection listing carries the same definition as the individual route, including the
/// properties preserved through the extension bag.
/// </summary>
/// <remarks>
/// Asserted so the boundary is explicit and a future change to the listing endpoint is a
/// deliberate decision rather than a silent one. If this test starts failing because the
/// properties now appear, that is an improvement — update the test.
///
/// The gap has narrowed twice: <c>scoringProfiles</c> was listed here until issue #47 made
/// it a modelled property, and <c>vectorSearch</c> until issue #46 did the same. In both
/// cases the EDM model began emitting it and it moved to the assertions below. Anything
/// still named here is genuinely unmodelled.
/// This used to document a gap: the listing serialized through OData, which emits only what
/// the EDM model declares, so <c>corsOptions</c> and the like were missing from it. It moved
/// to the same serializer as the individual route in issue #91, after the SDK's
/// <c>GetIndexesAsync</c> was found failing on OData's rendering of text weights.
/// </remarks>
[Fact]
public async Task ListEndpoint_DoesNotYetIncludeUnmodelledProperties()
public async Task ListEndpoint_IncludesTheFullDefinition()
{
const string indexName = "test-roundtrip-listing";
var client = await CreateClientAsync();
Expand All @@ -156,15 +151,7 @@ public async Task ListEndpoint_DoesNotYetIncludeUnmodelledProperties()
.FirstOrDefault(i => i?["name"]?.GetValue<string>() == indexName);

Assert.NotNull(listed);
Assert.Null(listed["corsOptions"]);

// Modelled since issues #47 and #46, so the listing carries them like any other
// declared property.
Assert.NotNull(listed["scoringProfiles"]);
Assert.NotNull(listed["vectorSearch"]);

// The individual route is the one that carries the full definition.
Assert.NotNull((await GetIndexAsync(client, indexName))["corsOptions"]);
Assert.True(JsonNode.DeepEquals(await GetIndexAsync(client, indexName), listed));

await DeleteIndexAsync(client, indexName);
}
Expand Down
148 changes: 148 additions & 0 deletions AzureSearchEmulator.UnitTests/IndexPagesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using Azure.Search.Documents.Indexes.Models;
using AzureSearchEmulator.Dashboard;
using AzureSearchEmulator.SearchData;
using Lucene.Net.Store;
using Xunit;

namespace AzureSearchEmulator.UnitTests;

/// <summary>
/// Tests for the pieces behind the dashboard's index pages (issue #91) that do not need a running
/// emulator: how a definition is laid out as a table, and how a document is pulled out of a
/// response for its raw view.
/// </summary>
public class IndexPagesTests
{
[Fact]
public void Flatten_PutsSubfieldsAfterTheirParent()
{
var fields = new List<SearchField>
{
new("id", SearchFieldDataType.String) { IsKey = true, IsSortable = true },
new("address", SearchFieldDataType.Complex)
{
Fields =
{
new SearchField("city", SearchFieldDataType.String) { IsFilterable = true },
new SearchField("geo", SearchFieldDataType.Complex)
{
Fields = { new SearchField("lat", SearchFieldDataType.Double) }
}
}
},
new("rating", SearchFieldDataType.Int32) { IsHidden = true }
};

var rows = IndexSchema.Flatten(fields);

Assert.Equal(["id", "address", "address/city", "address/geo", "address/geo/lat", "rating"], rows.Select(i => i.Path));
Assert.Equal([0, 0, 1, 1, 2, 0], rows.Select(i => i.Depth));
Assert.True(rows[0].IsKey);
Assert.True(rows[1].IsComplex);
Assert.False(rows[2].IsComplex);
Assert.True(rows[2].Filterable);
Assert.False(rows[5].Retrievable);
Assert.Null(rows[2].Retrievable);
}

[Fact]
public void Flatten_DescribesAnalyzersAndVectors()
{
var fields = new List<SearchField>
{
new("plain", SearchFieldDataType.String) { AnalyzerName = LexicalAnalyzerName.EnLucene },
new("split", SearchFieldDataType.String)
{
IndexAnalyzerName = LexicalAnalyzerName.Keyword,
SearchAnalyzerName = LexicalAnalyzerName.StandardLucene
},
new("vector", SearchFieldDataType.Collection(SearchFieldDataType.Single))
{
VectorSearchDimensions = 3,
VectorSearchProfileName = "hnsw"
}
};

var rows = IndexSchema.Flatten(fields);

Assert.Equal("en.lucene", rows[0].Analyzer);
Assert.Equal("index: keyword, search: standard.lucene", rows[1].Analyzer);
Assert.Null(rows[2].Analyzer);
Assert.Equal(3, rows[2].Dimensions);
Assert.Equal("hnsw", rows[2].VectorProfile);
Assert.Equal("Collection(Edm.Single)", rows[2].Type);
}

[Fact]
public void IsKeySortable_FollowsTheKeyField()
{
var sortable = new SearchIndex("a") { Fields = { new SearchField("id", SearchFieldDataType.String) { IsKey = true, IsSortable = true } } };
var unsortable = new SearchIndex("b") { Fields = { new SearchField("id", SearchFieldDataType.String) { IsKey = true } } };

Assert.True(IndexSchema.IsKeySortable(sortable));
Assert.False(IndexSchema.IsKeySortable(unsortable));
Assert.Equal("id", IndexSchema.KeyField(sortable));
}

[Fact]
public void DocumentJson_TakesTheFirstSearchResultWithoutAnnotations()
{
var json = DocumentJson.Extract("""{"@odata.count": 2, "value": [{"@search.score": 1.0, "id": "1", "name": "Café <b>"}]}""");

Assert.Equal("""{"id":"1","name":"Café <b>"}""", json);
}

[Fact]
public void DocumentJson_TakesALookedUpDocumentAsIs()
{
Assert.Equal("""{"id":"1"}""", DocumentJson.Extract("""{"id": "1"}"""));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("""{"value": []}""")]
[InlineData("not json")]
[InlineData("[1, 2]")]
public void DocumentJson_WithNoDocument_IsNull(string? body)
{
Assert.Null(DocumentJson.Extract(body));
}

[Theory]
[InlineData(0, "0 B")]
[InlineData(1023, "1023 B")]
[InlineData(1024, "1 KB")]
[InlineData(1536, "1.5 KB")]
[InlineData(15 * 1024 + 512, "16 KB")]
[InlineData(5L * 1024 * 1024 * 1024, "5 GB")]
public void ByteSize_UsesTheLargestWholeUnit(long bytes, string expected)
{
Assert.Equal(expected, ByteSize.Format(bytes));
}

[Fact]
public void LuceneStorage_SumsTheDirectorysFiles()
{
using var directory = new RAMDirectory();

foreach (var (name, length) in new[] { ("a", 10), ("b", 32) })
{
using var output = directory.CreateOutput(name, IOContext.DEFAULT);
output.WriteBytes(new byte[length], length);
}

Assert.Equal(42, LuceneStorage.GetSize(directory));
}

/// <remarks>
/// An index nothing has been written to yet has no folder on disk.
/// </remarks>
[Fact]
public void LuceneStorage_OfAMissingDirectory_IsZero()
{
using var directory = new SimpleFSDirectory(Path.Combine(Path.GetTempPath(), "azs-missing-" + Guid.NewGuid().ToString("N")));

Assert.Equal(0, LuceneStorage.GetSize(directory));
}
}
26 changes: 26 additions & 0 deletions AzureSearchEmulator.UnitTests/LuceneNetIndexSearcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,32 @@ public async Task Search_NullSearch_ReturnsAllDocuments()
Assert.Equal(5, response.Results.Count);
}

/// <remarks>
/// A wildcard matches every document whether or not the index has a field to search. It used
/// to fail with "no searchable fields", which the SDK's <c>SearchAsync("*")</c> and the
/// dashboard's document pager both ran into (issue #91).
/// </remarks>
[Theory]
[InlineData("simple", "*")]
[InlineData("full", "*")]
[InlineData("full", "*:*")]
public async Task Search_Wildcard_OnAnIndexWithNoSearchableFields_ReturnsAllDocuments(string queryType, string search)
{
var index = LuceneTestHelper.CreateCityIndex();

foreach (var field in index.Fields)
{
field.Searchable = false;
}

using var helper = new LuceneTestHelper(index, LuceneTestHelper.CreateCityDocuments());
var searcher = new LuceneNetIndexSearcher(new StubIndexReaderFactory(helper.Directory));

var response = await searcher.Search(index, new SearchRequest { Search = search, QueryType = queryType, Top = 50 });

Assert.Equal(6, response.Results.Count);
}

[Fact]
public async Task Search_SimpleTextQuery_ReturnsMatchingDocuments()
{
Expand Down
1 change: 1 addition & 0 deletions AzureSearchEmulator/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
</a>
<nav class="topnav" aria-label="Dashboard">
<NavLink class="topnav-link" href="" Match="NavLinkMatch.All">Status</NavLink>
<NavLink class="topnav-link" href="browse">Indexes</NavLink>
<NavLink class="topnav-link" href="search">Search</NavLink>
</nav>
</header>
Expand Down
Loading
Loading