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 da69b0d..3f8e586 100644 --- a/README.md +++ b/README.md @@ -48,24 +48,24 @@ docker compose up -d --build # Ask it for a page of movies curl http://localhost:8080/graphql \ -H 'Content-Type: application/json' \ - -d '{ "query": "{ movies(query: \"global\", first: 2) { totalCount nodes { title reviewCount averageStars } } }" }' + -d '{ "query": "{ movies(query: \"space\", first: 2) { totalCount nodes { title reviewCount averageStars } } }" }' ``` ```json { "data": { "movies": { - "totalCount": 2, + "totalCount": 4, "nodes": [ { - "title": "Assimilated global infrastructure", - "reviewCount": 1, - "averageStars": 1 + "title": "Deep Space Gardeners", + "reviewCount": 5, + "averageStars": 3.4 }, { - "title": "Automated global alliance", - "reviewCount": 3, - "averageStars": 3.3 + "title": "Postcards from Outer Space", + "reviewCount": 1, + "averageStars": 3 } ] } @@ -73,7 +73,7 @@ curl http://localhost:8080/graphql \ } ``` -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. Typing GraphQL into curl is a character-building exercise. Open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at instead, where the schema is documented from the same XML comments as the code. diff --git a/src/Application/Reviews/Commands/CreateReview/CreateReviewCommand.cs b/src/Application/Reviews/Commands/CreateReview/CreateReviewCommand.cs index db7a5bc..2826a76 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 33517ea..9411d33 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 3c076cc..39a2e79 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 976e1e1..9df4cf9 100644 --- a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs +++ b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs @@ -4,7 +4,7 @@ namespace CleanGraphQLApi.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 897d6b0..1bff482 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 1a7dad8..7abcab0 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 d42ed93..3f2357d 100644 --- a/src/Application/Reviews/Entities/Review.cs +++ b/src/Application/Reviews/Entities/Review.cs @@ -10,8 +10,10 @@ namespace CleanGraphQLApi.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 6ccef74..83e2744 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 abfb723..7f4888c 100644 --- a/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs @@ -1,7 +1,14 @@ namespace CleanGraphQLApi.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 1d77928..7450b6b 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 bb39001..9395252 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..fcd3a44 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.Designer.cs @@ -0,0 +1,141 @@ +// +using System; +using CleanGraphQLApi.Infrastructure.Databases.MovieReviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CleanGraphQLApi.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("CleanGraphQLApi.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("CleanGraphQLApi.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("CleanGraphQLApi.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("CleanGraphQLApi.Infrastructure.Databases.MovieReviews.Models.Review", b => + { + b.HasOne("CleanGraphQLApi.Infrastructure.Databases.MovieReviews.Models.Author", "ReviewAuthor") + .WithMany("Reviews") + .HasForeignKey("ReviewAuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CleanGraphQLApi.Infrastructure.Databases.MovieReviews.Models.Movie", "ReviewedMovie") + .WithMany("Reviews") + .HasForeignKey("ReviewedMovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewAuthor"); + + b.Navigation("ReviewedMovie"); + }); + + modelBuilder.Entity("CleanGraphQLApi.Infrastructure.Databases.MovieReviews.Models.Author", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("CleanGraphQLApi.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..0235d25 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.cs @@ -0,0 +1,30 @@ +// +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CleanGraphQLApi.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 cbc3a37..4ff341e 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 887a87b..688f0f6 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/GraphQL/Mutations/ReviewMutations.cs b/src/Presentation/GraphQL/Mutations/ReviewMutations.cs index d689c7f..245ced3 100644 --- a/src/Presentation/GraphQL/Mutations/ReviewMutations.cs +++ b/src/Presentation/GraphQL/Mutations/ReviewMutations.cs @@ -23,6 +23,7 @@ public static partial class ReviewMutations /// The unique identifier of the author writing the review. /// The unique identifier of the movie being reviewed. /// The star rating, from 1 to 5. + /// What the author has to say about the movie, up to 1000 characters. #if includeAuth [Authorize] #endif @@ -32,6 +33,7 @@ public static async Task CreateReview( Guid authorId, Guid movieId, int stars, + string? text, ISender sender, CancellationToken cancellationToken) { @@ -39,15 +41,17 @@ public static async Task CreateReview( { AuthorId = authorId, MovieId = movieId, - Stars = stars + Stars = stars, + Text = text }, cancellationToken); } /// - /// Changes the star rating of a review and returns the updated review. The author and the movie of a review can't change. + /// Changes the star rating and text of a review and returns the updated review. The author and the movie of a review can't change. /// /// The unique identifier of the review. /// The new star rating, from 1 to 5. + /// The new text, up to 1000 characters. Leave it out to keep the text the review has. #if includeAuth [Authorize] #endif @@ -56,13 +60,15 @@ public static async Task CreateReview( public static async Task UpdateReview( Guid id, int stars, + string? text, ISender sender, CancellationToken cancellationToken) { return await sender.Send(new Commands.UpdateReview.UpdateReviewCommand { Id = id, - Stars = stars + Stars = stars, + Text = text }, cancellationToken); } diff --git a/src/Presentation/GraphQL/Types/ReviewType.cs b/src/Presentation/GraphQL/Types/ReviewType.cs index 6ff15f1..cbb6cde 100644 --- a/src/Presentation/GraphQL/Types/ReviewType.cs +++ b/src/Presentation/GraphQL/Types/ReviewType.cs @@ -10,6 +10,7 @@ static partial void Configure(IObjectTypeDescriptor descriptor) { _ = descriptor.Field(r => r.Id); _ = descriptor.Field(r => r.Stars); + _ = descriptor.Field(r => r.Text); _ = descriptor.Field(r => r.ReviewedMovie).Name("movie"); _ = descriptor.Field(r => r.ReviewAuthor).Name("author"); } diff --git a/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs index 72c7ad6..f157000 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 c0d7c5f..b9411b0 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 02bc7b1..09980ab 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 ec0546d..2a36f04 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 8774791..2b70d8a 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/GraphQL/Movies/MovieTests.cs b/tests/Presentation.Tests.Integration/GraphQL/Movies/MovieTests.cs index 999a99c..1463fb9 100644 --- a/tests/Presentation.Tests.Integration/GraphQL/Movies/MovieTests.cs +++ b/tests/Presentation.Tests.Integration/GraphQL/Movies/MovieTests.cs @@ -10,6 +10,24 @@ public class MovieTests : IDisposable { private readonly GraphQLApplication application = new(); + [Theory] + [InlineData("space")] + [InlineData("love")] + [InlineData("murder")] + [InlineData("dragon")] + public async Task Movies_ShouldFind_SampleMovies(string query) + { + // Arrange + using var client = this.application.CreateClient(); + + // Act + using var response = await client.GetAsync($"/graphql?query={{movies(query:\"{query}\",first:5){{nodes{{id}}}}}}"); + var result = (await response.Content.ReadAsStringAsync()).Deserialize(); + + // Assert + result.Data.Movies.Nodes.ShouldNotBeEmpty(); + } + [Fact] public async Task Movies_ShouldReturn_MoviesList() { diff --git a/tests/Presentation.Tests.Integration/GraphQL/Reviews/Create/CreateReviewInputData.cs b/tests/Presentation.Tests.Integration/GraphQL/Reviews/Create/CreateReviewInputData.cs index a734a36..de6a651 100644 --- a/tests/Presentation.Tests.Integration/GraphQL/Reviews/Create/CreateReviewInputData.cs +++ b/tests/Presentation.Tests.Integration/GraphQL/Reviews/Create/CreateReviewInputData.cs @@ -28,4 +28,7 @@ public partial class Review [JsonPropertyName("stars")] public int Stars { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } } diff --git a/tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql b/tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql index 06a4f6e..8339948 100644 --- a/tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql +++ b/tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql @@ -74,7 +74,7 @@ type Mutation { "Creates a review of a movie and returns it." createReview(input: CreateReviewInput!): CreateReviewPayload! @cost(weight: "10") - "Changes the star rating of a review and returns the updated review. The author and the movie of a review can't change." + "Changes the star rating and text of a review and returns the updated review. The author and the movie of a review can't change." updateReview(input: UpdateReviewInput!): UpdateReviewPayload! @cost(weight: "10") "Deletes a review and returns the id it had." @@ -216,6 +216,8 @@ type Review { id: UUID! "The star rating, from 1 to 5." stars: Int! + "What the author said about the movie, if anything." + text: String "The movie that was reviewed." movie: ReviewedMovie "The author who wrote the review." @@ -298,6 +300,8 @@ input CreateReviewInput { movieId: UUID! "The star rating, from 1 to 5." stars: Int! + "What the author has to say about the movie, up to 1000 characters." + text: String } input DeleteReviewInput { @@ -310,6 +314,8 @@ input UpdateReviewInput { id: UUID! "The new star rating, from 1 to 5." stars: Int! + "The new text, up to 1000 characters. Leave it out to keep the text the review has." + text: String } #if (includeAuth)