diff --git a/.agents/skills/cancellation/SKILL.md b/.agents/skills/cancellation/SKILL.md index 9ec0ba5..0214ca6 100644 --- a/.agents/skills/cancellation/SKILL.md +++ b/.agents/skills/cancellation/SKILL.md @@ -31,7 +31,7 @@ var token = tokenSource.Token; var result = await handler.Handle(command, token); // Assert -_ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token); +_ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, command.Text, token); ``` ## Don't diff --git a/.agents/skills/errors-and-exceptions/SKILL.md b/.agents/skills/errors-and-exceptions/SKILL.md index 44bb6ab..2159b9e 100644 --- a/.agents/skills/errors-and-exceptions/SKILL.md +++ b/.agents/skills/errors-and-exceptions/SKILL.md @@ -36,7 +36,7 @@ The presentation layer maps each exception to its own shape in one place (in a w ```csharp // src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs -var review = await reviewsRepository.UpdateReview(request.Id, request.Stars, cancellationToken); +var review = await reviewsRepository.UpdateReview(request.Id, request.Stars, request.Text, cancellationToken); NotFoundException.ThrowIfNull(review, EntityType.Review); diff --git a/.agents/skills/sample-data/SKILL.md b/.agents/skills/sample-data/SKILL.md index 2050b9b..614c449 100644 --- a/.agents/skills/sample-data/SKILL.md +++ b/.agents/skills/sample-data/SKILL.md @@ -21,9 +21,10 @@ Sample data is only added when `Database:SeedSampleData` is `true` (on in `appse ## Steps 1. New column on a model? Add a `RuleFor` to the matching `Faker` in `AddData`, and a value to the rows in `AddTestData` if tests need it. -2. Keep the fixed `Seed` and `ReferenceDate`. Dates come from `f.Date.Past(refDate: ReferenceDate)`, never from the clock, so every run gives the same data. -3. Tests that need a specific row look it up by its known value (`Authors.First(a => a.FirstName == "One")`), not by position. -4. Presentation integration tests that need a row with children pick one that has them. Bogus spreads reviews at random, so a few movies and authors have none. +2. Movie titles and review text come from the `Titles` and `ReviewTexts` lists in `AddData`. Keep them made up, with no real films, characters or brands, and keep a few titles for common searches such as "space" or "love". +3. Keep the fixed `Seed` and `ReferenceDate`. Dates come from `f.Date.Past(refDate: ReferenceDate)`, never from the clock, so every run gives the same data. +4. Tests that need a specific row look it up by its known value (`Authors.First(a => a.FirstName == "One")`), not by position. +5. Presentation integration tests that need a row with children pick one that has them. Bogus spreads reviews at random, so a few movies and authors have none. ## Don't diff --git a/.agents/skills/test-design/SKILL.md b/.agents/skills/test-design/SKILL.md index 388b9bd..f482e72 100644 --- a/.agents/skills/test-design/SKILL.md +++ b/.agents/skills/test-design/SKILL.md @@ -64,7 +64,7 @@ public async Task Handle_ShouldThrowException_ReviewDoesNotExist() // Assert exception.Message.ShouldBe("The Review with the supplied id was not found."); - _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token); + _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, command.Text, token); } ``` diff --git a/README.md b/README.md index 8303306..51a3277 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ curl http://localhost:8080/mcp \ "method": "tools/call", "params": { "name": "movies_search", - "arguments": { "query": "global", "limit": 2 } + "arguments": { "query": "space", "limit": 2 } } }' ``` @@ -66,23 +66,24 @@ The answer comes back as an event stream, and its result carries the movies as s { "items": [ { - "id": "30fca414-247a-779e-105a-d81aa2506f58", - "title": "Assimilated global infrastructure", - "reviewCount": 1, - "averageStars": 1 + "id": "cb188ef6-14e7-f85f-2f78-924c2ab26b28", + "title": "Deep Space Gardeners", + "reviewCount": 5, + "averageStars": 3.4 }, { - "id": "c27d6600-79a6-9346-2595-a52b7f07dc3e", - "title": "Automated global alliance", - "reviewCount": 3, - "averageStars": 3.3 + "id": "dfefa450-5454-6a4d-a4aa-95a9b047c17b", + "title": "Postcards from Outer Space", + "reviewCount": 1, + "averageStars": 3 } ], - "totalCount": 2 + "totalCount": 4, + "nextOffset": 2 } ``` -Yes, the sample movies are named by a corporate buzzword generator, and the critics are harsh. +The sample movies are all made up, and the critics are harsh. Curl is a fine way to prove it works and a terrible way to spend an afternoon. Open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) at instead. It's already pointed at the server, so flip the switch to connect and open **Tools**. diff --git a/src/Application/Reviews/Commands/CreateReview/CreateReviewCommand.cs b/src/Application/Reviews/Commands/CreateReview/CreateReviewCommand.cs index a889115..1a26aed 100644 --- a/src/Application/Reviews/Commands/CreateReview/CreateReviewCommand.cs +++ b/src/Application/Reviews/Commands/CreateReview/CreateReviewCommand.cs @@ -10,4 +10,6 @@ public class CreateReviewCommand : IRequest public Guid MovieId { get; init; } public int Stars { get; init; } + + public string? Text { get; init; } } diff --git a/src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs b/src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs index d7e4ff4..678de16 100644 --- a/src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs +++ b/src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs @@ -25,6 +25,6 @@ public async ValueTask Handle(CreateReviewCommand request, CancellationT } return await reviewsRepository - .CreateReview(request.AuthorId, request.MovieId, request.Stars, cancellationToken); + .CreateReview(request.AuthorId, request.MovieId, request.Stars, request.Text, cancellationToken); } } diff --git a/src/Application/Reviews/Commands/CreateReview/CreateReviewValidator.cs b/src/Application/Reviews/Commands/CreateReview/CreateReviewValidator.cs index deb987b..ba23336 100644 --- a/src/Application/Reviews/Commands/CreateReview/CreateReviewValidator.cs +++ b/src/Application/Reviews/Commands/CreateReview/CreateReviewValidator.cs @@ -9,5 +9,6 @@ public CreateReviewValidator() _ = this.RuleFor(r => r.AuthorId).NotEqual(Guid.Empty).WithMessage("An author id was not supplied to create the review."); _ = this.RuleFor(r => r.MovieId).NotEqual(Guid.Empty).WithMessage("A movie id was not supplied to create the review."); _ = this.RuleFor(r => r.Stars).InclusiveBetween(1, 5).WithMessage("A star rating must be between 1 and 5."); + _ = this.RuleFor(r => r.Text).MaximumLength(1000).WithMessage("Review text can't be longer than 1000 characters."); } } diff --git a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs index 7e02405..d171a90 100644 --- a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs +++ b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs @@ -4,7 +4,7 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.UpdateReview; using Mediator; /// -/// Changes the star rating of a review. The author and the movie of a review can't change. +/// Changes the star rating of a review, and its text if there's new text. The author and the movie of a review can't change. /// public class UpdateReviewCommand : IRequest { @@ -13,4 +13,7 @@ public class UpdateReviewCommand : IRequest /// The new star rating, from 1 to 5. public int Stars { get; init; } + + /// The new text. Leave it null to keep the text the review has. + public string? Text { get; init; } } diff --git a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs index e2f7d54..d675afb 100644 --- a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs +++ b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs @@ -9,7 +9,7 @@ public class UpdateReviewHandler(IReviewsRepository reviewsRepository) : IReques { public async ValueTask Handle(UpdateReviewCommand request, CancellationToken cancellationToken) { - var review = await reviewsRepository.UpdateReview(request.Id, request.Stars, cancellationToken); + var review = await reviewsRepository.UpdateReview(request.Id, request.Stars, request.Text, cancellationToken); NotFoundException.ThrowIfNull(review, EntityType.Review); diff --git a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewValidator.cs b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewValidator.cs index a1bb7b1..ea4ee62 100644 --- a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewValidator.cs +++ b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewValidator.cs @@ -8,5 +8,6 @@ public UpdateReviewValidator() { _ = this.RuleFor(r => r.Id).NotEqual(Guid.Empty).WithMessage("A review id was not supplied to Update the review."); _ = this.RuleFor(r => r.Stars).InclusiveBetween(1, 5).WithMessage("A star rating must be between 1 and 5."); + _ = this.RuleFor(r => r.Text).MaximumLength(1000).WithMessage("Review text can't be longer than 1000 characters."); } } diff --git a/src/Application/Reviews/Entities/Review.cs b/src/Application/Reviews/Entities/Review.cs index da115ce..a2e70fe 100644 --- a/src/Application/Reviews/Entities/Review.cs +++ b/src/Application/Reviews/Entities/Review.cs @@ -10,8 +10,10 @@ namespace CleanModelContextProtocol.Application.Reviews.Entities; /// The star rating, from 1 to 5. /// The movie that was reviewed. /// The author who wrote the review. +/// What the author said about the movie, if anything. public record Review( Guid Id, int Stars, ReviewedMovie? ReviewedMovie = null, - ReviewAuthor? ReviewAuthor = null); + ReviewAuthor? ReviewAuthor = null, + string? Text = null); diff --git a/src/Application/Reviews/IReviewsRepository.cs b/src/Application/Reviews/IReviewsRepository.cs index 3fd8c77..7cb99ee 100644 --- a/src/Application/Reviews/IReviewsRepository.cs +++ b/src/Application/Reviews/IReviewsRepository.cs @@ -9,6 +9,7 @@ public Task CreateReview( Guid authorId, Guid movieId, int stars, + string? text, CancellationToken cancellationToken); public Task DeleteReview(Guid id, CancellationToken cancellationToken); @@ -25,5 +26,5 @@ public Task> GetReviews( public Task GetReviewById(Guid id, CancellationToken cancellationToken); - public Task UpdateReview(Guid id, int stars, CancellationToken cancellationToken); + public Task UpdateReview(Guid id, int stars, string? text, CancellationToken cancellationToken); } diff --git a/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs b/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs index 182a7d5..987ca4c 100644 --- a/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs @@ -1,7 +1,14 @@ namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Configuration; +using Microsoft.EntityFrameworkCore.Metadata.Builders; using Models; internal class ReviewConfiguration : EntityConfiguration { + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + _ = builder.Property(r => r.Text).HasMaxLength(1000); + } } diff --git a/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs b/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs index 51ae2e6..41c0c1a 100644 --- a/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs +++ b/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs @@ -148,6 +148,7 @@ public async Task CreateReview( Guid authorId, Guid movieId, int stars, + string? text, CancellationToken cancellationToken) { var now = this.timeProvider.GetUtcNow().UtcDateTime; @@ -157,6 +158,7 @@ public async Task CreateReview( ReviewAuthorId = authorId, ReviewedMovieId = movieId, Stars = stars, + Text = text, DateCreated = now, DateModified = now }; @@ -254,7 +256,7 @@ public async Task> GetReviewsForAuthors(IReadOnlyCollect return result is null ? null : this.mapper.ToDomain(result); } - public async Task UpdateReview(Guid id, int stars, CancellationToken cancellationToken) + public async Task UpdateReview(Guid id, int stars, string? text, CancellationToken cancellationToken) { var now = this.timeProvider.GetUtcNow().UtcDateTime; @@ -264,6 +266,7 @@ public async Task> GetReviewsForAuthors(IReadOnlyCollect .ExecuteUpdateAsync( setters => setters .SetProperty(r => r.Stars, stars) + .SetProperty(r => r.Text, r => text ?? r.Text) .SetProperty(r => r.DateModified, now), cancellationToken); diff --git a/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs b/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs index 28fa862..089f295 100644 --- a/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs +++ b/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs @@ -9,6 +9,126 @@ internal static class MovieReviewsDbContextExtensions private const int Seed = 20211014; private static readonly DateTime ReferenceDate = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // Made-up titles, picked so that searches like "space", "love", "murder" and "dragon" find something. + private static readonly string[] Titles = + [ + "Deep Space Gardeners", + "The Last Space Lighthouse", + "Postcards from Outer Space", + "Space Station Bake Sale", + "Love Letters to a Lighthouse Keeper", + "Love and Other Spreadsheets", + "The Accidental Love Algorithm", + "Love on the Night Ferry", + "Murder at the Marmalade Factory", + "A Quiet Murder in Wagga Wagga", + "Murder on the Midnight Tram", + "The Book Club Murder Rota", + "The Dragon Who Hated Heights", + "Dragon Tax Season", + "Dragon of the Salt Flats", + "Apprentice to a Retired Dragon", + "The Long Drive to Coober Pedy", + "Ghosts of the Hardware Aisle", + "The Haunted Car Wash", + "Seven Days in Saltwater Bay", + "The Cartographer's Last Map", + "Thunder Over the Wheat Belt", + "The Beekeeper's Alibi", + "Midnight at the Laundromat", + "The Ninth Lifeguard", + "Lawn Bowls at Dawn", + "The Great Sourdough Heist", + "Heist at the Lost Property Office", + "Rust and Rain", + "Tin Roof Summer", + "The Clockmaker's Overdraft", + "Wedding at the Caravan Park", + "The Substitute Mayor", + "Echoes Under the Ice Shelf", + "The Robot Who Forgot Tuesday", + "Night Shift on Mars Base Four", + "Time Machines Repaired While You Wait", + "The Pirate Accountant", + "Treasure of the Tidal Flats", + "My Neighbour the Werewolf Plumber", + "Zombies of the Bowling Green", + "Final Whistle at Shark Bay", + "The Goalkeeper's Grandmother", + "Wild Horses of the High Country", + "The Mountain Does Not Care", + "Letters from the Lost Valley", + "The Last Train to Nowhere Junction", + "The Violin in the Attic", + "Two Tickets to the Moon Festival", + "The Courtroom Cockatoo" + ]; + + // What reviewers say, one row per star rating. + private static readonly string[][] ReviewTexts = + [ + [ + "Two hours I will never get back.", + "I asked for a refund and a hug.", + "The popcorn had a better plot.", + "I checked my watch more often than the hero did.", + "Somebody should apologise, and I'm not fussy who.", + "I've had more fun at the dentist, and they gave me a sticker.", + "The credits were the best twist.", + "Even the dog left the room.", + "A brave attempt at being a film.", + "I stayed to the end out of spite." + ], + [ + "Some good ideas, lost in the second act.", + "The trailer was better.", + "Pretty to look at, hard to care about.", + "It starts well, then wanders off and never comes back.", + "One good scene, and I've forgotten which one.", + "The lead tries hard, but the script doesn't help.", + "Twenty minutes too long, in a film that needed to be an hour shorter.", + "I liked the music more than the movie.", + "Not terrible, which is the nicest thing I can say.", + "It's trying to be three films and manages half of one." + ], + [ + "Fine for a rainy Sunday.", + "Solid, if you've got nothing else on.", + "Not bad, not great, mostly fine.", + "You'll enjoy it and forget it by Tuesday.", + "A decent watch with a very predictable ending.", + "It does what it says on the poster.", + "Good cast, shame about the middle.", + "Perfectly pleasant, like a sandwich.", + "Worth streaming, not worth the cinema ticket.", + "I laughed twice and looked at my phone once." + ], + [ + "Great film, with one scene too many.", + "Funny, warm and better than it needed to be.", + "I'd happily watch it again.", + "Smart, quick and well acted, with a slightly soft ending.", + "The best surprise I've had at the movies this year.", + "It takes a while to warm up, then it really goes.", + "A lovely little film that knows exactly what it is.", + "Strong performances all round, and the ending earns it.", + "Not perfect, but I've been thinking about it all week.", + "I took my mum, and we both loved it." + ], + [ + "An instant favourite.", + "Great film. I cried at the ending.", + "Perfect from the first scene to the last.", + "I walked out and bought a ticket for the next session.", + "The kind of movie you tell strangers about.", + "Funny, sad and completely gripping.", + "I can't find a single thing wrong with it, and I tried.", + "Everyone involved should be very proud.", + "It's the best thing I've seen in years.", + "I want to watch it again for the first time." + ] + ]; + public static MovieReviewsDbContext AddData(this MovieReviewsDbContext context) { if (context.Authors.Any()) @@ -30,7 +150,7 @@ public static MovieReviewsDbContext AddData(this MovieReviewsDbContext context) var movies = new Faker() .UseSeed(Seed) .RuleFor(m => m.Id, f => f.Random.Guid()) - .RuleFor(m => m.Title, f => f.Company.CatchPhrase()) + .RuleFor(m => m.Title, f => Titles[f.IndexFaker]) .RuleFor(m => m.DateCreated, f => f.Date.Past(refDate: ReferenceDate)) .RuleFor(m => m.DateModified, f => f.Date.Past(refDate: ReferenceDate)) .Generate(50); @@ -43,6 +163,7 @@ public static MovieReviewsDbContext AddData(this MovieReviewsDbContext context) .RuleFor(r => r.ReviewedMovieId, f => f.PickRandom(movies).Id) .RuleFor(r => r.ReviewAuthorId, f => f.PickRandom(authors).Id) .RuleFor(r => r.Stars, f => f.Random.Number(1, 5)) + .RuleFor(r => r.Text, (f, r) => f.PickRandom(ReviewTexts[r.Stars - 1])) .RuleFor(r => r.DateCreated, f => f.Date.Past(refDate: ReferenceDate)) .RuleFor(r => r.DateModified, f => f.Date.Past(refDate: ReferenceDate)) .Generate(150); diff --git a/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.Designer.cs b/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.Designer.cs new file mode 100644 index 0000000..a033ee2 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.Designer.cs @@ -0,0 +1,141 @@ +// +using System; +using CleanModelContextProtocol.Infrastructure.Databases.MovieReviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Migrations +{ + [DbContext(typeof(MovieReviewsDbContext))] + [Migration("20261106000000_AddReviewText")] + partial class AddReviewText + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("LastName", "FirstName"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Title"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("ReviewAuthorId") + .HasColumnType("TEXT"); + + b.Property("ReviewedMovieId") + .HasColumnType("TEXT"); + + b.Property("Stars") + .HasColumnType("INTEGER"); + + b.Property("Text") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReviewAuthorId"); + + b.HasIndex("ReviewedMovieId"); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Review", b => + { + b.HasOne("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Author", "ReviewAuthor") + .WithMany("Reviews") + .HasForeignKey("ReviewAuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Movie", "ReviewedMovie") + .WithMany("Reviews") + .HasForeignKey("ReviewedMovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewAuthor"); + + b.Navigation("ReviewedMovie"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Author", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Movie", b => + { + b.Navigation("Reviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.cs b/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.cs new file mode 100644 index 0000000..91a0c90 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.cs @@ -0,0 +1,30 @@ +// +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Migrations +{ + /// + public partial class AddReviewText : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Text", + table: "Reviews", + type: "TEXT", + maxLength: 1000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Text", + table: "Reviews"); + } + } +} diff --git a/src/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs b/src/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs index 9beac9e..89d4058 100644 --- a/src/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs @@ -91,6 +91,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Stars") .HasColumnType("INTEGER"); + b.Property("Text") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("ReviewAuthorId"); diff --git a/src/Infrastructure/Databases/MovieReviews/Models/Review.cs b/src/Infrastructure/Databases/MovieReviews/Models/Review.cs index aa401e5..173029c 100644 --- a/src/Infrastructure/Databases/MovieReviews/Models/Review.cs +++ b/src/Infrastructure/Databases/MovieReviews/Models/Review.cs @@ -7,6 +7,8 @@ internal record Review : Entity { public int Stars { get; set; } + public string? Text { get; set; } + public Guid ReviewedMovieId { get; set; } public Movie ReviewedMovie { get; init; } = null!; diff --git a/src/Presentation/Models/ResultMapper.cs b/src/Presentation/Models/ResultMapper.cs index 0a01620..0c199c4 100644 --- a/src/Presentation/Models/ResultMapper.cs +++ b/src/Presentation/Models/ResultMapper.cs @@ -65,7 +65,8 @@ public static ReviewResult ToResult(this Review review, ReviewedMovie? movie = n movie?.Id, movie?.Title, author?.Id, - author is null ? null : FullName(author.FirstName, author.LastName)); + author is null ? null : FullName(author.FirstName, author.LastName), + review.Text); } public static ResultPage ToResult(this Page page, Func map) diff --git a/src/Presentation/Models/ReviewResult.cs b/src/Presentation/Models/ReviewResult.cs index 9f89c8c..44a3b2a 100644 --- a/src/Presentation/Models/ReviewResult.cs +++ b/src/Presentation/Models/ReviewResult.cs @@ -11,10 +11,12 @@ namespace CleanModelContextProtocol.Presentation.Models; /// The title of the movie that was reviewed. /// The id of the author who wrote the review. /// The full name of the author who wrote the review. +/// What the author said about the movie, if anything. public record ReviewResult( [property: Description("The review's id.")] Guid Id, [property: Description("The star rating, from 1 (worst) to 5 (best).")] int Stars, [property: Description("The id of the movie that was reviewed.")] Guid? MovieId, [property: Description("The title of the movie that was reviewed.")] string? MovieTitle, [property: Description("The id of the author who wrote the review.")] Guid? AuthorId, - [property: Description("The full name of the author who wrote the review.")] string? AuthorName); + [property: Description("The full name of the author who wrote the review.")] string? AuthorName, + [property: Description("What the author said about the movie, if anything.")] string? Text); diff --git a/src/Presentation/Prompts/MoviePrompts.cs b/src/Presentation/Prompts/MoviePrompts.cs index 760f1a3..64f7d1f 100644 --- a/src/Presentation/Prompts/MoviePrompts.cs +++ b/src/Presentation/Prompts/MoviePrompts.cs @@ -89,7 +89,7 @@ public static partial GetPromptResult ReviewMovie( [ Text($"I want to review the movie \"{movieTitle}\" as the author \"{authorName}\". Find the movie with movies_search " + "and the author with authors_search. If either search doesn't return exactly one clear match, show me the closest " + - "results and ask which one I meant. Ask me for a star rating from 1 to 5, then save the review with reviews_write " + + "results and ask which one I meant. Ask me for a star rating from 1 to 5 and what I thought of it, then save the review with reviews_write " + "and show me the saved review.") ] }; diff --git a/src/Presentation/Tools/ReviewTools.cs b/src/Presentation/Tools/ReviewTools.cs index 3955842..92bd76e 100644 --- a/src/Presentation/Tools/ReviewTools.cs +++ b/src/Presentation/Tools/ReviewTools.cs @@ -50,14 +50,15 @@ public static async partial Task> ListReviews( } /// - /// Create a review of a movie by an author with a star rating from 1 to 5, or pass a reviewId to change that review's - /// stars. The movie and author of a review can't change. Returns the saved review. Without a reviewId every call + /// Create a review of a movie by an author with a star rating from 1 to 5 and optional text, or pass a reviewId to change + /// that review's stars and text. The movie and author of a review can't change. Returns the saved review. Without a reviewId every call /// creates another review, so don't retry a call that succeeded. /// /// The star rating, a whole number from 1 (worst) to 5 (best). /// The id of the movie being reviewed, needed to create a review. Use movies_search to find it. /// The id of the author writing the review, needed to create a review. Use authors_search to find it. - /// The id of an existing review to change the stars of. Leave empty to create a new review. + /// The id of an existing review to change. Leave empty to create a new review. + /// What the author has to say about the movie, up to 1000 characters. Leave empty when changing a review to keep its text. [McpServerTool(Name = "reviews_write", Title = "Write review", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Review)] public static async partial Task WriteReview( ISender sender, @@ -65,7 +66,8 @@ public static async partial Task WriteReview( CancellationToken cancellationToken, Guid? movieId = null, Guid? authorId = null, - Guid? reviewId = null) + Guid? reviewId = null, + string? text = null) { if (!reviewId.HasValue) { @@ -73,7 +75,8 @@ public static async partial Task WriteReview( { AuthorId = authorId ?? Guid.Empty, MovieId = movieId ?? Guid.Empty, - Stars = stars + Stars = stars, + Text = text }, cancellationToken); return created.ToResult(); @@ -82,7 +85,8 @@ public static async partial Task WriteReview( var updated = await sender.Send(new Commands.UpdateReview.UpdateReviewCommand { Id = reviewId.Value, - Stars = stars + Stars = stars, + Text = text }, cancellationToken); return updated.ToResult(); diff --git a/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs index 17bc181..bd44eb8 100644 --- a/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs @@ -19,7 +19,8 @@ public async Task Handle_ShouldPassThrough_Command() { AuthorId = Guid.Empty, MovieId = Guid.Empty, - Stars = 5 + Stars = 5, + Text = "Great film" }; var authorsRepository = Substitute.For(); @@ -39,7 +40,7 @@ public async Task Handle_ShouldPassThrough_Command() // Assert _ = await authorsRepository.Received(1).AuthorExists(command.AuthorId, token); _ = await moviesRepository.Received(1).MovieExists(command.MovieId, token); - _ = await reviewsRepository.Received(1).CreateReview(command.AuthorId, command.MovieId, command.Stars, token); + _ = await reviewsRepository.Received(1).CreateReview(command.AuthorId, command.MovieId, command.Stars, command.Text, token); } [Fact] @@ -72,7 +73,7 @@ public async Task Handle_ShouldThrowException_AuthorDoesNotExist() _ = await authorsRepository.Received(1).AuthorExists(command.AuthorId, token); _ = await moviesRepository.DidNotReceive().MovieExists(command.MovieId, token); - _ = await reviewsRepository.DidNotReceive().CreateReview(command.AuthorId, command.MovieId, command.Stars, token); + _ = await reviewsRepository.DidNotReceive().CreateReview(command.AuthorId, command.MovieId, command.Stars, command.Text, token); } [Fact] @@ -104,6 +105,6 @@ public async Task Handle_ShouldThrowException_MovieDoesNotExist() _ = await authorsRepository.Received(1).AuthorExists(command.AuthorId, token); _ = await moviesRepository.Received(1).MovieExists(command.MovieId, token); - _ = await reviewsRepository.DidNotReceive().CreateReview(command.AuthorId, command.MovieId, command.Stars, token); + _ = await reviewsRepository.DidNotReceive().CreateReview(command.AuthorId, command.MovieId, command.Stars, command.Text, token); } } diff --git a/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs index 4face0f..85a8411 100644 --- a/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs @@ -126,4 +126,44 @@ public void Validator_ShouldHaveValidationErrorFor_Stars(int stars) // Assert _ = result.ShouldHaveValidationErrorFor(command => command.Stars).WithErrorMessage("A star rating must be between 1 and 5."); } + + [Theory] + [InlineData(0)] + [InlineData(1000)] + public void Validator_ShouldNotHaveValidationErrorFor_Text(int length) + { + // Arrange + var command = new CreateReviewCommand + { + AuthorId = Guid.NewGuid(), + MovieId = Guid.NewGuid(), + Stars = 5, + Text = new string('a', length) + }; + + // Act + var result = new CreateReviewValidator().TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(r => r.Text); + } + + [Fact] + public void Validator_ShouldHaveValidationErrorFor_LongText() + { + // Arrange + var command = new CreateReviewCommand + { + AuthorId = Guid.NewGuid(), + MovieId = Guid.NewGuid(), + Stars = 5, + Text = new string('a', 1001) + }; + + // Act + var result = new CreateReviewValidator().TestValidate(command); + + // Assert + _ = result.ShouldHaveValidationErrorFor(r => r.Text).WithErrorMessage("Review text can't be longer than 1000 characters."); + } } diff --git a/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs index 8c71a49..b7f17dd 100644 --- a/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs @@ -23,7 +23,7 @@ public async Task Handle_ShouldReturnUpdatedReview_ReviewExists() var review = new Review(command.Id, command.Stars); var reviewsRepository = Substitute.For(); - _ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(review); + _ = reviewsRepository.UpdateReview(default, default, default, default).ReturnsForAnyArgs(review); var handler = new UpdateReviewHandler(reviewsRepository); using var tokenSource = new CancellationTokenSource(); @@ -35,7 +35,7 @@ public async Task Handle_ShouldReturnUpdatedReview_ReviewExists() // Assert result.ShouldBe(review); - _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token); + _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, command.Text, token); } [Fact] @@ -50,7 +50,7 @@ public async Task Handle_ShouldThrowException_ReviewDoesNotExist() var reviewsRepository = Substitute.For(); - _ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(Task.FromResult(null)); + _ = reviewsRepository.UpdateReview(default, default, default, default).ReturnsForAnyArgs(Task.FromResult(null)); var handler = new UpdateReviewHandler(reviewsRepository); using var tokenSource = new CancellationTokenSource(); @@ -62,6 +62,6 @@ public async Task Handle_ShouldThrowException_ReviewDoesNotExist() // Assert exception.Message.ShouldBe("The Review with the supplied id was not found."); - _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token); + _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, command.Text, token); } } diff --git a/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewValidatorTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewValidatorTests.cs index 32c209e..f9a009e 100644 --- a/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewValidatorTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewValidatorTests.cs @@ -60,4 +60,42 @@ public void Validator_ShouldHaveValidationErrorFor_Stars(int stars) // Assert _ = result.ShouldHaveValidationErrorFor(command => command.Stars); } + + [Theory] + [InlineData(0)] + [InlineData(1000)] + public void Validator_ShouldNotHaveValidationErrorFor_Text(int length) + { + // Arrange + var command = new UpdateReviewCommand + { + Id = Guid.NewGuid(), + Stars = 5, + Text = new string('a', length) + }; + + // Act + var result = new UpdateReviewValidator().TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(r => r.Text); + } + + [Fact] + public void Validator_ShouldHaveValidationErrorFor_LongText() + { + // Arrange + var command = new UpdateReviewCommand + { + Id = Guid.NewGuid(), + Stars = 5, + Text = new string('a', 1001) + }; + + // Act + var result = new UpdateReviewValidator().TestValidate(command); + + // Assert + _ = result.ShouldHaveValidationErrorFor(r => r.Text).WithErrorMessage("Review text can't be longer than 1000 characters."); + } } diff --git a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs index 4d2d984..9c0aee2 100644 --- a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs +++ b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs @@ -320,7 +320,7 @@ public async Task CreateReview_ShouldReturn_NewReviews() var token = tokenSource.Token; // Act - var result = await this.database.Repository.CreateReview(review.AuthorId, review.MovieId, review.Stars, token); + var result = await this.database.Repository.CreateReview(review.AuthorId, review.MovieId, review.Stars, "Great film", token); // Assert _ = result.ShouldNotBeNull(); @@ -328,6 +328,7 @@ public async Task CreateReview_ShouldReturn_NewReviews() result.ReviewAuthor.ShouldNotBeNull().Id.ShouldBe(review.AuthorId); result.ReviewedMovie.ShouldNotBeNull().Id.ShouldBe(review.MovieId); result.Stars.ShouldBe(review.Stars); + result.Text.ShouldBe("Great film"); } [Fact] @@ -415,7 +416,7 @@ public async Task UpdateReview_ShouldReturn_UpdatedReview() var token = tokenSource.Token; // Act - var result = await this.database.Repository.UpdateReview(review.Id, review.Stars, token); + var result = await this.database.Repository.UpdateReview(review.Id, review.Stars, null, token); // Assert _ = result.ShouldNotBeNull(); @@ -433,6 +434,39 @@ public async Task UpdateReview_ShouldReturn_UpdatedReview() updatedReview.DateModified.ShouldBe(this.database.TimeProvider.GetUtcNow().UtcDateTime); } + [Fact] + public async Task UpdateReview_ShouldChange_Text() + { + // Arrange + var id = this.database.Context.Reviews.First(r => r.Stars == 2).Id; + using var tokenSource = new CancellationTokenSource(); + var token = tokenSource.Token; + + // Act + var result = await this.database.Repository.UpdateReview(id, 2, "Better the second time", token); + + // Assert + result.ShouldNotBeNull().Text.ShouldBe("Better the second time"); + } + + [Fact] + public async Task UpdateReview_WithoutText_ShouldKeep_Text() + { + // Arrange + var id = this.database.Context.Reviews.First(r => r.Stars == 2).Id; + using var tokenSource = new CancellationTokenSource(); + var token = tokenSource.Token; + + _ = await this.database.Repository.UpdateReview(id, 2, "Better the second time", token); + + // Act + var result = await this.database.Repository.UpdateReview(id, 3, null, token); + + // Assert + result.ShouldNotBeNull().Stars.ShouldBe(3); + result.Text.ShouldBe("Better the second time"); + } + [Fact] public async Task UpdateReview_ShouldReturn_Null() { @@ -446,7 +480,7 @@ public async Task UpdateReview_ShouldReturn_Null() var token = tokenSource.Token; // Act - var result = await this.database.Repository.UpdateReview(review.Id, review.Stars, token); + var result = await this.database.Repository.UpdateReview(review.Id, review.Stars, null, token); // Assert result.ShouldBeNull(); diff --git a/tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs b/tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs index 3cd6e93..d225f47 100644 --- a/tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs +++ b/tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs @@ -71,6 +71,20 @@ public async Task SearchMovies_ShouldWrite_NullNextOffset_OnTheLastPage() result.StructuredContent.ShouldNotBeNull().GetProperty("nextOffset").ValueKind.ShouldBe(JsonValueKind.Null); } + [Theory] + [InlineData("space")] + [InlineData("love")] + [InlineData("murder")] + [InlineData("dragon")] + public async Task SearchMovies_ShouldFind_SampleMovies(string query) + { + // Act + var page = await this.CallAsync>("movies_search", new Dictionary { ["query"] = query }); + + // Assert + page.Items.ShouldNotBeEmpty(); + } + [Fact] public async Task SearchMovies_ShouldFind_MovieByTitle() { diff --git a/tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs b/tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs index 4b6a321..a3a2356 100644 --- a/tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs +++ b/tests/Presentation.Tests.Integration/Tools/ReviewToolTests.cs @@ -96,11 +96,12 @@ public async Task WriteReview_ShouldCreate_Review() // Act var review = await this.CallAsync("reviews_write", - new Dictionary { ["movieId"] = movie.Id, ["authorId"] = author.Id, ["stars"] = 5 }); + new Dictionary { ["movieId"] = movie.Id, ["authorId"] = author.Id, ["stars"] = 5, ["text"] = "Great film" }); // Assert review.Id.ShouldNotBe(Guid.Empty); review.Stars.ShouldBe(5); + review.Text.ShouldBe("Great film"); review.MovieId.ShouldBe(movie.Id); review.MovieTitle.ShouldBe(movie.Title); review.AuthorId.ShouldBe(author.Id);