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/search"); attribute.Name.ShouldBe("Search"); } [Test] public async Task Search_Should_Return_422_For_Empty_Query() { IActionResult result = await _controller.Search(" ", 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", 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_Return_Grouped_Results() { SearchResultsResponseModel results = EmptyResults(); _mediator.Send(Arg.Any(), Arg.Any()).Returns(results); IActionResult result = await _controller.Search("star", 50, CancellationToken.None); var ok = result.ShouldBeOfType(); ok.Value.ShouldBe(results); } [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); } }