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 CleanGraphQLApi.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
6 changes: 6 additions & 0 deletions src/Presentation/GraphQL/Queries/ReviewQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@ public static partial class ReviewQueries
/// </summary>
/// <param name="movieId">Only return reviews of this movie. Leave empty for every movie.</param>
/// <param name="authorId">Only return reviews by this author. Leave empty for every author.</param>
/// <param name="query">Only return reviews whose text contains this, ignoring case. Leave empty for every review.</param>
/// <param name="minStars">Only return reviews with at least this many stars, from 1 to 5. Leave empty for every rating.</param>
[UsePaging(ConnectionName = "Reviews")]
#if includeAuth
[Authorize]
#endif
public static async Task<Connection<Review>> GetReviews(
Guid? movieId,
Guid? authorId,
string? query,
int? minStars,
int? first,
string? after,
ISender sender,
Expand All @@ -47,6 +51,8 @@ public static async Task<Connection<Review>> GetReviews(
{
MovieId = movieId,
AuthorId = authorId,
Query = query,
MinStars = minStars,
Offset = after.ToOffset(),
Limit = first ?? Paging.DefaultLimit
}, 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,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<GraphData>();

// Assert
result.Data.Reviews.Nodes.ShouldNotBeEmpty();
result.Data.Reviews.Nodes.ShouldAllBe(r => r.Stars >= 4);
}

[Fact]
public async Task Create_ShouldCreate_Review()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading