diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dfd79efb..819cee82 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -70,7 +70,7 @@ jobs: uses: gpreviatti/github-actions-templates/.github/workflows/dotnet-pack.yml@v1 with: dotnet_version: "${{ vars.PROJECT_DOTNET_VERSION }}" - package_version: "10.15.0" + package_version: "10.16.0" secrets: nuget_api_key: ${{ secrets.NUGET_API_KEY }} diff --git a/GPreviatti.Template.Hexagonal.Solution.csproj b/GPreviatti.Template.Hexagonal.Solution.csproj index d3b09388..23b5eb07 100644 --- a/GPreviatti.Template.Hexagonal.Solution.csproj +++ b/GPreviatti.Template.Hexagonal.Solution.csproj @@ -18,7 +18,9 @@ git://github.com/gpreviatti/hexagonal-solution-template true - - Remove unnecessary library from Bff project + - Add full text search use case on Full template + - Add full text search request and response on Contracts template + - Add full text search implementation on Simple template diff --git a/templates/Contracts/AGENTS.md b/templates/Contracts/AGENTS.md index dd52202a..59e15515 100644 --- a/templates/Contracts/AGENTS.md +++ b/templates/Contracts/AGENTS.md @@ -35,7 +35,7 @@ tests/UnitTests/ ### C# Contracts - All contracts are `sealed record` — immutable, serialization-safe, value-equal by default -- All requests **must** extend `BaseRequest(Guid CorrelationId)` or `BasePaginatedRequest` +- All requests **must** extend `BaseRequest(Guid CorrelationId)` or `BasePaginatedRequest` or `BaseFullTextSearchPaginatedRequest` - All responses **must** extend `BaseResponse(bool IsSuccess, string Message)` - Use file-scoped namespaces (`namespace Contracts.[Domain];`) - Nullable reference types are enabled — annotate all nullable properties with `?` diff --git a/templates/Contracts/src/Contracts/Common/BaseRequest.cs b/templates/Contracts/src/Contracts/Common/BaseRequest.cs index 43692755..30d99b38 100644 --- a/templates/Contracts/src/Contracts/Common/BaseRequest.cs +++ b/templates/Contracts/src/Contracts/Common/BaseRequest.cs @@ -4,7 +4,9 @@ /// Base request structure /// /// The unique identifier for correlating requests -public record BaseRequest(Guid CorrelationId); +/// The user making the request +/// The timezone identifier for the request +public record BaseRequest(Guid CorrelationId, string User = "", string TimezoneId = ""); /// /// Base paginated request structure @@ -15,11 +17,37 @@ public record BaseRequest(Guid CorrelationId); /// The field to sort by /// Indicates whether the sorting is in descending order /// A dictionary of search criteria +/// The user making the request +/// The timezone identifier for the request public record BasePaginatedRequest( Guid CorrelationId, int Page = 1, int PageSize = 10, string? SortBy = null, bool SortDescending = false, - Dictionary? SearchByValues = null -) : BaseRequest(CorrelationId); + Dictionary? SearchByValues = null, + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); + +/// +/// Represents a request for paginated data with full-text search capabilities. +/// +/// The unique identifier for correlating requests +/// The page number to retrieve +/// The number of items per page +/// The field to sort by +/// Indicates whether the sorting is in descending order +/// The value to search for within the full-text index e.g. "search term" +/// The user making the request +/// The timezone identifier for the request +public record BaseFullTextSearchPaginatedRequest( + Guid CorrelationId, + int Page = 1, + int PageSize = 10, + string? SortBy = null, + bool SortDescending = false, + string SearchValue = "", + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/Contracts/src/Contracts/Orders/CreateOrderRequest.cs b/templates/Contracts/src/Contracts/Orders/CreateOrderRequest.cs index 3f6ec283..c462b3cd 100644 --- a/templates/Contracts/src/Contracts/Orders/CreateOrderRequest.cs +++ b/templates/Contracts/src/Contracts/Orders/CreateOrderRequest.cs @@ -8,11 +8,15 @@ namespace Contracts.Orders; /// The unique identifier for the request /// A description of the order /// The items included in the order +/// The user creating the order +/// The timezone identifier for the request public sealed record CreateOrderRequest( Guid CorrelationId, string Description, - CreateOrderItemRequest[] Items -) : BaseRequest(CorrelationId); + CreateOrderItemRequest[] Items, + string CreatedBy = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, CreatedBy, TimezoneId); /// /// An item included in the order diff --git a/templates/Contracts/tests/UnitTests/Common/BaseRequestTests.cs b/templates/Contracts/tests/UnitTests/Common/BaseRequestTests.cs index 4e722abe..1af719c4 100644 --- a/templates/Contracts/tests/UnitTests/Common/BaseRequestTests.cs +++ b/templates/Contracts/tests/UnitTests/Common/BaseRequestTests.cs @@ -14,6 +14,24 @@ public void GivenABaseRequestWhenInstantiatedThenShouldAssignCorrelationId() Assert.Equal(correlationId, request.CorrelationId); } + [Fact(DisplayName = nameof(GivenABaseRequestWhenInstantiatedThenShouldAssignDefaultUserAndTimezone))] + public void GivenABaseRequestWhenInstantiatedThenShouldAssignDefaultUserAndTimezone() + { + var request = new BaseRequest(Guid.NewGuid()); + + Assert.Equal(string.Empty, request.User); + Assert.Equal(string.Empty, request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenABaseRequestWhenInstantiatedWithCustomUserAndTimezoneThenShouldAssignValues))] + public void GivenABaseRequestWhenInstantiatedWithCustomUserAndTimezoneThenShouldAssignValues() + { + var request = new BaseRequest(Guid.NewGuid(), User: "alice", TimezoneId: "America/New_York"); + + Assert.Equal("alice", request.User); + Assert.Equal("America/New_York", request.TimezoneId); + } + [Fact(DisplayName = nameof(GivenABasePaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpectedDefaults))] public void GivenABasePaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpectedDefaults() { @@ -27,6 +45,8 @@ public void GivenABasePaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpe Assert.Null(request.SortBy); Assert.False(request.SortDescending); Assert.Null(request.SearchByValues); + Assert.Equal(string.Empty, request.User); + Assert.Equal(string.Empty, request.TimezoneId); } [Fact(DisplayName = nameof(GivenABasePaginatedRequestWhenInstantiatedThenShouldAssignCustomValues))] @@ -44,7 +64,9 @@ public void GivenABasePaginatedRequestWhenInstantiatedThenShouldAssignCustomValu PageSize: 25, SortBy: "CreatedAt", SortDescending: true, - SearchByValues: searchByValues + SearchByValues: searchByValues, + User: "bob", + TimezoneId: "Europe/London" ); Assert.Equal(correlationId, request.CorrelationId); @@ -53,5 +75,66 @@ public void GivenABasePaginatedRequestWhenInstantiatedThenShouldAssignCustomValu Assert.Equal("CreatedAt", request.SortBy); Assert.True(request.SortDescending); Assert.Equal(searchByValues, request.SearchByValues); + Assert.Equal("bob", request.User); + Assert.Equal("Europe/London", request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenABaseFullTextSearchPaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpectedDefaults))] + public void GivenABaseFullTextSearchPaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpectedDefaults() + { + var correlationId = Guid.NewGuid(); + + var request = new BaseFullTextSearchPaginatedRequest(correlationId); + + Assert.Equal(correlationId, request.CorrelationId); + Assert.Equal(1, request.Page); + Assert.Equal(10, request.PageSize); + Assert.Null(request.SortBy); + Assert.False(request.SortDescending); + Assert.Equal(string.Empty, request.SearchValue); + Assert.Equal(string.Empty, request.User); + Assert.Equal(string.Empty, request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenABaseFullTextSearchPaginatedRequestWhenInstantiatedThenShouldAssignCustomValues))] + public void GivenABaseFullTextSearchPaginatedRequestWhenInstantiatedThenShouldAssignCustomValues() + { + var correlationId = Guid.NewGuid(); + + var request = new BaseFullTextSearchPaginatedRequest( + correlationId, + Page: 3, + PageSize: 20, + SortBy: "Name", + SortDescending: true, + SearchValue: "laptop", + User: "carol", + TimezoneId: "Asia/Tokyo" + ); + + Assert.Equal(correlationId, request.CorrelationId); + Assert.Equal(3, request.Page); + Assert.Equal(20, request.PageSize); + Assert.Equal("Name", request.SortBy); + Assert.True(request.SortDescending); + Assert.Equal("laptop", request.SearchValue); + Assert.Equal("carol", request.User); + Assert.Equal("Asia/Tokyo", request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenABaseFullTextSearchPaginatedRequestThenShouldInheritFromBaseRequest))] + public void GivenABaseFullTextSearchPaginatedRequestThenShouldInheritFromBaseRequest() + { + var request = new BaseFullTextSearchPaginatedRequest(Guid.NewGuid()); + + Assert.IsAssignableFrom(request); + } + + [Fact(DisplayName = nameof(GivenABasePaginatedRequestThenShouldInheritFromBaseRequest))] + public void GivenABasePaginatedRequestThenShouldInheritFromBaseRequest() + { + var request = new BasePaginatedRequest(Guid.NewGuid()); + + Assert.IsAssignableFrom(request); } } diff --git a/templates/Contracts/tests/UnitTests/Common/BaseResponseTests.cs b/templates/Contracts/tests/UnitTests/Common/BaseResponseTests.cs index eb5c7381..cbf0a31a 100644 --- a/templates/Contracts/tests/UnitTests/Common/BaseResponseTests.cs +++ b/templates/Contracts/tests/UnitTests/Common/BaseResponseTests.cs @@ -23,6 +23,25 @@ public void GivenABaseResponseWhenInstantiatedThenShouldAssignSuccessAndMessage( Assert.Equal("ok", response.Message); } + [Fact(DisplayName = nameof(GivenABaseResponseWhenInstantiatedWithSuccessOnlyThenMessageShouldBeNull))] + public void GivenABaseResponseWhenInstantiatedWithSuccessOnlyThenMessageShouldBeNull() + { + var response = new BaseResponse(true); + + Assert.True(response.Success); + Assert.Null(response.Message); + } + + [Fact(DisplayName = nameof(GivenABaseResponseOfTDataWhenInstantiatedWithDefaultConstructorThenShouldHaveDefaultValues))] + public void GivenABaseResponseOfTDataWhenInstantiatedWithDefaultConstructorThenShouldHaveDefaultValues() + { + var response = new BaseResponse(); + + Assert.False(response.Success); + Assert.Null(response.Message); + Assert.Null(response.Data); + } + [Fact(DisplayName = nameof(GivenABaseResponseOfTDataWhenInstantiatedThenShouldAssignSuccessMessageAndData))] public void GivenABaseResponseOfTDataWhenInstantiatedThenShouldAssignSuccessMessageAndData() { @@ -40,6 +59,28 @@ public void GivenABaseResponseOfTDataWhenInstantiatedThenShouldAssignSuccessMess Assert.Equal(data, response.Data); } + [Fact(DisplayName = nameof(GivenABaseResponseOfTDataWhenInstantiatedWithNullDataThenDataShouldBeNull))] + public void GivenABaseResponseOfTDataWhenInstantiatedWithNullDataThenDataShouldBeNull() + { + var response = new BaseResponse(false, null, "not found"); + + Assert.False(response.Success); + Assert.Equal("not found", response.Message); + Assert.Null(response.Data); + } + + [Fact(DisplayName = nameof(GivenABasePaginatedResponseWhenInstantiatedWithDefaultConstructorThenShouldHaveDefaultValues))] + public void GivenABasePaginatedResponseWhenInstantiatedWithDefaultConstructorThenShouldHaveDefaultValues() + { + var response = new BasePaginatedResponse(); + + Assert.False(response.Success); + Assert.Null(response.Message); + Assert.Null(response.Data); + Assert.Equal(0, response.TotalPages); + Assert.Equal(0, response.TotalRecords); + } + [Fact(DisplayName = nameof(GivenABasePaginatedResponseWhenInstantiatedThenShouldAssignPaginationAndData))] public void GivenABasePaginatedResponseWhenInstantiatedThenShouldAssignPaginationAndData() { @@ -68,4 +109,22 @@ public void GivenABasePaginatedResponseWhenInstantiatedThenShouldAssignPaginatio Assert.Equal(30, response.TotalRecords); Assert.Equal(data, response.Data); } + + [Fact(DisplayName = nameof(GivenABasePaginatedResponseWhenInstantiatedWithNullDataThenDataShouldBeNull))] + public void GivenABasePaginatedResponseWhenInstantiatedWithNullDataThenDataShouldBeNull() + { + var response = new BasePaginatedResponse( + success: false, + totalPages: 0, + totalRecords: 0, + data: null, + message: "empty" + ); + + Assert.False(response.Success); + Assert.Equal("empty", response.Message); + Assert.Equal(0, response.TotalPages); + Assert.Equal(0, response.TotalRecords); + Assert.Null(response.Data); + } } diff --git a/templates/Contracts/tests/UnitTests/Orders/CreateOrderRequestTests.cs b/templates/Contracts/tests/UnitTests/Orders/CreateOrderRequestTests.cs index 277dab9e..0d907289 100644 --- a/templates/Contracts/tests/UnitTests/Orders/CreateOrderRequestTests.cs +++ b/templates/Contracts/tests/UnitTests/Orders/CreateOrderRequestTests.cs @@ -1,3 +1,4 @@ +using Contracts.Common; using Contracts.Orders; namespace UnitTests.Orders; @@ -30,4 +31,57 @@ public void GivenACreateOrderRequestWhenInstantiatedThenShouldAssignProperties() Assert.Equal("Order description", request.Description); Assert.Equal(items, request.Items); } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestWhenInstantiatedWithDefaultOptionalParamsThenShouldAssignEmptyStrings))] + public void GivenACreateOrderRequestWhenInstantiatedWithDefaultOptionalParamsThenShouldAssignEmptyStrings() + { + var request = new CreateOrderRequest(Guid.NewGuid(), "Order description", []); + + Assert.Equal(string.Empty, request.CreatedBy); + Assert.Equal(string.Empty, request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestWhenInstantiatedWithCustomCreatedByAndTimezoneThenShouldAssignValues))] + public void GivenACreateOrderRequestWhenInstantiatedWithCustomCreatedByAndTimezoneThenShouldAssignValues() + { + var request = new CreateOrderRequest( + Guid.NewGuid(), + "Order description", + [], + CreatedBy: "alice", + TimezoneId: "America/Sao_Paulo" + ); + + Assert.Equal("alice", request.CreatedBy); + Assert.Equal("America/Sao_Paulo", request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestThenShouldInheritFromBaseRequest))] + public void GivenACreateOrderRequestThenShouldInheritFromBaseRequest() + { + var request = new CreateOrderRequest(Guid.NewGuid(), "desc", []); + + Assert.IsAssignableFrom(request); + } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestWhenCreatedByIsSetThenUserPropertyShouldMatchCreatedBy))] + public void GivenACreateOrderRequestWhenCreatedByIsSetThenUserPropertyShouldMatchCreatedBy() + { + var request = new CreateOrderRequest( + Guid.NewGuid(), + "desc", + [], + CreatedBy: "bob" + ); + + Assert.Equal("bob", request.User); + } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestWhenInstantiatedWithEmptyItemsThenItemsShouldBeEmpty))] + public void GivenACreateOrderRequestWhenInstantiatedWithEmptyItemsThenItemsShouldBeEmpty() + { + var request = new CreateOrderRequest(Guid.NewGuid(), "desc", []); + + Assert.Empty(request.Items); + } } diff --git a/templates/Contracts/tests/UnitTests/Orders/OrderDtoTests.cs b/templates/Contracts/tests/UnitTests/Orders/OrderDtoTests.cs index 69399446..c2e29497 100644 --- a/templates/Contracts/tests/UnitTests/Orders/OrderDtoTests.cs +++ b/templates/Contracts/tests/UnitTests/Orders/OrderDtoTests.cs @@ -32,6 +32,42 @@ public void GivenAnOrderDtoWhenPropertiesAreAssignedThenShouldExposeExpectedValu Assert.Equal(items, orderDto.Items); } + [Fact(DisplayName = nameof(GivenAnOrderDtoWhenItemsIsNullThenShouldExposeNullItems))] + public void GivenAnOrderDtoWhenItemsIsNullThenShouldExposeNullItems() + { + var orderDto = new OrderDto + { + Id = 1, + Description = "Order with no items", + Total = 0m, + Items = null + }; + + Assert.Null(orderDto.Items); + } + + [Fact(DisplayName = nameof(GivenAnOrderDtoWhenMultipleItemsAreAssignedThenShouldExposeAllItems))] + public void GivenAnOrderDtoWhenMultipleItemsAreAssignedThenShouldExposeAllItems() + { + ItemDto[] items = + [ + new() { Id = 1, Name = "Item A", Description = "Desc A", Value = 10m }, + new() { Id = 2, Name = "Item B", Description = "Desc B", Value = 20m }, + new() { Id = 3, Name = "Item C", Description = "Desc C", Value = 30m } + ]; + + var orderDto = new OrderDto + { + Id = 5, + Description = "Multi-item order", + Total = 60m, + Items = items + }; + + Assert.Equal(3, orderDto.Items!.Count); + Assert.Equal(60m, orderDto.Total); + } + [Fact(DisplayName = nameof(GivenAnItemDtoWhenPropertiesAreAssignedThenShouldExposeExpectedValues))] public void GivenAnItemDtoWhenPropertiesAreAssignedThenShouldExposeExpectedValues() { diff --git a/templates/Full/.editorconfig b/templates/Full/.editorconfig index 1e58c63c..bf83c78f 100644 --- a/templates/Full/.editorconfig +++ b/templates/Full/.editorconfig @@ -179,6 +179,7 @@ dotnet_diagnostic.IDE0002.severity = error dotnet_diagnostic.IDE0005.severity = error dotnet_diagnostic.IDE0051.severity = error dotnet_diagnostic.IDE0059.severity = error +dotnet_diagnostic.IDE0060.severity = error # Xml project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] diff --git a/templates/Full/AGENTS.md b/templates/Full/AGENTS.md index aaad4e79..cc3c5883 100644 --- a/templates/Full/AGENTS.md +++ b/templates/Full/AGENTS.md @@ -155,6 +155,46 @@ Orchestrates domain objects and coordinates infrastructure through **ports (inte - **Logging:** Use structured logging with `ILogger` - **Notification publishing:** Use `CreateNotification()` helper or `IProduceService.ProduceAsync()` for async messaging +#### Full-Text Search Use Cases (returning multiple records) + +When a use case must return **more than one record**, use the full-text search paginated pattern: + +- **Request:** Use `BaseFullTextSearchPaginatedRequest` (provides `Page`, `PageSize`, `SortBy`, `SortDescending`, `SearchValue`) +- **Response:** Return `BasePaginatedResponse` +- **Repository call:** `GetAllFullTextSearchPaginatedAsync`, passing `nameof(Entity.ColumnName)` as `searchQuery` and `request.SearchValue` as `searchValue` +- **Language:** Defaults to `"english"` inside the repository — do not pass it from the request +- **⚠️ Index required:** A GIN tsvector index **must** exist on the searched column(s) for the query to work — see Step 3 below + +```csharp +public sealed class GetAllOrdersUseCase : BaseInOutUseCase> +{ + private readonly IBaseRepository _repository; + + public GetAllOrdersUseCase(IBaseRepository repository) : base(validator) + => _repository = repository; + + public override async Task>> Execute( + BaseFullTextSearchPaginatedRequest request, CancellationToken cancellationToken) + { + var (items, total) = await _repository.GetAllFullTextSearchPaginatedAsync( + request.CorrelationId, + request.Page, + request.PageSize, + o => new() { Id = o.Id, Description = o.Description }, + cancellationToken, + request.SortBy, + request.SortDescending, + nameof(Order.Description), // column to search within + request.SearchValue + ); + + return items.Any() + ? Result.Success(new BasePaginatedResponse(items, total, request.Page, request.PageSize)) + : Result.Failure>("No orders found."); + } +} +``` + **Example use case structure:** ```csharp namespace Application.Orders; @@ -226,8 +266,9 @@ Implements ports from Application. Contains all adapters for data, caching, mess - Use Fluent API for all configuration - Configure: primary key, columns, relationships, enums, precision, and default values - Use `builder.HasQueryFilter(p => !p.IsDeleted)` for soft delete filtering +- **Full-text search index:** When an entity is used with `GetAllFullTextSearchPaginatedAsync`, add a GIN tsvector index on the searched column(s) -**Example mapping:** +**Example mapping (with full-text search index):** ```csharp public sealed class OrderConfiguration : IEntityTypeConfiguration { @@ -238,10 +279,22 @@ public sealed class OrderConfiguration : IEntityTypeConfiguration builder.Property(x => x.TotalAmount).HasPrecision(18, 2); builder.Property(x => x.Status).HasConversion(); builder.HasQueryFilter(p => !p.IsDeleted); + + // Required for GetAllFullTextSearchPaginatedAsync — single column + builder.HasIndex(p => new { p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); + + // Multi-column variant + // builder.HasIndex(p => new { p.Name, p.Description }) + // .HasMethod("GIN") + // .IsTsVectorExpressionIndex("english"); } } ``` +> The GIN index must be created via migration before the endpoint is used in any environment. Without it, full-text search queries will either fail or fall back to a sequential scan. + #### RabbitMQ Consumers - Inherit from `BaseConsumer` where `TMessage` is the integration message and `TConsumer` is the consumer class itself - Implement `HandleUseCaseAsync(IServiceProvider, TMessage, CancellationToken)` abstract method @@ -284,6 +337,7 @@ Entry point for HTTP/gRPC requests. Minimal API endpoints. - `PUT /{id}` → `200 OK` or `400 BadRequest` - `DELETE /{id}` → `200 OK` or `400 BadRequest` - `POST /paginated` → `200 OK` or `400 BadRequest` + - `POST /full-text-search-paginated` → `200 OK` or `400 BadRequest` - **Sealed classes:** Endpoint handler classes should be sealed - **Request/Response types:** Use `BaseResponse` for single items, `BasePaginatedResponse` for lists @@ -881,18 +935,21 @@ Always follow the dependency direction: **Domain → Application → Infrastruct - **Validators:** Create `{Operation}RequestValidator.cs` inheriting from `AbstractValidator` - **Port usage:** Inject `IBaseRepository`, `IProduceService`, `IHybridCacheService` as needed - **Example:** `CreateProductUseCase.cs`, `GetProductUseCase.cs`, `UpdateProductUseCase.cs` +- **Returning multiple records?** Use `BaseFullTextSearchPaginatedRequest` + `GetAllFullTextSearchPaginatedAsync` — see [Full-Text Search Use Cases](#full-text-search-use-cases-returning-multiple-records) ### Step 3: Infrastructure Persistence - **EF Core mapping:** `src/Infrastructure/Data/Mapping/{Entity}Configuration.cs` - Implement `IEntityTypeConfiguration` - Configure key, columns, constraints, enums, precision, relationships - Always add `builder.HasQueryFilter(p => !p.IsDeleted)` for soft deletes + - **If the use case uses full-text search:** add a GIN tsvector index via `HasIndex(...).HasMethod("GIN").IsTsVectorExpressionIndex("english")` — the migration must include this index or queries will fail - **Custom repository** (if needed): `src/Infrastructure/Data/Repositories/{Entity}Repository.cs` - **Migration:** ```bash dotnet ef migrations add Add{Feature} --project src/Infrastructure --startup-project src/Infrastructure --output-dir Data/Migrations dotnet ef database update --project src/Infrastructure --startup-project src/Infrastructure ``` + > When adding full-text search support to an existing entity, create a dedicated migration (e.g., `Add{Entity}FullTextSearchSupport`) so the index is tracked separately from schema changes. ### Step 4: WebApp Endpoints - **File:** `src/WebApp/Endpoints/{Feature}Endpoints.cs` diff --git a/templates/Full/scripts/sql/migrations.sql b/templates/Full/scripts/sql/migrations.sql index 1270c5ff..495fc33f 100644 --- a/templates/Full/scripts/sql/migrations.sql +++ b/templates/Full/scripts/sql/migrations.sql @@ -1,91 +1,116 @@ -CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( - "MigrationId" character varying(150) NOT NULL, - "ProductVersion" character varying(32) NOT NULL, - CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") -); - -START TRANSACTION; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - CREATE TABLE "Notification" ( - "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "NotificationType" integer NOT NULL, - "NotificationStatus" character varying(50) NOT NULL, - "Message" jsonb, - "CreatedAt" timestamp with time zone NOT NULL, - "CreatedBy" text, - "CreatedByTimezoneId" text NOT NULL, - "UpdatedAt" timestamp with time zone NOT NULL, - "UpdatedBy" text, - "UpdatedByTimezoneId" text, - "IsDeleted" boolean NOT NULL DEFAULT FALSE, - "DeletedAt" timestamp with time zone, - "DeletedBy" text, - CONSTRAINT "PK_Notification" PRIMARY KEY ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - CREATE TABLE "Order" ( - "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "Description" text NOT NULL, - "Total" numeric(18,2) NOT NULL, - "CreatedAt" timestamp with time zone NOT NULL, - "CreatedBy" text, - "CreatedByTimezoneId" text NOT NULL, - "UpdatedAt" timestamp with time zone NOT NULL, - "UpdatedBy" text, - "UpdatedByTimezoneId" text, - "IsDeleted" boolean NOT NULL DEFAULT FALSE, - "DeletedAt" timestamp with time zone, - "DeletedBy" text, - CONSTRAINT "PK_Order" PRIMARY KEY ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - CREATE TABLE "Item" ( - "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "Name" text NOT NULL, - "Description" text NOT NULL, - "Value" numeric(18,2) NOT NULL, - "OrderId" integer, - "CreatedAt" timestamp with time zone NOT NULL, - "CreatedBy" text, - "CreatedByTimezoneId" text NOT NULL, - "UpdatedAt" timestamp with time zone NOT NULL, - "UpdatedBy" text, - "UpdatedByTimezoneId" text, - "IsDeleted" boolean NOT NULL DEFAULT FALSE, - "DeletedAt" timestamp with time zone, - "DeletedBy" text, - CONSTRAINT "PK_Item" PRIMARY KEY ("Id"), - CONSTRAINT "FK_Item_Order_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Order" ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - CREATE INDEX "IX_Item_OrderId" ON "Item" ("OrderId"); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES ('20260418162254_CreateTables', '10.0.5'); - END IF; -END $EF$; -COMMIT; - +CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( + "MigrationId" character varying(150) NOT NULL, + "ProductVersion" character varying(32) NOT NULL, + CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") +); + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + CREATE TABLE "Notification" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "NotificationType" integer NOT NULL, + "NotificationStatus" character varying(50) NOT NULL, + "Message" jsonb, + "CreatedAt" timestamp with time zone NOT NULL, + "CreatedBy" text, + "CreatedByTimezoneId" text NOT NULL, + "UpdatedAt" timestamp with time zone NOT NULL, + "UpdatedBy" text, + "UpdatedByTimezoneId" text, + "IsDeleted" boolean NOT NULL DEFAULT FALSE, + "DeletedAt" timestamp with time zone, + "DeletedBy" text, + CONSTRAINT "PK_Notification" PRIMARY KEY ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + CREATE TABLE "Order" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Description" text NOT NULL, + "Total" numeric(18,2) NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + "CreatedBy" text, + "CreatedByTimezoneId" text NOT NULL, + "UpdatedAt" timestamp with time zone NOT NULL, + "UpdatedBy" text, + "UpdatedByTimezoneId" text, + "IsDeleted" boolean NOT NULL DEFAULT FALSE, + "DeletedAt" timestamp with time zone, + "DeletedBy" text, + CONSTRAINT "PK_Order" PRIMARY KEY ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + CREATE TABLE "Item" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + "Description" text NOT NULL, + "Value" numeric(18,2) NOT NULL, + "OrderId" integer, + "CreatedAt" timestamp with time zone NOT NULL, + "CreatedBy" text, + "CreatedByTimezoneId" text NOT NULL, + "UpdatedAt" timestamp with time zone NOT NULL, + "UpdatedBy" text, + "UpdatedByTimezoneId" text, + "IsDeleted" boolean NOT NULL DEFAULT FALSE, + "DeletedAt" timestamp with time zone, + "DeletedBy" text, + CONSTRAINT "PK_Item" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Item_Order_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Order" ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + CREATE INDEX "IX_Item_OrderId" ON "Item" ("OrderId"); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260418162254_CreateTables', '10.0.10'); + END IF; +END $EF$; +COMMIT; + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260729195925_FullTextSearchSupport') THEN + CREATE INDEX "IX_Order_Description" ON "Order" USING GIN (to_tsvector('english', "Description")); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260729195925_FullTextSearchSupport') THEN + CREATE INDEX "IX_Item_Name_Description" ON "Item" USING GIN (to_tsvector('english', "Name" || ' ' || "Description")); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260729195925_FullTextSearchSupport') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260729195925_FullTextSearchSupport', '10.0.10'); + END IF; +END $EF$; +COMMIT; + diff --git a/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs b/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs index aa446b85..fc032338 100644 --- a/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs +++ b/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs @@ -12,16 +12,20 @@ public interface IBaseRepository Task RemoveAsync(TEntity entity, Guid correlationId, CancellationToken cancellationToken, bool? newContext = null) where TEntity : DomainEntity; Task RemoveRangeAsync(TEntity[] entities, Guid correlationId, CancellationToken cancellationToken, bool? newContext = null) where TEntity : DomainEntity; IQueryable GetQueryable(Guid correlationId, bool? newContext = null, [CallerMemberName] string methodName = null!) where TEntity : DomainEntity; - Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( + + Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( Guid correlationId, int page, int pageSize, + Expression> selector, CancellationToken cancellationToken, - string? sortBy = null, + string? sortBy = null!, bool sortDescending = false, - Dictionary? searchByValues = null, - bool? newContext = null, - params Expression>[]? includes + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null ) where TEntity : DomainEntity; Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( diff --git a/templates/Full/src/Application/Common/Requests/BaseRequest.cs b/templates/Full/src/Application/Common/Requests/BaseRequest.cs index 5bb451a2..dc946f1c 100644 --- a/templates/Full/src/Application/Common/Requests/BaseRequest.cs +++ b/templates/Full/src/Application/Common/Requests/BaseRequest.cs @@ -15,3 +15,26 @@ public record BasePaginatedRequest( string User = "", string TimezoneId = "" ) : BaseRequest(CorrelationId, User, TimezoneId); + +/// +/// Represents a request for paginated data with full-text search capabilities. +/// +/// +/// +/// +/// +/// +/// The value to search for within the full-text index e.g. "search term" +/// The language to use for the full-text search +/// +/// +public record BaseFullTextSearchPaginatedRequest( + Guid CorrelationId, + [property: Range(1, int.MaxValue, ErrorMessage = "Page must be greater than 0")] int Page = 1, + [property: Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100")] int PageSize = 10, + string? SortBy = null, + bool SortDescending = false, + string SearchValue = "", + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs b/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs new file mode 100644 index 00000000..e541fa9b --- /dev/null +++ b/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs @@ -0,0 +1,43 @@ +using Application.Common.Helpers; +using Application.Common.Requests; +using Application.Common.UseCases; +using Domain.Orders; + +namespace Application.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCase(IServiceProvider serviceProvider): BaseInOutUseCase>(serviceProvider) +{ + public override async Task> HandleInternalAsync( + BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) + { + var (orders, totalRecords) = await Repository.GetAllFullTextSearchPaginatedAsync( + request.CorrelationId, + request.Page, + request.PageSize, + o => new() + { + Id = o.Id, + Description = o.Description, + Total = o.Total, + PeriodSinceWasCreated = o.GetPeriodSinceWasCreated() + }, + cancellationToken, + request.SortBy, + request.SortDescending, + nameof(Order.Description), + request.SearchValue + ); + + if (orders is null || !orders.Any()) + { + Logs.NotFound(Logger, request.CorrelationId, nameof(orders)); + return new(false, 0, 0, [], "No orders found."); + } + + var totalPages = (int) Math.Ceiling(totalRecords / (double) request.PageSize); + + return new(true, totalPages, totalRecords, orders); + } +} diff --git a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs index be1a5e8d..d895d3f4 100644 --- a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs @@ -108,23 +108,30 @@ await HandleBaseQueryAsync(async dbEntitySet => return await _dbContext.SaveChangesAsync(cancellationToken); }, correlationId, newContext); - public async Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( + public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( Guid correlationId, int page, int pageSize, + Expression> selector, CancellationToken cancellationToken, string? sortBy = null!, bool sortDescending = false, - Dictionary? searchByValues = null!, - bool? newContext = null, - params Expression>[]? includes - ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { - var query = dbEntitySet.AsNoTracking(); + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); - if (includes is not null) - foreach (var include in includes) - query = query.Include(include); + var query = dbEntitySet.AsQueryable(); + + if (predicate != null) + query = query.Where(predicate); if (!string.IsNullOrWhiteSpace(sortBy)) query = sortDescending @@ -133,20 +140,18 @@ params Expression>[]? includes else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); - - if (searchByValues != null && searchByValues.Count != 0) - foreach (var searchByValue in searchByValues) - query = query.Where(e => - EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%") - ); + if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) + .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); public async Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( @@ -162,6 +167,11 @@ params Expression>[]? includes bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -174,20 +184,18 @@ params Expression>[]? includes else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); - if (searchByValues != null && searchByValues.Count != 0) foreach (var searchByValue in searchByValues) - query = query.Where(e => - EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%") - ); + query = query.Where(e => EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%")); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); } diff --git a/templates/Full/src/Infrastructure/Data/Mapping/ItemDbMapping.cs b/templates/Full/src/Infrastructure/Data/Mapping/ItemDbMapping.cs index 7212a550..2f9dcf2b 100644 --- a/templates/Full/src/Infrastructure/Data/Mapping/ItemDbMapping.cs +++ b/templates/Full/src/Infrastructure/Data/Mapping/ItemDbMapping.cs @@ -1,5 +1,6 @@ using Domain.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -19,5 +20,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) builder.Property(p => p.Description) .HasMaxLength(255) .IsRequired(); + + builder.HasIndex(p => new { p.Name, p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/Full/src/Infrastructure/Data/Mapping/OrderDbMapping.cs b/templates/Full/src/Infrastructure/Data/Mapping/OrderDbMapping.cs index 7854a2d4..fefeb090 100644 --- a/templates/Full/src/Infrastructure/Data/Mapping/OrderDbMapping.cs +++ b/templates/Full/src/Infrastructure/Data/Mapping/OrderDbMapping.cs @@ -1,5 +1,6 @@ using Domain.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -17,5 +18,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) .HasPrecision(18, 2); builder.HasMany(p => p.Items); + + builder.HasIndex(p => new { p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.Designer.cs b/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.Designer.cs new file mode 100644 index 00000000..33973fcd --- /dev/null +++ b/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.Designer.cs @@ -0,0 +1,236 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + [Migration("20260729195925_FullTextSearchSupport")] + partial class FullTextSearchSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Domain.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Domain.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Domain.Orders.Item", b => + { + b.HasOne("Domain.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.cs b/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.cs new file mode 100644 index 00000000..31faba64 --- /dev/null +++ b/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations; + +/// +public partial class FullTextSearchSupport : Migration +{ + private static readonly string[] s_columns = ["Name", "Description"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Order_Description", + table: "Order", + column: "Description") + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + + migrationBuilder.CreateIndex( + name: "IX_Item_Name_Description", + table: "Item", + columns: s_columns) + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Order_Description", + table: "Order"); + + migrationBuilder.DropIndex( + name: "IX_Item_Name_Description", + table: "Item"); + } +} diff --git a/templates/Full/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs b/templates/Full/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs index 42508011..6d614c56 100644 --- a/templates/Full/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs +++ b/templates/Full/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs @@ -1,223 +1,233 @@ -// -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(MyDbContext))] - partial class MyDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Notifications.Notification", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Message") - .HasMaxLength(4000) - .HasColumnType("jsonb"); - - b.Property("NotificationStatus") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("NotificationType") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Notification"); - }); - - modelBuilder.Entity("Domain.Orders.Item", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("text"); - - b.Property("OrderId") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Value") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.HasKey("Id"); - - b.HasIndex("OrderId"); - - b.ToTable("Item"); - }); - - modelBuilder.Entity("Domain.Orders.Order", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Total") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Order"); - }); - - modelBuilder.Entity("Domain.Orders.Item", b => - { - b.HasOne("Domain.Orders.Order", null) - .WithMany("Items") - .HasForeignKey("OrderId"); - }); - - modelBuilder.Entity("Domain.Orders.Order", b => - { - b.Navigation("Items"); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + partial class MyDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Domain.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Domain.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Domain.Orders.Item", b => + { + b.HasOne("Domain.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/Full/src/WebApp/Endpoints/OrderEndpoints.cs b/templates/Full/src/WebApp/Endpoints/OrderEndpoints.cs index 86797bb1..81eb6177 100644 --- a/templates/Full/src/WebApp/Endpoints/OrderEndpoints.cs +++ b/templates/Full/src/WebApp/Endpoints/OrderEndpoints.cs @@ -62,6 +62,17 @@ CancellationToken cancellationToken return response.Success ? Results.Ok(response) : Results.BadRequest(response); }); + ordersGroup.MapPost("/full-text-search-paginated", async ( + [FromServices] IBaseInOutUseCase> useCase, + [FromBody] BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) => + { + var response = await useCase.HandleAsync(request, cancellationToken); + + return response.Success ? Results.Ok(response) : Results.BadRequest(response); + }); + ordersGroup.MapPut("/{id}", async ( [FromServices] IBaseInOutUseCase> useCase, [FromBody] UpdateOrderRequest request, diff --git a/templates/Full/tests/IntegrationTests/Data/BaseRepositoryTest.cs b/templates/Full/tests/IntegrationTests/Data/BaseRepositoryTest.cs index 51b68b17..c84974fc 100644 --- a/templates/Full/tests/IntegrationTests/Data/BaseRepositoryTest.cs +++ b/templates/Full/tests/IntegrationTests/Data/BaseRepositoryTest.cs @@ -91,27 +91,6 @@ public async Task GivenAOrderAndNotificationShouldExecuteInParallelWithSuccess() Assert.Equal(id, order2!.Id); } - [Fact] - public async Task GivenAValidRequestThenReturnAllOrdersPaginatedWithSuccess() - { - // Arrange - var pageNumber = 1; - var pageSize = 5; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken - ); - - // Assert - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.True(totalRecords > 0); - } - [Fact] public async Task GivenAValidRequestThenReturnAllOrdersDtosPaginatedWithSuccess() { @@ -145,104 +124,4 @@ public async Task GivenAValidRequestThenReturnAllOrdersDtosPaginatedWithSuccess( Assert.All(result, r => Assert.IsType(r)); Assert.All(result, r => Assert.NotNull(r.Items)); } - - [Fact] - public async Task GivenAValidRequestThenReturnNoOrdersPaginated() - { - // Arrange - var pageNumber = 50; - var pageSize = 5; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken - ); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - Assert.True(totalRecords > 0); - } - - [Fact] - public async Task GivenAValidRequestThenReturnFilteredOrdersPaginated() - { - // Arrange - var pageNumber = 1; - var pageSize = 5; - var valueToSearch = "client"; - var searchByValues = new Dictionary { - { "Description", valueToSearch } - }; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken, - searchByValues: searchByValues - ); - - // Assert - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.True(totalRecords > 0); - Assert.All(result, o => Assert.Contains(valueToSearch.ToLowerInvariant(), o.Description.ToLowerInvariant())); - } - - [Fact] - public async Task GivenAValidRequestThenReturnNoFilteredOrdersPaginated() - { - // Arrange - var pageNumber = 1; - var pageSize = 5; - var searchByValues = new Dictionary { - { "Description", "non-existing-description" } - }; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken, - searchByValues: searchByValues - ); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - Assert.True(totalRecords > 0); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task GivenAValidRequestThenReturnSortedOrdersPaginated(bool sortDescending) - { - // Arrange - var pageNumber = 1; - var pageSize = 5; - var sortBy = "Description"; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken, - sortBy: sortBy, - sortDescending: sortDescending - ); - - // Assert - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.True(totalRecords > 0); - Assert.All(result, r => Assert.NotNull(r.Description)); - } } diff --git a/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs b/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs new file mode 100644 index 00000000..ec5cdca5 --- /dev/null +++ b/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs @@ -0,0 +1,102 @@ +using System.Net; +using Application.Common.Requests; +using Application.Orders; +using IntegrationTests.Common; +using IntegrationTests.WebApp.Http.Common; +using WebApp; + +namespace IntegrationTests.WebApp.Http.Orders; + +public class GetAllFullTextSearchOrdersTestFixture : BaseHttpFixture +{ + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); + public static BaseFullTextSearchPaginatedRequest SetInvalidPageSizeRequest() => new(Guid.NewGuid(), 1, 0); +} + +[Collection("WebApplicationFactoryCollectionDefinition")] +public class GetAllFullTextSearchOrdersTest : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersTestFixture _fixture; + + public GetAllFullTextSearchOrdersTest(CustomWebApplicationFactory customWebApplicationFactory, GetAllFullTextSearchOrdersTestFixture fixture) + { + _fixture = fixture; + _fixture.SetApiHelper(customWebApplicationFactory); + _fixture.ResourceUrl = "orders/full-text-search-paginated"; + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetValidRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.True(response!.Success); + Assert.NotNull(response.Data); + Assert.True(response.TotalPages >= 0); + Assert.True(response.TotalRecords >= 0); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False(response!.Success); + Assert.Contains("Page must be greater than 0", response.Message); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageSizeRequestThenFails))] + public async Task GivenAnInvalidPageSizeRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetInvalidPageSizeRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False(response!.Success); + Assert.Contains("PageSize must be between 1 and 100", response.Message); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWithSearchValueThenPass))] + public async Task GivenAValidRequestWithSearchValueThenPass() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetValidRequest(searchValue: "client"); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.NotNull(response); + Assert.True( + result.StatusCode == HttpStatusCode.OK || result.StatusCode == HttpStatusCode.BadRequest, + $"Unexpected status code: {result.StatusCode}" + ); + if (result.StatusCode == HttpStatusCode.BadRequest) + Assert.Equal("No orders found.", response.Message); + } +} diff --git a/templates/Full/tests/UnitTests/Application/Common/RepositoryMockExtensions.cs b/templates/Full/tests/UnitTests/Application/Common/RepositoryMockExtensions.cs index 4e0aef7f..6e1488d2 100644 --- a/templates/Full/tests/UnitTests/Application/Common/RepositoryMockExtensions.cs +++ b/templates/Full/tests/UnitTests/Application/Common/RepositoryMockExtensions.cs @@ -42,6 +42,56 @@ public static void SetFailedUpdate(this Mock mockRepos .Setup(d => d.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(0); + public static void SetValidGetAllFullTextSearchPaginatedAsync( + this Mock mockRepository, + IEnumerable data, + int totalRecords + ) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync((data, totalRecords)); + + public static void SetInvalidGetAllFullTextSearchPaginatedAsync(this Mock mockRepository) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync(([], 0)); + + public static void VerifyGetAllFullTextSearchPaginated(this Mock mockRepository, int times = 1) where TEntity : DomainEntity where TResult : class => mockRepository + .Verify(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + ), Times.Exactly(times)); + public static void SetValidGetAllPaginatedAsyncNoIncludes( this Mock mockRepository, IEnumerable data, diff --git a/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs b/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs new file mode 100644 index 00000000..4588490a --- /dev/null +++ b/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs @@ -0,0 +1,96 @@ +using Application.Common.Requests; +using Application.Orders; +using Domain.Orders; +using UnitTests.Application.Common; + +namespace UnitTests.Application.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCaseFixture : BaseApplicationFixture +{ + public GetAllFullTextSearchOrdersUseCaseFixture() => UseCase = new(MockServiceProvider.Object); + + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "client") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); + + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); +} + +public sealed class GetAllFullTextSearchOrdersUseCaseTests : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersUseCaseFixture _fixture; + + public GetAllFullTextSearchOrdersUseCaseTests(GetAllFullTextSearchOrdersUseCaseFixture fixture) + { + _fixture = fixture; + _fixture.ClearInvocations(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var totalRecords = 5; + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + var expectedOrders = _fixture.AutoFixture.CreateMany(totalRecords); + + _fixture.MockRepository.SetValidGetAllFullTextSearchPaginatedAsync(expectedOrders, totalRecords); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Null(result.Message); + Assert.NotNull(result.Data); + Assert.Equal(expectedOrders.Count(), result.Data.Count()); + Assert.Equal(1, result.TotalPages); + Assert.Equal(totalRecords, result.TotalRecords); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWhenNoOrdersFoundThenFails))] + public async Task GivenAValidRequestWhenNoOrdersFoundThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + _fixture.MockRepository.SetInvalidGetAllFullTextSearchPaginatedAsync(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + Assert.Equal("No orders found.", result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(1); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(0); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(0); + } +} diff --git a/templates/Simple/src/Core/Common/Repositories/IBaseRepository.cs b/templates/Simple/src/Core/Common/Repositories/IBaseRepository.cs index d4c3d131..e70ff85c 100644 --- a/templates/Simple/src/Core/Common/Repositories/IBaseRepository.cs +++ b/templates/Simple/src/Core/Common/Repositories/IBaseRepository.cs @@ -36,4 +36,19 @@ params Expression>[]? includes Expression> predicate = null!, bool? newContext = null ) where TEntity : DomainEntity; + + Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity; } diff --git a/templates/Simple/src/Core/Common/Requests/BaseRequest.cs b/templates/Simple/src/Core/Common/Requests/BaseRequest.cs index 083e388b..5d43ecdb 100644 --- a/templates/Simple/src/Core/Common/Requests/BaseRequest.cs +++ b/templates/Simple/src/Core/Common/Requests/BaseRequest.cs @@ -15,3 +15,14 @@ public record BasePaginatedRequest( string User = "", string TimezoneId = "" ) : BaseRequest(CorrelationId, User, TimezoneId); + +public record BaseFullTextSearchPaginatedRequest( + Guid CorrelationId, + [property: Range(1, int.MaxValue, ErrorMessage = "Page must be greater than 0")] int Page = 1, + [property: Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100")] int PageSize = 10, + string? SortBy = null, + bool SortDescending = false, + string SearchValue = "", + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/Simple/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs b/templates/Simple/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs new file mode 100644 index 00000000..c1b5591e --- /dev/null +++ b/templates/Simple/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs @@ -0,0 +1,40 @@ +using Core.Common.Helpers; +using Core.Common.Requests; +using Core.Common.UseCases; + +namespace Core.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCase(IServiceProvider serviceProvider) : BaseInOutUseCase>(serviceProvider) +{ + public override async Task> HandleInternalAsync( + BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) + { + var (orders, totalRecords) = await Repository.GetAllFullTextSearchPaginatedAsync( + request.CorrelationId, + request.Page, + request.PageSize, + o => new() + { + Id = o.Id, + Total = o.Total + }, + cancellationToken, + request.SortBy, + request.SortDescending, + nameof(Order.Description), + request.SearchValue + ); + + if (orders is null || !orders.Any()) + { + Logs.NotFound(Logger, request.CorrelationId, nameof(orders)); + return new(false, 0, 0, [], "No orders found."); + } + + var totalPages = (int) Math.Ceiling(totalRecords / (double) request.PageSize); + + return new(true, totalPages, totalRecords, orders); + } +} diff --git a/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs index 002a8a83..4ada0ee7 100644 --- a/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs @@ -162,6 +162,11 @@ params Expression>[]? includes bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -174,20 +179,64 @@ params Expression>[]? includes else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); - if (searchByValues != null && searchByValues.Count != 0) foreach (var searchByValue in searchByValues) - query = query.Where(e => - EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%") - ); + query = query.Where(e => EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%")); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); + }, correlationId, newContext); + + public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => + { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + + var query = dbEntitySet.AsQueryable(); + + if (predicate != null) + query = query.Where(predicate); + + if (!string.IsNullOrWhiteSpace(sortBy)) + query = sortDescending + ? query.OrderByDescending(e => EF.Property(e, sortBy)) + : query.OrderBy(e => EF.Property(e, sortBy)); + else + query = query.OrderBy(e => e.CreatedAt); + + if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); + + var items = query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(selector) + .ToListAsync(cancellationToken); + + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); } diff --git a/templates/Simple/src/Infrastructure/Data/Mapping/ItemDbMapping.cs b/templates/Simple/src/Infrastructure/Data/Mapping/ItemDbMapping.cs index b95207e7..492e3f30 100644 --- a/templates/Simple/src/Infrastructure/Data/Mapping/ItemDbMapping.cs +++ b/templates/Simple/src/Infrastructure/Data/Mapping/ItemDbMapping.cs @@ -1,5 +1,6 @@ using Core.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -19,5 +20,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) builder.Property(p => p.Description) .HasMaxLength(255) .IsRequired(); + + builder.HasIndex(p => new { p.Name, p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/Simple/src/Infrastructure/Data/Mapping/OrderDbMapping.cs b/templates/Simple/src/Infrastructure/Data/Mapping/OrderDbMapping.cs index 4bcd88f7..daa19a1f 100644 --- a/templates/Simple/src/Infrastructure/Data/Mapping/OrderDbMapping.cs +++ b/templates/Simple/src/Infrastructure/Data/Mapping/OrderDbMapping.cs @@ -1,5 +1,6 @@ using Core.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -17,5 +18,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) .HasPrecision(18, 2); builder.HasMany(p => p.Items); + + builder.HasIndex(p => new { p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.Designer.cs b/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.Designer.cs new file mode 100644 index 00000000..500c3e13 --- /dev/null +++ b/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.Designer.cs @@ -0,0 +1,236 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + [Migration("20260730124653_FullTextSearchSupport")] + partial class FullTextSearchSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.HasOne("Core.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.cs b/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.cs new file mode 100644 index 00000000..31faba64 --- /dev/null +++ b/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations; + +/// +public partial class FullTextSearchSupport : Migration +{ + private static readonly string[] s_columns = ["Name", "Description"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Order_Description", + table: "Order", + column: "Description") + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + + migrationBuilder.CreateIndex( + name: "IX_Item_Name_Description", + table: "Item", + columns: s_columns) + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Order_Description", + table: "Order"); + + migrationBuilder.DropIndex( + name: "IX_Item_Name_Description", + table: "Item"); + } +} diff --git a/templates/Simple/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs b/templates/Simple/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs index 9c3d97f6..8c6708ab 100644 --- a/templates/Simple/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs +++ b/templates/Simple/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs @@ -1,223 +1,233 @@ -// -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(MyDbContext))] - partial class MyDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Core.Notifications.Notification", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Message") - .HasMaxLength(4000) - .HasColumnType("jsonb"); - - b.Property("NotificationStatus") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("NotificationType") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Notification"); - }); - - modelBuilder.Entity("Core.Orders.Item", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("text"); - - b.Property("OrderId") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Value") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.HasKey("Id"); - - b.HasIndex("OrderId"); - - b.ToTable("Item"); - }); - - modelBuilder.Entity("Core.Orders.Order", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Total") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Order"); - }); - - modelBuilder.Entity("Core.Orders.Item", b => - { - b.HasOne("Core.Orders.Order", null) - .WithMany("Items") - .HasForeignKey("OrderId"); - }); - - modelBuilder.Entity("Core.Orders.Order", b => - { - b.Navigation("Items"); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + partial class MyDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.HasOne("Core.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/Simple/src/WebApp/Endpoints/OrderEndpoints.cs b/templates/Simple/src/WebApp/Endpoints/OrderEndpoints.cs index b7eeca6b..65eabfcd 100644 --- a/templates/Simple/src/WebApp/Endpoints/OrderEndpoints.cs +++ b/templates/Simple/src/WebApp/Endpoints/OrderEndpoints.cs @@ -62,6 +62,17 @@ CancellationToken cancellationToken return response.Success ? Results.Ok(response) : Results.BadRequest(response); }); + ordersGroup.MapPost("/full-text-search-paginated", async ( + [FromServices] IBaseInOutUseCase> useCase, + [FromBody] BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) => + { + var response = await useCase.HandleAsync(request, cancellationToken); + + return response.Success ? Results.Ok(response) : Results.BadRequest(response); + }); + ordersGroup.MapPut("/{id}", async ( [FromServices] IBaseInOutUseCase> useCase, [FromBody] UpdateOrderRequest request, diff --git a/templates/Simple/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs b/templates/Simple/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs new file mode 100644 index 00000000..f24a2391 --- /dev/null +++ b/templates/Simple/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs @@ -0,0 +1,102 @@ +using System.Net; +using Core.Common.Requests; +using Core.Orders; +using IntegrationTests.Common; +using IntegrationTests.WebApp.Http.Common; +using WebApp; + +namespace IntegrationTests.WebApp.Http.Orders; + +public class GetAllFullTextSearchOrdersTestFixture : BaseHttpFixture +{ + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); + public static BaseFullTextSearchPaginatedRequest SetInvalidPageSizeRequest() => new(Guid.NewGuid(), 1, 0); +} + +[Collection(nameof(WebApplicationFactoryCollectionDefinition))] +public class GetAllFullTextSearchOrdersTest : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersTestFixture _fixture; + + public GetAllFullTextSearchOrdersTest(CustomWebApplicationFactory customWebApplicationFactory, GetAllFullTextSearchOrdersTestFixture fixture) + { + _fixture = fixture; + _fixture.SetApiHelper(customWebApplicationFactory); + _fixture.ResourceUrl = "orders/full-text-search-paginated"; + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetValidRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.True(response!.Success); + Assert.NotNull(response.Data); + Assert.True(response.TotalPages >= 0); + Assert.True(response.TotalRecords >= 0); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False(response!.Success); + Assert.Contains("Page must be greater than 0", response.Message); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageSizeRequestThenFails))] + public async Task GivenAnInvalidPageSizeRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetInvalidPageSizeRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False(response!.Success); + Assert.Contains("PageSize must be between 1 and 100", response.Message); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWithSearchValueThenPass))] + public async Task GivenAValidRequestWithSearchValueThenPass() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetValidRequest(searchValue: "client"); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.NotNull(response); + Assert.True( + result.StatusCode == HttpStatusCode.OK || result.StatusCode == HttpStatusCode.BadRequest, + $"Unexpected status code: {result.StatusCode}" + ); + if (result.StatusCode == HttpStatusCode.BadRequest) + Assert.Equal("No orders found.", response.Message); + } +} diff --git a/templates/Simple/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs b/templates/Simple/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs index 54939c25..f0366b99 100644 --- a/templates/Simple/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs +++ b/templates/Simple/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs @@ -42,6 +42,56 @@ public static void SetFailedUpdate(this Mock mockRepos .Setup(d => d.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(0); + public static void SetValidGetAllFullTextSearchPaginatedAsync( + this Mock mockRepository, + IEnumerable data, + int totalRecords + ) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync((data, totalRecords)); + + public static void SetInvalidGetAllFullTextSearchPaginatedAsync(this Mock mockRepository) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync(([], 0)); + + public static void VerifyGetAllFullTextSearchPaginated(this Mock mockRepository, int times = 1) where TEntity : DomainEntity where TResult : class => mockRepository + .Verify(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + ), Times.Exactly(times)); + public static void SetValidGetAllPaginatedAsyncNoIncludes( this Mock mockRepository, IEnumerable data, diff --git a/templates/Simple/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs b/templates/Simple/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs new file mode 100644 index 00000000..efb95c20 --- /dev/null +++ b/templates/Simple/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs @@ -0,0 +1,95 @@ +using Core.Common.Requests; +using Core.Orders; +using UnitTests.Core.Common; + +namespace UnitTests.Core.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCaseFixture : BaseCoreFixture +{ + public GetAllFullTextSearchOrdersUseCaseFixture() => UseCase = new(MockServiceProvider.Object); + + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "client") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); + + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); +} + +public sealed class GetAllFullTextSearchOrdersUseCaseTests : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersUseCaseFixture _fixture; + + public GetAllFullTextSearchOrdersUseCaseTests(GetAllFullTextSearchOrdersUseCaseFixture fixture) + { + _fixture = fixture; + _fixture.ClearInvocations(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var totalRecords = 5; + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + var expectedOrders = _fixture.AutoFixture.CreateMany(totalRecords); + + _fixture.MockRepository.SetValidGetAllFullTextSearchPaginatedAsync(expectedOrders, totalRecords); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Null(result.Message); + Assert.NotNull(result.Data); + Assert.Equal(expectedOrders.Count(), result.Data.Count()); + Assert.Equal(1, result.TotalPages); + Assert.Equal(totalRecords, result.TotalRecords); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWhenNoOrdersFoundThenFails))] + public async Task GivenAValidRequestWhenNoOrdersFoundThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + _fixture.MockRepository.SetInvalidGetAllFullTextSearchPaginatedAsync(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + Assert.Equal("No orders found.", result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(1); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(0); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(0); + } +} diff --git a/templates/SimpleWebUi/Directory.Packages.props b/templates/SimpleWebUi/Directory.Packages.props index e0886fd2..06da13a0 100644 --- a/templates/SimpleWebUi/Directory.Packages.props +++ b/templates/SimpleWebUi/Directory.Packages.props @@ -7,7 +7,7 @@ - + diff --git a/templates/SimpleWebUi/Dockerfile b/templates/SimpleWebUi/Dockerfile index 316e2455..411a32d8 100644 --- a/templates/SimpleWebUi/Dockerfile +++ b/templates/SimpleWebUi/Dockerfile @@ -1,13 +1,12 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0.300 AS build-env +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build-env WORKDIR /app COPY . . -WORKDIR /app/src/WebUi RUN dotnet restore \ - && dotnet publish -c Release -o /app/out + && dotnet publish src/WebUI/WebUi.csproj -c Release -o /app/out -FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 +FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app COPY --from=build-env /app/out . diff --git a/templates/SimpleWebUi/Dockerfile.migrate b/templates/SimpleWebUi/Dockerfile.migrate index 2dc5533c..53fd9dbf 100644 --- a/templates/SimpleWebUi/Dockerfile.migrate +++ b/templates/SimpleWebUi/Dockerfile.migrate @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0.300 +FROM mcr.microsoft.com/dotnet/sdk:10.0 WORKDIR /app diff --git a/templates/SimpleWebUi/SimpleWebUi.slnx b/templates/SimpleWebUi/SimpleWebUi.slnx index 334ec686..025c7e03 100644 --- a/templates/SimpleWebUi/SimpleWebUi.slnx +++ b/templates/SimpleWebUi/SimpleWebUi.slnx @@ -9,7 +9,7 @@ - + diff --git a/templates/SimpleWebUi/docker-compose-e2e.yml b/templates/SimpleWebUi/docker-compose-e2e.yml index c126f505..22e0c7cc 100644 --- a/templates/SimpleWebUi/docker-compose-e2e.yml +++ b/templates/SimpleWebUi/docker-compose-e2e.yml @@ -25,7 +25,7 @@ services: condition: service_healthy postgres: - image: &postgresImage postgres:17-alpine + image: postgres:17-alpine container_name: postgres environment: POSTGRES_PASSWORD: "cY5VvZkkh4AzES" diff --git a/templates/SimpleWebUi/src/Core/Common/Repositories/IBaseRepository.cs b/templates/SimpleWebUi/src/Core/Common/Repositories/IBaseRepository.cs index d4c3d131..e70ff85c 100644 --- a/templates/SimpleWebUi/src/Core/Common/Repositories/IBaseRepository.cs +++ b/templates/SimpleWebUi/src/Core/Common/Repositories/IBaseRepository.cs @@ -36,4 +36,19 @@ params Expression>[]? includes Expression> predicate = null!, bool? newContext = null ) where TEntity : DomainEntity; + + Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity; } diff --git a/templates/SimpleWebUi/src/Core/Common/Requests/BaseRequest.cs b/templates/SimpleWebUi/src/Core/Common/Requests/BaseRequest.cs index 083e388b..5d43ecdb 100644 --- a/templates/SimpleWebUi/src/Core/Common/Requests/BaseRequest.cs +++ b/templates/SimpleWebUi/src/Core/Common/Requests/BaseRequest.cs @@ -15,3 +15,14 @@ public record BasePaginatedRequest( string User = "", string TimezoneId = "" ) : BaseRequest(CorrelationId, User, TimezoneId); + +public record BaseFullTextSearchPaginatedRequest( + Guid CorrelationId, + [property: Range(1, int.MaxValue, ErrorMessage = "Page must be greater than 0")] int Page = 1, + [property: Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100")] int PageSize = 10, + string? SortBy = null, + bool SortDescending = false, + string SearchValue = "", + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/SimpleWebUi/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs b/templates/SimpleWebUi/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs new file mode 100644 index 00000000..e0f6c5e8 --- /dev/null +++ b/templates/SimpleWebUi/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs @@ -0,0 +1,41 @@ +using Core.Common.Helpers; +using Core.Common.Requests; +using Core.Common.UseCases; + +namespace Core.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCase(IServiceProvider serviceProvider) : BaseInOutUseCase>(serviceProvider) +{ + public override async Task> HandleInternalAsync( + BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) + { + var (orders, totalRecords) = await Repository.GetAllFullTextSearchPaginatedAsync( + request.CorrelationId, + request.Page, + request.PageSize, + o => new() + { + Id = o.Id, + Description = o.Description, + Total = o.Total + }, + cancellationToken, + request.SortBy, + request.SortDescending, + nameof(Order.Description), + request.SearchValue + ); + + if (orders is null || !orders.Any()) + { + Logs.NotFound(Logger, request.CorrelationId, nameof(orders)); + return new(false, 0, 0, [], "No orders found."); + } + + var totalPages = (int) Math.Ceiling(totalRecords / (double) request.PageSize); + + return new(true, totalPages, totalRecords, orders); + } +} diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs index 002a8a83..3437a6b9 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs @@ -162,6 +162,11 @@ params Expression>[]? includes bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -174,7 +179,6 @@ params Expression>[]? includes else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); if (searchByValues != null && searchByValues.Count != 0) foreach (var searchByValue in searchByValues) @@ -182,12 +186,60 @@ params Expression>[]? includes EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%") ); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); + }, correlationId, newContext); + + public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => + { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + + var query = dbEntitySet.AsQueryable(); + + if (predicate != null) + query = query.Where(predicate); + + if (!string.IsNullOrWhiteSpace(sortBy)) + query = sortDescending + ? query.OrderByDescending(e => EF.Property(e, sortBy)) + : query.OrderBy(e => EF.Property(e, sortBy)); + else + query = query.OrderBy(e => e.CreatedAt); + + if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); + + var items = query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(selector) + .ToListAsync(cancellationToken); + + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); } diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/ItemDbMapping.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/ItemDbMapping.cs index b95207e7..492e3f30 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/ItemDbMapping.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/ItemDbMapping.cs @@ -1,5 +1,6 @@ using Core.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -19,5 +20,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) builder.Property(p => p.Description) .HasMaxLength(255) .IsRequired(); + + builder.HasIndex(p => new { p.Name, p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/OrderDbMapping.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/OrderDbMapping.cs index 4bcd88f7..daa19a1f 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/OrderDbMapping.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/OrderDbMapping.cs @@ -1,5 +1,6 @@ using Core.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -17,5 +18,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) .HasPrecision(18, 2); builder.HasMany(p => p.Items); + + builder.HasIndex(p => new { p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.Designer.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.Designer.cs new file mode 100644 index 00000000..3f6db4f7 --- /dev/null +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.Designer.cs @@ -0,0 +1,236 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + [Migration("20260730130753_FullTextSearchSupport")] + partial class FullTextSearchSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.HasOne("Core.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs new file mode 100644 index 00000000..31faba64 --- /dev/null +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations; + +/// +public partial class FullTextSearchSupport : Migration +{ + private static readonly string[] s_columns = ["Name", "Description"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Order_Description", + table: "Order", + column: "Description") + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + + migrationBuilder.CreateIndex( + name: "IX_Item_Name_Description", + table: "Item", + columns: s_columns) + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Order_Description", + table: "Order"); + + migrationBuilder.DropIndex( + name: "IX_Item_Name_Description", + table: "Item"); + } +} diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs index 2d7b8ca6..8c6708ab 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -146,6 +146,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OrderId"); + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + b.ToTable("Item"); }); @@ -203,6 +208,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + b.ToTable("Order"); }); diff --git a/templates/SimpleWebUi/src/WebUI/Pages/Orders/OrderList.razor.cs b/templates/SimpleWebUi/src/WebUI/Pages/Orders/OrderList.razor.cs index 6002299b..66c198be 100644 --- a/templates/SimpleWebUi/src/WebUI/Pages/Orders/OrderList.razor.cs +++ b/templates/SimpleWebUi/src/WebUI/Pages/Orders/OrderList.razor.cs @@ -9,7 +9,7 @@ namespace WebUi.Pages.Orders; public partial class OrderList : IDisposable { - [Inject] private IBaseInOutUseCase> GetAllOrders { get; set; } = default!; + [Inject] private IBaseInOutUseCase> GetAllOrders { get; set; } = default!; [Inject] private IBaseInOutUseCase DeleteOrder { get; set; } = default!; private readonly CancellationTokenSource _cancellationTokenSource = new(); private List _orders = []; @@ -32,11 +32,10 @@ private async Task LoadAsync() _loading = true; _showError = false; - Dictionary? search = string.IsNullOrWhiteSpace(_searchDescription) - ? null - : new() { ["Description"] = _searchDescription }; - - var result = await GetAllOrders.HandleAsync(new(Guid.NewGuid(), _currentPage, PageSize, SearchByValues: search), _cancellationTokenSource.Token); + var result = await GetAllOrders.HandleAsync( + new(Guid.NewGuid(), _currentPage, PageSize, SearchValue: _searchDescription), + _cancellationTokenSource.Token + ); _loading = false; diff --git a/templates/SimpleWebUi/tests/E2eTests/WebUI/OrderListPageTests.cs b/templates/SimpleWebUi/tests/E2eTests/WebUI/OrderListPageTests.cs index 6ac046eb..d29565b1 100644 --- a/templates/SimpleWebUi/tests/E2eTests/WebUI/OrderListPageTests.cs +++ b/templates/SimpleWebUi/tests/E2eTests/WebUI/OrderListPageTests.cs @@ -154,4 +154,56 @@ public async Task GivenSearchInputWhenTermEnteredThenSearchButtonIsPresent() Assert.NotNull(searchInput); Assert.NotNull(searchButton); } + + [Fact(DisplayName = nameof(GivenFullTextSearchWhenSearchButtonClickedThenEitherTableOrEmptyStateIsShown))] + public async Task GivenFullTextSearchWhenSearchButtonClickedThenEitherTableOrEmptyStateIsShown() + { + await _fixture.NavigateToOrdersAsync(); + await _fixture.Page.WaitForPageLoadAsync(); + + await _fixture.Page.FillAsync(Selectors.InputText, "client"); + await _fixture.Page.ClickAsync(Selectors.ButtonOutlineSecondary); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var tableVisible = await _fixture.Page.IsTableVisibleAsync(3000); + var emptyVisible = await _fixture.IsEmptyStateVisibleAsync(); + + Assert.True(tableVisible || emptyVisible, "Either the orders table or empty state must be shown after full-text search"); + } + + [Fact(DisplayName = nameof(GivenFullTextSearchWhenEnterPressedThenEitherTableOrEmptyStateIsShown))] + public async Task GivenFullTextSearchWhenEnterPressedThenEitherTableOrEmptyStateIsShown() + { + await _fixture.NavigateToOrdersAsync(); + await _fixture.Page.WaitForPageLoadAsync(); + + await _fixture.Page.FillAsync(Selectors.InputText, "order"); + await _fixture.Page.PressAsync(Selectors.InputText, "Enter"); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var tableVisible = await _fixture.Page.IsTableVisibleAsync(3000); + var emptyVisible = await _fixture.IsEmptyStateVisibleAsync(); + + Assert.True(tableVisible || emptyVisible, "Either the orders table or empty state must be shown after pressing Enter"); + } + + [Fact(DisplayName = nameof(GivenFullTextSearchWhenSearchClearedThenAllOrdersReload))] + public async Task GivenFullTextSearchWhenSearchClearedThenAllOrdersReload() + { + await _fixture.NavigateToOrdersAsync(); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await _fixture.Page.FillAsync(Selectors.InputText, "zzznomatch"); + await _fixture.Page.ClickAsync(Selectors.ButtonOutlineSecondary); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await _fixture.Page.FillAsync(Selectors.InputText, ""); + await _fixture.Page.ClickAsync(Selectors.ButtonOutlineSecondary); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var tableVisible = await _fixture.Page.IsTableVisibleAsync(3000); + var emptyVisible = await _fixture.IsEmptyStateVisibleAsync(); + + Assert.True(tableVisible || emptyVisible, "Either the orders table or empty state must be shown after clearing the search"); + } } diff --git a/templates/SimpleWebUi/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs b/templates/SimpleWebUi/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs index 54939c25..f0366b99 100644 --- a/templates/SimpleWebUi/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs +++ b/templates/SimpleWebUi/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs @@ -42,6 +42,56 @@ public static void SetFailedUpdate(this Mock mockRepos .Setup(d => d.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(0); + public static void SetValidGetAllFullTextSearchPaginatedAsync( + this Mock mockRepository, + IEnumerable data, + int totalRecords + ) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync((data, totalRecords)); + + public static void SetInvalidGetAllFullTextSearchPaginatedAsync(this Mock mockRepository) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync(([], 0)); + + public static void VerifyGetAllFullTextSearchPaginated(this Mock mockRepository, int times = 1) where TEntity : DomainEntity where TResult : class => mockRepository + .Verify(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + ), Times.Exactly(times)); + public static void SetValidGetAllPaginatedAsyncNoIncludes( this Mock mockRepository, IEnumerable data, diff --git a/templates/SimpleWebUi/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs b/templates/SimpleWebUi/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs new file mode 100644 index 00000000..efb95c20 --- /dev/null +++ b/templates/SimpleWebUi/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs @@ -0,0 +1,95 @@ +using Core.Common.Requests; +using Core.Orders; +using UnitTests.Core.Common; + +namespace UnitTests.Core.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCaseFixture : BaseCoreFixture +{ + public GetAllFullTextSearchOrdersUseCaseFixture() => UseCase = new(MockServiceProvider.Object); + + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "client") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); + + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); +} + +public sealed class GetAllFullTextSearchOrdersUseCaseTests : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersUseCaseFixture _fixture; + + public GetAllFullTextSearchOrdersUseCaseTests(GetAllFullTextSearchOrdersUseCaseFixture fixture) + { + _fixture = fixture; + _fixture.ClearInvocations(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var totalRecords = 5; + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + var expectedOrders = _fixture.AutoFixture.CreateMany(totalRecords); + + _fixture.MockRepository.SetValidGetAllFullTextSearchPaginatedAsync(expectedOrders, totalRecords); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Null(result.Message); + Assert.NotNull(result.Data); + Assert.Equal(expectedOrders.Count(), result.Data.Count()); + Assert.Equal(1, result.TotalPages); + Assert.Equal(totalRecords, result.TotalRecords); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWhenNoOrdersFoundThenFails))] + public async Task GivenAValidRequestWhenNoOrdersFoundThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + _fixture.MockRepository.SetInvalidGetAllFullTextSearchPaginatedAsync(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + Assert.Equal("No orders found.", result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(1); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(0); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(0); + } +} diff --git a/templates/SimpleWebUi/tests/UnitTests/WebUi/Pages/Orders/OrderListTests.cs b/templates/SimpleWebUi/tests/UnitTests/WebUi/Pages/Orders/OrderListTests.cs index 3953073e..6b8697af 100644 --- a/templates/SimpleWebUi/tests/UnitTests/WebUi/Pages/Orders/OrderListTests.cs +++ b/templates/SimpleWebUi/tests/UnitTests/WebUi/Pages/Orders/OrderListTests.cs @@ -9,7 +9,7 @@ namespace UnitTests.WebUi.Pages.Orders; public sealed class OrderListFixture : BaseComponentFixture { - public Mock>> MockGetAllOrders { get; } = new(); + public Mock>> MockGetAllOrders { get; } = new(); public Mock> MockDeleteOrder { get; } = new(); public OrderListFixture() @@ -20,7 +20,7 @@ public OrderListFixture() public void SetupGetAll(BasePaginatedResponse response) => MockGetAllOrders - .Setup(u => u.HandleAsync(It.IsAny(), It.IsAny())) + .Setup(u => u.HandleAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(response); public static BasePaginatedResponse SuccessPage( @@ -78,7 +78,7 @@ public void GivenOrdersReturnedThenTableRowsAreRendered() public void GivenUseCaseFailsThenErrorAlertIsShown() { _fixture.MockGetAllOrders - .Setup(u => u.HandleAsync(It.IsAny(), It.IsAny())) + .Setup(u => u.HandleAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new BasePaginatedResponse(false, 0, 0, null, "Load failed")); var component = _fixture.Render(); @@ -97,7 +97,7 @@ public void GivenSearchButtonClickThenReloadsFromPageOne() component.Find(Selectors.ButtonOutlineSecondary).Click(); _fixture.MockGetAllOrders.Verify(u => u.HandleAsync( - It.Is(r => r.Page == 1), + It.Is(r => r.Page == 1), It.IsAny()), Times.AtLeast(2)); } @@ -125,7 +125,7 @@ public void GivenNextButtonClickThenNavigatesToNextPage() component.Find(Selectors.LastChildLi).Click(); _fixture.MockGetAllOrders.Verify(u => u.HandleAsync( - It.Is(r => r.Page == 2), + It.Is(r => r.Page == 2), It.IsAny()), Times.Once); } diff --git a/templates/SimpleWebUi/tests/UnitTests/packages.lock.json b/templates/SimpleWebUi/tests/UnitTests/packages.lock.json index 741b8e46..5b912dcb 100644 --- a/templates/SimpleWebUi/tests/UnitTests/packages.lock.json +++ b/templates/SimpleWebUi/tests/UnitTests/packages.lock.json @@ -4,22 +4,22 @@ "net10.0": { "bunit": { "type": "Direct", - "requested": "[2.7.2, )", - "resolved": "2.7.2", - "contentHash": "Oe8AI7KU1tPNM1VHTtfdfcxbC2bWnrTW9rAcrEEuZ7JlvsY2NZHvWRa/I2PvfAQifTJf0moe+wZqt8OLbJRklw==", + "requested": "[2.8.6, )", + "resolved": "2.8.6", + "contentHash": "kBGHiCrn0LvUGRiDaqLxBewZXAOIIqYlJTcfh/+yaMc5XOzWm2eeuDl2+3ksK3AuLSSnVitmCLu8n1RV9CXpSQ==", "dependencies": { - "AngleSharp": "1.4.0", - "AngleSharp.Css": "1.0.0-beta.157", + "AngleSharp": "1.5.2", + "AngleSharp.Css": "1.0.0-beta.224", "AngleSharp.Diffing": "1.1.1", - "Microsoft.AspNetCore.Components": "10.0.0", - "Microsoft.AspNetCore.Components.Authorization": "10.0.0", - "Microsoft.AspNetCore.Components.Web": "10.0.0", - "Microsoft.AspNetCore.Components.WebAssembly": "10.0.0", - "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "10.0.0", - "Microsoft.Extensions.Caching.Memory": "10.0.0", - "Microsoft.Extensions.Localization.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.AspNetCore.Components.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Web": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Localization.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" } }, "coverlet.collector": { @@ -85,10 +85,10 @@ }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.0-beta.157", - "contentHash": "yptHmuGt2tzNGCeD7Orh6HRs4j7MH3IUhI+FM640OlmSwWRMd1yjKnicVcNG147IdF/tjOvgqtvpTU0pH6wVpA==", + "resolved": "1.0.0-beta.224", + "contentHash": "BF/hPs8sRjhR+rYkBom7WaMLN0w36Qvnu+WvoHK/gL67MrQOONkkivBTGilvta4sNix5Y8T9XVlZsRI8IU2/vA==", "dependencies": { - "AngleSharp": "[1.0.0, 2.0.0)" + "AngleSharp": "[1.5.0, 2.0.0)" } }, "AngleSharp.Diffing": { @@ -118,84 +118,84 @@ }, "Microsoft.AspNetCore.Authorization": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "8qUwBAtUj9kX8RwqEPGJu3zGM/GlwF2bR9gsxk/+kiSmCB6aDYlYF7E2gM9VOYS9NDF6zxTSEgJxwD9kFbu8Sw==", + "resolved": "10.0.10", + "contentHash": "FxWC2bc788/51CiSiMkKW5gQyqrSSs1991BhphS6ZARMGii+3Qr355Q9OSB/nBpdHVjIopfegAhreZ6UPpO9WA==", "dependencies": { - "Microsoft.AspNetCore.Metadata": "10.0.0", - "Microsoft.Extensions.Diagnostics": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" + "Microsoft.AspNetCore.Metadata": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.AspNetCore.Components": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "ljLBmYTPkIe/WhOn/y3kpMCJ4A/xg76H/SRm9XkoOBqlM96Ntcbr+WFJqV/0bEqjaUqQYIMITPnJXrVJ242cWQ==", + "resolved": "10.0.10", + "contentHash": "wlCBFU9YxqhUc2kVbl/FCGNFSbRjS312WVk9YRVjs/OcZ+Yqa9QsD7z+UcBzNNAbcPpEM98f/PaaD6lHt5rmIg==", "dependencies": { - "Microsoft.AspNetCore.Authorization": "10.0.0", - "Microsoft.AspNetCore.Components.Analyzers": "10.0.0" + "Microsoft.AspNetCore.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Analyzers": "10.0.10" } }, "Microsoft.AspNetCore.Components.Analyzers": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "5j9dQ/U51yLLKo8hKJOU7uTPppPsjLTua0PFzRotaePcmz7M9W7pM69pBXDEgkBfGD1tp2ufH0zHRHkwg1ikDg==" + "resolved": "10.0.10", + "contentHash": "ZBJobkEDv6yWftw3ycZMkuV9YpvJOBmjWpg2UbQ0Ol4THObhKai0PNl/SGFBmpw03JKCPA63RgplQOA1LQbRDg==" }, "Microsoft.AspNetCore.Components.Authorization": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "z/4yuTmCD1A3x++Dr8s2H3yD6nUGglqxvjsPL65BUynJlEO0ZaOaI0TIQ58+OyU8IAw98eprHlezix8UyRMqKg==", + "resolved": "10.0.10", + "contentHash": "em2xVQkvtn3BHFT3J9pp4K3yDgPnp0S5a+gcGxb6xkjqy+81yDa8XEEaQnkruCVRusFMltORFFmnlrnHa/A90w==", "dependencies": { - "Microsoft.AspNetCore.Authorization": "10.0.0", - "Microsoft.AspNetCore.Components": "10.0.0" + "Microsoft.AspNetCore.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components": "10.0.10" } }, "Microsoft.AspNetCore.Components.Forms": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "5kMwH8DqYFfUpyMNv+IzIoDHxR8fHTPKeJeFNAyjY5tMxo3yyM7ezmuYNcVt9QJ+6K2soHQjBnCvFs8yNd3UGQ==", + "resolved": "10.0.10", + "contentHash": "09cKk0/t83Y5DN5ne4J9Y+OiLJEQ+XBFGIoD0UXAnae8yQEkFa/OYbX0bBGkx0k5r5ndta/CMdlMk3lCG81R0g==", "dependencies": { - "Microsoft.AspNetCore.Components": "10.0.0", - "Microsoft.Extensions.Validation": "10.0.0" + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.Extensions.Validation": "10.0.10" } }, "Microsoft.AspNetCore.Components.Web": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "TAv9+cOrU27YPKuHE7AlG2UBWtTWivvcDHmfdN2yngzQFtas+3p2IBDAF6odNfOJiW3Cs1lHwzqLAoNw5Sc59Q==", + "resolved": "10.0.10", + "contentHash": "8+cyStKhSy94Q2L/2zmgk0H1f+GKvBGEvTImT0qdsCqeNbT7ilGDKTIJ1PiugsPtSNLuUEABUqMpA3rE1yAKFg==", "dependencies": { - "Microsoft.AspNetCore.Components": "10.0.0", - "Microsoft.AspNetCore.Components.Forms": "10.0.0", - "Microsoft.Extensions.DependencyInjection": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0", - "Microsoft.JSInterop": "10.0.0" + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.AspNetCore.Components.Forms": "10.0.10", + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10", + "Microsoft.JSInterop": "10.0.10" } }, "Microsoft.AspNetCore.Components.WebAssembly": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "ens4WMX0PBTHxRDYpS3i//VktXi+24my01qXI0P91q2rDMHcIKRN0/sdE+4kB1ZwJpHec9vYxDmmnw/JOcXCwQ==", + "resolved": "10.0.10", + "contentHash": "0Zib7U6HmGw4Noj/+yJz1YuEJwo3XOV0iLezXDLFsrHTaC8L+KUOR/1+1tX7AAeipDG6jP3gPZOwpirk6G3LYQ==", "dependencies": { - "Microsoft.AspNetCore.Components.Web": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.Configuration.Json": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.JSInterop.WebAssembly": "10.0.0" + "Microsoft.AspNetCore.Components.Web": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.JSInterop.WebAssembly": "10.0.10" } }, "Microsoft.AspNetCore.Components.WebAssembly.Authentication": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "u4cqo7IdawVPAPHDBlyiTmEodGom6m/OnqXnioz71ASA2l7pAebLed5vaErtsGT9Ud0Z1Nudthy+3FBk0BmHjQ==", + "resolved": "10.0.10", + "contentHash": "D2GIsqVJ2+tVDViJmbEF4tc+gxhdAl5xkBAMZHWCJV7Pt+oBkU2omBSRbgVTq3N2OgreVNSp7w+FrZNgu97d5A==", "dependencies": { - "Microsoft.AspNetCore.Components.Authorization": "10.0.0", - "Microsoft.AspNetCore.Components.Web": "10.0.0" + "Microsoft.AspNetCore.Components.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Web": "10.0.10" } }, "Microsoft.AspNetCore.Metadata": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "uMXReBNxJKabrdoxHn4LNDTlGQFGDPtntlVvqo/b0HaxJzeDAIMFXJJ64mufJCNRuQU/Pus8ngcTZErvnsh7vg==" + "resolved": "10.0.10", + "contentHash": "nmZYV9OQ69r3EDOj0SW1JLgD4uFHfMNDKb8mk8Mf4y0Enw3AzkMD85zPuAk5FqmEz8PS0WagR/tJCwLg8xpI3Q==" }, "Microsoft.CodeCoverage": { "type": "Transitive", @@ -234,11 +234,11 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Abstractions": { @@ -251,11 +251,11 @@ }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -269,25 +269,25 @@ }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "LqCTyF0twrG4tyEN6PpSC5ewRBDwCBazRUfCOdRddwaQ3n2S57GDDeYOlTLcbV/V2dxSSZWg5Ofr48h6BsBmxw==", + "resolved": "10.0.10", + "contentHash": "ZOhZYwvbXGTgGVRwswIirofEMVHuWdxjdh0JeUZXwaF9cgcjXdz/t0ELtgaevw7ezTyv47yPNCgGreWtLkn3IQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Physical": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "BIOPTEAZoeWbHlDT9Zudu+rpecZizFwhdIFRiyZKDml7JbayXmfTXKUt+ezifsSXfBkWDdJM10oDOxo8pufEng==", + "resolved": "10.0.10", + "contentHash": "uvJ6sHwjgrkMEJOgiC76G0mcZGXerwyyWkwX34EOjCbxKG6TCtfAoqDKAMsCvEBf9HxjlGQEgqsSMOGCmGBf+A==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { @@ -297,45 +297,45 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xjkxIPgrT0mKTfBwb+CVqZnRchyZgzKIfDQOp8z+WUC6vPe3WokIf71z+hJPkH0YBUYJwa7Z/al1R087ib9oiw==", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "resolved": "10.0.10", + "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "UZUQ74lQMmvcprlG8w+XpxBbyRDQqfb7GAnccITw32hdkUBlmm9yNC4xl4aR9YjgV3ounZcub194sdmLSfBmPA==", + "resolved": "10.0.10", + "contentHash": "jhJAyo38kSrH3ARvWUk0h8itogVnQu2DCZuPo+s0Z+tXes0ugTxMPaHYzap85785eHQmPFqD9TYERqBbtGxn/w==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "5hfVl/e+bx1px2UkN+1xXhd3hu7Ui6ENItBzckFaRDQXfr+SHT/7qrCDrlQekCF/PBtEu2vtk87U2+gDEF8EhQ==" + "resolved": "10.0.10", + "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", @@ -351,8 +351,8 @@ }, "Microsoft.Extensions.Localization.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "+gopxHC5+vWE61Qq2i+aFblxjSiS4UyjUG5kpfXodWbeBVwXPeP66s8uAi0LPAwnhS5jInmYJNRFYUMn+sdtKg==" + "resolved": "10.0.10", + "contentHash": "j1DygDJR1wP3LG3d7a1rTFPUZcPfhMggcmDEGKhK2iLK0TuAIaW9bHiq1f9k650gdsXsANTm71hEg/k9msNnAQ==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", @@ -388,14 +388,14 @@ }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Primitives": { @@ -405,24 +405,24 @@ }, "Microsoft.Extensions.Validation": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "viIKcmaf1akdhx77c+dLkODvhL+HyonHNpBsicndoi47zvbSeHJEWPOk188NnFCh+dCUz7Icld1jPMbS5E2fqw==", + "resolved": "10.0.10", + "contentHash": "d77zBZk+Z4uo6hEuLvMY+yMjLkn2T/vX5c2sB0sdoSxQVZk7qvec6SuORp2/uZp7UchX4hNp+VJm4GsoSE0waA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.JSInterop": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "Qfs4KtmfMUQ/BtD4jdt753kiAa7frm1QVms8UHiFArOokoXBkBOOKE2N5APdt3BlAdTSFJNiuDFfgDzVq/3EZg==" + "resolved": "10.0.10", + "contentHash": "VivWvz1iFR3Ntf/e5/xci6o/MF2tcHiprNkhRlL+gMkBoDjQJCioiO+Gwp/DP8scuzqbp3b9C3wh+zuy4pD4AA==" }, "Microsoft.JSInterop.WebAssembly": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "j0lrmG+7X4OZh75IzOX00nkwAEjXSgt2Ul6kjwmVkAOBzd8JC1Q/yJ/fC4jWcIXe4h22881Qq1o46p/TnSNaRQ==", + "resolved": "10.0.10", + "contentHash": "FjPAGQWbCP85FWY2Wl9opHSsY+u9nZb8Ui6eTVHEMY/RJateOhW78jEtC4QOQ/6slkS1MKyIAF6ZHIrUR1fX/w==", "dependencies": { - "Microsoft.JSInterop": "10.0.0" + "Microsoft.JSInterop": "10.0.10" } }, "Microsoft.NETCore.Platforms": {