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 CleanModelContextProtocol.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
8 changes: 7 additions & 1 deletion src/Presentation/Tools/ReviewTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ public static partial class ReviewTools
{
/// <summary>
/// List reviews with their star rating and the title and author of each, in movie-title order. Filter by movie, by
/// author or both, or pass a reviewId to get exactly one review. With no filters it pages through every review.
/// author, by words in the review or by a minimum star rating, or pass a reviewId to get exactly one review. With no filters it pages through every review.
/// </summary>
/// <param name="movieId">Only reviews of this movie. Use movies_search to find the id.</param>
/// <param name="authorId">Only reviews by this author. Use authors_search to find the id.</param>
/// <param name="reviewId">Get this one review instead of searching. The other filters are ignored.</param>
/// <param name="query">Only reviews whose text contains this, ignoring case, such as 'great film'. 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 results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page.</param>
/// <param name="limit">The most results to return, from 1 to 50.</param>
[McpServerTool(Name = "reviews_list", Title = "List reviews", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Review)]
Expand All @@ -25,6 +27,8 @@ public static async partial Task<ResultPage<ReviewResult>> ListReviews(
Guid? movieId = null,
Guid? authorId = null,
Guid? reviewId = null,
string? query = null,
int? minStars = null,
int offset = 0,
int limit = 10)
{
Expand All @@ -42,6 +46,8 @@ public static async partial Task<ResultPage<ReviewResult>> ListReviews(
{
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
12 changes: 12 additions & 0 deletions tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ public async Task ListReviews_WithEmptyMovieId_ShouldReturn_ValidationError()
text.ShouldStartWith("Validation Failed: MovieId: A Movie Id can't be empty.");
}

[Fact]
public async Task ListReviews_ShouldFilter_ByTextAndStars()
{
// Act
var page = await this.CallAsync<ResultPage<ReviewResult>>("reviews_list",
new Dictionary<string, object?> { ["query"] = "great", ["minStars"] = 4, ["limit"] = 50 });

// Assert
page.Items.ShouldNotBeEmpty();
page.Items.ShouldAllBe(r => r.Stars >= 4 && r.Text!.Contains("great", StringComparison.OrdinalIgnoreCase));
}

[Fact]
public async Task WriteReview_ShouldCreate_Review()
{
Expand Down
2 changes: 1 addition & 1 deletion tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task ListReviews_ShouldReturn_Page()
.Returns(new Page<Entities.Review>([SampleReview()], 2, 1, 3));

// Act
var response = await ReviewTools.ListReviews(sender, CancellationToken.None, MovieId, AuthorId, null, 2, 1);
var response = await ReviewTools.ListReviews(sender, CancellationToken.None, MovieId, AuthorId, null, null, null, 2, 1);

// Assert
response.TotalCount.ShouldBe(3);
Expand Down
Loading