From aa94fd31b0cbd8fba7749631edfbddb5b3c1456a Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:35:38 -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 +++++ .../GraphQL/Queries/ReviewQueries.cs | 6 ++ .../GetReviews/GetReviewsHandlerTests.cs | 10 ++-- .../GetReviews/GetReviewsValidatorTests.cs | 44 ++++++++++++++ ...ityFrameworkMovieReviewsRepositoryTests.cs | 59 ++++++++++++++++++- .../GraphQL/Reviews/ReviewTests.cs | 15 +++++ .../GraphQL/Schema/schema.graphql | 4 ++ 11 files changed, 157 insertions(+), 10 deletions(-) diff --git a/src/Application/Reviews/IReviewsRepository.cs b/src/Application/Reviews/IReviewsRepository.cs index 83e2744..582bc18 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 8a82702..3b90cc9 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 8e706a7..4bfd83d 100644 --- a/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs +++ b/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs @@ -5,7 +5,8 @@ namespace CleanGraphQLApi.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 7bc6c9a..c1898d8 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 7450b6b..d607c86 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/GraphQL/Queries/ReviewQueries.cs b/src/Presentation/GraphQL/Queries/ReviewQueries.cs index 33e4d23..c4b5769 100644 --- a/src/Presentation/GraphQL/Queries/ReviewQueries.cs +++ b/src/Presentation/GraphQL/Queries/ReviewQueries.cs @@ -31,6 +31,8 @@ public static partial class ReviewQueries /// /// Only return reviews of this movie. Leave empty for every movie. /// Only return reviews by this author. Leave empty for every author. + /// Only return reviews whose text contains this, ignoring case. Leave empty for every review. + /// Only return reviews with at least this many stars, from 1 to 5. Leave empty for every rating. [UsePaging(ConnectionName = "Reviews")] #if includeAuth [Authorize] @@ -38,6 +40,8 @@ public static partial class ReviewQueries public static async Task> GetReviews( Guid? movieId, Guid? authorId, + string? query, + int? minStars, int? first, string? after, ISender sender, @@ -47,6 +51,8 @@ public static async Task> GetReviews( { MovieId = movieId, AuthorId = authorId, + Query = query, + MinStars = minStars, Offset = after.ToOffset(), Limit = first ?? Paging.DefaultLimit }, cancellationToken); diff --git a/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs index ab447c5..d2b9264 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 3cffc3a..560afb6 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 2b70d8a..14bcf5f 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/GraphQL/Reviews/ReviewTests.cs b/tests/Presentation.Tests.Integration/GraphQL/Reviews/ReviewTests.cs index 831bb7c..4632024 100644 --- a/tests/Presentation.Tests.Integration/GraphQL/Reviews/ReviewTests.cs +++ b/tests/Presentation.Tests.Integration/GraphQL/Reviews/ReviewTests.cs @@ -23,6 +23,21 @@ public class ReviewTests : IDisposable private const string UpdateMutation = "mutation($review:UpdateReviewInput!){updateReview(input:$review){review{id,stars,author{id},movie{id}}," + Errors + "}}"; private const string DeleteMutation = "mutation($review:DeleteReviewInput!){deleteReview(input:$review){reviewId," + Errors + "}}"; + [Fact] + public async Task Reviews_ShouldFilter_ByTextAndStars() + { + // Arrange + using var client = this.application.CreateClient(); + + // Act + using var response = await client.GetAsync("/graphql?query={reviews(query:\"great\",minStars:4,first:50){nodes{id,stars}}}"); + var result = (await response.Content.ReadAsStringAsync()).Deserialize(); + + // Assert + result.Data.Reviews.Nodes.ShouldNotBeEmpty(); + result.Data.Reviews.Nodes.ShouldAllBe(r => r.Stars >= 4); + } + [Fact] public async Task Create_ShouldCreate_Review() { diff --git a/tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql b/tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql index 8339948..5acea9e 100644 --- a/tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql +++ b/tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql @@ -53,6 +53,10 @@ type Query { movieId: UUID "Only return reviews by this author. Leave empty for every author." authorId: UUID + "Only return reviews whose text contains this, ignoring case. Leave empty for every review." + query: String + "Only return reviews with at least this many stars, from 1 to 5. Leave empty for every rating." + minStars: Int "Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor."