using System.Reflection; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Search; using ErsatzTV.Controllers.Api; using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Api.Search; using ErsatzTV.Core.Domain; using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class SearchControllerTests { private SearchController _controller = null!; private IMediator _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _controller = new SearchController(_mediator); } [Test] public void Controller_Should_Expose_Search_Route_With_Stable_Operation_Name() { MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.Search)) ?? throw new AssertionException($"Missing action {nameof(SearchController.Search)}"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/search"); attribute.Name.ShouldBe("Search"); } [Test] public async Task Search_Should_Return_422_For_Empty_Query() { IActionResult result = await _controller.Search(" ", 0, 50, CancellationToken.None); var unprocessable = result.ShouldBeOfType(); unprocessable.StatusCode.ShouldBe(422); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Search_Should_Clamp_PageSize_And_Send_Query() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(EmptyResults()); IActionResult result = await _controller.Search("star", 0, 500, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "star" && q.PageSize == 100), Arg.Any()); } [Test] public async Task Search_Should_Thread_PageNum_And_Clamp_Negative() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(EmptyResults()); // A valid page number is threaded through; a negative one clamps to 0. await _controller.Search("star", 3, 50, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "star" && q.PageNum == 3 && q.PageSize == 50), Arg.Any()); await _controller.Search("star", -7, 50, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "star" && q.PageNum == 0 && q.PageSize == 50), Arg.Any()); } [Test] public async Task Search_Should_Return_Grouped_Results() { SearchResultsResponseModel results = EmptyResults(); _mediator.Send(Arg.Any(), Arg.Any()).Returns(results); IActionResult result = await _controller.Search("star", 0, 50, CancellationToken.None); var ok = result.ShouldBeOfType(); ok.Value.ShouldBe(results); } [Test] public void Controller_Should_Expose_SearchAllItems_Route_With_Stable_Operation_Name() { MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.SearchAllItems)) ?? throw new AssertionException($"Missing action {nameof(SearchController.SearchAllItems)}"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/search/all-items"); attribute.Name.ShouldBe("SearchAllItems"); } [Test] public async Task SearchAllItems_Should_Return_422_For_Empty_Query() { IActionResult result = await _controller.SearchAllItems(" ", 0, 500, CancellationToken.None); var unprocessable = result.ShouldBeOfType(); unprocessable.StatusCode.ShouldBe(422); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task SearchAllItems_Should_Clamp_Paging_And_Thread_PageNum() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(EmptyAllItems()); // A valid pageNum threads through; a pageSize above the max clamps to 1000 (issue #293). await _controller.SearchAllItems("star", 2, 50_000, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "star" && q.PageNum == 2 && q.PageSize == 1000), Arg.Any()); // A negative pageNum clamps to 0; a pageSize below 1 clamps to 1. await _controller.SearchAllItems("star", -3, 0, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "star" && q.PageNum == 0 && q.PageSize == 1), Arg.Any()); // An absurd pageNum clamps to the max so pageNum * pageSize can't overflow int (issue #293 hardening). await _controller.SearchAllItems("star", 9_999_999, 1000, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "star" && q.PageNum == 2_000_000 && q.PageSize == 1000), Arg.Any()); } [Test] public async Task SearchAllItems_Should_Map_Id_Lists_And_Totals() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new SearchResultAllItemsViewModel( [1, 2], [3], [], [], [], [], [], [], [], [], new SearchResultAllItemsTotals(5, 1, 0, 0, 0, 0, 0, 0, 0, 0))); IActionResult result = await _controller.SearchAllItems("star", 0, 500, CancellationToken.None); var body = result.ShouldBeOfType().Value .ShouldBeOfType(); body.MovieIds.ShouldBe(new List { 1, 2 }); body.ShowIds.ShouldBe(new List { 3 }); body.Totals.MovieCount.ShouldBe(5); body.Totals.ShowCount.ShouldBe(1); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "star"), Arg.Any()); } [Test] public async Task SearchCollections_Should_Map_To_Picker_Options() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(CollectionType.Collection, 3, "Movies", false, new MediaItemState()) }); List result = await _controller.SearchCollections("mov", CancellationToken.None); result.ShouldHaveSingleItem(); result[0].Id.ShouldBe(3); result[0].Name.ShouldBe("Movies"); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "mov"), Arg.Any()); } [Test] public async Task SearchTelevisionShows_Should_Map_MediaItemId_To_Option_Id() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(42, "The Show") }); List result = await _controller.SearchTelevisionShows("show", CancellationToken.None); result.ShouldHaveSingleItem(); result[0].Id.ShouldBe(42); result[0].Name.ShouldBe("The Show"); } [Test] public async Task SearchSmartCollections_Should_Map_To_Picker_Options() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(9, "Recent", "query") }); List result = await _controller.SearchSmartCollections("rec", CancellationToken.None); result.ShouldHaveSingleItem(); result[0].Id.ShouldBe(9); } private static SearchResultsResponseModel EmptyResults() { var empty = new SearchResultGroupResponseModel(0, []); return new SearchResultsResponseModel(empty, empty, empty, empty, empty, empty, empty, empty, empty, empty); } private static SearchResultAllItemsViewModel EmptyAllItems() => new( [], [], [], [], [], [], [], [], [], [], new SearchResultAllItemsTotals(0, 0, 0, 0, 0, 0, 0, 0, 0, 0)); }