Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Application/Reviews/IReviewsRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public Task<Review> CreateReview(
public Task<Page<Review>> GetReviews(
Guid? movieId,
Guid? authorId,
string? query,
int? minStars,
PageRequest page,
CancellationToken cancellationToken);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ public class GetReviewsHandler(IReviewsRepository repository) : IRequestHandler<
{
public async ValueTask<Page<Review>> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace CleanLambdaFunction.Application.Reviews.Queries.GetReviews;
using Mediator;

/// <summary>
/// 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.
/// </summary>
public class GetReviewsQuery : IRequest<Page<Review>>
{
Expand All @@ -15,6 +16,12 @@ public class GetReviewsQuery : IRequest<Page<Review>>
/// <summary>Only return reviews by this author. Leave empty for every author.</summary>
public Guid? AuthorId { get; init; }

/// <summary>Only return reviews whose text contains this, ignoring case. Leave empty for every review.</summary>
public string? Query { get; init; }

/// <summary>Only return reviews with at least this many stars, from 1 to 5. Leave empty for every rating.</summary>
public int? MinStars { get; init; }

/// <summary>The number of results to skip, 0 for the first page.</summary>
public int Offset { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ public async Task<bool> DeleteReview(Guid id, CancellationToken cancellationToke
public async Task<Page<ApplicationReview>> GetReviews(
Guid? movieId,
Guid? authorId,
string? query,
int? minStars,
PageRequest page,
CancellationToken cancellationToken)
{
Expand All @@ -203,6 +205,18 @@ public async Task<Page<ApplicationReview>> 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
Expand Down
10 changes: 9 additions & 1 deletion src/Presentation/LambdaFunction/LambdaFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ private async Task<Page<Review>> ListReviews(ListReviewsRequest request, ILambda
{
MovieId = request.MovieId,
AuthorId = request.AuthorId,
Query = request.Query,
MinStars = request.MinStars,
Offset = request.Offset,
Limit = request.Limit
}, context);
Expand Down Expand Up @@ -183,6 +185,8 @@ public async Task<IHttpResult> HttpGetAuthors(
/// </summary>
/// <param name="movieId">Only reviews of this movie. Leave empty for every movie.</param>
/// <param name="authorId">Only reviews by this author. Leave empty for every author.</param>
/// <param name="query">Only reviews whose text contains this, ignoring case. Leave empty for every review.</param>
/// <param name="minStars">Only reviews with at least this many stars, from 1 to 5. Leave empty for every rating.</param>
/// <param name="offset">The number of reviews to skip.</param>
/// <param name="limit">The most reviews to return, from 1 to 50.</param>
/// <param name="context">The Lambda invocation context.</param>
Expand All @@ -194,11 +198,13 @@ public async Task<IHttpResult> HttpGetAuthors(
public async Task<IHttpResult> 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<string, string[]>();
Expand All @@ -214,6 +220,8 @@ public async Task<IHttpResult> HttpGetReviews(
{
MovieId = movie,
AuthorId = author,
Query = query,
MinStars = minStars,
Offset = offset ?? 0,
Limit = limit ?? AppPaging.Paging.DefaultLimit
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ namespace CleanLambdaFunction.Presentation.LambdaFunction.Models;
using Application.Common.Paging;

/// <summary>
/// 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.
/// </summary>
public record ListReviewsRequest
{
Expand All @@ -13,6 +14,12 @@ public record ListReviewsRequest
/// <summary>Only return reviews by this author. Leave empty for every author.</summary>
public Guid? AuthorId { get; init; }

/// <summary>Only return reviews whose text contains this, ignoring case. Leave empty for every review.</summary>
public string? Query { get; init; }

/// <summary>Only return reviews with at least this many stars, from 1 to 5. Leave empty for every rating.</summary>
public int? MinStars { get; init; }

/// <summary>The number of results to skip, 0 for the first page. Use the NextOffset from a previous page to get the next one.</summary>
public int Offset { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IReviewsRepository>();
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<Review>([new Review(Guid.Empty, 5)], 20, 5, 21));
_ = repository.GetReviews(MovieId, AuthorId, "great", 4, new PageRequest(20, 5), token).Returns(new Page<Review>([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);
Expand All @@ -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<Review>([new Review(Guid.Empty, 5)], 0, Paging.DefaultLimit, 1));
_ = repository.GetReviews(null, null, null, null, new PageRequest(0, Paging.DefaultLimit), token).Returns(new Page<Review>([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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions tests/Presentation.Tests.Integration/HandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
21 changes: 18 additions & 3 deletions tests/Presentation.Tests.Integration/HttpApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,29 @@ 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<Page<Review>>(response);

page.Items.ShouldNotBeEmpty();
page.Items.ShouldAllBe(r => r.Stars >= 4);
}

[Fact]
public async Task HttpGetReviews_ShouldReturn_ReviewsOfMovie()
{
// Arrange
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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/Presentation.Tests.Integration/TelemetryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading