From e87a3e59ba05e723c4c06dd310a9d48fcb6902f1 Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:35:40 -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 +++++ .../LambdaFunction/LambdaFunction.cs | 10 +++- .../Models/ListReviewsRequest.cs | 9 ++- .../GetReviews/GetReviewsHandlerTests.cs | 10 ++-- .../GetReviews/GetReviewsValidatorTests.cs | 44 ++++++++++++++ ...ityFrameworkMovieReviewsRepositoryTests.cs | 59 ++++++++++++++++++- .../HandlerTests.cs | 11 ++++ .../HttpApiTests.cs | 21 ++++++- .../TelemetryTests.cs | 2 +- 13 files changed, 179 insertions(+), 16 deletions(-) diff --git a/src/Application/Reviews/IReviewsRepository.cs b/src/Application/Reviews/IReviewsRepository.cs index c0173cc..0e3b64b 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 403076f..95ce8e3 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 1ff3d0e..0898c6f 100644 --- a/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs +++ b/src/Application/Reviews/Queries/GetReviews/GetReviewsQuery.cs @@ -5,7 +5,8 @@ namespace CleanLambdaFunction.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 ac0d44f..2904a55 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 76c0053..59c78ca 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/LambdaFunction/LambdaFunction.cs b/src/Presentation/LambdaFunction/LambdaFunction.cs index 24c1d00..d50598f 100644 --- a/src/Presentation/LambdaFunction/LambdaFunction.cs +++ b/src/Presentation/LambdaFunction/LambdaFunction.cs @@ -117,6 +117,8 @@ private async Task> ListReviews(ListReviewsRequest request, ILambda { MovieId = request.MovieId, AuthorId = request.AuthorId, + Query = request.Query, + MinStars = request.MinStars, Offset = request.Offset, Limit = request.Limit }, context); @@ -183,6 +185,8 @@ public async Task HttpGetAuthors( /// /// Only reviews of this movie. Leave empty for every movie. /// Only reviews by this author. Leave empty for every author. + /// Only reviews whose text contains this, ignoring case. 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 reviews to skip. /// The most reviews to return, from 1 to 50. /// The Lambda invocation context. @@ -194,11 +198,13 @@ public async Task HttpGetAuthors( public async Task HttpGetReviews( [FromQuery] string movieId, [FromQuery] string authorId, + [FromQuery] string query, + [FromQuery] int? minStars, [FromQuery] int? offset, [FromQuery] int? limit, ILambdaContext context) { - return await this.Invoke((movieId, authorId, offset, limit), context, async () => + return await this.Invoke((movieId, authorId, query, minStars, offset, limit), context, async () => { // The generator only binds primitive types from the query string, so the ids arrive as text. var errors = new Dictionary(); @@ -214,6 +220,8 @@ public async Task HttpGetReviews( { MovieId = movie, AuthorId = author, + Query = query, + MinStars = minStars, Offset = offset ?? 0, Limit = limit ?? AppPaging.Paging.DefaultLimit }; diff --git a/src/Presentation/LambdaFunction/Models/ListReviewsRequest.cs b/src/Presentation/LambdaFunction/Models/ListReviewsRequest.cs index 4ba1aef..ac33d26 100644 --- a/src/Presentation/LambdaFunction/Models/ListReviewsRequest.cs +++ b/src/Presentation/LambdaFunction/Models/ListReviewsRequest.cs @@ -3,7 +3,8 @@ namespace CleanLambdaFunction.Presentation.LambdaFunction.Models; using Application.Common.Paging; /// -/// 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 record ListReviewsRequest { @@ -13,6 +14,12 @@ public record ListReviewsRequest /// 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. Use the NextOffset from a previous page to get the next one. public int Offset { get; init; } diff --git a/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs index cd6f710..a5cb53e 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 bc1519c..8456530 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 546d85a..8f77160 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/HandlerTests.cs b/tests/Presentation.Tests.Integration/HandlerTests.cs index 9d9c693..ce722c0 100644 --- a/tests/Presentation.Tests.Integration/HandlerTests.cs +++ b/tests/Presentation.Tests.Integration/HandlerTests.cs @@ -258,6 +258,17 @@ public async Task GetAuthors_ShouldReturn_Matches() result.Items.ShouldAllBe(a => $"{a.FirstName} {a.LastName}".Contains(lastName, StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task GetReviews_ShouldFilter_ByTextAndStars() + { + // Act + var result = await this.handler.GetReviews(new ListReviewsRequest { Query = "great", MinStars = 4, Limit = 50 }, this.context); + + // Assert + result.Items.ShouldNotBeEmpty(); + result.Items.ShouldAllBe(r => r.Stars >= 4 && r.Text!.Contains("great", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task GetReviews_ShouldReturn_FirstPage() { diff --git a/tests/Presentation.Tests.Integration/HttpApiTests.cs b/tests/Presentation.Tests.Integration/HttpApiTests.cs index 4dcc609..dbbab56 100644 --- a/tests/Presentation.Tests.Integration/HttpApiTests.cs +++ b/tests/Presentation.Tests.Integration/HttpApiTests.cs @@ -81,6 +81,21 @@ public async Task HttpGetAuthors_ShouldReturn_Ok() page.NextOffset.ShouldBeNull(); } + [Fact] + public async Task HttpGetReviews_ShouldFilter_ByTextAndStars() + { + // Act + var response = Read(await this.handler.HttpGetReviews(string.Empty, string.Empty, "great", 4, null, 50, this.context)); + + // Assert + response.StatusCode.ShouldBe((int)HttpStatusCode.OK); + + var page = Body>(response); + + page.Items.ShouldNotBeEmpty(); + page.Items.ShouldAllBe(r => r.Stars >= 4); + } + [Fact] public async Task HttpGetReviews_ShouldReturn_ReviewsOfMovie() { @@ -88,7 +103,7 @@ public async Task HttpGetReviews_ShouldReturn_ReviewsOfMovie() var movieId = (await this.handler.GetReviews(new ListReviewsRequest(), this.context)).Items[0].ReviewedMovie.ShouldNotBeNull().Id; // Act - var response = Read(await this.handler.HttpGetReviews(movieId.ToString(), string.Empty, null, 50, this.context)); + var response = Read(await this.handler.HttpGetReviews(movieId.ToString(), string.Empty, string.Empty, null, null, 50, this.context)); // Assert response.StatusCode.ShouldBe((int)HttpStatusCode.OK); @@ -102,7 +117,7 @@ public async Task HttpGetReviews_ShouldReturn_ReviewsOfMovie() public async Task HttpGetReviews_ShouldReturn_BadRequest_WhenIdsAreNotGuids() { // Act - var response = Read(await this.handler.HttpGetReviews("not-a-movie", "not-an-author", null, null, this.context)); + var response = Read(await this.handler.HttpGetReviews("not-a-movie", "not-an-author", string.Empty, null, null, null, this.context)); // Assert response.StatusCode.ShouldBe((int)HttpStatusCode.BadRequest); @@ -116,7 +131,7 @@ public async Task HttpGetReviews_ShouldReturn_BadRequest_WhenIdsAreNotGuids() public async Task HttpGetReviews_ShouldReturn_BadRequest_WhenMovieIdIsEmpty() { // Act - var response = Read(await this.handler.HttpGetReviews(Guid.Empty.ToString(), string.Empty, null, null, this.context)); + var response = Read(await this.handler.HttpGetReviews(Guid.Empty.ToString(), string.Empty, string.Empty, null, null, null, this.context)); // Assert response.StatusCode.ShouldBe((int)HttpStatusCode.BadRequest); diff --git a/tests/Presentation.Tests.Integration/TelemetryTests.cs b/tests/Presentation.Tests.Integration/TelemetryTests.cs index b74da63..e1d14b8 100644 --- a/tests/Presentation.Tests.Integration/TelemetryTests.cs +++ b/tests/Presentation.Tests.Integration/TelemetryTests.cs @@ -79,7 +79,7 @@ public async Task HttpInvocation_ShouldTrace_OneSpanPerInvocation() using var parent = TestSource.StartActivity("Test")!; // Act - _ = await handler.HttpGetReviews("not-a-guid", string.Empty, null, null, this.context); + _ = await handler.HttpGetReviews("not-a-guid", string.Empty, string.Empty, null, null, null, this.context); // Assert var trace = this.SpansIn(parent);