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