From b5b9a36b5005d633a6df0103aa41c5c22870e76f Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:35:22 -0600 Subject: [PATCH 1/3] Give the sample movies recognisable titles and add review text --- .agents/skills/cancellation/SKILL.md | 2 +- .agents/skills/errors-and-exceptions/SKILL.md | 2 +- .agents/skills/sample-data/SKILL.md | 7 +- .agents/skills/test-design/SKILL.md | 2 +- NUGET.md | 2 +- README.md | 25 ++-- .../CreateReview/CreateReviewCommand.cs | 2 + .../CreateReview/CreateReviewHandler.cs | 2 +- .../CreateReview/CreateReviewValidator.cs | 1 + .../UpdateReview/UpdateReviewCommand.cs | 5 +- .../UpdateReview/UpdateReviewHandler.cs | 2 +- .../UpdateReview/UpdateReviewValidator.cs | 1 + src/Application/Reviews/Entities/Review.cs | 4 +- src/Application/Reviews/IReviewsRepository.cs | 3 +- .../Configuration/ReviewConfiguration.cs | 7 + .../EntityFrameworkMovieReviewsRepository.cs | 5 +- .../MovieReviewsDbContextExtensions.cs | 68 ++++++++- .../20261106000000_AddReviewText.Designer.cs | 141 ++++++++++++++++++ .../20261106000000_AddReviewText.cs | 30 ++++ .../MovieReviewsDbContextModelSnapshot.cs | 4 + .../Databases/MovieReviews/Models/Review.cs | 2 + .../LambdaFunction/Models/Review.cs | 3 +- .../CreateReview/CreateReviewHandlerTests.cs | 9 +- .../CreateReviewValidatorTests.cs | 40 +++++ .../UpdateReview/UpdateReviewHandlerTests.cs | 8 +- .../UpdateReviewValidatorTests.cs | 38 +++++ ...ityFrameworkMovieReviewsRepositoryTests.cs | 40 ++++- .../HandlerTests.cs | 14 ++ 28 files changed, 431 insertions(+), 38 deletions(-) create mode 100644 src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.Designer.cs create mode 100644 src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.cs 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/NUGET.md b/NUGET.md index 2f4b623..31c383f 100644 --- a/NUGET.md +++ b/NUGET.md @@ -45,7 +45,7 @@ docker compose up -d --build # Ask it for a page of movies curl -X POST http://localhost:9000/2015-03-31/functions/function/invocations \ - -d '{ "query": "global", "limit": 2 }' + -d '{ "query": "space", "limit": 2 }' # Run the tests while you're here dotnet test diff --git a/README.md b/README.md index 1ec1fcf..bea5023 100644 --- a/README.md +++ b/README.md @@ -46,30 +46,31 @@ docker compose up -d --build # Ask it for a page of movies curl -X POST http://localhost:9000/2015-03-31/functions/function/invocations \ - -d '{ "query": "global", "limit": 2 }' + -d '{ "query": "space", "limit": 2 }' ``` ```json { "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. Every function returns one page at a time, with a `totalCount` and a `nextOffset` when there's more to come. Send the `nextOffset` back as `offset` to get the next page. Movies and authors come with their review count and average stars, worked out by the database rather than in a loop somewhere. Each invocation's logs, trace and metrics land in the [Aspire dashboard](https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone) at . @@ -88,7 +89,7 @@ docker run --rm -p 9000:8080 \ # Reviews take a movie or an author instead of a search term curl -X POST http://localhost:9000/2015-03-31/functions/function/invocations \ - -d '{ "movieId": "30fca414-247a-779e-105a-d81aa2506f58", "limit": 5 }' + -d '{ "movieId": "cb188ef6-14e7-f85f-2f78-924c2ab26b28", "limit": 5 }' ``` The database is a SQLite file in `/tmp`, the only place Lambda lets you write, and the function stops any work that would outrun the invocation's remaining time. Lambda bills by the millisecond, so it knows when to call it a day. diff --git a/src/Application/Reviews/Commands/CreateReview/CreateReviewCommand.cs b/src/Application/Reviews/Commands/CreateReview/CreateReviewCommand.cs index 6016507..43dbef7 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 d16c1c3..2f413aa 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 ad5180a..5ba28cf 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 157203f..0abe903 100644 --- a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs +++ b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs @@ -4,7 +4,7 @@ namespace CleanLambdaFunction.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 f0895fc..3729773 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 68482b4..cfcc4ca 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 a8a3d98..f9c4344 100644 --- a/src/Application/Reviews/Entities/Review.cs +++ b/src/Application/Reviews/Entities/Review.cs @@ -10,8 +10,10 @@ namespace CleanLambdaFunction.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 d92371e..c0173cc 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 d95bb42..30b8e08 100644 --- a/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs @@ -1,7 +1,14 @@ namespace CleanLambdaFunction.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 d22acf7..76c0053 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 6c6c3b5..3f88ac8 100644 --- a/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs +++ b/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs @@ -9,6 +9,71 @@ 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."], + ["Some good ideas, lost in the second act.", "The trailer was better.", "Pretty to look at, hard to care about."], + ["Fine for a rainy Sunday.", "Solid, if you've got nothing else on.", "Not bad, not great, mostly fine."], + ["Great film, with one scene too many.", "Funny, warm and better than it needed to be.", "I'd happily watch it again."], + ["An instant favourite.", "Great film. I cried at the ending.", "Perfect from the first scene to the last."] + ]; + public static MovieReviewsDbContext AddData(this MovieReviewsDbContext context) { if (context.Authors.Any()) @@ -30,7 +95,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 +108,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..d4395b3 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.Designer.cs @@ -0,0 +1,141 @@ +// +using System; +using CleanLambdaFunction.Infrastructure.Databases.MovieReviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CleanLambdaFunction.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("CleanLambdaFunction.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("CleanLambdaFunction.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("CleanLambdaFunction.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("CleanLambdaFunction.Infrastructure.Databases.MovieReviews.Models.Review", b => + { + b.HasOne("CleanLambdaFunction.Infrastructure.Databases.MovieReviews.Models.Author", "ReviewAuthor") + .WithMany("Reviews") + .HasForeignKey("ReviewAuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CleanLambdaFunction.Infrastructure.Databases.MovieReviews.Models.Movie", "ReviewedMovie") + .WithMany("Reviews") + .HasForeignKey("ReviewedMovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewAuthor"); + + b.Navigation("ReviewedMovie"); + }); + + modelBuilder.Entity("CleanLambdaFunction.Infrastructure.Databases.MovieReviews.Models.Author", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("CleanLambdaFunction.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..f5f2a27 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/20261106000000_AddReviewText.cs @@ -0,0 +1,30 @@ +// +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CleanLambdaFunction.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 e54a0a5..4930109 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 3cc5c24..f4e36dd 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/LambdaFunction/Models/Review.cs b/src/Presentation/LambdaFunction/Models/Review.cs index 3652051..8ef4791 100644 --- a/src/Presentation/LambdaFunction/Models/Review.cs +++ b/src/Presentation/LambdaFunction/Models/Review.cs @@ -7,4 +7,5 @@ namespace CleanLambdaFunction.Presentation.LambdaFunction.Models; /// The star rating, from 1 to 5. /// The movie the review is about. /// The author who wrote the review. -public record Review(Guid Id, int Stars, Movie? ReviewedMovie, Author? ReviewAuthor); +/// What the author said about the movie, if anything. +public record Review(Guid Id, int Stars, Movie? ReviewedMovie, Author? ReviewAuthor, string? Text = null); diff --git a/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs index beb8b53..5dfde1e 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 a7c7214..3ccc366 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 243de07..b92dd4c 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 a9e2639..2b8c593 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 67a09ae..546d85a 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/HandlerTests.cs b/tests/Presentation.Tests.Integration/HandlerTests.cs index a247d29..76fa8ff 100644 --- a/tests/Presentation.Tests.Integration/HandlerTests.cs +++ b/tests/Presentation.Tests.Integration/HandlerTests.cs @@ -37,6 +37,20 @@ public async Task GetMovies_ShouldReturn_FirstPage() result.Items.ShouldAllBe(m => m.ReviewCount == 0 ? m.AverageStars == null : m.AverageStars != null); } + [Theory] + [InlineData("space")] + [InlineData("love")] + [InlineData("murder")] + [InlineData("dragon")] + public async Task GetMovies_ShouldFind_SampleMovies(string query) + { + // Act + var result = await this.handler.GetMovies(new ListRequest { Query = query }, this.context); + + // Assert + result.Items.ShouldNotBeEmpty(); + } + [Fact] public async Task GetMovies_ShouldReturn_NextPage() { From 50a54268b58310538d41d44bb194c6a70b04a8eb Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:14:43 -0600 Subject: [PATCH 2/3] Give each star rating ten review texts --- .../MovieReviewsDbContextExtensions.cs | 65 +++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs b/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs index 3f88ac8..356ce60 100644 --- a/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs +++ b/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs @@ -67,11 +67,66 @@ internal static class MovieReviewsDbContextExtensions // 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."], - ["Some good ideas, lost in the second act.", "The trailer was better.", "Pretty to look at, hard to care about."], - ["Fine for a rainy Sunday.", "Solid, if you've got nothing else on.", "Not bad, not great, mostly fine."], - ["Great film, with one scene too many.", "Funny, warm and better than it needed to be.", "I'd happily watch it again."], - ["An instant favourite.", "Great film. I cried at the ending.", "Perfect from the first scene to the last."] + [ + "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) From a8a1b076bc2bc4cb16efe99bafd94d4073d4fa5a Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:27:11 -0600 Subject: [PATCH 3/3] Keep tests that touch environment variables away from the rest --- .../Presentation.Tests.Integration/HandlerTests.cs | 1 + .../Presentation.Tests.Integration/HttpApiTests.cs | 1 + .../ProcessEnvironment.cs | 13 +++++++++++++ 3 files changed, 15 insertions(+) create mode 100644 tests/Presentation.Tests.Integration/ProcessEnvironment.cs diff --git a/tests/Presentation.Tests.Integration/HandlerTests.cs b/tests/Presentation.Tests.Integration/HandlerTests.cs index 76fa8ff..9d9c693 100644 --- a/tests/Presentation.Tests.Integration/HandlerTests.cs +++ b/tests/Presentation.Tests.Integration/HandlerTests.cs @@ -13,6 +13,7 @@ namespace CleanLambdaFunction.Presentation.Tests.Integration; using Shouldly; using Xunit; +[Collection(ProcessEnvironment.Name)] public class HandlerTests { // Every test class gets its own in-memory database, so tests never share or keep data. diff --git a/tests/Presentation.Tests.Integration/HttpApiTests.cs b/tests/Presentation.Tests.Integration/HttpApiTests.cs index 9377852..4dcc609 100644 --- a/tests/Presentation.Tests.Integration/HttpApiTests.cs +++ b/tests/Presentation.Tests.Integration/HttpApiTests.cs @@ -12,6 +12,7 @@ namespace CleanLambdaFunction.Presentation.Tests.Integration; using Shouldly; using Xunit; +[Collection(ProcessEnvironment.Name)] public class HttpApiTests { private static readonly LambdaFunctionJsonSerializer Serializer = new(); diff --git a/tests/Presentation.Tests.Integration/ProcessEnvironment.cs b/tests/Presentation.Tests.Integration/ProcessEnvironment.cs new file mode 100644 index 0000000..d66676f --- /dev/null +++ b/tests/Presentation.Tests.Integration/ProcessEnvironment.cs @@ -0,0 +1,13 @@ +namespace CleanLambdaFunction.Presentation.Tests.Integration; + +using Xunit; + +/// +/// Environment variables belong to the whole process, and the handlers read them. Tests that set them, and tests that +/// rely on a setting being missing, run here, away from everything else, so one can't leak into another. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ProcessEnvironment +{ + public const string Name = "Process environment"; +}