From e2b2b4104be21ec74ddab096062db5de537b684e Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:35:39 -0600 Subject: [PATCH] Search reviews by their text and a minimum star rating --- src/Application/Reviews/IReviewsRepository.cs | 2 + .../Queries/GetReviews/GetReviewsHandler.cs | 2 +- .../Queries/GetReviews/GetReviewsQuery.cs | 9 ++- .../Queries/GetReviews/GetReviewsValidator.cs | 2 + .../EntityFrameworkMovieReviewsRepository.cs | 14 +++++ .../Controllers/Reviews/ReviewsController.cs | 11 +++- .../GetReviews/GetReviewsHandlerTests.cs | 10 ++-- .../GetReviews/GetReviewsValidatorTests.cs | 44 ++++++++++++++ ...ityFrameworkMovieReviewsRepositoryTests.cs | 59 ++++++++++++++++++- .../Controllers/ReviewEndpointTests.cs | 29 +++++++++ 10 files changed, 170 insertions(+), 12 deletions(-) diff --git a/src/Application/Reviews/IReviewsRepository.cs b/src/Application/Reviews/IReviewsRepository.cs index 2469bf9..0c107c6 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 87ad050..390353c 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 5354cec..15a4213 100644 --- a/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs +++ b/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs @@ -5,7 +5,8 @@ namespace CleanWebApi.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 3e6aa80..f61ed13 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 b238898..f918677 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/Controllers/Reviews/ReviewsController.cs b/src/Presentation/Controllers/Reviews/ReviewsController.cs index b6391b4..a69209c 100644 --- a/src/Presentation/Controllers/Reviews/ReviewsController.cs +++ b/src/Presentation/Controllers/Reviews/ReviewsController.cs @@ -17,7 +17,8 @@ public class ReviewsController(ISender sender) : ApiControllerBase(sender) /// /// /// Returns one page of reviews, ordered by movie then author, along with the movie each is about and the - /// author who wrote it. Optionally return only the reviews of one movie, by one author, or both. To fetch + /// author who wrote it. Optionally return only the reviews of one movie, by one author, with some text in them, or with at least + /// some stars. To fetch /// the next page, add the limit to the offset. /// /// Sample request: @@ -26,10 +27,12 @@ public class ReviewsController(ISender sender) : ApiControllerBase(sender) /// /// Only return reviews of this movie. Leave it out for every movie. /// Only return reviews by this author. Leave it out for every author. + /// Only return reviews whose text contains this, ignoring case. Leave it out for every review. + /// Only return reviews with at least this many stars, from 1 to 5. Leave it out for every rating. /// The number of reviews to skip, 0 for the first page. /// The largest number of reviews to return, from 1 to 50. /// One page of Reviews. - /// An Id, the offset or the limit is not valid. + /// An Id, the search text, the minimum stars, the offset or the limit is not valid. /// An unexpected error occurred. [HttpGet] [EndpointName(nameof(GetReviews))] @@ -40,6 +43,8 @@ public class ReviewsController(ISender sender) : ApiControllerBase(sender) [FromQuery] Guid? movieId, [FromQuery] Guid? authorId, CancellationToken cancellationToken, + [FromQuery] string? query = null, + [FromQuery] int? minStars = null, [FromQuery] int offset = 0, [FromQuery] int limit = Paging.DefaultLimit) { @@ -47,6 +52,8 @@ public class ReviewsController(ISender sender) : ApiControllerBase(sender) { 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 b7db6b6..1e9e1c3 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 fc72878..55fbdde 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 a5a3b3f..bfd0efc 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/Controllers/ReviewEndpointTests.cs b/tests/Presentation.Tests.Integration/Controllers/ReviewEndpointTests.cs index 4f33dd8..4a2908c 100644 --- a/tests/Presentation.Tests.Integration/Controllers/ReviewEndpointTests.cs +++ b/tests/Presentation.Tests.Integration/Controllers/ReviewEndpointTests.cs @@ -23,6 +23,35 @@ public ReviewEndpointTests() this.application = new(); } + [Fact] + public async Task GetReviews_ShouldFilter_ByTextAndStars() + { + // Arrange + using var client = this.application.CreateClient(); + + // Act + using var response = await client.GetAsync("/api/reviews?query=great&minStars=4&limit=50"); + var result = (await response.Content.ReadAsStringAsync()).Deserialize>(); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + result.Items.ShouldNotBeEmpty(); + result.Items.ShouldAllBe(r => r.Stars >= 4 && r.Text!.Contains("great", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetReviews_WithBadMinStars_ShouldReturn_BadRequest() + { + // Arrange + using var client = this.application.CreateClient(); + + // Act + using var response = await client.GetAsync("/api/reviews?minStars=6"); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + [Fact] public async Task CreateReview_ShouldReturn_Created() {