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 CleanWebApi.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
11 changes: 9 additions & 2 deletions src/Presentation/Controllers/Reviews/ReviewsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public class ReviewsController(ISender sender) : ApiControllerBase(sender)
/// </summary>
/// <remarks>
/// 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:
Expand All @@ -26,10 +27,12 @@ public class ReviewsController(ISender sender) : ApiControllerBase(sender)
/// </remarks>
/// <param name="movieId">Only return reviews of this movie. Leave it out for every movie.</param>
/// <param name="authorId">Only return reviews by this author. Leave it out for every author.</param>
/// <param name="query">Only return reviews whose text contains this, ignoring case. Leave it out for every review.</param>
/// <param name="minStars">Only return reviews with at least this many stars, from 1 to 5. Leave it out for every rating.</param>
/// <param name="offset">The number of reviews to skip, 0 for the first page.</param>
/// <param name="limit">The largest number of reviews to return, from 1 to 50.</param>
/// <response code="200">One page of Reviews.</response>
/// <response code="400">An Id, the offset or the limit is not valid.</response>
/// <response code="400">An Id, the search text, the minimum stars, the offset or the limit is not valid.</response>
/// <response code="500">An unexpected error occurred.</response>
[HttpGet]
[EndpointName(nameof(GetReviews))]
Expand All @@ -40,13 +43,17 @@ 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)
{
return this.Ok(await this.Sender.Send(new Queries.GetReviews.GetReviewsQuery
{
MovieId = movieId,
AuthorId = authorId,
Query = query,
MinStars = minStars,
Offset = offset,
Limit = limit
}, cancellationToken));
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page<Entities.Review>>();

// 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()
{
Expand Down
Loading