Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/cancellation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/errors-and-exceptions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
7 changes: 4 additions & 3 deletions .agents/skills/sample-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/test-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
```

Expand Down
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}'
```
Expand All @@ -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 <http://localhost:6274> instead. It's already pointed at the server, so flip the switch to connect and open **Tools**.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public class CreateReviewCommand : IRequest<Review>
public Guid MovieId { get; init; }

public int Stars { get; init; }

public string? Text { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ public async ValueTask<Review> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.UpdateReview;
using Mediator;

/// <summary>
/// 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.
/// </summary>
public class UpdateReviewCommand : IRequest<Review>
{
Expand All @@ -13,4 +13,7 @@ public class UpdateReviewCommand : IRequest<Review>

/// <summary>The new star rating, from 1 to 5.</summary>
public int Stars { get; init; }

/// <summary>The new text. Leave it null to keep the text the review has.</summary>
public string? Text { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public class UpdateReviewHandler(IReviewsRepository reviewsRepository) : IReques
{
public async ValueTask<Review> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
}
4 changes: 3 additions & 1 deletion src/Application/Reviews/Entities/Review.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ namespace CleanModelContextProtocol.Application.Reviews.Entities;
/// <param name="Stars">The star rating, from 1 to 5.</param>
/// <param name="ReviewedMovie">The movie that was reviewed.</param>
/// <param name="ReviewAuthor">The author who wrote the review.</param>
/// <param name="Text">What the author said about the movie, if anything.</param>
public record Review(
Guid Id,
int Stars,
ReviewedMovie? ReviewedMovie = null,
ReviewAuthor? ReviewAuthor = null);
ReviewAuthor? ReviewAuthor = null,
string? Text = null);
3 changes: 2 additions & 1 deletion src/Application/Reviews/IReviewsRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public Task<Review> CreateReview(
Guid authorId,
Guid movieId,
int stars,
string? text,
CancellationToken cancellationToken);

public Task<bool> DeleteReview(Guid id, CancellationToken cancellationToken);
Expand All @@ -25,5 +26,5 @@ public Task<Page<Review>> GetReviews(

public Task<Review?> GetReviewById(Guid id, CancellationToken cancellationToken);

public Task<Review?> UpdateReview(Guid id, int stars, CancellationToken cancellationToken);
public Task<Review?> UpdateReview(Guid id, int stars, string? text, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Configuration;

using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Models;

internal class ReviewConfiguration : EntityConfiguration<Review>
{
public override void Configure(EntityTypeBuilder<Review> builder)
{
base.Configure(builder);

_ = builder.Property(r => r.Text).HasMaxLength(1000);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ public async Task<ApplicationReview> CreateReview(
Guid authorId,
Guid movieId,
int stars,
string? text,
CancellationToken cancellationToken)
{
var now = this.timeProvider.GetUtcNow().UtcDateTime;
Expand All @@ -157,6 +158,7 @@ public async Task<ApplicationReview> CreateReview(
ReviewAuthorId = authorId,
ReviewedMovieId = movieId,
Stars = stars,
Text = text,
DateCreated = now,
DateModified = now
};
Expand Down Expand Up @@ -254,7 +256,7 @@ public async Task<List<ApplicationReview>> GetReviewsForAuthors(IReadOnlyCollect
return result is null ? null : this.mapper.ToDomain(result);
}

public async Task<ApplicationReview?> UpdateReview(Guid id, int stars, CancellationToken cancellationToken)
public async Task<ApplicationReview?> UpdateReview(Guid id, int stars, string? text, CancellationToken cancellationToken)
{
var now = this.timeProvider.GetUtcNow().UtcDateTime;

Expand All @@ -264,6 +266,7 @@ public async Task<List<ApplicationReview>> GetReviewsForAuthors(IReadOnlyCollect
.ExecuteUpdateAsync(
setters => setters
.SetProperty(r => r.Stars, stars)
.SetProperty(r => r.Text, r => text ?? r.Text)
.SetProperty(r => r.DateModified, now),
cancellationToken);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -30,7 +150,7 @@ public static MovieReviewsDbContext AddData(this MovieReviewsDbContext context)
var movies = new Faker<Movie>()
.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);
Expand All @@ -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);
Expand Down
Loading
Loading