Adds GET /api/v1/search/fields/{name}/values?q=&limit= — the backend slice of the
visual rule builder's facet-value typeahead (#434). Enumerates distinct Lucene term
values for a text field via MultiFields.GetTerms + TermsEnum, filtered by a
case-insensitive prefix, limit clamped to [1,50]. 404s when the field is absent from
SearchFieldCatalog or is not type "text". ElasticSearchIndex (the optional external
backend) throws NotSupportedException for this method — its text fields are analyzed,
not keyword-mapped, so a terms aggregation isn't safe to guess at without verifying
against a live cluster.
Regenerated OpenAPI trio (v1.json, v1.d.ts, endpoint-index.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
85 lines
2.9 KiB
C#
85 lines
2.9 KiB
C#
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ErsatzTV.Application.Search.Queries;
|
|
using ErsatzTV.Core.Api.Search;
|
|
using ErsatzTV.Core.Interfaces.Search;
|
|
using LanguageExt;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Application.Search;
|
|
|
|
[TestFixture]
|
|
public class GetSearchFieldValuesHandlerTests
|
|
{
|
|
[Test]
|
|
public async Task Returns_Filtered_Values_For_AllowListed_Text_Field()
|
|
{
|
|
ISearchIndex searchIndex = Substitute.For<ISearchIndex>();
|
|
searchIndex.GetFieldValues("genre", "A", 50)
|
|
.Returns(Task.FromResult(new List<string> { "Action", "Adventure" }));
|
|
|
|
var handler = new GetSearchFieldValuesHandler(searchIndex);
|
|
|
|
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("genre", "A", 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action", "Adventure" }));
|
|
|
|
await searchIndex.Received(1).GetFieldValues("genre", "A", 50);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Returns_NotFound_For_Unknown_Field()
|
|
{
|
|
ISearchIndex searchIndex = Substitute.For<ISearchIndex>();
|
|
var handler = new GetSearchFieldValuesHandler(searchIndex);
|
|
|
|
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("nope", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsNone.ShouldBeTrue();
|
|
await searchIndex.DidNotReceive().GetFieldValues(
|
|
Arg.Any<string>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Returns_NotFound_For_NonText_Field()
|
|
{
|
|
ISearchIndex searchIndex = Substitute.For<ISearchIndex>();
|
|
var handler = new GetSearchFieldValuesHandler(searchIndex);
|
|
|
|
// "minutes" is a "number" field in SearchFieldCatalog, not "text"
|
|
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("minutes", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsNone.ShouldBeTrue();
|
|
await searchIndex.DidNotReceive().GetFieldValues(
|
|
Arg.Any<string>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Clamps_Limit_To_Fifty()
|
|
{
|
|
ISearchIndex searchIndex = Substitute.For<ISearchIndex>();
|
|
searchIndex.GetFieldValues(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
|
|
.Returns(Task.FromResult(new List<string>()));
|
|
|
|
var handler = new GetSearchFieldValuesHandler(searchIndex);
|
|
|
|
await handler.Handle(new GetSearchFieldValues("genre", string.Empty, 500), CancellationToken.None);
|
|
|
|
await searchIndex.Received(1).GetFieldValues("genre", string.Empty, 50);
|
|
}
|
|
}
|