diff --git a/src/Application/Reviews/IReviewsRepository.cs b/src/Application/Reviews/IReviewsRepository.cs index 7cb99ee..a4431d5 100644 --- a/src/Application/Reviews/IReviewsRepository.cs +++ b/src/Application/Reviews/IReviewsRepository.cs @@ -17,6 +17,8 @@ public Task CreateReview( public Task> GetReviews( Guid? movieId, Guid? authorId, + string? query, + int? minStars, PageRequest page, CancellationToken cancellationToken); diff --git a/src/Application/Reviews/Queries/GetReviews/GetReviewsHandler.cs b/src/Application/Reviews/Queries/GetReviews/GetReviewsHandler.cs index ae2edda..b1500c7 100644 --- a/src/Application/Reviews/Queries/GetReviews/GetReviewsHandler.cs +++ b/src/Application/Reviews/Queries/GetReviews/GetReviewsHandler.cs @@ -8,6 +8,6 @@ public class GetReviewsHandler(IReviewsRepository repository) : IRequestHandler< { public async ValueTask> Handle(GetReviewsQuery request, CancellationToken cancellationToken) { - return await repository.GetReviews(request.MovieId, request.AuthorId, new PageRequest(request.Offset, request.Limit), cancellationToken); + return await repository.GetReviews(request.MovieId, request.AuthorId, request.Query, request.MinStars, new PageRequest(request.Offset, request.Limit), cancellationToken); } } diff --git a/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs b/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs index 6e735bd..5dc465e 100644 --- a/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs +++ b/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs @@ -5,7 +5,8 @@ namespace CleanModelContextProtocol.Application.Reviews.Queries.GetReviews; using Mediator; /// -/// Lists reviews one page at a time, optionally only those of one movie, by one author, or both. +/// Lists reviews one page at a time, optionally only those of one movie, by one author, with some text, or with at +/// least some stars. /// public class GetReviewsQuery : IRequest> { @@ -15,6 +16,12 @@ public class GetReviewsQuery : IRequest> /// Only return reviews by this author. Leave empty for every author. public Guid? AuthorId { get; init; } + /// Only return reviews whose text contains this, ignoring case. Leave empty for every review. + public string? Query { get; init; } + + /// Only return reviews with at least this many stars, from 1 to 5. Leave empty for every rating. + public int? MinStars { get; init; } + /// The number of results to skip, 0 for the first page. public int Offset { get; init; } diff --git a/src/Application/Reviews/Queries/GetReviews/GetReviewsValidator.cs b/src/Application/Reviews/Queries/GetReviews/GetReviewsValidator.cs index e917878..91e726e 100644 --- a/src/Application/Reviews/Queries/GetReviews/GetReviewsValidator.cs +++ b/src/Application/Reviews/Queries/GetReviews/GetReviewsValidator.cs @@ -9,6 +9,8 @@ public GetReviewsValidator() { _ = this.RuleFor(r => r.MovieId).NotEqual(Guid.Empty).WithMessage("A Movie Id can't be empty. Leave it out to list every movie."); _ = this.RuleFor(r => r.AuthorId).NotEqual(Guid.Empty).WithMessage("An Author Id can't be empty. Leave it out to list every author."); + _ = this.RuleFor(r => r.Query).ValidSearchTerm(); + _ = this.RuleFor(r => r.MinStars).InclusiveBetween(1, 5).WithMessage("A minimum star rating must be between 1 and 5."); _ = this.RuleFor(r => r.Offset).ValidOffset(); _ = this.RuleFor(r => r.Limit).ValidLimit(); } diff --git a/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs b/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs index 41c0c1a..dc55c79 100644 --- a/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs +++ b/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs @@ -188,6 +188,8 @@ public async Task DeleteReview(Guid id, CancellationToken cancellationToke public async Task> GetReviews( Guid? movieId, Guid? authorId, + string? query, + int? minStars, PageRequest page, CancellationToken cancellationToken) { @@ -203,6 +205,18 @@ public async Task> GetReviews( reviews = reviews.Where(r => r.ReviewAuthorId == reviewAuthorId); } + if (!string.IsNullOrWhiteSpace(query)) + { + var pattern = ContainsPattern(query); + + reviews = reviews.Where(r => EF.Functions.Like(r.Text, pattern, EscapeCharacter)); + } + + if (minStars is int lowestStars) + { + reviews = reviews.Where(r => r.Stars >= lowestStars); + } + var totalCount = await reviews.CountAsync(cancellationToken); var result = await reviews diff --git a/src/Presentation/Tools/ReviewTools.cs b/src/Presentation/Tools/ReviewTools.cs index 92bd76e..db6b3d6 100644 --- a/src/Presentation/Tools/ReviewTools.cs +++ b/src/Presentation/Tools/ReviewTools.cs @@ -11,11 +11,13 @@ public static partial class ReviewTools { /// /// List reviews with their star rating and the title and author of each, in movie-title order. Filter by movie, by - /// author or both, or pass a reviewId to get exactly one review. With no filters it pages through every review. + /// author, by words in the review or by a minimum star rating, or pass a reviewId to get exactly one review. With no filters it pages through every review. /// /// Only reviews of this movie. Use movies_search to find the id. /// Only reviews by this author. Use authors_search to find the id. /// Get this one review instead of searching. The other filters are ignored. + /// Only reviews whose text contains this, ignoring case, such as 'great film'. Leave empty for every review. + /// Only reviews with at least this many stars, from 1 to 5. Leave empty for every rating. /// The number of results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page. /// The most results to return, from 1 to 50. [McpServerTool(Name = "reviews_list", Title = "List reviews", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Review)] @@ -25,6 +27,8 @@ public static async partial Task> ListReviews( Guid? movieId = null, Guid? authorId = null, Guid? reviewId = null, + string? query = null, + int? minStars = null, int offset = 0, int limit = 10) { @@ -42,6 +46,8 @@ public static async partial Task> ListReviews( { MovieId = movieId, AuthorId = authorId, + Query = query, + MinStars = minStars, Offset = offset, Limit = limit }, cancellationToken); diff --git a/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs index 331844b..02d933a 100644 --- a/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs @@ -17,20 +17,20 @@ public class GetReviewsHandlerTests public async Task Handle_ShouldReturn_Page() { // Arrange - var query = new GetReviewsQuery { MovieId = MovieId, AuthorId = AuthorId, Offset = 20, Limit = 5 }; + var query = new GetReviewsQuery { MovieId = MovieId, AuthorId = AuthorId, Query = "great", MinStars = 4, Offset = 20, Limit = 5 }; var repository = Substitute.For(); var handler = new GetReviewsHandler(repository); using var tokenSource = new CancellationTokenSource(); var token = tokenSource.Token; - _ = repository.GetReviews(MovieId, AuthorId, new PageRequest(20, 5), token).Returns(new Page([new Review(Guid.Empty, 5)], 20, 5, 21)); + _ = repository.GetReviews(MovieId, AuthorId, "great", 4, new PageRequest(20, 5), token).Returns(new Page([new Review(Guid.Empty, 5)], 20, 5, 21)); // Act var result = await handler.Handle(query, token); // Assert - _ = await repository.Received(1).GetReviews(MovieId, AuthorId, new PageRequest(20, 5), token); + _ = await repository.Received(1).GetReviews(MovieId, AuthorId, "great", 4, new PageRequest(20, 5), token); _ = result.ShouldNotBeNull(); result.Items.Count.ShouldBe(1); @@ -51,13 +51,13 @@ public async Task Handle_ShouldStartAt_FirstPage() using var tokenSource = new CancellationTokenSource(); var token = tokenSource.Token; - _ = repository.GetReviews(null, null, new PageRequest(0, Paging.DefaultLimit), token).Returns(new Page([new Review(Guid.Empty, 5)], 0, Paging.DefaultLimit, 1)); + _ = repository.GetReviews(null, null, null, null, new PageRequest(0, Paging.DefaultLimit), token).Returns(new Page([new Review(Guid.Empty, 5)], 0, Paging.DefaultLimit, 1)); // Act var result = await handler.Handle(query, token); // Assert - _ = await repository.Received(1).GetReviews(null, null, new PageRequest(0, Paging.DefaultLimit), token); + _ = await repository.Received(1).GetReviews(null, null, null, null, new PageRequest(0, Paging.DefaultLimit), token); result.Offset.ShouldBe(0); result.Limit.ShouldBe(Paging.DefaultLimit); diff --git a/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsValidatorTests.cs b/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsValidatorTests.cs index 4293e1a..3ef888e 100644 --- a/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsValidatorTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsValidatorTests.cs @@ -8,6 +8,50 @@ public class GetReviewsValidatorTests { private static readonly GetReviewsValidator Validator = new(); + [Theory] + [InlineData(0)] + [InlineData(6)] + public void Validator_ShouldHaveValidationErrorFor_MinStars(int minStars) + { + // Arrange + var query = new GetReviewsQuery { MinStars = minStars }; + + // Act + var result = Validator.TestValidate(query); + + // Assert + _ = result.ShouldHaveValidationErrorFor(query => query.MinStars).WithErrorMessage("A minimum star rating must be between 1 and 5."); + } + + [Theory] + [InlineData(null)] + [InlineData(1)] + [InlineData(5)] + public void Validator_ShouldNotHaveValidationErrorFor_MinStars(int? minStars) + { + // Arrange + var query = new GetReviewsQuery { MinStars = minStars }; + + // Act + var result = Validator.TestValidate(query); + + // Assert + result.ShouldNotHaveValidationErrorFor(query => query.MinStars); + } + + [Fact] + public void Validator_ShouldHaveValidationErrorFor_LongQuery() + { + // Arrange + var query = new GetReviewsQuery { Query = new string('a', 201) }; + + // Act + var result = Validator.TestValidate(query); + + // Assert + _ = result.ShouldHaveValidationErrorFor(query => query.Query); + } + [Theory] [InlineData(0)] [InlineData(51)] diff --git a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs index 9c0aee2..c70700d 100644 --- a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs +++ b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs @@ -486,6 +486,59 @@ public async Task UpdateReview_ShouldReturn_Null() result.ShouldBeNull(); } + [Fact] + public async Task GetReviews_ShouldMatch_TextIgnoringCase() + { + // Arrange + var repository = this.database.Repository; + using var tokenSource = new CancellationTokenSource(); + var token = tokenSource.Token; + var reviewId = this.database.Context.Reviews.First(r => r.Stars == 2).Id; + + _ = await repository.UpdateReview(reviewId, 2, "A quiet triumph", token); + + // Act + var (items, _, _, totalCount) = await repository.GetReviews(null, null, "QUIET", null, new PageRequest(0, 50), token); + + // Assert + totalCount.ShouldBe(1); + items.ShouldHaveSingleItem().Id.ShouldBe(reviewId); + } + + [Fact] + public async Task GetReviews_ShouldTreat_WildcardsAsText() + { + // Arrange + var repository = this.database.Repository; + using var tokenSource = new CancellationTokenSource(); + var token = tokenSource.Token; + var reviewId = this.database.Context.Reviews.First(r => r.Stars == 2).Id; + + _ = await repository.UpdateReview(reviewId, 2, "A quiet triumph", token); + + // Act + var (items, _, _, _) = await repository.GetReviews(null, null, "%", null, new PageRequest(0, 50), token); + + // Assert + items.ShouldBeEmpty(); + } + + [Fact] + public async Task GetReviews_ShouldReturn_ReviewsWithEnoughStars() + { + // Arrange + var repository = this.database.Repository; + using var tokenSource = new CancellationTokenSource(); + var token = tokenSource.Token; + + // Act + var (items, _, _, _) = await repository.GetReviews(null, null, null, 4, new PageRequest(0, 50), token); + + // Assert + items.ShouldNotBeEmpty(); + items.ShouldAllBe(r => r.Stars >= 4); + } + [Fact] public async Task GetReviews_ShouldReturn_ReviewsForMovie() { @@ -496,7 +549,7 @@ public async Task GetReviews_ShouldReturn_ReviewsForMovie() var movieId = this.database.Context.Movies.First(m => m.Title == "Two").Id; // Act - var (items, _, _, totalCount) = await repository.GetReviews(movieId, null, new PageRequest(0, 50), token); + var (items, _, _, totalCount) = await repository.GetReviews(movieId, null, null, null, new PageRequest(0, 50), token); // Assert items.ShouldNotBeEmpty(); @@ -516,7 +569,7 @@ public async Task GetReviews_ShouldReturn_ReviewsForAuthorAndMovie() var authorId = this.database.Context.Authors.First(a => a.FirstName == "Two").Id; // Act - var (items, _, _, _) = await repository.GetReviews(movieId, authorId, new PageRequest(0, 50), token); + var (items, _, _, _) = await repository.GetReviews(movieId, authorId, null, null, new PageRequest(0, 50), token); // Assert items.ShouldAllBe(r => r.ReviewedMovie!.Id == movieId && r.ReviewAuthor!.Id == authorId); @@ -531,7 +584,7 @@ public async Task GetReviews_ShouldReturn_Page() var token = tokenSource.Token; // Act - var (items, _, _, totalCount) = await repository.GetReviews(null, null, new PageRequest(0, 2), token); + var (items, _, _, totalCount) = await repository.GetReviews(null, null, null, null, new PageRequest(0, 2), token); // Assert items.Count.ShouldBe(2); diff --git a/tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs b/tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs index a3a2356..d7eead5 100644 --- a/tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs +++ b/tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs @@ -88,6 +88,18 @@ public async Task ListReviews_WithEmptyMovieId_ShouldReturn_ValidationError() text.ShouldStartWith("Validation Failed: MovieId: A Movie Id can't be empty."); } + [Fact] + public async Task ListReviews_ShouldFilter_ByTextAndStars() + { + // Act + var page = await this.CallAsync>("reviews_list", + new Dictionary { ["query"] = "great", ["minStars"] = 4, ["limit"] = 50 }); + + // Assert + page.Items.ShouldNotBeEmpty(); + page.Items.ShouldAllBe(r => r.Stars >= 4 && r.Text!.Contains("great", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task WriteReview_ShouldCreate_Review() { diff --git a/tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs b/tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs index 572c192..3d9c5ef 100644 --- a/tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs +++ b/tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs @@ -34,7 +34,7 @@ public async Task ListReviews_ShouldReturn_Page() .Returns(new Page([SampleReview()], 2, 1, 3)); // Act - var response = await ReviewTools.ListReviews(sender, CancellationToken.None, MovieId, AuthorId, null, 2, 1); + var response = await ReviewTools.ListReviews(sender, CancellationToken.None, MovieId, AuthorId, null, null, null, 2, 1); // Assert response.TotalCount.ShouldBe(3);