diff --git a/.agents/skills/data-access-ef-core/SKILL.md b/.agents/skills/data-access-ef-core/SKILL.md index c6e9d4c..30e4104 100644 --- a/.agents/skills/data-access-ef-core/SKILL.md +++ b/.agents/skills/data-access-ef-core/SKILL.md @@ -1,6 +1,6 @@ --- name: data-access-ef-core -description: How the repository reads and writes the SQLite database with EF Core - scoped DbContext, no-tracking reads, SQL projections for lists, set-based updates and deletes, and safe LIKE searches. Use when adding or changing a repository method or a query. +description: How the repository reads and writes the SQLite database with EF Core - scoped DbContext, no tracking by default, SQL projections for lists, set-based updates and deletes, and safe LIKE searches. Use when adding or changing a repository method or a query. metadata: scope: shared --- @@ -9,19 +9,21 @@ metadata: ## The rule -One repository, `EntityFrameworkMovieReviewsRepository` in `src/Infrastructure/Databases/MovieReviews/`, implements every repository interface from Application. It and `MovieReviewsDbContext` are scoped: one per request, never shared between threads. Reads don't track. Lists are projected in SQL. Updates and deletes are set-based. The repository returns Application entities, never database models. +One repository, `EntityFrameworkMovieReviewsRepository` in `src/Infrastructure/Databases/MovieReviews/`, implements every repository interface from Application. It and `MovieReviewsDbContext` are scoped: one per request, never shared between threads. The context doesn't track queries (`QueryTrackingBehavior.NoTracking` in `AddInfrastructure`), because nothing loads an entity to change it. Lists are projected in SQL. Updates and deletes are set-based and are their own existence check. The repository returns Application entities, never database models. ## Steps 1. Add the method to the Application interface first (`IMoviesRepository` and friends), taking a `CancellationToken` last. -2. Implement it in the matching `#region` of the repository, as `virtual async Task<...>` like its neighbours. -3. Reads: start from `this.context..AsNoTracking()`, filter, then either +2. Implement it in the matching `#region` of the repository, as `public virtual async Task<...>` like every other method. +3. Reads: start from `this.context.`, filter, then either - **a list**: count, order (ending with the id), skip, take, and `Select` the summary fields so SQL does the counting and averaging (`paging`), or - **one entity**: `Include` what the entity needs, `FirstOrDefaultAsync`, then map with `this.mapper.ToDomain(...)` (`mapping-with-mapperly`). 4. Writes: - **create**: build the model, set `DateCreated` and `DateModified` from `this.timeProvider.GetUtcNow().UtcDateTime`, `Add`, `SaveChangesAsync`, then read it back with its includes. - - **update**: `Where(...).ExecuteUpdateAsync(setters => ...)`, always setting `DateModified`. - - **delete**: `Where(...).ExecuteDeleteAsync(...)`. + - **update**: `Where(...).ExecuteUpdateAsync(setters => ...)`, always setting `DateModified`. No rows changed means not found, so return `null`; otherwise read the entity back and return it. + - **delete**: `Where(...).ExecuteDeleteAsync(...)`, returning whether a row was deleted. + - Don't check first. The handler turns `null` or `false` into `NotFoundException` (`errors-and-exceptions`). + - New rows get a version 7 GUID from `Version7GuidValueGenerator`, set up for every model in `EntityConfiguration`. Each model needs its own configuration class, even an empty one, or it gets random GUIDs. 5. Text search uses `EF.Functions.Like` with the `ContainsPattern` helper, which escapes `%`, `_` and `\`. 6. Add repository tests (`integration-tests`). @@ -29,10 +31,11 @@ One repository, `EntityFrameworkMovieReviewsRepository` in `src/Infrastructure/D ```csharp // src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs -public async Task UpdateReview(Guid id, int stars, CancellationToken cancellationToken) +public virtual async Task UpdateReview(Guid id, int stars, CancellationToken cancellationToken) { var now = this.timeProvider.GetUtcNow().UtcDateTime; + // The update is the existence check: no rows changed means there's no such review. var updated = await this.context.Reviews .Where(r => r.Id == id) .ExecuteUpdateAsync( @@ -41,7 +44,7 @@ public async Task UpdateReview(Guid id, int stars, CancellationToken cance .SetProperty(r => r.DateModified, now), cancellationToken); - return updated > 0; + return updated == 0 ? null : await this.GetReviewById(id, cancellationToken); } ``` @@ -61,6 +64,8 @@ if (!string.IsNullOrWhiteSpace(query)) - Use `DateTime.UtcNow`. Use the injected `TimeProvider` so tests can fix the clock. - `Include` children on a list query to count them. - Load an entity, change it and `SaveChanges` to update one column. Use `ExecuteUpdateAsync`. +- Add `AsNoTracking()` to a query. It's already the default. +- Call an `Exists` method before an update or delete. - Return a database model or `IQueryable` from the repository. ## Check diff --git a/.agents/skills/database-migrations/SKILL.md b/.agents/skills/database-migrations/SKILL.md index 706a9a6..1cac3e3 100644 --- a/.agents/skills/database-migrations/SKILL.md +++ b/.agents/skills/database-migrations/SKILL.md @@ -13,7 +13,7 @@ The schema comes only from migrations in `src/Infrastructure/Databases/MovieRevi ## Steps -1. Change the database model in `Models/` and its configuration in `Configuration/`. Relationships and keys are configured with the fluent API in an `EntityConfiguration` subclass. +1. Change the database model in `Models/` and its configuration in `Configuration/`. Relationships, keys, indexes and collations are configured with the fluent API in an `EntityConfiguration` subclass. Text people search or sort by uses `UseCollation(Collations.NoCase)`, and columns we sort or filter by get a `HasIndex`. 2. Install the tool once: `dotnet tool install --global dotnet-ef`. 3. Add the migration from the repository root: @@ -25,15 +25,17 @@ The schema comes only from migrations in `src/Infrastructure/Databases/MovieRevi ``` 4. Check where the files landed. If `MovieReviewsDbContextModelSnapshot.cs` was written somewhere else or in another namespace, move it into `Migrations/` and fix its namespace. -5. Strip the byte order mark the tool adds, so `dotnet format` and the diff stay clean: +5. Strip the byte order mark the tool adds, so `dotnet format` and the diff stay clean, and make sure the migration file starts with `// ` so the analysers skip it. The tool adds that line to the `.Designer.cs` file but not always to the migration itself: ``` bash sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs ``` -6. Name the migration for what it does (`AddReviewComment`), in PascalCase. -7. Update `MovieReviewsDbContextExtensions.AddData` and the test data if the new column needs a value (`sample-data`). -8. Run every test project. Migrations are excluded from coverage. +6. Rename both files and the `[Migration]` attribute to `000000_`, so the id is the day it was written and not the second you ran the tool. + +7. Name the migration for what it does (`AddReviewComment`), in PascalCase. +8. Update `MovieReviewsDbContextExtensions.AddData` and the test data if the new column needs a value (`sample-data`). +9. Check nothing is left over with `dotnet ef migrations has-pending-model-changes --project ./src/Infrastructure`, then run every test project. Migrations are excluded from coverage. ## Don't diff --git a/.agents/skills/errors-and-exceptions/SKILL.md b/.agents/skills/errors-and-exceptions/SKILL.md index 7ba4fa6..44bb6ab 100644 --- a/.agents/skills/errors-and-exceptions/SKILL.md +++ b/.agents/skills/errors-and-exceptions/SKILL.md @@ -24,8 +24,9 @@ The presentation layer maps each exception to its own shape in one place (in a w ## Steps 1. For a missing entity, use the helpers rather than `throw new`: - - `NotFoundException.ThrowIfNull(result, EntityType.Movie);` after a fetch. - - `NotFoundException.Throw(EntityType.Author);` after an existence check. + - `NotFoundException.ThrowIfNull(result, EntityType.Movie);` after a fetch, or after an update that returns the changed entity, or `null` when no row matched. + - `NotFoundException.Throw(EntityType.Review);` when a delete changed no rows. + - `NotFoundException.Throw(EntityType.Author);` after an existence check. Only check first when the write itself can't tell you which thing is missing, such as a new review's author and movie. 2. A new kind of entity needs a value in `EntityType` (`src/Application/Common/Enums/EntityType.cs`). 3. Test the not-found path in the handler tests with `Should.ThrowAsync`. 4. If you add a new exception type, map it in the presentation layer's error handler in the same change, and test the mapping. If the caller should see it as an answer rather than a fault, log it in `LoggingBehaviour` too. @@ -33,6 +34,15 @@ The presentation layer maps each exception to its own shape in one place (in a w ## Example +```csharp +// src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs +var review = await reviewsRepository.UpdateReview(request.Id, request.Stars, cancellationToken); + +NotFoundException.ThrowIfNull(review, EntityType.Review); + +return review; +``` + ```csharp // src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs if (!await authorsRepository.AuthorExists(request.AuthorId, cancellationToken)) @@ -48,6 +58,7 @@ if (!await authorsRepository.AuthorExists(request.AuthorId, cancellationToken)) - Return `null`, `false` or an empty result to mean "not found" from a handler. - Catch exceptions in a handler to turn them into something else. - Introduce a `Result` type. We use exceptions on purpose. +- Check that a row exists and then update or delete it. That's two trips and a race; let the write tell you. - Put a stack trace, SQL or exception type name in a message. ## Check diff --git a/.agents/skills/paging/SKILL.md b/.agents/skills/paging/SKILL.md index 21aec1b..43a1365 100644 --- a/.agents/skills/paging/SKILL.md +++ b/.agents/skills/paging/SKILL.md @@ -17,7 +17,7 @@ Lists return summaries (`MovieSummary`, `AuthorSummary`) worked out in SQL, not 1. Give the query `Offset` (default 0) and `Limit` (default `Paging.DefaultLimit`) properties, plus an optional `Query` search term if the list can be searched. 2. Validate them with `ValidOffset()`, `ValidLimit()` and `ValidSearchTerm()` (`validation`). -3. Have the repository return `(List Items, int TotalCount)`. Count before you skip and take. +3. Have the repository return `(List Items, int TotalCount)`; `Page` exposes the items as an `IReadOnlyList`. Count before you skip and take. 4. Order by something that makes a stable order, ending with the id, so a row never shows up on two pages. 5. Build the page in the handler: `new Page(items, request.Offset, request.Limit, totalCount)`. diff --git a/.agents/skills/test-design/SKILL.md b/.agents/skills/test-design/SKILL.md index 9bf8efd..388b9bd 100644 --- a/.agents/skills/test-design/SKILL.md +++ b/.agents/skills/test-design/SKILL.md @@ -54,7 +54,7 @@ public async Task Handle_ShouldThrowException_ReviewDoesNotExist() // Arrange var command = new UpdateReviewCommand { Id = Guid.NewGuid(), Stars = 5 }; var reviewsRepository = Substitute.For(); - _ = reviewsRepository.ReviewExists(default, default).ReturnsForAnyArgs(false); + _ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs((Review?)null); var handler = new UpdateReviewHandler(reviewsRepository); using var tokenSource = new CancellationTokenSource(); var token = tokenSource.Token; @@ -64,8 +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).ReviewExists(command.Id, token); - _ = await reviewsRepository.DidNotReceiveWithAnyArgs().UpdateReview(default, default, default); + _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token); } ``` diff --git a/.agents/skills/unit-tests/SKILL.md b/.agents/skills/unit-tests/SKILL.md index c60e891..115b78e 100644 --- a/.agents/skills/unit-tests/SKILL.md +++ b/.agents/skills/unit-tests/SKILL.md @@ -34,7 +34,7 @@ Names read as `Method_Should_`: `Handle_ShouldThrowException ```csharp // tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs [Fact] -public async Task Handle_ShouldPassThrough_Command() +public async Task Handle_ShouldReturnUpdatedReview_ReviewExists() { // Arrange var command = new UpdateReviewCommand @@ -43,10 +43,10 @@ public async Task Handle_ShouldPassThrough_Command() Stars = 1 }; + var review = new Review(command.Id, command.Stars); var reviewsRepository = Substitute.For(); - _ = reviewsRepository.ReviewExists(default, default).ReturnsForAnyArgs(true); - _ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(true); + _ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(review); var handler = new UpdateReviewHandler(reviewsRepository); using var tokenSource = new CancellationTokenSource(); @@ -56,9 +56,8 @@ public async Task Handle_ShouldPassThrough_Command() var result = await handler.Handle(command, token); // Assert - result.ShouldBeTrue(); + result.ShouldBe(review); - _ = await reviewsRepository.Received(1).ReviewExists(command.Id, token); _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token); } ``` diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index fc540ff..6bfaa72 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -10,4 +10,10 @@ + + + <_Parameter1>$(AssemblyName).Tests.Unit + + + diff --git a/src/Application/Authors/Queries/GetAuthorById/GetAuthorByIdHandler.cs b/src/Application/Authors/Queries/GetAuthorById/GetAuthorByIdHandler.cs index d837845..295d567 100644 --- a/src/Application/Authors/Queries/GetAuthorById/GetAuthorByIdHandler.cs +++ b/src/Application/Authors/Queries/GetAuthorById/GetAuthorByIdHandler.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Authors.Queries.GetAuthorById; -using System.Threading; -using System.Threading.Tasks; using Common.Enums; using Common.Exceptions; using Entities; diff --git a/src/Application/Authors/Queries/GetAuthorById/GetAuthorByIdQuery.cs b/src/Application/Authors/Queries/GetAuthorById/GetAuthorByIdQuery.cs index e4d9b6c..9991751 100644 --- a/src/Application/Authors/Queries/GetAuthorById/GetAuthorByIdQuery.cs +++ b/src/Application/Authors/Queries/GetAuthorById/GetAuthorByIdQuery.cs @@ -1,11 +1,9 @@ namespace CleanModelContextProtocol.Application.Authors.Queries.GetAuthorById; -using System.ComponentModel.DataAnnotations; using Entities; using Mediator; public class GetAuthorByIdQuery : IRequest { - [Required] public Guid Id { get; init; } } diff --git a/src/Application/Common/Behaviours/LoggingBehaviour.cs b/src/Application/Common/Behaviours/LoggingBehaviour.cs index 3fffbe4..317f52c 100644 --- a/src/Application/Common/Behaviours/LoggingBehaviour.cs +++ b/src/Application/Common/Behaviours/LoggingBehaviour.cs @@ -1,9 +1,5 @@ namespace CleanModelContextProtocol.Application.Common.Behaviours; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Exceptions; using FluentValidation; using Mediator; diff --git a/src/Application/Common/Behaviours/ValidationBehaviour.cs b/src/Application/Common/Behaviours/ValidationBehaviour.cs index 152d716..38fda0a 100644 --- a/src/Application/Common/Behaviours/ValidationBehaviour.cs +++ b/src/Application/Common/Behaviours/ValidationBehaviour.cs @@ -1,9 +1,5 @@ namespace CleanModelContextProtocol.Application.Common.Behaviours; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using FluentValidation; using Mediator; diff --git a/src/Application/Common/Exceptions/NotFoundException.cs b/src/Application/Common/Exceptions/NotFoundException.cs index 57cbe7d..ce6417f 100644 --- a/src/Application/Common/Exceptions/NotFoundException.cs +++ b/src/Application/Common/Exceptions/NotFoundException.cs @@ -1,6 +1,5 @@ namespace CleanModelContextProtocol.Application.Common.Exceptions; -using System; using System.Diagnostics.CodeAnalysis; using Enums; diff --git a/src/Application/Common/Paging/Page.cs b/src/Application/Common/Paging/Page.cs index e01132a..4c54936 100644 --- a/src/Application/Common/Paging/Page.cs +++ b/src/Application/Common/Paging/Page.cs @@ -8,4 +8,4 @@ namespace CleanModelContextProtocol.Application.Common.Paging; /// The number of results skipped before this page. /// The largest number of results this page could hold. /// The number of results across every page. -public record Page(List Items, int Offset, int Limit, int TotalCount); +public record Page(IReadOnlyList Items, int Offset, int Limit, int TotalCount); diff --git a/src/Application/DependencyInjection.cs b/src/Application/DependencyInjection.cs index d01fbd3..b3d8a83 100644 --- a/src/Application/DependencyInjection.cs +++ b/src/Application/DependencyInjection.cs @@ -1,6 +1,5 @@ namespace CleanModelContextProtocol.Application; -using System; using System.Diagnostics.CodeAnalysis; using System.Reflection; using Common.Behaviours; diff --git a/src/Application/Movies/Queries/GetMovieById/GetMovieByIdHandler.cs b/src/Application/Movies/Queries/GetMovieById/GetMovieByIdHandler.cs index 35b1043..276f8a0 100644 --- a/src/Application/Movies/Queries/GetMovieById/GetMovieByIdHandler.cs +++ b/src/Application/Movies/Queries/GetMovieById/GetMovieByIdHandler.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Movies.Queries.GetMovieById; -using System.Threading; -using System.Threading.Tasks; using Common.Enums; using Common.Exceptions; using Entities; diff --git a/src/Application/Movies/Queries/GetMovieById/GetMovieByIdQuery.cs b/src/Application/Movies/Queries/GetMovieById/GetMovieByIdQuery.cs index e11049f..1a6a966 100644 --- a/src/Application/Movies/Queries/GetMovieById/GetMovieByIdQuery.cs +++ b/src/Application/Movies/Queries/GetMovieById/GetMovieByIdQuery.cs @@ -1,11 +1,9 @@ namespace CleanModelContextProtocol.Application.Movies.Queries.GetMovieById; -using System.ComponentModel.DataAnnotations; using Entities; using Mediator; public class GetMovieByIdQuery : IRequest { - [Required] public Guid Id { get; init; } } diff --git a/src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs b/src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs index ff9378e..d7e4ff4 100644 --- a/src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs +++ b/src/Application/Reviews/Commands/CreateReview/CreateReviewHandler.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.CreateReview; -using System.Threading; -using System.Threading.Tasks; using Authors; using Common.Enums; using Common.Exceptions; diff --git a/src/Application/Reviews/Commands/DeleteReview/DeleteReviewCommand.cs b/src/Application/Reviews/Commands/DeleteReview/DeleteReviewCommand.cs index 404db65..f05156a 100644 --- a/src/Application/Reviews/Commands/DeleteReview/DeleteReviewCommand.cs +++ b/src/Application/Reviews/Commands/DeleteReview/DeleteReviewCommand.cs @@ -2,7 +2,7 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.DeleteReview; using Mediator; -public class DeleteReviewCommand : IRequest +public class DeleteReviewCommand : IRequest { public Guid Id { get; init; } } diff --git a/src/Application/Reviews/Commands/DeleteReview/DeleteReviewHandler.cs b/src/Application/Reviews/Commands/DeleteReview/DeleteReviewHandler.cs index 088dacf..47eccc1 100644 --- a/src/Application/Reviews/Commands/DeleteReview/DeleteReviewHandler.cs +++ b/src/Application/Reviews/Commands/DeleteReview/DeleteReviewHandler.cs @@ -1,20 +1,18 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.DeleteReview; -using System.Threading; -using System.Threading.Tasks; using Common.Enums; using Common.Exceptions; using Mediator; -public class DeleteReviewHandler(IReviewsRepository repository) : IRequestHandler +public class DeleteReviewHandler(IReviewsRepository repository) : IRequestHandler { - public async ValueTask Handle(DeleteReviewCommand request, CancellationToken cancellationToken) + public async ValueTask Handle(DeleteReviewCommand request, CancellationToken cancellationToken) { - if (!await repository.ReviewExists(request.Id, cancellationToken)) + if (!await repository.DeleteReview(request.Id, cancellationToken)) { NotFoundException.Throw(EntityType.Review); } - return await repository.DeleteReview(request.Id, cancellationToken); + return Unit.Value; } } diff --git a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs index 2ba0410..7e02405 100644 --- a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs +++ b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewCommand.cs @@ -1,11 +1,12 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.UpdateReview; +using Entities; using Mediator; /// /// Changes the star rating of a review. The author and the movie of a review can't change. /// -public class UpdateReviewCommand : IRequest +public class UpdateReviewCommand : IRequest { /// The unique identifier of the review. public Guid Id { get; init; } diff --git a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs index 7e9dedc..e2f7d54 100644 --- a/src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs +++ b/src/Application/Reviews/Commands/UpdateReview/UpdateReviewHandler.cs @@ -2,17 +2,17 @@ namespace CleanModelContextProtocol.Application.Reviews.Commands.UpdateReview; using Common.Enums; using Common.Exceptions; +using Entities; using Mediator; -public class UpdateReviewHandler(IReviewsRepository reviewsRepository) : IRequestHandler +public class UpdateReviewHandler(IReviewsRepository reviewsRepository) : IRequestHandler { - public async ValueTask Handle(UpdateReviewCommand request, CancellationToken cancellationToken) + public async ValueTask Handle(UpdateReviewCommand request, CancellationToken cancellationToken) { - if (!await reviewsRepository.ReviewExists(request.Id, cancellationToken)) - { - NotFoundException.Throw(EntityType.Review); - } + var review = await reviewsRepository.UpdateReview(request.Id, request.Stars, cancellationToken); - return await reviewsRepository.UpdateReview(request.Id, request.Stars, cancellationToken); + NotFoundException.ThrowIfNull(review, EntityType.Review); + + return review; } } diff --git a/src/Application/Reviews/IReviewsRepository.cs b/src/Application/Reviews/IReviewsRepository.cs index cf1781e..ea60f49 100644 --- a/src/Application/Reviews/IReviewsRepository.cs +++ b/src/Application/Reviews/IReviewsRepository.cs @@ -25,7 +25,5 @@ public Task CreateReview( public Task GetReviewById(Guid id, CancellationToken cancellationToken); - public Task ReviewExists(Guid id, CancellationToken cancellationToken); - - public Task UpdateReview(Guid id, int stars, CancellationToken cancellationToken); + public Task UpdateReview(Guid id, int stars, CancellationToken cancellationToken); } diff --git a/src/Application/Reviews/Queries/GetReviewById/GetReviewByIdHandler.cs b/src/Application/Reviews/Queries/GetReviewById/GetReviewByIdHandler.cs index 53f4489..ea2fb14 100644 --- a/src/Application/Reviews/Queries/GetReviewById/GetReviewByIdHandler.cs +++ b/src/Application/Reviews/Queries/GetReviewById/GetReviewByIdHandler.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Reviews.Queries.GetReviewById; -using System.Threading; -using System.Threading.Tasks; using Common.Enums; using Common.Exceptions; using Entities; diff --git a/src/Application/Versions/Entities/Version.cs b/src/Application/Versions/Entities/VersionInfo.cs similarity index 95% rename from src/Application/Versions/Entities/Version.cs rename to src/Application/Versions/Entities/VersionInfo.cs index 9eaf65d..ecabcd2 100644 --- a/src/Application/Versions/Entities/Version.cs +++ b/src/Application/Versions/Entities/VersionInfo.cs @@ -3,7 +3,7 @@ namespace CleanModelContextProtocol.Application.Versions.Entities; /// /// The version details of the running application. /// -public class Version +public class VersionInfo { /// /// The assembly file version. diff --git a/src/Application/Versions/Queries/GetVersion/GetVersionHandler.cs b/src/Application/Versions/Queries/GetVersion/GetVersionHandler.cs index fb698f5..9968ac8 100644 --- a/src/Application/Versions/Queries/GetVersion/GetVersionHandler.cs +++ b/src/Application/Versions/Queries/GetVersion/GetVersionHandler.cs @@ -1,23 +1,33 @@ namespace CleanModelContextProtocol.Application.Versions.Queries.GetVersion; using System.Reflection; -using System.Threading; -using System.Threading.Tasks; using Entities; using Mediator; -public class GetVersionHandler : IRequestHandler +public class GetVersionHandler : IRequestHandler { - public ValueTask Handle(GetVersionQuery request, CancellationToken cancellationToken) + private readonly Assembly? assembly; + + /// Reads the version of the app that's running. + public GetVersionHandler() + : this(Assembly.GetEntryAssembly()) + { + } + + /// Reads the version of the given assembly, so tests can choose one with known values. + internal GetVersionHandler(Assembly? assembly) { - var assembly = Assembly.GetEntryAssembly(); + this.assembly = assembly; + } - var version = new Version + public ValueTask Handle(GetVersionQuery request, CancellationToken cancellationToken) + { + var version = new VersionInfo { FileVersion = - $"{assembly?.GetCustomAttribute()?.Version}", + $"{this.assembly?.GetCustomAttribute()?.Version}", InformationalVersion = - $"{assembly?.GetCustomAttribute()?.InformationalVersion}" + $"{this.assembly?.GetCustomAttribute()?.InformationalVersion}" }; return ValueTask.FromResult(version); diff --git a/src/Application/Versions/Queries/GetVersion/GetVersionQuery.cs b/src/Application/Versions/Queries/GetVersion/GetVersionQuery.cs index a53461a..a2b2c3e 100644 --- a/src/Application/Versions/Queries/GetVersion/GetVersionQuery.cs +++ b/src/Application/Versions/Queries/GetVersion/GetVersionQuery.cs @@ -3,6 +3,6 @@ namespace CleanModelContextProtocol.Application.Versions.Queries.GetVersion; using Entities; using Mediator; -public class GetVersionQuery : IRequest +public class GetVersionQuery : IRequest { } diff --git a/src/Infrastructure/Databases/MovieReviews/Configuration/AuthorConfiguration.cs b/src/Infrastructure/Databases/MovieReviews/Configuration/AuthorConfiguration.cs index d3d0e2c..cc42bd6 100644 --- a/src/Infrastructure/Databases/MovieReviews/Configuration/AuthorConfiguration.cs +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/AuthorConfiguration.cs @@ -1,5 +1,6 @@ namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Configuration; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Models; @@ -9,6 +10,10 @@ public override void Configure(EntityTypeBuilder builder) { base.Configure(builder); + _ = builder.Property(a => a.FirstName).UseCollation(Collations.NoCase); + _ = builder.Property(a => a.LastName).UseCollation(Collations.NoCase); + _ = builder.HasIndex(a => new { a.LastName, a.FirstName }); + _ = builder.HasMany(m => m.Reviews).WithOne(r => r.ReviewAuthor).HasForeignKey(r => r.ReviewAuthorId); } } diff --git a/src/Infrastructure/Databases/MovieReviews/Configuration/Collations.cs b/src/Infrastructure/Databases/MovieReviews/Configuration/Collations.cs new file mode 100644 index 0000000..d4ed0b4 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/Collations.cs @@ -0,0 +1,12 @@ +namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Configuration; + +/// +/// The SQLite collations the model uses. +/// +internal static class Collations +{ + /// + /// Compares ASCII letters without case, so names and titles sort and match the way people expect. + /// + public const string NoCase = "NOCASE"; +} diff --git a/src/Infrastructure/Databases/MovieReviews/Configuration/EntityConfiguration.cs b/src/Infrastructure/Databases/MovieReviews/Configuration/EntityConfiguration.cs index fb4ce72..9e006ec 100644 --- a/src/Infrastructure/Databases/MovieReviews/Configuration/EntityConfiguration.cs +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/EntityConfiguration.cs @@ -10,7 +10,7 @@ internal abstract class EntityConfiguration : IEntityTypeConfiguration public virtual void Configure(EntityTypeBuilder builder) { _ = builder.HasKey(e => e.Id); - _ = builder.Property(m => m.Id).ValueGeneratedOnAdd().IsRequired(); + _ = builder.Property(m => m.Id).ValueGeneratedOnAdd().HasValueGenerator().IsRequired(); _ = builder.Property(m => m.DateCreated).IsRequired(); _ = builder.Property(m => m.DateModified).IsRequired(); } diff --git a/src/Infrastructure/Databases/MovieReviews/Configuration/MovieConfiguration.cs b/src/Infrastructure/Databases/MovieReviews/Configuration/MovieConfiguration.cs index 29267c3..1fc084c 100644 --- a/src/Infrastructure/Databases/MovieReviews/Configuration/MovieConfiguration.cs +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/MovieConfiguration.cs @@ -1,5 +1,6 @@ namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Configuration; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Models; @@ -9,6 +10,9 @@ public override void Configure(EntityTypeBuilder builder) { base.Configure(builder); + _ = builder.Property(m => m.Title).UseCollation(Collations.NoCase); + _ = builder.HasIndex(m => m.Title); + _ = builder.HasMany(m => m.Reviews).WithOne(r => r.ReviewedMovie).HasForeignKey(r => r.ReviewedMovieId); } } diff --git a/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs b/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs new file mode 100644 index 0000000..182a7d5 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/ReviewConfiguration.cs @@ -0,0 +1,7 @@ +namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Configuration; + +using Models; + +internal class ReviewConfiguration : EntityConfiguration +{ +} diff --git a/src/Infrastructure/Databases/MovieReviews/Configuration/Version7GuidValueGenerator.cs b/src/Infrastructure/Databases/MovieReviews/Configuration/Version7GuidValueGenerator.cs new file mode 100644 index 0000000..9e6d61a --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Configuration/Version7GuidValueGenerator.cs @@ -0,0 +1,18 @@ +namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Configuration; + +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.ValueGeneration; + +/// +/// Gives new rows a version 7 GUID. They start with a timestamp, so new rows land at the end of the primary key +/// index instead of at a random place in it. +/// +internal sealed class Version7GuidValueGenerator : ValueGenerator +{ + public override bool GeneratesTemporaryValues => false; + + public override Guid Next(EntityEntry entry) + { + return Guid.CreateVersion7(); + } +} diff --git a/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs b/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs index 3c15542..76eed37 100644 --- a/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs +++ b/src/Infrastructure/Databases/MovieReviews/EntityFrameworkMovieReviewsRepository.cs @@ -1,6 +1,5 @@ namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews; -using System; using Application.Authors; using Application.Movies; using Application.Reviews; @@ -41,7 +40,7 @@ public EntityFrameworkMovieReviewsRepository( int limit, CancellationToken cancellationToken) { - var authors = this.context.Authors.AsNoTracking(); + var authors = this.context.Authors.AsQueryable(); if (!string.IsNullOrWhiteSpace(query)) { @@ -77,7 +76,6 @@ public EntityFrameworkMovieReviewsRepository( var author = await this.context.Authors .Where(r => r.Id == id).Include(a => a.Reviews) .ThenInclude(r => r.ReviewedMovie) - .AsNoTracking() .FirstOrDefaultAsync(cancellationToken); return author is null ? null : this.mapper.ToDomain(author); @@ -85,7 +83,7 @@ public EntityFrameworkMovieReviewsRepository( public virtual async Task AuthorExists(Guid id, CancellationToken cancellationToken) { - return await this.context.Authors.AsNoTracking().AnyAsync(a => a.Id == id, cancellationToken); + return await this.context.Authors.AnyAsync(a => a.Id == id, cancellationToken); } #endregion Authors @@ -98,7 +96,7 @@ public virtual async Task AuthorExists(Guid id, CancellationToken cancella int limit, CancellationToken cancellationToken) { - var movies = this.context.Movies.AsNoTracking(); + var movies = this.context.Movies.AsQueryable(); if (!string.IsNullOrWhiteSpace(query)) { @@ -133,7 +131,6 @@ public virtual async Task AuthorExists(Guid id, CancellationToken cancella .Where(r => r.Id == id) .Include(m => m.Reviews) .ThenInclude(r => r.ReviewAuthor) - .AsNoTracking() .FirstOrDefaultAsync(cancellationToken); return result is null ? null : this.mapper.ToDomain(result); @@ -141,14 +138,14 @@ public virtual async Task AuthorExists(Guid id, CancellationToken cancella public virtual async Task MovieExists(Guid id, CancellationToken cancellationToken) { - return await this.context.Movies.AsNoTracking().AnyAsync(m => m.Id == id, cancellationToken); + return await this.context.Movies.AnyAsync(m => m.Id == id, cancellationToken); } #endregion Movies #region Reviews - public async Task CreateReview( + public virtual async Task CreateReview( Guid authorId, Guid movieId, int stars, @@ -173,13 +170,12 @@ public async Task CreateReview( .Where(r => r.Id == id) .Include(r => r.ReviewAuthor) .Include(r => r.ReviewedMovie) - .AsNoTracking() .FirstAsync(cancellationToken); return this.mapper.ToDomain(result); } - public async Task DeleteReview(Guid id, CancellationToken cancellationToken) + public virtual async Task DeleteReview(Guid id, CancellationToken cancellationToken) { var deleted = await this.context.Reviews .Where(r => r.Id == id) @@ -188,14 +184,14 @@ public async Task DeleteReview(Guid id, CancellationToken cancellationToke return deleted > 0; } - public async Task<(List Items, int TotalCount)> GetReviews( + public virtual async Task<(List Items, int TotalCount)> GetReviews( Guid? movieId, Guid? authorId, int offset, int limit, CancellationToken cancellationToken) { - var reviews = this.context.Reviews.AsNoTracking(); + var reviews = this.context.Reviews.AsQueryable(); if (movieId is Guid reviewedMovieId) { @@ -222,10 +218,9 @@ public async Task DeleteReview(Guid id, CancellationToken cancellationToke return ([.. result.Select(this.mapper.ToDomain)], totalCount); } - public async Task> GetReviewsForMovies(IReadOnlyCollection movieIds, CancellationToken cancellationToken) + public virtual async Task> GetReviewsForMovies(IReadOnlyCollection movieIds, CancellationToken cancellationToken) { var result = await this.context.Reviews - .AsNoTracking() .Where(r => movieIds.Contains(r.ReviewedMovieId)) .Include(r => r.ReviewAuthor) .Include(r => r.ReviewedMovie) @@ -237,10 +232,9 @@ public async Task> GetReviewsForMovies(IReadOnlyCollecti return [.. result.Select(this.mapper.ToDomain)]; } - public async Task> GetReviewsForAuthors(IReadOnlyCollection authorIds, CancellationToken cancellationToken) + public virtual async Task> GetReviewsForAuthors(IReadOnlyCollection authorIds, CancellationToken cancellationToken) { var result = await this.context.Reviews - .AsNoTracking() .Where(r => authorIds.Contains(r.ReviewAuthorId)) .Include(r => r.ReviewAuthor) .Include(r => r.ReviewedMovie) @@ -251,27 +245,22 @@ public async Task> GetReviewsForAuthors(IReadOnlyCollect return [.. result.Select(this.mapper.ToDomain)]; } - public async Task GetReviewById(Guid id, CancellationToken cancellationToken) + public virtual async Task GetReviewById(Guid id, CancellationToken cancellationToken) { var result = await this.context.Reviews .Where(r => r.Id == id) .Include(r => r.ReviewAuthor) .Include(r => r.ReviewedMovie) - .AsNoTracking() .FirstOrDefaultAsync(cancellationToken); return result is null ? null : this.mapper.ToDomain(result); } - public async Task ReviewExists(Guid id, CancellationToken cancellationToken) - { - return await this.context.Reviews.AnyAsync(r => r.Id == id, cancellationToken); - } - - public async Task UpdateReview(Guid id, int stars, CancellationToken cancellationToken) + public virtual async Task UpdateReview(Guid id, int stars, CancellationToken cancellationToken) { var now = this.timeProvider.GetUtcNow().UtcDateTime; + // The update is the existence check: no rows changed means there's no such review. var updated = await this.context.Reviews .Where(r => r.Id == id) .ExecuteUpdateAsync( @@ -280,7 +269,7 @@ public async Task UpdateReview(Guid id, int stars, CancellationToken cance .SetProperty(r => r.DateModified, now), cancellationToken); - return updated > 0; + return updated == 0 ? null : await this.GetReviewById(id, cancellationToken); } #endregion Reviews diff --git a/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs b/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs index 6aef8c3..28fa862 100644 --- a/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs +++ b/src/Infrastructure/Databases/MovieReviews/Extensions/MovieReviewsDbContextExtensions.cs @@ -1,6 +1,5 @@ namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Extensions; -using System; using Bogus; using Models; diff --git a/src/Infrastructure/Databases/MovieReviews/Migrations/20261103000000_AddCollationsAndIndexes.Designer.cs b/src/Infrastructure/Databases/MovieReviews/Migrations/20261103000000_AddCollationsAndIndexes.Designer.cs new file mode 100644 index 0000000..250370b --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/20261103000000_AddCollationsAndIndexes.Designer.cs @@ -0,0 +1,137 @@ +// +using System; +using CleanModelContextProtocol.Infrastructure.Databases.MovieReviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Migrations +{ + [DbContext(typeof(MovieReviewsDbContext))] + [Migration("20261103000000_AddCollationsAndIndexes")] + partial class AddCollationsAndIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("LastName", "FirstName"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Title"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("ReviewAuthorId") + .HasColumnType("TEXT"); + + b.Property("ReviewedMovieId") + .HasColumnType("TEXT"); + + b.Property("Stars") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReviewAuthorId"); + + b.HasIndex("ReviewedMovieId"); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Review", b => + { + b.HasOne("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Author", "ReviewAuthor") + .WithMany("Reviews") + .HasForeignKey("ReviewAuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Movie", "ReviewedMovie") + .WithMany("Reviews") + .HasForeignKey("ReviewedMovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewAuthor"); + + b.Navigation("ReviewedMovie"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Author", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models.Movie", b => + { + b.Navigation("Reviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Databases/MovieReviews/Migrations/20261103000000_AddCollationsAndIndexes.cs b/src/Infrastructure/Databases/MovieReviews/Migrations/20261103000000_AddCollationsAndIndexes.cs new file mode 100644 index 0000000..eeb9a02 --- /dev/null +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/20261103000000_AddCollationsAndIndexes.cs @@ -0,0 +1,91 @@ +// +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Migrations +{ + /// + public partial class AddCollationsAndIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Title", + table: "Movies", + type: "TEXT", + nullable: false, + collation: "NOCASE", + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "LastName", + table: "Authors", + type: "TEXT", + nullable: false, + collation: "NOCASE", + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "FirstName", + table: "Authors", + type: "TEXT", + nullable: false, + collation: "NOCASE", + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.CreateIndex( + name: "IX_Movies_Title", + table: "Movies", + column: "Title"); + + migrationBuilder.CreateIndex( + name: "IX_Authors_LastName_FirstName", + table: "Authors", + columns: new[] { "LastName", "FirstName" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Movies_Title", + table: "Movies"); + + migrationBuilder.DropIndex( + name: "IX_Authors_LastName_FirstName", + table: "Authors"); + + migrationBuilder.AlterColumn( + name: "Title", + table: "Movies", + type: "TEXT", + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldCollation: "NOCASE"); + + migrationBuilder.AlterColumn( + name: "LastName", + table: "Authors", + type: "TEXT", + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldCollation: "NOCASE"); + + migrationBuilder.AlterColumn( + name: "FirstName", + table: "Authors", + type: "TEXT", + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldCollation: "NOCASE"); + } + } +} diff --git a/src/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs b/src/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs index 3552e71..9beac9e 100644 --- a/src/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs +++ b/src/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs @@ -31,14 +31,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FirstName") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .UseCollation("NOCASE"); b.Property("LastName") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .UseCollation("NOCASE"); b.HasKey("Id"); + b.HasIndex("LastName", "FirstName"); + b.ToTable("Authors"); }); @@ -56,10 +60,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Title") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .UseCollation("NOCASE"); b.HasKey("Id"); + b.HasIndex("Title"); + b.ToTable("Movies"); }); diff --git a/src/Infrastructure/Databases/MovieReviews/Models/Review.cs b/src/Infrastructure/Databases/MovieReviews/Models/Review.cs index 9aa944f..aa401e5 100644 --- a/src/Infrastructure/Databases/MovieReviews/Models/Review.cs +++ b/src/Infrastructure/Databases/MovieReviews/Models/Review.cs @@ -1,6 +1,5 @@ namespace CleanModelContextProtocol.Infrastructure.Databases.MovieReviews.Models; -using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; [ExcludeFromCodeCoverage] @@ -8,12 +7,10 @@ internal record Review : Entity { public int Stars { get; set; } - [ForeignKey("ReviewedMovie")] public Guid ReviewedMovieId { get; set; } public Movie ReviewedMovie { get; init; } = null!; - [ForeignKey("ReviewAuthor")] public Guid ReviewAuthorId { get; set; } public Author ReviewAuthor { get; init; } = null!; diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index e2f0ede..ea048c0 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -1,6 +1,5 @@ namespace CleanModelContextProtocol.Infrastructure; -using System; using Application.Authors; using Application.Movies; using Application.Reviews; @@ -19,10 +18,15 @@ public static class DependencyInjection public static IServiceCollection AddInfrastructure(this IServiceCollection services, string? connectionString = null) { - var database = new MovieReviewsDatabase(string.IsNullOrWhiteSpace(connectionString) ? DefaultConnectionString : connectionString); + var databaseConnectionString = string.IsNullOrWhiteSpace(connectionString) ? DefaultConnectionString : connectionString; - _ = services.AddSingleton(database); - _ = services.AddDbContext(options => options.UseSqlite(database.ConnectionString)); + // Registered through a factory, so the container owns it and closes its connection when the app stops. + _ = services.AddSingleton(_ => new MovieReviewsDatabase(databaseConnectionString)); + + // Nothing reads an entity to change it, so queries don't track by default. + _ = services.AddDbContext(options => options + .UseSqlite(databaseConnectionString) + .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)); _ = services.AddSingleton(); _ = services.AddScoped(); diff --git a/src/Presentation/Serialization/AppJsonSerializerContext.cs b/src/Presentation/Serialization/AppJsonSerializerContext.cs index bce2cb0..7d536d6 100644 --- a/src/Presentation/Serialization/AppJsonSerializerContext.cs +++ b/src/Presentation/Serialization/AppJsonSerializerContext.cs @@ -13,7 +13,7 @@ namespace CleanModelContextProtocol.Presentation.Serialization; PropertyNameCaseInsensitive = true, WriteIndented = false, GenerationMode = JsonSourceGenerationMode.Default)] -[JsonSerializable(typeof(Version))] +[JsonSerializable(typeof(VersionInfo))] [JsonSerializable(typeof(DeletedReview))] [JsonSerializable(typeof(MovieResult))] [JsonSerializable(typeof(AuthorResult))] diff --git a/src/Presentation/Tools/ReviewTools.cs b/src/Presentation/Tools/ReviewTools.cs index feb43a6..5a767fb 100644 --- a/src/Presentation/Tools/ReviewTools.cs +++ b/src/Presentation/Tools/ReviewTools.cs @@ -79,17 +79,12 @@ public static async partial Task WriteReview( return created.ToResult(); } - _ = await sender.Send(new Commands.UpdateReview.UpdateReviewCommand + var updated = await sender.Send(new Commands.UpdateReview.UpdateReviewCommand { Id = reviewId.Value, Stars = stars }, cancellationToken); - var updated = await sender.Send(new Queries.GetReviewById.GetReviewByIdQuery - { - Id = reviewId.Value - }, cancellationToken); - return updated.ToResult(); } @@ -101,11 +96,12 @@ public static async partial Task DeleteReview( Guid reviewId, CancellationToken cancellationToken) { - var deleted = await sender.Send(new Commands.DeleteReview.DeleteReviewCommand + // A review that isn't there throws not found, so getting here means it was deleted. + _ = await sender.Send(new Commands.DeleteReview.DeleteReviewCommand { Id = reviewId }, cancellationToken); - return new DeletedReview(reviewId, deleted); + return new DeletedReview(reviewId, true); } } diff --git a/tests/Application.Tests.Unit/Authors/Queries/GetAuthorById/GetAuthorByIdHandlerTests.cs b/tests/Application.Tests.Unit/Authors/Queries/GetAuthorById/GetAuthorByIdHandlerTests.cs index 25d354c..9b4ef35 100644 --- a/tests/Application.Tests.Unit/Authors/Queries/GetAuthorById/GetAuthorByIdHandlerTests.cs +++ b/tests/Application.Tests.Unit/Authors/Queries/GetAuthorById/GetAuthorByIdHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Authors.Queries.GetAuthorById; -using System.Threading; -using System.Threading.Tasks; using Application.Authors; using Application.Authors.Entities; using Application.Authors.Queries.GetAuthorById; diff --git a/tests/Application.Tests.Unit/Authors/Queries/GetAuthors/GetAuthorsHandlerTests.cs b/tests/Application.Tests.Unit/Authors/Queries/GetAuthors/GetAuthorsHandlerTests.cs index d161ecd..f5273bd 100644 --- a/tests/Application.Tests.Unit/Authors/Queries/GetAuthors/GetAuthorsHandlerTests.cs +++ b/tests/Application.Tests.Unit/Authors/Queries/GetAuthors/GetAuthorsHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Authors.Queries.GetAuthors; -using System.Threading; -using System.Threading.Tasks; using Application.Authors; using Application.Authors.Entities; using Application.Authors.Queries.GetAuthors; diff --git a/tests/Application.Tests.Unit/Common/Behaviours/LoggingBehaviourTests.cs b/tests/Application.Tests.Unit/Common/Behaviours/LoggingBehaviourTests.cs index f3ce311..7389589 100644 --- a/tests/Application.Tests.Unit/Common/Behaviours/LoggingBehaviourTests.cs +++ b/tests/Application.Tests.Unit/Common/Behaviours/LoggingBehaviourTests.cs @@ -1,8 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Common.Behaviours; -using System; -using System.Threading; -using System.Threading.Tasks; using Application.Authors.Entities; using Application.Authors.Queries.GetAuthorById; using Application.Common.Behaviours; diff --git a/tests/Application.Tests.Unit/Common/Behaviours/ValidationBehaviourTests.cs b/tests/Application.Tests.Unit/Common/Behaviours/ValidationBehaviourTests.cs index 2286ed0..1dd01c8 100644 --- a/tests/Application.Tests.Unit/Common/Behaviours/ValidationBehaviourTests.cs +++ b/tests/Application.Tests.Unit/Common/Behaviours/ValidationBehaviourTests.cs @@ -1,9 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Common.Behaviours; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Application.Authors.Entities; using Application.Authors.Queries.GetAuthorById; using Application.Common.Behaviours; diff --git a/tests/Application.Tests.Unit/Movies/Queries/GetMovieById/GetReviewByIdHandlerTests.cs b/tests/Application.Tests.Unit/Movies/Queries/GetMovieById/GetReviewByIdHandlerTests.cs index c4bf168..7ecd479 100644 --- a/tests/Application.Tests.Unit/Movies/Queries/GetMovieById/GetReviewByIdHandlerTests.cs +++ b/tests/Application.Tests.Unit/Movies/Queries/GetMovieById/GetReviewByIdHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Movies.Queries.GetMovieById; -using System.Threading; -using System.Threading.Tasks; using Application.Common.Exceptions; using Application.Movies; using Application.Movies.Entities; diff --git a/tests/Application.Tests.Unit/Movies/Queries/GetMovies/GetMoviesHandlerTests.cs b/tests/Application.Tests.Unit/Movies/Queries/GetMovies/GetMoviesHandlerTests.cs index 68f135e..d85ab03 100644 --- a/tests/Application.Tests.Unit/Movies/Queries/GetMovies/GetMoviesHandlerTests.cs +++ b/tests/Application.Tests.Unit/Movies/Queries/GetMovies/GetMoviesHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Movies.Queries.GetMovies; -using System.Threading; -using System.Threading.Tasks; using Application.Common.Paging; using Application.Movies; using Application.Movies.Entities; diff --git a/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs index 87a3a1b..17bc181 100644 --- a/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Commands.CreateReview; -using System.Threading; -using System.Threading.Tasks; using Application.Authors; using Application.Common.Exceptions; using Application.Movies; diff --git a/tests/Application.Tests.Unit/Reviews/Commands/DeleteReview/DeleteReviewHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/DeleteReview/DeleteReviewHandlerTests.cs index 8c2a86f..a78b2c6 100644 --- a/tests/Application.Tests.Unit/Reviews/Commands/DeleteReview/DeleteReviewHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Commands/DeleteReview/DeleteReviewHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Commands.DeleteReview; -using System.Threading; -using System.Threading.Tasks; using Application.Common.Exceptions; using Application.Reviews; using Application.Reviews.Commands.DeleteReview; @@ -12,13 +10,13 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Commands.Dele public class DeleteReviewHandlerTests { [Fact] - public async Task Handle_ShouldPassThrough_Command() + public async Task Handle_ShouldDeleteReview_ReviewExists() { // Arrange - var command = new DeleteReviewCommand { Id = Guid.Empty }; + var command = new DeleteReviewCommand { Id = Guid.NewGuid() }; var reviewsRepository = Substitute.For(); - _ = reviewsRepository.ReviewExists(default, default).ReturnsForAnyArgs(true); + _ = reviewsRepository.DeleteReview(default, default).ReturnsForAnyArgs(true); var handler = new DeleteReviewHandler(reviewsRepository); using var tokenSource = new CancellationTokenSource(); @@ -28,7 +26,6 @@ public async Task Handle_ShouldPassThrough_Command() _ = await handler.Handle(command, token); // Assert - _ = await reviewsRepository.Received(1).ReviewExists(command.Id, token); _ = await reviewsRepository.Received(1).DeleteReview(command.Id, token); } @@ -36,22 +33,21 @@ public async Task Handle_ShouldPassThrough_Command() public async Task Handle_ShouldThrowException_ReviewDoesNotExist() { // Arrange - var command = new DeleteReviewCommand { Id = Guid.Empty }; + var command = new DeleteReviewCommand { Id = Guid.NewGuid() }; var reviewsRepository = Substitute.For(); - _ = reviewsRepository.ReviewExists(default, default).ReturnsForAnyArgs(false); + _ = reviewsRepository.DeleteReview(default, default).ReturnsForAnyArgs(false); var handler = new DeleteReviewHandler(reviewsRepository); using var tokenSource = new CancellationTokenSource(); var token = tokenSource.Token; // Act - var exception = Should.Throw(async () => await handler.Handle(command, token)); + var exception = await Should.ThrowAsync(async () => await handler.Handle(command, token)); // Assert exception.Message.ShouldBe("The Review with the supplied id was not found."); - _ = await reviewsRepository.Received(1).ReviewExists(command.Id, token); - _ = await reviewsRepository.DidNotReceive().DeleteReview(command.Id, token); + _ = await reviewsRepository.Received(1).DeleteReview(command.Id, token); } } diff --git a/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs index 0968c3d..8c71a49 100644 --- a/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Commands/UpdateReview/UpdateReviewHandlerTests.cs @@ -1,10 +1,9 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Commands.UpdateReview; -using System.Threading; -using System.Threading.Tasks; using Application.Common.Exceptions; using Application.Reviews; using Application.Reviews.Commands.UpdateReview; +using Application.Reviews.Entities; using NSubstitute; using Shouldly; using Xunit; @@ -12,7 +11,7 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Commands.Upda public class UpdateReviewHandlerTests { [Fact] - public async Task Handle_ShouldPassThrough_Command() + public async Task Handle_ShouldReturnUpdatedReview_ReviewExists() { // Arrange var command = new UpdateReviewCommand @@ -21,10 +20,10 @@ public async Task Handle_ShouldPassThrough_Command() Stars = 1 }; + var review = new Review(command.Id, command.Stars); var reviewsRepository = Substitute.For(); - _ = reviewsRepository.ReviewExists(default, default).ReturnsForAnyArgs(true); - _ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(true); + _ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(review); var handler = new UpdateReviewHandler(reviewsRepository); using var tokenSource = new CancellationTokenSource(); @@ -34,9 +33,8 @@ public async Task Handle_ShouldPassThrough_Command() var result = await handler.Handle(command, token); // Assert - result.ShouldBeTrue(); + result.ShouldBe(review); - _ = await reviewsRepository.Received(1).ReviewExists(command.Id, token); _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token); } @@ -52,7 +50,7 @@ public async Task Handle_ShouldThrowException_ReviewDoesNotExist() var reviewsRepository = Substitute.For(); - _ = reviewsRepository.ReviewExists(default, default).ReturnsForAnyArgs(false); + _ = reviewsRepository.UpdateReview(default, default, default).ReturnsForAnyArgs(Task.FromResult(null)); var handler = new UpdateReviewHandler(reviewsRepository); using var tokenSource = new CancellationTokenSource(); @@ -64,7 +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).ReviewExists(command.Id, token); - _ = await reviewsRepository.DidNotReceiveWithAnyArgs().UpdateReview(default, default, default); + _ = await reviewsRepository.Received(1).UpdateReview(command.Id, command.Stars, token); } } diff --git a/tests/Application.Tests.Unit/Reviews/Queries/GetReviewById/GetReviewByIdHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Queries/GetReviewById/GetReviewByIdHandlerTests.cs index b6ac029..61027b7 100644 --- a/tests/Application.Tests.Unit/Reviews/Queries/GetReviewById/GetReviewByIdHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Queries/GetReviewById/GetReviewByIdHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Queries.GetReviewById; -using System.Threading; -using System.Threading.Tasks; using Application.Common.Exceptions; using Application.Reviews; using Application.Reviews.Entities; diff --git a/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs index 1eca187..d362052 100644 --- a/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Queries/GetReviews/GetReviewsHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Queries.GetReviews; -using System.Threading; -using System.Threading.Tasks; using Application.Common.Paging; using Application.Reviews; using Application.Reviews.Entities; diff --git a/tests/Application.Tests.Unit/Reviews/Queries/GetReviewsForAuthors/GetReviewsForAuthorsHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Queries/GetReviewsForAuthors/GetReviewsForAuthorsHandlerTests.cs index 641c216..1a3429d 100644 --- a/tests/Application.Tests.Unit/Reviews/Queries/GetReviewsForAuthors/GetReviewsForAuthorsHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Queries/GetReviewsForAuthors/GetReviewsForAuthorsHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Queries.GetReviewsForAuthors; -using System.Threading; -using System.Threading.Tasks; using Application.Authors.Entities; using Application.Reviews; using Application.Reviews.Entities; diff --git a/tests/Application.Tests.Unit/Reviews/Queries/GetReviewsForMovies/GetReviewsForMoviesHandlerTests.cs b/tests/Application.Tests.Unit/Reviews/Queries/GetReviewsForMovies/GetReviewsForMoviesHandlerTests.cs index 0ab09b0..25c2d66 100644 --- a/tests/Application.Tests.Unit/Reviews/Queries/GetReviewsForMovies/GetReviewsForMoviesHandlerTests.cs +++ b/tests/Application.Tests.Unit/Reviews/Queries/GetReviewsForMovies/GetReviewsForMoviesHandlerTests.cs @@ -1,7 +1,5 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Reviews.Queries.GetReviewsForMovies; -using System.Threading; -using System.Threading.Tasks; using Application.Movies.Entities; using Application.Reviews; using Application.Reviews.Entities; diff --git a/tests/Application.Tests.Unit/Version/Queries/GetVersion/GetVersionHandlerTests.cs b/tests/Application.Tests.Unit/Version/Queries/GetVersion/GetVersionHandlerTests.cs index 570e208..c8520d6 100644 --- a/tests/Application.Tests.Unit/Version/Queries/GetVersion/GetVersionHandlerTests.cs +++ b/tests/Application.Tests.Unit/Version/Queries/GetVersion/GetVersionHandlerTests.cs @@ -1,8 +1,6 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Version.Queries.GetVersion; -using System.Threading; -using System.Threading.Tasks; -using Application.Versions.Entities; +using System.Reflection; using Application.Versions.Queries.GetVersion; using Shouldly; using Xunit; @@ -10,25 +8,47 @@ namespace CleanModelContextProtocol.Application.Tests.Unit.Version.Queries.GetVe public class GetVersionHandlerTests { [Fact] - public async Task Handle_ShouldPassThrough_Query() + public async Task Handle_ShouldReturnVersions_AssemblyHasVersionAttributes() { // Arrange - var query = new GetVersionQuery(); + var assembly = typeof(GetVersionHandlerTests).Assembly; + var handler = new GetVersionHandler(assembly); + using var tokenSource = new CancellationTokenSource(); - var handler = new GetVersionHandler(); + // Act + var result = await handler.Handle(new GetVersionQuery(), tokenSource.Token); + + // Assert + result.FileVersion.ShouldBe(assembly.GetCustomAttribute()!.Version); + result.InformationalVersion.ShouldBe(assembly.GetCustomAttribute()!.InformationalVersion); + } + + [Fact] + public async Task Handle_ShouldReturnEmptyVersions_NoAssembly() + { + // Arrange + var handler = new GetVersionHandler(null); using var tokenSource = new CancellationTokenSource(); - var token = tokenSource.Token; // Act - var result = await handler.Handle(query, token); + var result = await handler.Handle(new GetVersionQuery(), tokenSource.Token); // Assert - _ = result.ShouldNotBeNull(); - _ = result.ShouldBeOfType(); + result.FileVersion.ShouldBeEmpty(); + result.InformationalVersion.ShouldBeEmpty(); + } + + [Fact] + public async Task Handle_ShouldReadEntryAssembly_DefaultConstructor() + { + // Arrange + var handler = new GetVersionHandler(); + using var tokenSource = new CancellationTokenSource(); - _ = result.FileVersion.ShouldBeOfType(); + // Act + var result = await handler.Handle(new GetVersionQuery(), tokenSource.Token); + + // Assert result.FileVersion.ShouldNotBeNullOrWhiteSpace(); - _ = result.InformationalVersion.ShouldBeOfType(); - result.InformationalVersion.ShouldNotBeNullOrWhiteSpace(); } } diff --git a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs index 851907b..8124077 100644 --- a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs +++ b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs @@ -2,6 +2,7 @@ namespace CleanModelContextProtocol.Infrastructure.Tests.Integration.Databases.M using Application.Reviews.Commands.CreateReview; using Application.Reviews.Commands.UpdateReview; +using Infrastructure.Databases.MovieReviews.Models; using Shouldly; using Xunit; @@ -229,6 +230,24 @@ public async Task GetMovies_ShouldReturn_Matches(string? query, int expected) totalCount.ShouldBe(expected); } + [Fact] + public async Task GetMovies_ShouldSort_TitlesIgnoringCase() + { + // Arrange + this.database.Context.Add(new Movie { Title = "aardvark" }); + _ = await this.database.Context.SaveChangesAsync(); + + var repository = this.database.Repository; + using var tokenSource = new CancellationTokenSource(); + var token = tokenSource.Token; + + // Act + var (items, _) = await repository.GetMovies(null, 0, 10, token); + + // Assert + items[0].Title.ShouldBe("aardvark"); + } + [Fact] public async Task GetMovies_ShouldReturn_ReviewCountAndAverageStars() { @@ -304,7 +323,7 @@ public async Task CreateReview_ShouldReturn_NewReviews() // Assert _ = result.ShouldNotBeNull(); - result.Id.ShouldNotBe(Guid.Empty); + result.Id.Version.ShouldBe(7); result.ReviewAuthor.ShouldNotBeNull().Id.ShouldBe(review.AuthorId); result.ReviewedMovie.ShouldNotBeNull().Id.ShouldBe(review.MovieId); result.Stars.ShouldBe(review.Stars); @@ -382,38 +401,7 @@ public async Task GetReviewById_ShouldReturn_Null() } [Fact] - public async Task ReviewExists_ShouldReturn_True() - { - // Arrange - var repository = this.database.Repository; - using var tokenSource = new CancellationTokenSource(); - var token = tokenSource.Token; - var review = this.database.Context.Reviews.First(m => m.Stars == 5); - - // Act - var result = await repository.ReviewExists(review.Id, token); - - // Assert - result.ShouldBeTrue(); - } - - [Fact] - public async Task ReviewExists_ShouldReturn_False() - { - // Arrange - var repository = this.database.Repository; - using var tokenSource = new CancellationTokenSource(); - var token = tokenSource.Token; - - // Act - var result = await repository.ReviewExists(Guid.Empty, token); - - // Assert - result.ShouldBeFalse(); - } - - [Fact] - public async Task UpdateReview_ShouldReturn_True() + public async Task UpdateReview_ShouldReturn_UpdatedReview() { // Arrange var original = this.database.Context.Reviews.First(a => a.Stars == 2); @@ -429,7 +417,11 @@ public async Task UpdateReview_ShouldReturn_True() var result = await this.database.Repository.UpdateReview(review.Id, review.Stars, token); // Assert - result.ShouldBeTrue(); + _ = result.ShouldNotBeNull(); + result.Id.ShouldBe(review.Id); + result.Stars.ShouldBe(review.Stars); + _ = result.ReviewAuthor.ShouldNotBeNull(); + _ = result.ReviewedMovie.ShouldNotBeNull(); var updatedReview = this.database.Context.Reviews.First(a => a.Id == review.Id); @@ -441,7 +433,7 @@ public async Task UpdateReview_ShouldReturn_True() } [Fact] - public async Task UpdateReview_ShouldReturn_False() + public async Task UpdateReview_ShouldReturn_Null() { // Arrange var review = new UpdateReviewCommand @@ -456,7 +448,7 @@ public async Task UpdateReview_ShouldReturn_False() var result = await this.database.Repository.UpdateReview(review.Id, review.Stars, token); // Assert - result.ShouldBeFalse(); + result.ShouldBeNull(); } [Fact] diff --git a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs index d664a1e..a8ba434 100644 --- a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs +++ b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs @@ -1,6 +1,5 @@ namespace CleanModelContextProtocol.Infrastructure.Tests.Integration.Databases.MovieReviews.Extensions; -using System; using Infrastructure.Databases.MovieReviews; using Infrastructure.Databases.MovieReviews.Models; diff --git a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/MovieReviewsTestDatabase.cs b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/MovieReviewsTestDatabase.cs index 5940667..1e3573e 100644 --- a/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/MovieReviewsTestDatabase.cs +++ b/tests/Infrastructure.Tests.Integration/Databases/MovieReviews/MovieReviewsTestDatabase.cs @@ -1,6 +1,5 @@ namespace CleanModelContextProtocol.Infrastructure.Tests.Integration.Databases.MovieReviews; -using System; using Extensions; using Infrastructure.Databases.MovieReviews; using Infrastructure.Databases.MovieReviews.Mapping; diff --git a/tests/Infrastructure.Tests.Integration/DependencyInjectionTests.cs b/tests/Infrastructure.Tests.Integration/DependencyInjectionTests.cs index 0d1d528..fdff7e8 100644 --- a/tests/Infrastructure.Tests.Integration/DependencyInjectionTests.cs +++ b/tests/Infrastructure.Tests.Integration/DependencyInjectionTests.cs @@ -4,6 +4,7 @@ namespace CleanModelContextProtocol.Infrastructure.Tests.Integration; using Application.Movies; using Application.Reviews; using Infrastructure.Databases.MovieReviews; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -28,6 +29,46 @@ public void AddInfrastructure_ShouldRegister_ScopedRepositories() services.Single(s => s.ServiceType == typeof(IReviewsRepository)).Lifetime.ShouldBe(ServiceLifetime.Scoped); } + [Fact] + public void AddInfrastructure_ShouldNotTrack_Queries() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddInfrastructure(InMemoryConnectionString()); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + // Act + var context = scope.ServiceProvider.GetRequiredService(); + + // Assert + context.ChangeTracker.QueryTrackingBehavior.ShouldBe(QueryTrackingBehavior.NoTracking); + } + + [Fact] + public void AddInfrastructure_ShouldClose_InMemoryDatabaseWithTheContainer() + { + // Arrange + var connectionString = InMemoryConnectionString(); + var services = new ServiceCollection(); + _ = services.AddInfrastructure(connectionString); + + // Act: leaving the block disposes the container, even if setting up the database throws + using (var provider = services.BuildServiceProvider()) + { + _ = provider.InitialiseInfrastructure(false); + } + + // Assert + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'"; + + command.ExecuteScalar().ShouldBe(0L); + } + [Fact] public void AddInfrastructure_ShouldUse_DefaultConnectionString() { diff --git a/tests/Presentation.Tests.Integration/Resources/ResourceTests.cs b/tests/Presentation.Tests.Integration/Resources/ResourceTests.cs index c057fb3..c5a03b5 100644 --- a/tests/Presentation.Tests.Integration/Resources/ResourceTests.cs +++ b/tests/Presentation.Tests.Integration/Resources/ResourceTests.cs @@ -37,7 +37,7 @@ public async Task ListResources_ShouldReturn_ServerVersion() public async Task ReadVersion_ShouldReturn_Version() { // Act - var version = await this.ReadAsync("server://version"); + var version = await this.ReadAsync("server://version"); // Assert version.FileVersion.ShouldNotBeNullOrWhiteSpace(); diff --git a/tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs b/tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs index f1603a7..a7d549e 100644 --- a/tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs +++ b/tests/Presentation.Tests.Unit/Tools/ReviewToolTests.cs @@ -112,10 +112,6 @@ public async Task WriteReview_ShouldUpdate_OnlyTheStars() _ = sender .Send(Arg.Is(c => c.Id == ReviewId && c.Stars == 2)) - .Returns(true); - - _ = sender - .Send(Arg.Is(q => q.Id == ReviewId)) .Returns(SampleReview(2)); // Act @@ -125,6 +121,7 @@ public async Task WriteReview_ShouldUpdate_OnlyTheStars() response.Id.ShouldBe(ReviewId); response.Stars.ShouldBe(2); _ = await sender.DidNotReceive().Send(Arg.Any()); + _ = await sender.DidNotReceive().Send(Arg.Any()); } [Fact] @@ -135,7 +132,7 @@ public async Task DeleteReview_ShouldReturn_Result() _ = sender .Send(Arg.Is(c => c.Id == ReviewId)) - .Returns(true); + .Returns(Unit.Value); // Act var response = await ReviewTools.DeleteReview(sender, ReviewId, CancellationToken.None);