fix(434): accurate endpoint description (DB-sourced) + tests for case-insensitivity, tag exclusion, distinct
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m50s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 20:55:32 +02:00
co-authored by Claude Opus 4.8
parent cc5ec712e0
commit 7e6861a928
5 changed files with 86 additions and 11 deletions
@@ -26,8 +26,6 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit);
string qLower = (request.Query ?? string.Empty).ToLower();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// in-memory special cases (no DB query needed)
switch (request.Name)
{
@@ -37,9 +35,14 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
case "video_dynamic_range":
return new SearchFieldValuesResponseModel(
FilterSortTake(["hdr", "sdr"], qLower, limit));
case "content_rating":
return new SearchFieldValuesResponseModel(
await GetContentRatingValues(dbContext, qLower, limit, cancellationToken));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
if (request.Name == "content_rating")
{
return new SearchFieldValuesResponseModel(
await GetContentRatingValues(dbContext, qLower, limit, cancellationToken));
}
IQueryable<string> source = GetSource(dbContext, request.Name);
@@ -125,4 +125,75 @@ public class GetSearchFieldValuesHandlerTests
result.IsNone.ShouldBeTrue();
}
[Test]
public async Task Matches_Case_Insensitive_Prefix()
{
await using (TvContext context = _db.CreateContext())
{
context.Set<Genre>().AddRange(
new Genre { Name = "Action" },
new Genre { Name = "Comedy" });
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(_db.Factory);
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" }));
}
[Test]
public async Task Excludes_Network_And_Country_Tags_From_Tag_Field_And_Routes_Network_Tags_To_Network_Field()
{
await using (TvContext context = _db.CreateContext())
{
context.Set<Tag>().AddRange(
new Tag { Name = "PlainTag" },
new Tag { Name = "HBO", ExternalTypeId = Tag.PlexNetworkTypeId },
new Tag { Name = "USA", ExternalTypeId = Tag.NfoCountryTypeId });
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> tagResult = await handler.Handle(
new GetSearchFieldValues("tag", string.Empty, 50),
CancellationToken.None);
tagResult.IsSome.ShouldBeTrue();
tagResult.IfSome(r => r.Values.ShouldBe(new List<string> { "PlainTag" }));
Option<SearchFieldValuesResponseModel> networkResult = await handler.Handle(
new GetSearchFieldValues("network", string.Empty, 50),
CancellationToken.None);
networkResult.IsSome.ShouldBeTrue();
networkResult.IfSome(r => r.Values.ShouldBe(new List<string> { "HBO" }));
}
[Test]
public async Task Dedupes_Repeated_Values()
{
await using (TvContext context = _db.CreateContext())
{
context.Set<Genre>().AddRange(
new Genre { Name = "Action" },
new Genre { Name = "Action" });
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("genre", string.Empty, 50),
CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action" }));
}
}
+4 -3
View File
@@ -191,11 +191,12 @@ public class SearchController(IMediator mediator) : ControllerBase
[HttpGet("/api/v1/search/fields/{name}/values", Name = "GetSearchFieldValues")]
[Tags("Search")]
[EndpointSummary("List distinct term values for a text search field")]
[EndpointSummary("List distinct database values for a text search field")]
[EndpointDescription(
"Returns distinct term values from the search index for the given text field, filtered by an " +
"Returns distinct whole values from the database for the given text field, filtered by an " +
"optional case-insensitive prefix. Powers the visual rule builder's facet-value typeahead. " +
"404 when the field is unknown or is not a text field.")]
"404 when the field is unknown, is not a text field, or is a text field with no distinct-value " +
"source.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SearchFieldValuesResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+2 -2
View File
@@ -18040,8 +18040,8 @@
"tags": [
"Search"
],
"summary": "List distinct term values for a text search field",
"description": "Returns distinct term values from the search index for the given text field, filtered by an optional case-insensitive prefix. Powers the visual rule builder's facet-value typeahead. 404 when the field is unknown or is not a text field.",
"summary": "List distinct database values for a text search field",
"description": "Returns distinct whole values from the database for the given text field, filtered by an optional case-insensitive prefix. Powers the visual rule builder's facet-value typeahead. 404 when the field is unknown, is not a text field, or is a text field with no distinct-value source.",
"operationId": "GetSearchFieldValues",
"parameters": [
{
+1 -1
View File
@@ -338,7 +338,7 @@
| GET | `/api/v1/search/artists` | SearchArtists | Search artists by name |
| GET | `/api/v1/search/collections` | SearchCollections | Search collections by name |
| GET | `/api/v1/search/fields` | GetSearchFields | List the filterable fields for the visual rule builder |
| GET | `/api/v1/search/fields/{name}/values` | GetSearchFieldValues | List distinct term values for a text search field |
| GET | `/api/v1/search/fields/{name}/values` | GetSearchFieldValues | List distinct database values for a text search field |
| GET | `/api/v1/search/multi-collections` | SearchMultiCollections | Search multi collections by name |
| GET | `/api/v1/search/smart-collections` | SearchSmartCollections | Search smart collections by name |
| GET | `/api/v1/search/television-seasons` | SearchTelevisionSeasons | Search television seasons by name |