From 847c262734d22e0faccef0d20dc0bad272e03e80 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Wed, 9 Sep 2026 00:31:17 +0330 Subject: [PATCH] feat(domain): add Demo Commerce aggregates and required catalog identity Keep money as decimal; drop unused Lab Money VO. Backfill BrandId/CategoryId on leftover CatalogInit rows so storefront migrate does not write FK 0. Pin analyzer tests to net8 ref assemblies so Roslyn tests do not download the broken Net90 preview pack. --- .config/dotnet-tools.json | 12 + .gitignore | 3 - AGENTS.md | 6 +- CONTRIBUTING.md | 3 +- FeatureFusion.sln | 45 + README.md | 37 +- ...tion.EntityFrameworkCore.Benchmarks.csproj | 2 +- docs/adr/0003-pagination-keyset.md | 2 +- .../building-blocks/PAGINATION_TEST_MATRIX.md | 2 +- docs/building-blocks/idempotency.md | 9 +- docs/building-blocks/mcp.md | 6 +- docs/building-blocks/pagination.md | 18 +- docs/lab/README.md | 18 +- docs/lab/capability-admission.md | 61 ++ docs/linkedin-posts.md | 6 +- llms.txt | 4 + .../Domain.EntityFrameworkCore/AGENTS.md | 24 + ...ngBlocks.Domain.EntityFrameworkCore.csproj | 62 ++ .../IdentityValueConverter.cs | 48 + .../PACKAGE_README.md | 19 + .../PropertyBuilderExtensions.cs | 44 + .../ValueObjectValueConverter.cs | 31 + src/BuildingBlocks/Domain/AGENTS.md | 36 + src/BuildingBlocks/Domain/AggregateId.cs | 16 + src/BuildingBlocks/Domain/AggregateRoot.cs | 52 + .../Domain/BuildingBlocks.Domain.csproj | 47 + .../Domain/BusinessRuleValidationException.cs | 16 + src/BuildingBlocks/Domain/DomainEvent.cs | 19 + src/BuildingBlocks/Domain/DomainException.cs | 17 + src/BuildingBlocks/Domain/Entity.cs | 62 ++ src/BuildingBlocks/Domain/EntityId.cs | 16 + src/BuildingBlocks/Domain/Enumeration.cs | 80 ++ src/BuildingBlocks/Domain/IAggregate.cs | 7 + src/BuildingBlocks/Domain/IBusinessRule.cs | 11 + src/BuildingBlocks/Domain/IDomainEvent.cs | 20 + src/BuildingBlocks/Domain/IEntity.cs | 9 + src/BuildingBlocks/Domain/IHaveAggregate.cs | 17 + .../Domain/IHaveAggregateVersion.cs | 13 + src/BuildingBlocks/Domain/IHaveAudit.cs | 21 + src/BuildingBlocks/Domain/IHaveIdentity.cs | 19 + src/BuildingBlocks/Domain/IHaveSoftDelete.cs | 10 + src/BuildingBlocks/Domain/IIdentity.cs | 13 + src/BuildingBlocks/Domain/Identity.cs | 32 + src/BuildingBlocks/Domain/PACKAGE_README.md | 51 + src/BuildingBlocks/Domain/ValueObject.cs | 45 + src/BuildingBlocks/Idempotency/AGENTS.md | 2 + .../IdempotencyRequestBufferingMiddleware.cs | 42 + .../AspNetCore/IdempotentEndpointFilter.cs | 5 +- .../Idempotency/Core/IdempotencyGate.cs | 8 +- .../Idempotency/PACKAGE_README.md | 4 +- .../Pagination.Dapper/README.md | 2 +- ...ocks.Pagination.EntityFrameworkCore.csproj | 12 +- .../PACKAGE_README.md | 6 +- src/Lab/FeatureFusion.AppHost/Program.cs | 2 +- .../Extensions.cs | 3 +- .../FeatureFusion/Apis/MinimalApiGreeting.cs | 176 ---- .../Controllers/V1/Authentication.cs | 38 - .../Controllers/V1/GreetingController.cs | 31 - .../Controllers/V2/Authentication.cs | 37 - .../Controllers/V2/GreetingController.cs | 65 -- .../Controllers/V2/OrderController.cs | 52 - .../Controllers/V2/ProductController.cs | 109 --- src/Lab/FeatureFusion/Domain/Carts/Cart.cs | 86 ++ src/Lab/FeatureFusion/Domain/Carts/CartId.cs | 14 + .../FeatureFusion/Domain/Carts/CartItem.cs | 49 + .../FeatureFusion/Domain/Carts/CartItemId.cs | 14 + src/Lab/FeatureFusion/Domain/Catalog/Brand.cs | 37 + .../FeatureFusion/Domain/Catalog/BrandId.cs | 18 + .../FeatureFusion/Domain/Catalog/Category.cs | 33 + .../Domain/Catalog/CategoryId.cs | 18 + .../FeatureFusion/Domain/Catalog/Product.cs | 184 ++++ .../Domain/Catalog/ProductChildIds.cs | 33 + .../FeatureFusion/Domain/Catalog/ProductId.cs | 21 + .../Domain/Catalog/ProductImage.cs | 54 ++ .../Domain/Catalog/ProductSpecification.cs | 49 + src/Lab/FeatureFusion/Domain/Catalog/Sku.cs | 35 + src/Lab/FeatureFusion/Domain/Catalog/Slug.cs | 54 ++ .../Domain/Customers/Customer.cs | 40 + .../Domain/Customers/CustomerId.cs | 21 + .../FeatureFusion/Domain/Customers/Email.cs | 35 + .../FeatureFusion/Domain/Entities/Person.cs | 6 +- .../FeatureFusion/Domain/Entities/Product.cs | 15 - src/Lab/FeatureFusion/Domain/Orders/Order.cs | 117 +++ .../FeatureFusion/Domain/Orders/OrderId.cs | 21 + .../FeatureFusion/Domain/Orders/OrderItem.cs | 57 ++ .../Domain/Orders/OrderItemId.cs | 18 + .../Domain/Orders/OrderNumber.cs | 35 + .../Domain/Orders/OrderShipping.cs | 75 ++ .../Domain/Orders/OrderStatus.cs | 17 + .../Domain/Orders/ShippingStatus.cs | 9 + .../Domain/Payments/PaymentRecord.cs | 63 ++ src/Lab/FeatureFusion/Dtos/LoginDto.cs | 4 +- src/Lab/FeatureFusion/Dtos/PersonDto.cs | 2 +- src/Lab/FeatureFusion/Dtos/ProductDto.cs | 2 +- .../FeatureFusion/Dtos/ProductPromotionDto.cs | 2 +- .../Dtos/Validator/OrderRequestValidator.cs | 6 - .../Dtos/Validator/ValidationResultWrapper.cs | 4 +- src/Lab/FeatureFusion/FeatureFusion.csproj | 16 +- .../Features/Admission/AdmissionDecision.cs | 29 + .../Admission/CapabilityAdmissionOptions.cs | 19 + .../Admission/CapabilityAdmissionService.cs | 262 +++++ .../Features/Admission/CapabilityIds.cs | 7 + .../CreateOrderCapabilityExecutor.cs | 68 ++ .../Admission/Endpoints/AdmissionEndpoints.cs | 87 ++ .../Admission/ICapabilityAdmission.cs | 20 + .../Features/Admission/IntentTicket.cs | 43 + .../Admission/OrderCreateAdmissionGate.cs | 47 + .../Features/Auth/Endpoints/AuthEndpoints.cs | 35 + .../Features/Carts/CartContracts.cs | 33 + .../Features/Carts/CartEndpoints.cs | 114 +++ .../Features/Carts/CartHandlers.cs | 258 +++++ .../Features/Carts/CartValidators.cs | 43 + .../Features/Catalog/CatalogContracts.cs | 92 ++ .../Features/Catalog/CatalogEndpoints.cs | 114 +++ .../Features/Catalog/CatalogProjections.cs | 107 +++ .../Features/Catalog/CatalogSlug.cs | 24 + .../GetCatalogProductBySlugQueryHandler.cs | 56 ++ .../Catalog/ListCatalogBrandsQueryHandler.cs | 23 + .../ListCatalogCategoriesQueryHandler.cs | 23 + .../ListCatalogProductsQueryHandler.cs | 71 ++ .../ListRelatedCatalogProductsQueryHandler.cs | 56 ++ .../Features/Checkout/CheckoutCommand.cs | 57 ++ .../Checkout/CheckoutCommandHandler.cs | 197 ++++ .../Features/Checkout/CheckoutEndpoints.cs | 48 + .../Features/Customers/CustomerContracts.cs | 72 ++ .../Features/Customers/CustomerEndpoints.cs | 86 ++ .../Features/Customers/CustomerSortKeys.cs | 14 + .../Customers/GetCustomerQueryHandler.cs | 33 + .../ListCustomerOrdersQueryHandler.cs | 52 + .../Customers/ListCustomersQueryHandler.cs | 54 ++ .../FeatureFilterPreviewEndpoints.cs | 44 + .../Features/Lab/Endpoints/LabEndpoints.cs | 133 +++ .../Endpoints/MediatorDemoEndpoints.cs | 24 +- .../Queries/GetEchoStatusQuery.cs | 2 +- .../Orders/Commands/CreateOrderCommand.cs | 135 ++- .../Commands/CreateOrderCommandHandler.cs | 261 +++-- .../Orders/Commands/CreateOrderCommandVoid.cs | 55 -- .../Commands/CreateOrderCommandVoidHandler.cs | 60 -- .../Orders/Endpoints/OrderEndpoints.cs | 84 ++ .../Features/Orders/GetOrderQueryHandler.cs | 67 ++ .../IntegrationEventService.cs | 4 +- .../Features/Orders/ListOrdersQueryHandler.cs | 58 ++ .../Features/Orders/OrderContracts.cs | 61 ++ .../Features/Orders/OrderQueryEndpoints.cs | 59 ++ .../Features/Orders/OrderSortKeys.cs | 14 + .../Features/Orders/Types/Results.cs | 40 +- .../Features/Payments/IPaymentProcessor.cs | 39 + .../Endpoints/ProductPaginationEndpoints.cs | 117 ++- .../Features/Shipping/IShippingPolicy.cs | 30 + .../Features/Tax/ITaxCalculator.cs | 24 + .../Infrastructure/Caching/CacheKeyService.cs | 4 +- .../Caching/IStaticCacheManager.cs | 10 +- .../Caching/MemoryCacheManager.cs | 32 +- .../Caching/RedisCacheManager.cs | 8 +- .../Caching/RedisConnectionWrapper.cs | 18 +- .../Infrastructure/Caching/RedisOptions.cs | 2 +- .../Dapper/CatalogDapperTypeHandlers.cs | 56 ++ .../DbContext/CatalogDContextSeed.cs | 46 +- .../DbContext/CatalogDbContext.cs | 38 +- .../BrandEntityTypeConfiguration.cs | 26 + .../CartEntityTypeConfiguration.cs | 42 + .../CartItemEntityTypeConfiguration.cs | 30 + .../CategoryEntityTypeConfiguration.cs | 25 + .../CustomerEntityTypeConfiguration.cs | 33 + .../IntentTicketEntityTypeConfiguration.cs | 45 + .../OrderEntityTypeConfiguration.cs | 69 ++ .../OrderItemEntityTypeConfiguration.cs | 37 + .../PaymentRecordEntityTypeConfiguration.cs | 28 + .../ProductEntityTypeConfiguration.cs | 117 ++- .../ProductImageEntityTypeConfiguration.cs | 23 + ...uctSpecificationEntityTypeConfiguration.cs | 23 + .../Extensions/ApiVersioningExtensions.cs | 16 + .../Extensions/BuilderExtensions.cs | 39 +- .../Extensions/MigrateDbContextExtensions.cs | 4 +- .../Extensions/ProductExtension.cs | 4 +- .../Infrastructure/Filters/Evaluation.cs | 5 +- .../Filters/ValidationFilter.cs | 2 +- .../Middleware/MiddlewareCache.cs | 2 +- ...502133232_CatalogInitMigration.Designer.cs | 4 +- ...4205214_DemoCommerceFoundation.Designer.cs | 584 ++++++++++++ .../20260904205214_DemoCommerceFoundation.cs | 312 ++++++ .../20260904220000_IntentTickets.cs | 56 ++ ...260904222603_CatalogStorefront.Designer.cs | 711 ++++++++++++++ .../20260904222603_CatalogStorefront.cs | 286 ++++++ ...908091824_DemoCommerceCheckout.Designer.cs | 891 ++++++++++++++++++ .../20260908091824_DemoCommerceCheckout.cs | 234 +++++ ...ductOriginalVersionConcurrency.Designer.cs | 889 +++++++++++++++++ ...92920_ProductOriginalVersionConcurrency.cs | 41 + .../CatalogDbContextModelSnapshot.cs | 626 +++++++++++- .../Pagination/ProductSortKeys.cs | 22 +- .../Seeding/DemoCommerceSeed.cs | 424 +++++++++ .../Swagger/SwaggerTagDocumentFilter.cs | 34 + .../ValidatorProvider/IValidatorProvider.cs | 4 +- .../ValidatorProvider/ValidatorProvider.cs | 15 +- src/Lab/FeatureFusion/Program.cs | 52 +- .../Services/Authentication/AuthService.cs | 2 +- .../Services/Product/ProductService.cs | 32 +- .../appsettings.Development.json | 4 + src/src.sln | 144 +++ .../BuildingBlocks.Domain.Tests.csproj | 24 + .../Domain.Tests/DomainTests.cs | 117 +++ .../Mcp.Analyzers.Tests/AnalyzerTestHelper.cs | 4 +- .../AnalyzerTestHelper.cs | 4 +- ...EntityFrameworkCore.SqlServer.Tests.csproj | 2 +- ...agination.EntityFrameworkCore.Tests.csproj | 12 +- .../CreateOrderAdmissionPersistenceTests.cs | 120 +++ .../Admission/OrderCreateAdmissionTests.cs | 408 ++++++++ .../Api/CartCheckoutApiTests.cs | 358 +++++++ .../CatalogProductHttpMcpConvergenceTests.cs | 105 +++ .../Api/CatalogStorefrontTests.cs | 219 +++++ .../Api/CreateOrderApiTests.cs | 299 ++++++ .../Api/CreateOrderOutboxTests.cs | 56 ++ .../IntegrationTests/Api/CustomersApiTests.cs | 138 +++ .../Api/FeatureFusionApiTests.cs | 63 +- .../Api/FeatureFusionMcpTests.cs | 16 +- .../Api/FeatureFusionTraceEvidenceTests.cs | 6 +- .../Api/MediatorDemoApiTests.cs | 12 +- .../IntegrationTests/Api/OrdersApiTests.cs | 150 +++ .../IntegrationTests/Aspire/AspireFixture.cs | 73 +- .../DemoCommerceFoundationTests.cs | 128 +++ .../DemoCommerce/OrderAggregateTests.cs | 123 +++ .../EventBus/RabbitMQEventBusTests.cs | 6 +- .../AsyncTraceCorrelationExperimentTests.cs | 2 +- .../CacheVsProductionExperimentTests.cs | 2 +- .../DuplicateDeliveryExperimentTests.cs | 2 +- ...ntBusObservationBaselineExperimentTests.cs | 4 +- .../EventBusPublishCrashExperimentTests.cs | 65 +- ...empotencyProcessingLeaseExperimentTests.cs | 3 +- .../MafMcpPrototype/MafMcpPrototypeTests.cs | 2 +- .../McpAgentKeyRegenerationExperimentTests.cs | 26 +- .../McpConcurrentSameKeyExperimentTests.cs | 2 +- .../McpOrderOutboxExperimentTests.cs | 24 +- .../McpToolStormRateLimitExperimentTests.cs | 2 +- .../OutboxDeliveryExperimentTests.cs | 18 +- .../OutboxLifecycleExperimentTests.cs | 15 +- .../CarelessPaginationClient.cs | 2 +- .../PaginationAbuseExperimentTests.cs | 2 +- ...ssedMessageDeduplicationExperimentTests.cs | 2 +- .../IntegrationTests/Experiments/README.md | 16 +- .../Collections/ThreadSafeList.cs | 53 ++ .../EventBusLab/EventBusLabHook.cs | 78 +- .../Infrastructure/Mcp/McpToolSpans.cs | 25 + .../Orders/CreatedOrderCleanup.cs | 33 + .../Infrastructure/Orders/HttpOrderCreate.cs | 2 +- .../Telemetry/CapturedActivity.cs | 2 + web/README.md | 2 +- 246 files changed, 14241 insertions(+), 1241 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 docs/lab/capability-admission.md create mode 100644 src/BuildingBlocks/Domain.EntityFrameworkCore/AGENTS.md create mode 100644 src/BuildingBlocks/Domain.EntityFrameworkCore/BuildingBlocks.Domain.EntityFrameworkCore.csproj create mode 100644 src/BuildingBlocks/Domain.EntityFrameworkCore/IdentityValueConverter.cs create mode 100644 src/BuildingBlocks/Domain.EntityFrameworkCore/PACKAGE_README.md create mode 100644 src/BuildingBlocks/Domain.EntityFrameworkCore/PropertyBuilderExtensions.cs create mode 100644 src/BuildingBlocks/Domain.EntityFrameworkCore/ValueObjectValueConverter.cs create mode 100644 src/BuildingBlocks/Domain/AGENTS.md create mode 100644 src/BuildingBlocks/Domain/AggregateId.cs create mode 100644 src/BuildingBlocks/Domain/AggregateRoot.cs create mode 100644 src/BuildingBlocks/Domain/BuildingBlocks.Domain.csproj create mode 100644 src/BuildingBlocks/Domain/BusinessRuleValidationException.cs create mode 100644 src/BuildingBlocks/Domain/DomainEvent.cs create mode 100644 src/BuildingBlocks/Domain/DomainException.cs create mode 100644 src/BuildingBlocks/Domain/Entity.cs create mode 100644 src/BuildingBlocks/Domain/EntityId.cs create mode 100644 src/BuildingBlocks/Domain/Enumeration.cs create mode 100644 src/BuildingBlocks/Domain/IAggregate.cs create mode 100644 src/BuildingBlocks/Domain/IBusinessRule.cs create mode 100644 src/BuildingBlocks/Domain/IDomainEvent.cs create mode 100644 src/BuildingBlocks/Domain/IEntity.cs create mode 100644 src/BuildingBlocks/Domain/IHaveAggregate.cs create mode 100644 src/BuildingBlocks/Domain/IHaveAggregateVersion.cs create mode 100644 src/BuildingBlocks/Domain/IHaveAudit.cs create mode 100644 src/BuildingBlocks/Domain/IHaveIdentity.cs create mode 100644 src/BuildingBlocks/Domain/IHaveSoftDelete.cs create mode 100644 src/BuildingBlocks/Domain/IIdentity.cs create mode 100644 src/BuildingBlocks/Domain/Identity.cs create mode 100644 src/BuildingBlocks/Domain/PACKAGE_README.md create mode 100644 src/BuildingBlocks/Domain/ValueObject.cs create mode 100644 src/BuildingBlocks/Idempotency/AspNetCore/IdempotencyRequestBufferingMiddleware.cs delete mode 100644 src/Lab/FeatureFusion/Apis/MinimalApiGreeting.cs delete mode 100644 src/Lab/FeatureFusion/Controllers/V1/Authentication.cs delete mode 100644 src/Lab/FeatureFusion/Controllers/V1/GreetingController.cs delete mode 100644 src/Lab/FeatureFusion/Controllers/V2/Authentication.cs delete mode 100644 src/Lab/FeatureFusion/Controllers/V2/GreetingController.cs delete mode 100644 src/Lab/FeatureFusion/Controllers/V2/OrderController.cs delete mode 100644 src/Lab/FeatureFusion/Controllers/V2/ProductController.cs create mode 100644 src/Lab/FeatureFusion/Domain/Carts/Cart.cs create mode 100644 src/Lab/FeatureFusion/Domain/Carts/CartId.cs create mode 100644 src/Lab/FeatureFusion/Domain/Carts/CartItem.cs create mode 100644 src/Lab/FeatureFusion/Domain/Carts/CartItemId.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/Brand.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/BrandId.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/Category.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/CategoryId.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/Product.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/ProductChildIds.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/ProductId.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/ProductImage.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/ProductSpecification.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/Sku.cs create mode 100644 src/Lab/FeatureFusion/Domain/Catalog/Slug.cs create mode 100644 src/Lab/FeatureFusion/Domain/Customers/Customer.cs create mode 100644 src/Lab/FeatureFusion/Domain/Customers/CustomerId.cs create mode 100644 src/Lab/FeatureFusion/Domain/Customers/Email.cs delete mode 100644 src/Lab/FeatureFusion/Domain/Entities/Product.cs create mode 100644 src/Lab/FeatureFusion/Domain/Orders/Order.cs create mode 100644 src/Lab/FeatureFusion/Domain/Orders/OrderId.cs create mode 100644 src/Lab/FeatureFusion/Domain/Orders/OrderItem.cs create mode 100644 src/Lab/FeatureFusion/Domain/Orders/OrderItemId.cs create mode 100644 src/Lab/FeatureFusion/Domain/Orders/OrderNumber.cs create mode 100644 src/Lab/FeatureFusion/Domain/Orders/OrderShipping.cs create mode 100644 src/Lab/FeatureFusion/Domain/Orders/OrderStatus.cs create mode 100644 src/Lab/FeatureFusion/Domain/Orders/ShippingStatus.cs create mode 100644 src/Lab/FeatureFusion/Domain/Payments/PaymentRecord.cs delete mode 100644 src/Lab/FeatureFusion/Dtos/Validator/OrderRequestValidator.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/AdmissionDecision.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/CapabilityAdmissionOptions.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/CapabilityAdmissionService.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/CapabilityIds.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/CreateOrderCapabilityExecutor.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/Endpoints/AdmissionEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/ICapabilityAdmission.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/IntentTicket.cs create mode 100644 src/Lab/FeatureFusion/Features/Admission/OrderCreateAdmissionGate.cs create mode 100644 src/Lab/FeatureFusion/Features/Auth/Endpoints/AuthEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Carts/CartContracts.cs create mode 100644 src/Lab/FeatureFusion/Features/Carts/CartEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Carts/CartHandlers.cs create mode 100644 src/Lab/FeatureFusion/Features/Carts/CartValidators.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/CatalogContracts.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/CatalogEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/CatalogProjections.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/CatalogSlug.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/GetCatalogProductBySlugQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/ListCatalogBrandsQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/ListCatalogCategoriesQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/ListCatalogProductsQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Catalog/ListRelatedCatalogProductsQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Checkout/CheckoutCommand.cs create mode 100644 src/Lab/FeatureFusion/Features/Checkout/CheckoutCommandHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Checkout/CheckoutEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Customers/CustomerContracts.cs create mode 100644 src/Lab/FeatureFusion/Features/Customers/CustomerEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Customers/CustomerSortKeys.cs create mode 100644 src/Lab/FeatureFusion/Features/Customers/GetCustomerQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Customers/ListCustomerOrdersQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Customers/ListCustomersQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Lab/Endpoints/FeatureFilterPreviewEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Lab/Endpoints/LabEndpoints.cs delete mode 100644 src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoid.cs delete mode 100644 src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoidHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Orders/Endpoints/OrderEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Orders/GetOrderQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Orders/ListOrdersQueryHandler.cs create mode 100644 src/Lab/FeatureFusion/Features/Orders/OrderContracts.cs create mode 100644 src/Lab/FeatureFusion/Features/Orders/OrderQueryEndpoints.cs create mode 100644 src/Lab/FeatureFusion/Features/Orders/OrderSortKeys.cs create mode 100644 src/Lab/FeatureFusion/Features/Payments/IPaymentProcessor.cs create mode 100644 src/Lab/FeatureFusion/Features/Shipping/IShippingPolicy.cs create mode 100644 src/Lab/FeatureFusion/Features/Tax/ITaxCalculator.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Dapper/CatalogDapperTypeHandlers.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/BrandEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CartEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CartItemEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CategoryEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CustomerEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/IntentTicketEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/OrderEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/OrderItemEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/PaymentRecordEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductImageEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductSpecificationEntityTypeConfiguration.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Extensions/ApiVersioningExtensions.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260904205214_DemoCommerceFoundation.Designer.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260904205214_DemoCommerceFoundation.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260904220000_IntentTickets.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260904222603_CatalogStorefront.Designer.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260904222603_CatalogStorefront.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260908091824_DemoCommerceCheckout.Designer.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260908091824_DemoCommerceCheckout.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260908092920_ProductOriginalVersionConcurrency.Designer.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Migrations/20260908092920_ProductOriginalVersionConcurrency.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Seeding/DemoCommerceSeed.cs create mode 100644 src/Lab/FeatureFusion/Infrastructure/Swagger/SwaggerTagDocumentFilter.cs create mode 100644 src/src.sln create mode 100644 tests/BuildingBlocks/Domain.Tests/BuildingBlocks.Domain.Tests.csproj create mode 100644 tests/BuildingBlocks/Domain.Tests/DomainTests.cs create mode 100644 tests/Lab/IntegrationTests/Admission/CreateOrderAdmissionPersistenceTests.cs create mode 100644 tests/Lab/IntegrationTests/Admission/OrderCreateAdmissionTests.cs create mode 100644 tests/Lab/IntegrationTests/Api/CartCheckoutApiTests.cs create mode 100644 tests/Lab/IntegrationTests/Api/CatalogProductHttpMcpConvergenceTests.cs create mode 100644 tests/Lab/IntegrationTests/Api/CatalogStorefrontTests.cs create mode 100644 tests/Lab/IntegrationTests/Api/CreateOrderApiTests.cs create mode 100644 tests/Lab/IntegrationTests/Api/CreateOrderOutboxTests.cs create mode 100644 tests/Lab/IntegrationTests/Api/CustomersApiTests.cs create mode 100644 tests/Lab/IntegrationTests/Api/OrdersApiTests.cs create mode 100644 tests/Lab/IntegrationTests/DemoCommerce/DemoCommerceFoundationTests.cs create mode 100644 tests/Lab/IntegrationTests/DemoCommerce/OrderAggregateTests.cs create mode 100644 tests/Lab/IntegrationTests/Infrastructure/Collections/ThreadSafeList.cs create mode 100644 tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolSpans.cs create mode 100644 tests/Lab/IntegrationTests/Infrastructure/Orders/CreatedOrderCleanup.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..bc574d7 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.10", + "commands": [ + "dotnet-ef" + ] + } + } +} diff --git a/.gitignore b/.gitignore index 416b49b..30681b6 100644 --- a/.gitignore +++ b/.gitignore @@ -193,9 +193,6 @@ docs/** !docs/lab/ !docs/lab/** -# Local-only polluted/autogen solutions -src/src.sln - # Local pack / smoke artifacts artifacts/ *.nupkg diff --git a/AGENTS.md b/AGENTS.md index 0740320..e8e012b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ Public **.NET lab** plus extracted MIT **BuildingBlocks**. The application is the laboratory. A package is a reusable result that earned a boundary. +Open **`FeatureFusion.sln`** from the repository root. It is the single authoritative solution (Domain projects included). Do not recreate an inner `src/src.sln`. + Do not treat FeatureFusion as a framework. Install a BuildingBlock only when it solves a problem the host actually has. ## Lab vs package @@ -10,7 +12,7 @@ Do not treat FeatureFusion as a framework. Install a BuildingBlock only when it |------|------------|------------| | **Lab** | Runnable API + Aspire AppHost (`src/Lab/`) | This file + [docs/lab](docs/lab/README.md) + root README | | **Packable BuildingBlock** | NuGet on nuget.org | `src/BuildingBlocks//AGENTS.md` + `PACKAGE_README.md` | -| **In-repo sibling** | Pagination IR / Dapper — not packed | `Pagination/AGENTS.md`, `Pagination.Dapper/AGENTS.md` | +| **In-repo sibling** | Pagination IR / Dapper; Domain + Domain.EF (not packed yet) | `Pagination/AGENTS.md`, `Pagination.Dapper/AGENTS.md`, [Domain](src/BuildingBlocks/Domain/AGENTS.md), [Domain.EF](src/BuildingBlocks/Domain.EntityFrameworkCore/AGENTS.md) | | **Lab-only** | EventBus, YARP rate limit, feature flags | Code + [linkedin catalog](docs/linkedin-posts.md) — no package `AGENTS.md` | **Lab charter (evidence families, extraction rules, roadmap):** [docs/lab/README.md](docs/lab/README.md). Behavioral experiment catalog: [Experiments README](tests/Lab/IntegrationTests/Experiments/README.md) (Exp 1–18 + MAF prototype). @@ -28,6 +30,8 @@ Do not treat FeatureFusion as a framework. Install a BuildingBlock only when it Pagination IR (`BuildingBlocks.Pagination`) is bundled into the EF Core nupkg. Do not pack it. Dapper pagination is a lab project. +**In-repo (not nuget.org):** `BuildingBlocks.Domain` and `BuildingBlocks.Domain.EntityFrameworkCore` — project-reference only. Do not pack or publish them. + ## Human docs - [README](README.md) — lab + package overview diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 257fa1e..ed361b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,10 @@ Formerly published as `Maxofpower/FeatureManagement`; GitHub redirects the old U ## Development -1. Clone [FeatureFusion](https://github.com/Maxofpower/FeatureFusion). +1. Clone [FeatureFusion](https://github.com/Maxofpower/FeatureFusion) and open **`FeatureFusion.sln`** (the only solution). 2. `dotnet restore FeatureFusion.sln` 3. Package tests (multi-TFM where applicable): + - `dotnet test tests/BuildingBlocks/Domain.Tests` - `dotnet test tests/BuildingBlocks/Mediator.Tests` - `dotnet test tests/BuildingBlocks/Mediator.Analyzers.Tests` - `dotnet test tests/BuildingBlocks/Mcp.Tests` diff --git a/FeatureFusion.sln b/FeatureFusion.sln index 2058870..4b8f81a 100644 --- a/FeatureFusion.sln +++ b/FeatureFusion.sln @@ -105,6 +105,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Idempotency.Tests", "Idempo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Idempotency.Tests", "tests\BuildingBlocks\Idempotency.Tests\BuildingBlocks.Idempotency.Tests.csproj", "{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Domain", "src\BuildingBlocks\Domain\BuildingBlocks.Domain.csproj", "{DBD6F42C-E841-46F5-AEA0-487310C1AFE7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Domain.EntityFrameworkCore", "src\BuildingBlocks\Domain.EntityFrameworkCore\BuildingBlocks.Domain.EntityFrameworkCore.csproj", "{D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Domain.Tests", "tests\BuildingBlocks\Domain.Tests\BuildingBlocks.Domain.Tests.csproj", "{E09652D8-319A-498A-978A-308D55E08ECB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -511,6 +517,42 @@ Global {2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|x64.Build.0 = Release|Any CPU {2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|x86.ActiveCfg = Release|Any CPU {2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|x86.Build.0 = Release|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Debug|x64.Build.0 = Debug|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Debug|x86.Build.0 = Debug|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Release|Any CPU.Build.0 = Release|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Release|x64.ActiveCfg = Release|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Release|x64.Build.0 = Release|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Release|x86.ActiveCfg = Release|Any CPU + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7}.Release|x86.Build.0 = Release|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Debug|x64.ActiveCfg = Debug|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Debug|x64.Build.0 = Debug|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Debug|x86.ActiveCfg = Debug|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Debug|x86.Build.0 = Debug|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Release|Any CPU.Build.0 = Release|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Release|x64.ActiveCfg = Release|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Release|x64.Build.0 = Release|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Release|x86.ActiveCfg = Release|Any CPU + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE}.Release|x86.Build.0 = Release|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Debug|x64.ActiveCfg = Debug|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Debug|x64.Build.0 = Debug|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Debug|x86.ActiveCfg = Debug|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Debug|x86.Build.0 = Debug|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Release|Any CPU.Build.0 = Release|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Release|x64.ActiveCfg = Release|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Release|x64.Build.0 = Release|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Release|x86.ActiveCfg = Release|Any CPU + {E09652D8-319A-498A-978A-308D55E08ECB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -564,5 +606,8 @@ Global {C0B899A7-8C72-4D54-8A9A-E5C65A43F108} = {3B6DFA28-E423-47BD-1DC7-709D0430AE1C} {E409DBCD-B9E3-14BD-B95F-04CE755E3F82} = {8A1B2C3D-4E5F-6789-ABCD-333333333333} {2DBA7F51-AC3B-4464-8348-0374FD62FDA0} = {E409DBCD-B9E3-14BD-B95F-04CE755E3F82} + {DBD6F42C-E841-46F5-AEA0-487310C1AFE7} = {8A1B2C3D-4E5F-6789-ABCD-111111111111} + {D77790E8-FD2E-4DDE-AF8F-8FF3A0ABACDE} = {8A1B2C3D-4E5F-6789-ABCD-111111111111} + {E09652D8-319A-498A-978A-308D55E08ECB} = {8A1B2C3D-4E5F-6789-ABCD-333333333333} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 8d602d2..49aa1be 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ app.MapPost("/orders", CreateAsync).WithIdempotency(useLock: true); - Package README: [`src/BuildingBlocks/Idempotency/PACKAGE_README.md`](src/BuildingBlocks/Idempotency/PACKAGE_README.md) · agent notes: [`AGENTS.md`](src/BuildingBlocks/Idempotency/AGENTS.md) - Docs: [`docs/building-blocks/idempotency.md`](docs/building-blocks/idempotency.md) -- Lab: MVC `POST /api/v2/Order/order`, Minimal API smoke `POST /api/v2/idempotency-smoke`. Provenance: Experiments **3**, **4**, **12** ([catalog](tests/Lab/IntegrationTests/Experiments/README.md)). +- Lab: Minimal API `POST /api/v1/Order/order` (WithIdempotency), smoke `POST /api/v1/idempotency-smoke`. Provenance: Experiments **3**, **4**, **12** ([catalog](tests/Lab/IntegrationTests/Experiments/README.md)). --- @@ -451,7 +451,7 @@ dotnet run -c Release --project benchmarks/BuildingBlocks/Pagination.EntityFrame - Package README: [`Pagination.EntityFrameworkCore`](src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md) (includes the table) - Docs: [`docs/building-blocks/pagination.md`](docs/building-blocks/pagination.md) · ADR [`0003`](docs/adr/0003-pagination-keyset.md) · [test matrix](docs/building-blocks/PAGINATION_TEST_MATRIX.md) -- Lab: FeatureFusion PostgreSQL catalog — `GET /api/v2/products-page` (Minimal API EF; POST kept) · `POST /api/v2/Product/products` (MVC EF) · `POST /api/v2/Product/products-dapper` (Dapper **project** showcase) · MCP `products.list` — same `GetProductsQuery`. See [Pagination showcase](#pagination-showcase). +- Lab: FeatureFusion PostgreSQL catalog — `GET /api/v1/products-page` (Minimal API EF; POST kept) · `POST /api/v1/Product/products` (Minimal API EF) · `POST /api/v1/Product/products-dapper` (Dapper **project** showcase) · MCP `products.list` — same `GetProductsQuery`. See [Pagination showcase](#pagination-showcase). - Catalog: `docs/linkedin-posts.md` → `cursor-pagination` --- @@ -718,12 +718,12 @@ Install the packages above in your own hosts, **or** clone this repo and run **F | Telemetry | **`BuildingBlocks.Telemetry`** in ServiceDefaults; **`BuildingBlocks.Aspire.Hosting.SigNoz`** on AppHost | | Event bus | RabbitMQ + transactional outbox/inbox, DLQ, dedup hooks | | Aspire lab | AppHost orchestration for Postgres, Redis, RabbitMQ, Memcached, SigNoz | -| HTTP idempotency | **`BuildingBlocks.Idempotency` 1.0.1** — MVC + Minimal API, 2xx envelope replay, System.Text.Json, optional Redis lock (`POST /api/v2/Order/order`) | +| HTTP idempotency | **`BuildingBlocks.Idempotency` 1.0.1** — MVC + Minimal API, 2xx envelope replay, System.Text.Json, optional Redis lock (`POST /api/v1/Order/order`) | | Feature flags (demo) | ASP.NET Core Feature Management + custom filters (claims / VIP) | | API surface | Versioned controllers + Minimal APIs, FluentValidation patterns | | Gateway | YARP reverse proxy + Memcached distributed rate limiting | | Caching | Redis / Memcached / memory managers + middleware demos | -| Pagination | **`BuildingBlocks.Pagination.EntityFrameworkCore`** — PostgreSQL product catalog via `GET /api/v2/products-page` (same query on MVC, Dapper, MCP); Dapper is in-repo only | +| Pagination | **`BuildingBlocks.Pagination.EntityFrameworkCore`** — PostgreSQL product catalog via `GET /api/v1/products-page` (same query on Product/products, Dapper, MCP); Dapper is in-repo only | | Design patterns | Mediator, Decorator, CoR, Strategy, and more — see below | Also in the lab: app/DB initializers, middleware dynamic caching, Aspire AppHost integration tests, and performance-minded practices (OTel hooks, resilience). @@ -736,22 +736,22 @@ One `GetProductsQuery` drives: | Surface | Endpoint | |---------|----------| -| Minimal API (EF) | **`GET /api/v2/products-page`** (POST kept for compatibility) | -| MVC (EF) | `POST /api/v2/Product/products` | -| Dapper | `POST /api/v2/Product/products-dapper` | +| Minimal API (EF) | **`GET /api/v1/products-page`** (POST kept for compatibility) | +| Minimal API (EF) | `POST /api/v1/Product/products` | +| Dapper | `POST /api/v1/Product/products-dapper` | | MCP | `products.list` | What that path demonstrates: typed `SortKey` / `SortKeyRegistry`, composite keyset order (Price + Id, Name + Id, CreatedAt + Id), unique Id tie-breaker, forward and backward cursors, first-page `TotalCount`, `CancellationToken`, `HasKeysetIndex`, EF Core SQL projection, and the in-repo Dapper adapter. Query names are case-insensitive (`limit` / `Limit`). `sortBy`: `Id` · `Name` · `Price` · `CreatedAt`. `sortDirection`: `Ascending` · `Descending`. Empty cursor + `pageDirection=Backward` is the last page. **Cursors are opaque** — pass `NextCursor` / `PreviousCursor` back unchanged; do not construct them. FeatureFusion is PostgreSQL: `QueryHint` stays `None`. ```http -GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending +GET /api/v1/products-page?limit=20&sortBy=Price&sortDirection=Ascending ``` Response includes `items`, `hasMore`, `nextCursor`, `previousCursor`, `hasPrevious`, and `totalCount` on this first page. Then: ```http -GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= -GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= +GET /api/v1/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= +GET /api/v1/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= ``` Swagger: `http://localhost:5141/swagger`. Details: [`docs/building-blocks/pagination.md`](docs/building-blocks/pagination.md) · [NuGet](https://www.nuget.org/packages/BuildingBlocks.Pagination.EntityFrameworkCore). @@ -803,9 +803,11 @@ flowchart LR ## Repository layout ```text -FeatureFusion.sln # .NET only +FeatureFusion.sln # canonical solution — open from the repo root src/ # C# only BuildingBlocks/ + Domain/ # in-repo (not packed) + Domain.EntityFrameworkCore/ # in-repo (not packed) Mediator/ # CQRS Send + pipeline NuGet Mediator.Analyzers/ Mcp/ # [McpTool] / MapTool → MCP tools NuGet @@ -816,7 +818,7 @@ src/ # C# only Telemetry/ # Config-driven OpenTelemetry NuGet Aspire.Hosting.SigNoz/ # AddSigNoz() Aspire hosting NuGet Lab/ - FeatureFusion/ # Web API showcase (Features/, Infrastructure/, Controllers, Minimal APIs) + FeatureFusion/ # Web API showcase (Domain/, Features/, Infrastructure/, Minimal APIs) FeatureFusion.ApiGateway/ # YARP + Memcached rate limiter FeatureFusion.AppHost/ # Aspire AppHost (+ SigNoz stack) FeatureFusion.ServiceDefaults/ @@ -824,6 +826,7 @@ src/ # C# only web/ # reserved Next.js showcase (README only; not in the .sln) tests/ BuildingBlocks/ + Domain.Tests/ Mediator.Tests/ Mediator.Analyzers.Tests/ Mcp.Tests/ @@ -939,18 +942,18 @@ docker compose up -d ### Feature management filters -Conditional features via Microsoft.FeatureManagement and custom filters (e.g. VIP claims). Versioned controllers and Minimal APIs under `/api/v1|v2/...`. +Conditional features via Microsoft.FeatureManagement and custom filters (e.g. VIP claims). Single Asp.Versioning version under `/api/v1/...`. Feature filter preview: `GET /api/v1/lab/feature-filter-preview`. ### HTTP idempotency (BuildingBlocks.Idempotency) -REST idempotency with `IDistributedCache` status tracking, MVC `[Idempotent]` / Minimal API `WithIdempotency`, optional Redis lock, and **System.Text.Json** cache/body serialization (`POST /api/v2/Order/order`, smoke `POST /api/v2/idempotency-smoke`). Package **1.0.1**. See [BuildingBlocks.Idempotency](#buildingblocksidempotency). +REST idempotency with `IDistributedCache` status tracking, Minimal API `WithIdempotency`, optional Redis lock, and **System.Text.Json** cache/body serialization (`POST /api/v1/Order/order`, smoke `POST /api/v1/idempotency-smoke`). Package **1.0.1** still supports MVC `[Idempotent]`. See [BuildingBlocks.Idempotency](#buildingblocksidempotency). - [Idempotency with CQRS](https://www.linkedin.com/feed/update/urn:li:activity:7303686809891356676/) - [IdempotentFusion project](https://www.linkedin.com/feed/update/urn:li:activity:7309149985307029504/) (historical Lab name) ### API versioning & validation -Controllers + Minimal API groups; FluentValidation via controllers, generic endpoint filters, and `WithValidation` / `MapPostWithValidation`. +Single Asp.Versioning version (`1.0`, URL `/api/v1/...`). Minimal API groups; FluentValidation via generic endpoint filters and `WithValidation` / `MapPostWithValidation`. ### Caching, middleware & pagination @@ -958,7 +961,7 @@ Redis / Memcached / memory managers, feature-flagged recommendation cache middle ### Generic bidirectional cursor (keyset) pagination -See [Pagination showcase](#pagination-showcase) for the FeatureFusion catalog (`GET /api/v2/products-page`). Package API, QueryHint, and SQLite probe numbers: [`PACKAGE_README`](src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md). +See [Pagination showcase](#pagination-showcase) for the FeatureFusion catalog (`GET /api/v1/products-page`). Package API, QueryHint, and SQLite probe numbers: [`PACKAGE_README`](src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md). - Indexes: `(Price, Id)`, `(CreatedAt, Id)`, `(Name, Id)` on `products` (ASC and DESC variants) - LinkedIn: [Reusable Cursor (keyset) Pagination](https://www.linkedin.com/feed/update/urn:li:activity:7325068550614708225/) @@ -977,7 +980,7 @@ See [Pagination showcase](#pagination-showcase) for the FeatureFusion catalog (` | **Factory** | Resilience / connection helpers; gateway Memcached factory | | **Repository / DbContext** | EF Core `CatalogDbContext` + feature handlers | | **Unit of work** | `ResilientTransaction` spanning business write + outbox | -| **Strategy** | Feature filters & validation styles (controller vs Minimal API) | +| **Strategy** | Feature filters & validation styles (endpoint filter vs ValidationBehavior) | | **Template method** | `BaseValidator.PostInitialize` | | **Keyset pagination** | `BuildingBlocks.Pagination.EntityFrameworkCore` — typed bidirectional cursors | | **Chain of Responsibility** | Feature toggle rule evaluation; mediator pipeline chain | diff --git a/benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks/BuildingBlocks.Pagination.EntityFrameworkCore.Benchmarks.csproj b/benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks/BuildingBlocks.Pagination.EntityFrameworkCore.Benchmarks.csproj index ce630c0..ca0f915 100644 --- a/benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks/BuildingBlocks.Pagination.EntityFrameworkCore.Benchmarks.csproj +++ b/benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks/BuildingBlocks.Pagination.EntityFrameworkCore.Benchmarks.csproj @@ -11,7 +11,7 @@ - + diff --git a/docs/adr/0003-pagination-keyset.md b/docs/adr/0003-pagination-keyset.md index b3d65f1..38714e3 100644 --- a/docs/adr/0003-pagination-keyset.md +++ b/docs/adr/0003-pagination-keyset.md @@ -12,7 +12,7 @@ 3. Shared **seek IR** in the core project: ordered slots `{ Direction, ClrType, Value, SqlIdentifier?, Expression }`. The EF package executes; core does not reference EF. 4. EF project layout follows **EF Core package structure**: `Extensions/` (public `ToCursorPageAsync` / `HasKeysetIndex`), `Query/Internal/` (OrderBy + seek trees), `Infrastructure/Internal/` (DbContext / shadow / NULLS interceptor / soft Npgsql `HasNullSortOrder`). Mapped members use expressions; shadow uses typed `ByShadow` → `EF.Property`. 5. Cursors are opaque and versioned. No `pageIndex`. HMAC is optional in the API and **required for untrusted HTTP** (`SigningKey`). Slot decode failures are `InvalidCursor`. Unregistered `SortKeyRegistry.Get` is `InvalidOperationException`, not a bad cursor. -6. FeatureFusion is the showcase (Minimal API `GET /api/v2/products-page`, MVC `POST /api/v2/Product/products`, Dapper `products-dapper`, MCP `products.list` — one `GetProductsQuery`). No extra sample apps. Benchmarks: `benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks` (file SQLite: FeatureFusion vs OFFSET vs MR.EntityFrameworkCore.KeysetPagination; cursor codec separate). Publish Default-job or `--probe` only — never Dry. +6. FeatureFusion is the showcase (Minimal API `GET /api/v1/products-page`, `POST /api/v1/Product/products`, Dapper `products-dapper`, MCP `products.list` — one `GetProductsQuery`). No extra sample apps. Benchmarks: `benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks` (file SQLite: FeatureFusion vs OFFSET vs MR.EntityFrameworkCore.KeysetPagination; cursor codec separate). Publish Default-job or `--probe` only — never Dry. 7. **Query hints are optional and allowlisted.** `PaginationOptions.Hint` defaults to `QueryHint.None` (zero extra SQL). `QueryHint.ReadUncommitted` is SQL Server session isolation (`READ UNCOMMITTED`), not table-hint `WITH (NOLOCK)`. EF begins one transaction around COUNT (if requested) and PAGE when there is no ambient transaction, then restores `READ COMMITTED` on the still-open connection; an ambient transaction is ignored (no nest). Dapper prefixes `SET TRANSACTION ISOLATION LEVEL` and restores `READ COMMITTED`. PostgreSQL/Sqlite ignore it. No raw SQL strings (injection). Host `AsNoTracking` / `TagWith` / Dapper `WITH (NOLOCK)` remain valid when `Hint` is omitted. Kitchen-sink lock/index hints stay host-owned. 8. **No `IEnumerable` adapter.** Host `OrderBy` is replaced by `CursorOrder`, not merged. Nullable value-type sort slots are rejected (`NullableSortUnsupported`). On PostgreSQL/Sqlite, `NullOrder` drives seek and `ORDER BY … NULLS FIRST/LAST` when the host registers `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination`. EF does **not** use `AsyncLocal`: `ToCursorPageAsync` tags the query `BuildingBlocks.Pagination:First|Last` (inverted on backward walks) and a `DbCommandInterceptor` rewrites only those tagged commands. Dapper emits NULLS without that registration. SQL Server does not emit `NULLS`. 9. **Npgsql row comparison** for uniform non-nullable multi-column keys of any width (soft dependency on the host’s Npgsql package; 9+ columns nest `ValueTuple` `TRest`). Mixed ASC/DESC and string slots keep the expanded OR seek. `HasKeysetIndex(sortKey, NullOrder)` optionally calls Npgsql `HasNullSortOrder`; the one-argument overload does not write that metadata. diff --git a/docs/building-blocks/PAGINATION_TEST_MATRIX.md b/docs/building-blocks/PAGINATION_TEST_MATRIX.md index 3884118..b6dc5ea 100644 --- a/docs/building-blocks/PAGINATION_TEST_MATRIX.md +++ b/docs/building-blocks/PAGINATION_TEST_MATRIX.md @@ -26,7 +26,7 @@ Each supported row has a matching test name prefix. | `Nullable_String_` / `AddBuildingBlocksPagination_` | EF Sqlite with `UseBuildingBlocksPagination`: `NullOrder.Last`/`First` change first-page order; without interceptor, Sqlite NULL-first remains. Seek aligns with NULLS semantics | | `Cancellation_After_` | EF interceptor cancels after `ReaderExecuting` (command started). Existing `Cancellation()` is pre-cancelled | | `QueryHint_` | Default `None` emits no SET/NOLOCK; Dapper `ReadUncommitted` prefixes `SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;` on SqlServer only (Postgres/Sqlite SQL unchanged), including COUNT-shaped SQL via `QueryHintSql.Apply`; restore `READ COMMITTED` is a separate execute (not in `BuildSql`); host `WITH (NOLOCK)` still inside the subquery; EF/Dapper Sqlite execute with `ReadUncommitted` is a no-op; **SQL Server execute** (Aspire, separate project): dirty read, COUNT+PAGE same isolation, commit, exception rollback, cancellation cleanup, ambient txn ignored; **same open connection** isolation is 1 during RU paging and 2 after success/exception/cancellation (pooling on); Dapper execute restore on open SqlConnection; CI (`GITHUB_ACTIONS` / `PAGINATION_SQLSERVER_REQUIRED`) never skips; IntegrationTests Postgres execute is a no-op | -| Lab HTTP | `FeatureFusionApiTests` — `GET /api/v2/products-page` first/next/prev; empty cursor + `pageDirection=Backward` last page; GET matches POST; every `ProductSortField` × direction (incl. `NameThenPrice`); Price next-page seek SQL is Npgsql row comparison; JSON `"SortBy":"Price"`; invalid cursor 400; first-page `TotalCount > limit`; EF list uses `AsNoTracking` + `TagWith("products.list")` + SQL `Select` to `ProductDto`; MVC `POST /api/v2/Product/products` and GET Minimal API return the same ids; Dapper first page matches EF; Swagger v2 documents GET | +| Lab HTTP | `FeatureFusionApiTests` — `GET /api/v1/products-page` first/next/prev; empty cursor + `pageDirection=Backward` last page; GET matches POST; every `ProductSortField` × direction (incl. `NameThenPrice`); Price next-page seek SQL is Npgsql row comparison; JSON `"SortBy":"Price"`; invalid cursor 400; first-page `TotalCount > limit`; EF list uses `AsNoTracking` + `TagWith("products.list")` + SQL `Select` to `ProductDto`; `POST /api/v1/Product/products` and GET Minimal API return the same ids; Dapper first page matches EF; Swagger v1 documents GET | ## Explicitly unsupported diff --git a/docs/building-blocks/idempotency.md b/docs/building-blocks/idempotency.md index 751d555..5a2b3d7 100644 --- a/docs/building-blocks/idempotency.md +++ b/docs/building-blocks/idempotency.md @@ -39,7 +39,8 @@ builder.Services.AddBuildingBlocksIdempotency(o => [Idempotent(useLock: true)] public async Task> Create([FromBody] CreateOrder request) { ... } -// Minimal API +// Minimal API — UseIdempotencyRequestBuffering() early so fingerprint can rewind after binding +app.UseIdempotencyRequestBuffering(); app.MapPost("/orders", CreateAsync).WithIdempotency(useLock: true); ``` @@ -60,9 +61,9 @@ Full tables (TTL, options, ProblemDetails `type` URIs): [PACKAGE_README](../../s | Surface | Path | |---------|------| -| MVC (locked) | `POST /api/v2/Order/order` — `[Idempotent(useLock: true)]` | -| Minimal API smoke | `POST /api/v2/idempotency-smoke` — `.WithIdempotency(useLock: true)` | -| DI | `AddBuildingBlocksIdempotency` → `.UseRedisLock().UseTelemetry()`; OTel `AddSource("BuildingBlocks.Idempotency")` | +| Minimal API (locked) | `POST /api/v1/Order/order` — `.WithIdempotency(useLock: true)` | +| Minimal API smoke | `POST /api/v1/idempotency-smoke` — `.WithIdempotency(useLock: true)` | +| DI | `AddBuildingBlocksIdempotency` → `.UseRedisLock().UseTelemetry()`; OTel `AddSource("BuildingBlocks.Idempotency")`; `UseIdempotencyRequestBuffering()` for Minimal API fingerprint | Behavioral provenance (regression gates): Experiments **3** (cache vs production), **4** (concurrency / lock), **12** (fingerprint) — [Experiments README](../../tests/Lab/IntegrationTests/Experiments/README.md). Exp **15** documents ProcessingTtl lease overlap. diff --git a/docs/building-blocks/mcp.md b/docs/building-blocks/mcp.md index f5025f8..b72d1a3 100644 --- a/docs/building-blocks/mcp.md +++ b/docs/building-blocks/mcp.md @@ -203,10 +203,10 @@ Development only (`http://localhost:5141/mcp`): | Tool | Style | HTTP analogue | |------|--------|----------------| -| `demo.echo` | Scan + `ISender`, `Idempotent = false` | `POST /api/v2/mediator-demo/echo` | -| `orders.create` | Scan + `ISender`, idempotent + confirmation | `POST /api/v2/order` | +| `demo.echo` | Scan + `ISender`, `Idempotent = false` | `POST /api/v1/mediator-demo/echo` | +| `orders.create` | Scan + `ISender`, idempotent + confirmation | `POST /api/v1/Order/order` | | `products.list` | Scan + `ISender` (query) | products query | -| `lab.ping` | `[McpTool]` + `.WithMcp(app)` on `LabPing` | `GET /api/v2/lab-ping` | +| `lab.ping` | `[McpTool]` + `.WithMcp(app)` on `LabPing` | `GET /api/v1/lab-ping` | Production (`docker-compose` sets `ASPNETCORE_ENVIRONMENT=Production`) does not register or map MCP. diff --git a/docs/building-blocks/pagination.md b/docs/building-blocks/pagination.md index 5d4b558..02ff7d8 100644 --- a/docs/building-blocks/pagination.md +++ b/docs/building-blocks/pagination.md @@ -56,7 +56,7 @@ Set `SigningKey` on public HTTP APIs so clients cannot forge `Walk` or key value ## Dapper (repo project only) -FeatureFusion `POST /api/v2/Product/products-dapper` (same catalog as `GET /api/v2/products-page`). Not published to NuGet. Host SQL must not contain `ORDER BY` / `OFFSET` / `LIMIT`. +FeatureFusion `POST /api/v1/Product/products-dapper` (same catalog as `GET /api/v1/products-page`). Not published to NuGet. Host SQL must not contain `ORDER BY` / `OFFSET` / `LIMIT`. ## Cursors @@ -64,30 +64,30 @@ FeatureFusion `POST /api/v2/Product/products-dapper` (same catalog as `GET /api/ ## Runnable showcase (FeatureFusion) -FeatureFusion is the integration lab: a PostgreSQL `products` catalog (~1000 seeded rows) using **one** `GetProductsQuery` / `ProductService` on MVC, Minimal API, Dapper, and MCP. +FeatureFusion is the integration lab: a PostgreSQL `products` catalog (~1000 seeded rows) using **one** `GetProductsQuery` / `ProductService` on Minimal API (`GET`/`POST /api/v1/products-page`, `POST /api/v1/Product/products`), Dapper (`products-dapper`), and MCP `products.list`. Storefront OFFSET listing is a separate slice: `GET /api/v1/catalog/products` / MCP `catalog.products.list`. Primary HTTP surface: ```http -GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending +GET /api/v1/products-page?limit=20&sortBy=Price&sortDirection=Ascending ``` First page returns `items`, `hasMore`, `nextCursor`, `previousCursor`, `hasPrevious`, and `totalCount`. Cursors are **opaque** — pass `nextCursor` or `previousCursor` back unchanged. ```http -GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= -GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= +GET /api/v1/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= +GET /api/v1/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= ``` -Empty cursor + `pageDirection=Backward` starts at the last page (`GET /api/v2/products-page?limit=20&pageDirection=Backward`). `sortBy`: `Id` · `Name` · `Price` · `CreatedAt` · `NameThenPrice` (each composite key ends with unique `Id`). `sortDirection`: `Ascending` · `Descending`. +Empty cursor + `pageDirection=Backward` starts at the last page (`GET /api/v1/products-page?limit=20&pageDirection=Backward`). `sortBy`: `Id` · `Name` · `Price` · `CreatedAt` · `NameThenPrice` (each composite key ends with unique `Id`). `sortDirection`: `Ascending` · `Descending`. ![First page, next cursor, previous cursor, last page via pageDirection=Backward.](../medium/images/04b-cursor-flow.png) Same query on: -- `POST /api/v2/products-page` — Minimal API (compatibility) -- `POST /api/v2/Product/products` — MVC EF (`AsNoTracking`, `TagWith("products.list")`, SQL `Select` to `ProductDto`) -- `POST /api/v2/Product/products-dapper` — Dapper adapter (in-repo, not packed) +- `POST /api/v1/products-page` — Minimal API (compatibility) +- `POST /api/v1/Product/products` — Minimal API EF (`AsNoTracking`, `TagWith("products.list")`, SQL `Select` to `ProductDto`) +- `POST /api/v1/Product/products-dapper` — Dapper adapter (in-repo, not packed) - MCP `products.list` `HasKeysetIndex` on the Price, Name, and CreatedAt keys (ASC and DESC). Id-only sorts use the primary key. PostgreSQL — **`QueryHint` stays `None`**. Do not set `QueryHint.ReadUncommitted` in this lab (that option is SQL Server session isolation, not a FeatureFusion demo). diff --git a/docs/lab/README.md b/docs/lab/README.md index 3a8d7c1..8ca7280 100644 --- a/docs/lab/README.md +++ b/docs/lab/README.md @@ -27,9 +27,13 @@ Cursor / Claude / MAF prototype (optional) ▼ FeatureFusion (WAF in tests / AppHost in local) ├── BuildingBlocks.Mcp → tools catalog + invoker - ├── BuildingBlocks.Mediator → Send + pipeline + ├── BuildingBlocks.Mediator → Send + ValidationBehavior + handlers ├── BuildingBlocks.Idempotency → HTTP Idempotency-Key (Redis) - ├── BuildingBlocks.Pagination.* → products.list / products-page + ├── BuildingBlocks.Pagination.* → products.list / products-page **and** GET /api/v1/customers + /api/v1/orders + ├── Demo Commerce catalog → GET /api/v1/catalog/* (OFFSET + PDP + related; MCP catalog.products.list / catalog.product.get) + ├── Demo Commerce customers → GET /api/v1/customers* (keyset list + cart nested under customer) + ├── Demo Commerce writes → POST /api/v1/Order/order (CreateOrder) + POST /api/v1/customers/{id}/checkout + ├── Demo Commerce orders reads → GET /api/v1/orders* (keyset list + detail) ├── BuildingBlocks.Telemetry → OTel (IntegrateMediator / IntegrateMcp / EventBus source) └── Lab EventBus (not packed) → outbox → RabbitMQ → inbox → handlers ▲ @@ -91,14 +95,20 @@ Do **not** extract: Scenario DSL, one-off test gates, fixed-permit rate limiters | Tool | Kind | Notes | |------|------|-------| -| `orders.create` | Command | Confirmation + MCP memory idempotency | -| `products.list` | Query | Keyset pagination | +| `catalog.products.list` | Query | Storefront OFFSET listing (same as HTTP `/api/v1/catalog/products`) | +| `catalog.product.get` | Query | PDP by slug | +| `products.list` | Query | Pagination **lab** keyset (not storefront OFFSET) | +| `customers.list` / `customers.get` | Query | Same handlers as HTTP | +| `orders.list` / `orders.get` | Query | Same handlers as HTTP | +| `orders.create` | Command | Confirmation + MCP memory idempotency; Admission on dispatcher | +| `orders.checkout` | Command | Cart → tax/shipping/payment → CreateOrder | | `demo.echo` | Command | `Idempotent = false` smoke | | `lab.ping` | Query | Minimal API → MCP | ## Related - [Experiments catalog](../../tests/Lab/IntegrationTests/Experiments/README.md) +- [Capability admission (Defer-before-Send)](capability-admission.md) - [AGENTS.md](../../AGENTS.md) - [BuildingBlocks getting started](../building-blocks/getting-started.md) - [LinkedIn / Medium catalog](../linkedin-posts.md) diff --git a/docs/lab/capability-admission.md b/docs/lab/capability-admission.md new file mode 100644 index 0000000..3482972 --- /dev/null +++ b/docs/lab/capability-admission.md @@ -0,0 +1,61 @@ +# Capability admission (lab proof) + +Application-owned **Defer-before-Send** for capability `orders.create`. This is a FeatureFusion lab vertical slice — **not** a NuGet BuildingBlock yet. + +## Why it exists + +HTTP and MCP already share `CreateOrderCommand` / `CreateOrderCommandHandler`, but write safety lived on each surface (`Idempotency-Key`, MCP `confirmed` + memory keys). Agents (and careless clients) can satisfy same-request MCP confirmation. Admission puts a durable decision **in front of** `ISender.Send`. + +## Why Defer is before Send + +``` +HTTP / MCP → Admit(orders.create) → Allow | Deny | Defer +Allow → existing Send → existing handler +Defer → persist intent ticket → return Pending (no Send, no Order, no Outbox) +Release (trusted HTTP) → claim ticket → existing Send → existing handler +``` + +If admission ran inside the handler or after outbox insert, the business effect would already exist. + +## Why MCP `confirmed=true` is insufficient + +`RequireConfirmation` is a same-request JSON flag. The MAF prototype instructs the model to set it. It must **not** release a deferred ticket. + +## Why EventBus is not the mechanism + +Outbox → RabbitMQ → inbox runs **after** the order is created. Messaging is post-effect fan-out, not pre-execution admission. + +## Why MAF is a future consumer + +Microsoft Agent Framework already calls `/mcp` in a test prototype. Later it can receive a Pending ticket from `orders.create` and must not call the HTTP release endpoint. No MAF types belong in admission. + +## Ticket lifecycle + +| Status | Meaning | +|--------|---------| +| Pending | Intent stored; `Send` has not run | +| Released | Atomic claim succeeded; handler may have run | +| Expired | Past `ExpiresAt`; cannot release | + +**Request identity:** HTTP `Idempotency-Key` / MCP `idempotencyKey` + capability id. Same key while Pending returns the **same** `TicketId`. Different intent hash → 422 at admission (when Admit runs). Surface idempotency caches may replay the first Pending without re-entering Admit (fingerprint-off HTTP / MCP memory store) — characterized in tests. + +## Exactly-once limitation + +Release **atomically** transitions Pending → Released (one winner under concurrency). Then `Send` runs. A crash after claim and before/during handler may leave a Released ticket without an order. Ticket release is **not** mathematical exactly-once execution. Do not pretend otherwise. + +## Configuration + +```json +"CapabilityAdmission": { + "DeferredCapabilities": [ "orders.create" ], + "TicketTtl": "01:00:00" +} +``` + +Empty `DeferredCapabilities` → Allow (existing smoke / Exp 1–20 behavior). AspireFixture clears Defer so legacy experiments stay green; admission product tests re-enable it. + +## Endpoints + +- `POST /api/v1/Order/order` — Admit then Allow/Defer +- `POST /api/v1/admission/tickets/{ticketId}/release` — trusted release (`X-Released-By` optional) +- `GET /api/v1/admission/tickets/{ticketId}` — inspect diff --git a/docs/linkedin-posts.md b/docs/linkedin-posts.md index 979d753..6652184 100644 --- a/docs/linkedin-posts.md +++ b/docs/linkedin-posts.md @@ -8,14 +8,14 @@ Umbrella narrative (lab → packages): [Medium](https://medium.com/@m2hweb86/fea | Id | Title | Status | URL | Code map | Related | Summary | |----|-------|--------|-----|----------|---------|---------| -| `medium-engineering-lab` | FeatureFusion: an engineering lab that extracts reusable .NET building blocks | published | https://medium.com/@m2hweb86/featurefusion-an-engineering-lab-that-extracts-reusable-net-building-blocks-28152cafafb9 | repo + `src/BuildingBlocks/*` + lab `GetProductsQuery` | `building-blocks-overview`, `mediator-building-blocks`, `cursor-pagination`, `mcp-message-tools` | Long-form lab-vs-package story. Same `GetProductsQuery` on GET / POST / MVC / Dapper / MCP. | +| `medium-engineering-lab` | FeatureFusion: an engineering lab that extracts reusable .NET building blocks | published | https://medium.com/@m2hweb86/featurefusion-an-engineering-lab-that-extracts-reusable-net-building-blocks-28152cafafb9 | repo + `src/BuildingBlocks/*` + lab `GetProductsQuery` | `building-blocks-overview`, `mediator-building-blocks`, `cursor-pagination`, `mcp-message-tools` | Long-form lab-vs-package story. Same `GetProductsQuery` on GET / POST Minimal API, Dapper, and MCP (`products.list`). | | `building-blocks-overview` | BuildingBlocks + NuGet packages (Mediator 1.1.0, keyset, MCP, Telemetry, SigNoz) | published | https://lnkd.in/p/e4-FrM44 | `src/BuildingBlocks/*` | `medium-engineering-lab`, `mediator-building-blocks`, `cursor-pagination`, `mcp-message-tools` | Umbrella post: extract only when the contract earned a boundary. Not a SigNoz SDK. Dapper pagination stays in-repo. | | `mediator` | Manual Mediator + pipeline behaviors | published | https://www.linkedin.com/feed/update/urn:li:activity:7311311587372367873/ | was `Infrastructure/CQRS`; now `src/BuildingBlocks/Mediator` | `mediator-building-blocks` | Custom CQRS Mediator with cached wrappers, void via `ICommand : ICommand`, OTel-friendly behaviors. Follow-up NuGet post → `mediator-building-blocks`. | | `mediator-building-blocks` | BuildingBlocks.Mediator NuGet (v1.0.1 launch; current 1.1.0) | published | https://lnkd.in/p/eU5TsuR4 | `src/BuildingBlocks/Mediator` + `docs/building-blocks/mediator.md` | `mediator`, `building-blocks-overview` | Packaged CQRS Send + ordered pipeline (void via `ICommand : ICommand`). Publish/notifications out of v1. 1.1.0 adds typed command/query behaviors + Send metrics. Prior deep-dive → `mediator`. | -| `cursor-pagination` | Reusable generic bidirectional keyset (cursor) pagination | published | https://www.linkedin.com/feed/update/urn:li:activity:7325068550614708225/ | `src/BuildingBlocks/Pagination.EntityFrameworkCore` + lab `ProductService` | `building-blocks-overview` | Typed `SortKey`, opaque cursors, one EF Core nupkg (**1.1.0**: Npgsql row comparison, NULLS interceptor). Optional `QueryHint` (default none). Lab: MVC `POST /api/v2/Product/products`, Minimal API `GET /api/v2/products-page` (POST kept), Dapper `products-dapper`, MCP `products.list` — one `GetProductsQuery`. | +| `cursor-pagination` | Reusable generic bidirectional keyset (cursor) pagination | published | https://www.linkedin.com/feed/update/urn:li:activity:7325068550614708225/ | `src/BuildingBlocks/Pagination.EntityFrameworkCore` + lab `ProductService` | `building-blocks-overview` | Typed `SortKey`, opaque cursors, one EF Core nupkg (**1.1.0**: Npgsql row comparison, NULLS interceptor). Optional `QueryHint` (default none). Lab: Minimal API `POST /api/v1/Product/products`, `GET /api/v1/products-page` (POST kept), Dapper `products-dapper`, MCP `products.list` — one `GetProductsQuery`. | | `mcp-message-tools` | Message types as MCP tools | published | https://lnkd.in/p/e4-FrM44 | `src/BuildingBlocks/Mcp` + `docs/building-blocks/mcp.md` | `building-blocks-overview` | Deny-by-default `[McpTool]` / `WithMcp` / `MapTool` on the official SDK. Same logic as HTTP. Interim permalink is the umbrella post until a dedicated MCP post ships. Not OpenAPI, not a SOLID linter. | | `idempotency-cqrs` | Idempotency with CQRS commands | published | https://www.linkedin.com/feed/update/urn:li:activity:7303686809891356676/ | (pattern lab) | `idempotentfusion` | **Lab only, not NuGet.** Command-level idempotency via reusable `IdentifiedCommand` + ULID `Idempotency-Key`, intercepting before handlers. Complementary to Mediator — not core v1. | -| `idempotentfusion` | IdempotentFusion (REST API) | published | https://www.linkedin.com/feed/update/urn:li:activity:7309149985307029504/ | `src/BuildingBlocks/Idempotency` + Lab `OrderController` | `idempotency-cqrs` | HTTP Idempotency-Key via `BuildingBlocks.Idempotency` **1.0.1** (Redis cache host-owned, optional SET NX lock, System.Text.Json). Lab sets `UserIdFallback = "123"`. | +| `idempotentfusion` | IdempotentFusion (REST API) | published | https://www.linkedin.com/feed/update/urn:li:activity:7309149985307029504/ | `src/BuildingBlocks/Idempotency` + Lab `Features/Orders/Endpoints/OrderEndpoints.cs` (`POST /api/v1/Order/order`) | `idempotency-cqrs` | HTTP Idempotency-Key via `BuildingBlocks.Idempotency` **1.0.1** (Redis cache host-owned, optional SET NX lock, System.Text.Json). Lab sets `UserIdFallback = "123"`. | | `eventbus-outbox-inbox` | Lightweight modular EventBus (outbox, inbox, DLQ) | published | https://www.linkedin.com/posts/mhhoseini_a-lightweight-modular-eventbus-implementation-activity-7316381126535663631-6FiI | `src/Lab/EventBus` | `aspire-integration-testing` | **Lab only, not NuGet.** Sibling of Mediator Send — not `INotification`. | | `aspire-integration-testing` | Integration testing microservices with Aspire | published | https://www.linkedin.com/posts/mhhoseini_integration-testing-microservices-with-aspire-activity-7321088554565029888-Z0a8 | `tests/Lab/IntegrationTests` + `src/Lab/FeatureFusion.AppHost` | `building-blocks-overview` | **Lab only, not NuGet.** Aspire-hosted fixture against real Postgres / Redis / RabbitMQ / Memcached. | | `ddd-architecture` | Diving into DDD | published | https://www.linkedin.com/posts/mhhoseini_diving-into-ddd-activity-7427983383126896640-Eaaw | lab vertical slices under `src/Lab/FeatureFusion/Features` | `medium-engineering-lab` | **Lab only, not NuGet.** Architecture notes; FeatureFusion is the showcase host, not a DDD framework. | diff --git a/llms.txt b/llms.txt index 23ae73c..2efc0d8 100644 --- a/llms.txt +++ b/llms.txt @@ -22,6 +22,8 @@ | BuildingBlocks.Pagination.EntityFrameworkCore | 1.1.0 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Pagination.EntityFrameworkCore/1.1.0) | [pagination-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=pagination-v) | | BuildingBlocks.Telemetry | 1.0.2 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Telemetry/1.0.2) | [telemetry-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=telemetry-v) | | BuildingBlocks.Aspire.Hosting.SigNoz | 1.0.0 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Aspire.Hosting.SigNoz/1.0.0) | [signoz-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=signoz-v) | +| BuildingBlocks.Domain | 1.0.0 (in-repo; not published yet) | `src/BuildingBlocks/Domain` | — | +| BuildingBlocks.Domain.EntityFrameworkCore | 1.0.0 (in-repo; not published yet) | `src/BuildingBlocks/Domain.EntityFrameworkCore` | — | **Highlights:** Pagination **1.1.0** — any-width Npgsql row comparison, `NULLS FIRST/LAST` interceptor, `HasKeysetIndex` + `NullOrder`. Idempotency **1.0.1** — package icon, System.Text.Json only (no Newtonsoft). @@ -44,6 +46,8 @@ - [Mcp AGENTS](https://github.com/Maxofpower/FeatureFusion/blob/main/src/BuildingBlocks/Mcp/AGENTS.md) - [Telemetry AGENTS](https://github.com/Maxofpower/FeatureFusion/blob/main/src/BuildingBlocks/Telemetry/AGENTS.md) - [Aspire.Hosting.SigNoz AGENTS](https://github.com/Maxofpower/FeatureFusion/blob/main/src/BuildingBlocks/Aspire.Hosting.SigNoz/AGENTS.md) +- [Domain AGENTS](https://github.com/Maxofpower/FeatureFusion/blob/main/src/BuildingBlocks/Domain/AGENTS.md) (in-repo; not packed) +- [Domain.EntityFrameworkCore AGENTS](https://github.com/Maxofpower/FeatureFusion/blob/main/src/BuildingBlocks/Domain.EntityFrameworkCore/AGENTS.md) (in-repo; not packed) ## Guides diff --git a/src/BuildingBlocks/Domain.EntityFrameworkCore/AGENTS.md b/src/BuildingBlocks/Domain.EntityFrameworkCore/AGENTS.md new file mode 100644 index 0000000..4bd742f --- /dev/null +++ b/src/BuildingBlocks/Domain.EntityFrameworkCore/AGENTS.md @@ -0,0 +1,24 @@ +# BuildingBlocks.Domain.EntityFrameworkCore — agent notes +EF Core mapping helpers for `BuildingBlocks.Domain`. **In-repo only — not published to nuget.org.** Reference the project when the host uses EF. + +## When to choose +You map `Identity` / `ValueObject` columns and want **cached** `ValueConverter` instances (EF recommendation) plus fluent `HasIdentityConversion` / `HasValueObjectConversion`. +Do **not** put domain factories or business rules here. + +## Surface +| Type | Role | +|------|------| +| `IdentityValueConverter.For` / `ForNullable` | Cached identity ↔ primitive converters | +| `ValueObjectValueConverter.For` | Cached VO ↔ primitive converters | +| `HasIdentityConversion` / `HasValueObjectConversion` | Fluent property helpers | + +## Example +```csharp +builder.Property(p => p.Id) + .HasIdentityConversion(v => new ProductId(v)) + .ValueGeneratedOnAdd(); +builder.Property(p => p.BrandId) + .HasIdentityConversion(v => new BrandId(v)); +builder.Property(c => c.Email) + .HasValueObjectConversion(e => e.Value, v => Email.Create(v)); +``` \ No newline at end of file diff --git a/src/BuildingBlocks/Domain.EntityFrameworkCore/BuildingBlocks.Domain.EntityFrameworkCore.csproj b/src/BuildingBlocks/Domain.EntityFrameworkCore/BuildingBlocks.Domain.EntityFrameworkCore.csproj new file mode 100644 index 0000000..ceb1946 --- /dev/null +++ b/src/BuildingBlocks/Domain.EntityFrameworkCore/BuildingBlocks.Domain.EntityFrameworkCore.csproj @@ -0,0 +1,62 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + latest + true + CS1591 + BuildingBlocks.Domain.EntityFrameworkCore + + false + BuildingBlocks.Domain.EntityFrameworkCore + BuildingBlocks.Domain.EntityFrameworkCore + 1.0.0 + Mohammad Hasan Hosseini + Mohammad Hasan Hosseini + Copyright (c) 2026 Mohammad Hasan Hosseini + EF Core value converters for BuildingBlocks.Domain Identity and ValueObject types. Converters are cached per CLR type pair. + ddd;domain;efcore;entityframeworkcore;valueconverter;identity;net8;net9;net10 + MIT + https://github.com/Maxofpower/FeatureFusion + https://github.com/Maxofpower/FeatureFusion + git + PACKAGE_README.md + 1.0.0: Cached Identity and ValueObject converters + HasIdentityConversion helpers. + true + true + true + snupkg + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Domain.EntityFrameworkCore/IdentityValueConverter.cs b/src/BuildingBlocks/Domain.EntityFrameworkCore/IdentityValueConverter.cs new file mode 100644 index 0000000..5f1e211 --- /dev/null +++ b/src/BuildingBlocks/Domain.EntityFrameworkCore/IdentityValueConverter.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using BuildingBlocks.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace BuildingBlocks.Domain.EntityFrameworkCore; + +/// +/// Cached ValueConverter instances for Identity types. +/// One converter instance is shared per (identity, primitive) pair. +/// +public static class IdentityValueConverter +{ + private static readonly ConcurrentDictionary<(Type Identity, Type Primitive), ValueConverter> Cache = new(); + + /// + /// Returns a cached converter: identity to primitive via Value, primitive to identity via fromProvider. + /// + public static ValueConverter For(Expression> fromProvider) + where TIdentity : Identity + where TId : notnull + { + ArgumentNullException.ThrowIfNull(fromProvider); + var key = (typeof(TIdentity), typeof(TId)); + return (ValueConverter)Cache.GetOrAdd( + key, + _ => new ValueConverter( + id => id.Value, + fromProvider)); + } + + /// + /// Cached nullable converter for optional FK identity properties (BrandId? to int?). + /// + public static ValueConverter ForNullable(Expression> fromProvider) + where TIdentity : Identity + where TId : struct + { + ArgumentNullException.ThrowIfNull(fromProvider); + var factory = fromProvider.Compile(); + var key = (typeof(TIdentity), typeof(Nullable<>).MakeGenericType(typeof(TId))); + return (ValueConverter)Cache.GetOrAdd( + key, + _ => new ValueConverter( + id => id == null ? null : id.Value, + value => value.HasValue ? factory(value.Value) : null)); + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Domain.EntityFrameworkCore/PACKAGE_README.md b/src/BuildingBlocks/Domain.EntityFrameworkCore/PACKAGE_README.md new file mode 100644 index 0000000..b6fde1f --- /dev/null +++ b/src/BuildingBlocks/Domain.EntityFrameworkCore/PACKAGE_README.md @@ -0,0 +1,19 @@ +# BuildingBlocks.Domain.EntityFrameworkCore +Cached EF Core `ValueConverter` helpers for `BuildingBlocks.Domain` identities and value objects. + +## Install +Not published to nuget.org. Add a project reference (requires `BuildingBlocks.Domain`): + +```bash +dotnet add reference path/to/BuildingBlocks.Domain.EntityFrameworkCore.csproj +``` + +## Usage +```csharp +using BuildingBlocks.Domain.EntityFrameworkCore; +builder.Property(x => x.Id) + .HasIdentityConversion(v => new OrderId(v)); +builder.Property(x => x.Email) + .HasValueObjectConversion(e => e.Value, Email.Create); +``` +Converters are cached per CLR type pair so configurations share one instance. \ No newline at end of file diff --git a/src/BuildingBlocks/Domain.EntityFrameworkCore/PropertyBuilderExtensions.cs b/src/BuildingBlocks/Domain.EntityFrameworkCore/PropertyBuilderExtensions.cs new file mode 100644 index 0000000..728adec --- /dev/null +++ b/src/BuildingBlocks/Domain.EntityFrameworkCore/PropertyBuilderExtensions.cs @@ -0,0 +1,44 @@ +using System.Linq.Expressions; +using BuildingBlocks.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BuildingBlocks.Domain.EntityFrameworkCore; + +/// Fluent helpers for mapping typed identities and value objects. +public static class PropertyBuilderExtensions +{ + /// Maps an Identity property with a cached converter. + public static PropertyBuilder HasIdentityConversion( + this PropertyBuilder builder, + Expression> fromProvider) + where TIdentity : Identity + where TId : notnull + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasConversion(IdentityValueConverter.For(fromProvider)); + } + + /// Maps a nullable identity FK with a cached converter. + public static PropertyBuilder HasNullableIdentityConversion( + this PropertyBuilder builder, + Expression> fromProvider) + where TIdentity : Identity + where TId : struct + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasConversion(IdentityValueConverter.ForNullable(fromProvider)); + } + + /// Maps a ValueObject column with a cached converter. + public static PropertyBuilder HasValueObjectConversion( + this PropertyBuilder builder, + Expression> toProvider, + Expression> fromProvider) + where TValueObject : ValueObject + where TPrimitive : notnull + { + ArgumentNullException.ThrowIfNull(builder); + return builder.HasConversion(ValueObjectValueConverter.For(toProvider, fromProvider)); + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Domain.EntityFrameworkCore/ValueObjectValueConverter.cs b/src/BuildingBlocks/Domain.EntityFrameworkCore/ValueObjectValueConverter.cs new file mode 100644 index 0000000..f2c0612 --- /dev/null +++ b/src/BuildingBlocks/Domain.EntityFrameworkCore/ValueObjectValueConverter.cs @@ -0,0 +1,31 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using BuildingBlocks.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace BuildingBlocks.Domain.EntityFrameworkCore; + +/// +/// Cached converters for single-field ValueObject types persisted as a primitive column. +/// +public static class ValueObjectValueConverter +{ + private static readonly ConcurrentDictionary<(Type Vo, Type Primitive), ValueConverter> Cache = new(); + + /// + /// Returns a cached converter using toProvider / fromProvider expressions. + /// + public static ValueConverter For( + Expression> toProvider, + Expression> fromProvider) + where TValueObject : ValueObject + where TPrimitive : notnull + { + ArgumentNullException.ThrowIfNull(toProvider); + ArgumentNullException.ThrowIfNull(fromProvider); + var key = (typeof(TValueObject), typeof(TPrimitive)); + return (ValueConverter)Cache.GetOrAdd( + key, + _ => new ValueConverter(toProvider, fromProvider)); + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Domain/AGENTS.md b/src/BuildingBlocks/Domain/AGENTS.md new file mode 100644 index 0000000..a007d70 --- /dev/null +++ b/src/BuildingBlocks/Domain/AGENTS.md @@ -0,0 +1,36 @@ +# BuildingBlocks.Domain — agent notes +Focused DDD primitives. **In-repo only — not published to nuget.org.** Reference `src/BuildingBlocks/Domain/BuildingBlocks.Domain.csproj`. +EF converters: companion `BuildingBlocks.Domain.EntityFrameworkCore` (also in-repo, not packed). + +## When to choose this +You need `Entity` / `AggregateRoot` / `ValueObject` / typed `Identity` (`AggregateId` / `EntityId`) / `IBusinessRule` / `DomainException` without pulling an ORM, mediator, or event bus. +Do **not** use for CQRS messaging (Mediator), HTTP, or persistence mapping (use Domain.EntityFrameworkCore). + +## Surface +| Type | Role | +|------|------| +| `Identity` | Strongly-typed id record + implicit to primitive | +| `AggregateId` / `EntityId` | Semantic id bases for aggregates vs child entities | +| `Entity` | Identity equality + `CheckRule`; **Id is protected set** | +| `AggregateRoot` | Entity + in-memory domain events (host publishes) | +| `ValueObject` | Structural equality via `GetEqualityComponents` | +| `IBusinessRule` + `BusinessRuleValidationException` | Invariant checks | +| `DomainException` | Factory / invariant failures | +No domain-event dispatcher. Host clears and publishes events. + +## Example +```csharp +public sealed record OrderId : AggregateId +{ + public OrderId(int value) : base(value) { } + public static OrderId From(int value) => new(value); + public static implicit operator OrderId(int value) => new(value); +} + +public sealed class Order : AggregateRoot +{ + public string Number { get; private set; } = ""; + private Order() { } + public static Order Create(OrderId id, string number) { /* ... */ } +} +``` diff --git a/src/BuildingBlocks/Domain/AggregateId.cs b/src/BuildingBlocks/Domain/AggregateId.cs new file mode 100644 index 0000000..3c5f748 --- /dev/null +++ b/src/BuildingBlocks/Domain/AggregateId.cs @@ -0,0 +1,16 @@ +namespace BuildingBlocks.Domain; + +/// +/// Typed identity intended for aggregate roots. +/// Hosts specialize: public sealed record OrderId : AggregateId<int>. +/// +/// Underlying primitive type. +public abstract record AggregateId : Identity + where TId : notnull +{ + /// Creates an aggregate identity. + /// Underlying primitive value. + protected AggregateId(TId value) : base(value) + { + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Domain/AggregateRoot.cs b/src/BuildingBlocks/Domain/AggregateRoot.cs new file mode 100644 index 0000000..ce0bd9f --- /dev/null +++ b/src/BuildingBlocks/Domain/AggregateRoot.cs @@ -0,0 +1,52 @@ +namespace BuildingBlocks.Domain; + +/// +/// Consistency boundary for a cluster of entities. Collects instances +/// in memory; the application host is responsible for clearing and publishing them after persistence. +/// Use for invariants that must hold before state changes. +/// +/// Identity type of the root. +public abstract class AggregateRoot : Entity, IAggregate + where TId : notnull +{ + private readonly List _domainEvents = []; + + /// For ORMs. Domain code should use factories. + protected AggregateRoot() + { + } + + /// When the identity is known at construction. + /// Aggregate identity. + protected AggregateRoot(TId id) : base(id) + { + } + + /// + public long OriginalVersion { get; protected set; } + + /// Events raised since the last commit; not yet dispatched by the host. + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// Enqueues a domain event. Duplicate values are ignored. + /// Event to enqueue. + protected void Raise(IDomainEvent domainEvent) + { + ArgumentNullException.ThrowIfNull(domainEvent); + if (_domainEvents.Any(e => e.EventId == domainEvent.EventId)) + return; + _domainEvents.Add(domainEvent); + } + + /// + public bool HasUncommittedDomainEvents() => _domainEvents.Count > 0; + + /// + public IReadOnlyCollection GetUncommittedDomainEvents() => DomainEvents; + + /// + public void MarkUncommittedDomainEventsAsCommitted() => _domainEvents.Clear(); + + /// Clears recorded events after the host has dispatched them. Same as . + public void ClearDomainEvents() => MarkUncommittedDomainEventsAsCommitted(); +} diff --git a/src/BuildingBlocks/Domain/BuildingBlocks.Domain.csproj b/src/BuildingBlocks/Domain/BuildingBlocks.Domain.csproj new file mode 100644 index 0000000..425c0f7 --- /dev/null +++ b/src/BuildingBlocks/Domain/BuildingBlocks.Domain.csproj @@ -0,0 +1,47 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + latest + true + CS1591 + BuildingBlocks.Domain + + false + BuildingBlocks.Domain + BuildingBlocks.Domain + 1.0.0 + Mohammad Hasan Hosseini + Mohammad Hasan Hosseini + Copyright (c) 2026 Mohammad Hasan Hosseini + Focused DDD primitives for .NET: Entity, AggregateRoot, ValueObject, Identity/AggregateId/EntityId, IBusinessRule, DomainException. No ORM, mediator, or event-bus coupling — host maps persistence (see Domain.EntityFrameworkCore). + ddd;domain;entity;aggregate;value-object;identity;strongly-typed-id;business-rule;net8;net9;net10 + MIT + https://github.com/Maxofpower/FeatureFusion + https://github.com/Maxofpower/FeatureFusion + git + PACKAGE_README.md + 1.0.0: Initial Entity, AggregateRoot, ValueObject, Identity, IBusinessRule. + true + true + true + snupkg + true + + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Domain/BusinessRuleValidationException.cs b/src/BuildingBlocks/Domain/BusinessRuleValidationException.cs new file mode 100644 index 0000000..89779bc --- /dev/null +++ b/src/BuildingBlocks/Domain/BusinessRuleValidationException.cs @@ -0,0 +1,16 @@ +namespace BuildingBlocks.Domain; + +/// Thrown when an is broken. +public sealed class BusinessRuleValidationException : DomainException +{ + /// The rule that failed. + public IBusinessRule BrokenRule { get; } + + /// Creates the exception from a broken rule. + /// Rule whose returned true. + public BusinessRuleValidationException(IBusinessRule brokenRule) + : base(brokenRule?.Message ?? throw new ArgumentNullException(nameof(brokenRule))) + { + BrokenRule = brokenRule; + } +} diff --git a/src/BuildingBlocks/Domain/DomainEvent.cs b/src/BuildingBlocks/Domain/DomainEvent.cs new file mode 100644 index 0000000..0124a6f --- /dev/null +++ b/src/BuildingBlocks/Domain/DomainEvent.cs @@ -0,0 +1,19 @@ +namespace BuildingBlocks.Domain; + +/// +/// Base record for domain events. Subclass per event type; the host publishes after persistence. +/// +public abstract record DomainEvent : IDomainEvent +{ + /// + public Guid EventId { get; init; } = Guid.NewGuid(); + + /// + public DateTime OccurredOnUtc { get; init; } = DateTime.UtcNow; + + /// + public object? AggregateId { get; init; } + + /// + public long AggregateVersion { get; init; } +} diff --git a/src/BuildingBlocks/Domain/DomainException.cs b/src/BuildingBlocks/Domain/DomainException.cs new file mode 100644 index 0000000..0c18447 --- /dev/null +++ b/src/BuildingBlocks/Domain/DomainException.cs @@ -0,0 +1,17 @@ +namespace BuildingBlocks.Domain; + +/// Thrown when a factory or domain method rejects invalid state. +public class DomainException : Exception +{ + /// Creates a domain exception. + /// Why the operation was rejected. + public DomainException(string message) : base(message) + { + } + + /// Creates a domain exception with an inner cause. + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Domain/Entity.cs b/src/BuildingBlocks/Domain/Entity.cs new file mode 100644 index 0000000..c64a378 --- /dev/null +++ b/src/BuildingBlocks/Domain/Entity.cs @@ -0,0 +1,62 @@ +namespace BuildingBlocks.Domain; + +/// +/// Entity with identity-based equality. Prefer a typed +/// (or / ) as . +/// is protected-set so callers create instances through factories or domain methods. +/// +/// Identity type (typed id or primitive). +public abstract class Entity : IEntity, IEquatable> + where TId : notnull +{ + /// + public TId Id { get; protected set; } = default!; + + /// For ORMs. Domain code should use factories. + protected Entity() + { + } + + /// When the identity is known at construction. + /// Entity identity. + protected Entity(TId id) => Id = id; + + /// + public void CheckRule(IBusinessRule rule) => Validate(rule); + + /// Evaluates a rule and throws when broken. Safe for static factories. + /// Rule to evaluate. + /// Thrown when the rule is broken. + public static void Validate(IBusinessRule rule) + { + ArgumentNullException.ThrowIfNull(rule); + if (rule.IsBroken()) + throw new BusinessRuleValidationException(rule); + } + + /// + public bool Equals(Entity? other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + if (GetType() != other.GetType()) + return false; + if (Id is null || other.Id is null) + return false; + return EqualityComparer.Default.Equals(Id, other.Id); + } + + /// + public override bool Equals(object? obj) => obj is Entity other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(GetType(), Id); + + /// Identity equality. + public static bool operator ==(Entity? left, Entity? right) => Equals(left, right); + + /// Identity inequality. + public static bool operator !=(Entity? left, Entity? right) => !Equals(left, right); +} diff --git a/src/BuildingBlocks/Domain/EntityId.cs b/src/BuildingBlocks/Domain/EntityId.cs new file mode 100644 index 0000000..9e43721 --- /dev/null +++ b/src/BuildingBlocks/Domain/EntityId.cs @@ -0,0 +1,16 @@ +namespace BuildingBlocks.Domain; + +/// +/// Typed identity intended for child entities inside an aggregate (order lines, images). +/// Hosts specialize: public sealed record OrderItemId : EntityId<int>. +/// +/// Underlying primitive type. +public abstract record EntityId : Identity + where TId : notnull +{ + /// Creates an entity identity. + /// Underlying primitive value. + protected EntityId(TId value) : base(value) + { + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Domain/Enumeration.cs b/src/BuildingBlocks/Domain/Enumeration.cs new file mode 100644 index 0000000..5055e3c --- /dev/null +++ b/src/BuildingBlocks/Domain/Enumeration.cs @@ -0,0 +1,80 @@ +namespace BuildingBlocks.Domain; + +/// +/// Named enumeration class: comparable, equality by . +/// Prefer this over a raw enum when the value carries behavior; otherwise a C# enum is fine. +/// +public abstract class Enumeration : IEquatable, IComparable +{ + /// Creates an enumeration member. + /// Stable numeric identifier (persisted). + /// Display name. + protected Enumeration(int id, string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Enumeration name is required.", nameof(name)); + Id = id; + Name = name; + } + + /// Numeric identity. + public int Id { get; } + + /// Human-readable name. + public string Name { get; } + + /// All public static members of declared on that type. + public static IReadOnlyList GetAll() + where T : Enumeration + { + return typeof(T) + .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .OfType() + .ToList(); + } + + /// Resolves a member by numeric id. + public static T FromId(int id) + where T : Enumeration + { + return GetAll().SingleOrDefault(x => x.Id == id) + ?? throw new DomainException($"Unknown {typeof(T).Name} id '{id}'."); + } + + /// Resolves a member by name (ordinal ignore-case). + public static T FromName(string name) + where T : Enumeration + { + return GetAll().SingleOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? throw new DomainException($"Unknown {typeof(T).Name} name '{name}'."); + } + + /// + public bool Equals(Enumeration? other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + return GetType() == other.GetType() && Id == other.Id; + } + + /// + public override bool Equals(object? obj) => obj is Enumeration other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(GetType(), Id); + + /// + public override string ToString() => Name; + + /// + public int CompareTo(Enumeration? other) => Id.CompareTo(other?.Id ?? 0); + + /// Value equality. + public static bool operator ==(Enumeration? left, Enumeration? right) => Equals(left, right); + + /// Value inequality. + public static bool operator !=(Enumeration? left, Enumeration? right) => !Equals(left, right); +} diff --git a/src/BuildingBlocks/Domain/IAggregate.cs b/src/BuildingBlocks/Domain/IAggregate.cs new file mode 100644 index 0000000..b51f238 --- /dev/null +++ b/src/BuildingBlocks/Domain/IAggregate.cs @@ -0,0 +1,7 @@ +namespace BuildingBlocks.Domain; + +/// Aggregate root: entity plus uncommitted domain events and a concurrency version. +public interface IAggregate : IEntity, IHaveAggregate + where TId : notnull +{ +} diff --git a/src/BuildingBlocks/Domain/IBusinessRule.cs b/src/BuildingBlocks/Domain/IBusinessRule.cs new file mode 100644 index 0000000..63e4024 --- /dev/null +++ b/src/BuildingBlocks/Domain/IBusinessRule.cs @@ -0,0 +1,11 @@ +namespace BuildingBlocks.Domain; + +/// Domain invariant checked before applying a state change. +public interface IBusinessRule +{ + /// Message returned when is true. + string Message { get; } + + /// Returns true when the invariant does not hold. + bool IsBroken(); +} diff --git a/src/BuildingBlocks/Domain/IDomainEvent.cs b/src/BuildingBlocks/Domain/IDomainEvent.cs new file mode 100644 index 0000000..aa42280 --- /dev/null +++ b/src/BuildingBlocks/Domain/IDomainEvent.cs @@ -0,0 +1,20 @@ +namespace BuildingBlocks.Domain; + +/// +/// Something that happened in the domain and should be dispatched by the host after persistence. +/// This package does not publish events — the application layer does. +/// +public interface IDomainEvent +{ + /// Unique event identifier (used to de-duplicate the in-memory queue). + Guid EventId { get; } + + /// UTC timestamp when the event was raised. + DateTime OccurredOnUtc { get; } + + /// Identity of the aggregate that raised the event, when known. + object? AggregateId { get; } + + /// Aggregate version at the time the event was raised. + long AggregateVersion { get; } +} diff --git a/src/BuildingBlocks/Domain/IEntity.cs b/src/BuildingBlocks/Domain/IEntity.cs new file mode 100644 index 0000000..9024852 --- /dev/null +++ b/src/BuildingBlocks/Domain/IEntity.cs @@ -0,0 +1,9 @@ +namespace BuildingBlocks.Domain; + +/// Entity with typed identity and business-rule checks. +public interface IEntity : IHaveIdentity + where TId : notnull +{ + /// Validates and throws when it is broken. + void CheckRule(IBusinessRule rule); +} diff --git a/src/BuildingBlocks/Domain/IHaveAggregate.cs b/src/BuildingBlocks/Domain/IHaveAggregate.cs new file mode 100644 index 0000000..0f821b7 --- /dev/null +++ b/src/BuildingBlocks/Domain/IHaveAggregate.cs @@ -0,0 +1,17 @@ +namespace BuildingBlocks.Domain; + +/// Aggregate-root capabilities: domain events, version, and rule checks. +public interface IHaveAggregate : IHaveAggregateVersion +{ + /// True when at least one domain event has not been marked committed. + bool HasUncommittedDomainEvents(); + + /// Events raised since the last commit (host publishes, then marks committed). + IReadOnlyCollection GetUncommittedDomainEvents(); + + /// Clears the in-memory event queue after the host has dispatched them. + void MarkUncommittedDomainEventsAsCommitted(); + + /// Validates and throws when it is broken. + void CheckRule(IBusinessRule rule); +} diff --git a/src/BuildingBlocks/Domain/IHaveAggregateVersion.cs b/src/BuildingBlocks/Domain/IHaveAggregateVersion.cs new file mode 100644 index 0000000..43cb364 --- /dev/null +++ b/src/BuildingBlocks/Domain/IHaveAggregateVersion.cs @@ -0,0 +1,13 @@ +namespace BuildingBlocks.Domain; + +/// +/// Optimistic-concurrency version captured when the aggregate was loaded. +/// Compare against the store on save to detect lost updates. +/// +public interface IHaveAggregateVersion +{ + /// + /// Version last loaded from persistence (0 for a newly created aggregate). + /// + long OriginalVersion { get; } +} diff --git a/src/BuildingBlocks/Domain/IHaveAudit.cs b/src/BuildingBlocks/Domain/IHaveAudit.cs new file mode 100644 index 0000000..0f8d474 --- /dev/null +++ b/src/BuildingBlocks/Domain/IHaveAudit.cs @@ -0,0 +1,21 @@ +namespace BuildingBlocks.Domain; + +/// Optional creation audit. Hosts choose the user-id type. +public interface IHaveCreator +{ + /// UTC creation timestamp. + DateTime CreatedAt { get; } + + /// Who created the entity, when the host tracks it. + TUserId? CreatedBy { get; } +} + +/// Optional modification audit. +public interface IHaveAudit : IHaveCreator +{ + /// UTC last-modified timestamp, when known. + DateTime? LastModifiedAt { get; } + + /// Who last modified the entity, when the host tracks it. + TUserId? LastModifiedBy { get; } +} diff --git a/src/BuildingBlocks/Domain/IHaveIdentity.cs b/src/BuildingBlocks/Domain/IHaveIdentity.cs new file mode 100644 index 0000000..f946244 --- /dev/null +++ b/src/BuildingBlocks/Domain/IHaveIdentity.cs @@ -0,0 +1,19 @@ +namespace BuildingBlocks.Domain; + +/// Marks a type that is identified by . +public interface IHaveIdentity +{ + /// Untyped identity (useful when walking mixed aggregates). + object Id { get; } +} + +/// Typed identity accessor for . +public interface IHaveIdentity : IHaveIdentity + where TId : notnull +{ + /// Typed primary identity. + new TId Id { get; } + + /// + object IHaveIdentity.Id => Id; +} diff --git a/src/BuildingBlocks/Domain/IHaveSoftDelete.cs b/src/BuildingBlocks/Domain/IHaveSoftDelete.cs new file mode 100644 index 0000000..27fcdcf --- /dev/null +++ b/src/BuildingBlocks/Domain/IHaveSoftDelete.cs @@ -0,0 +1,10 @@ +namespace BuildingBlocks.Domain; + +/// +/// Soft-delete marker. The host may apply a global query filter; this package does not. +/// +public interface IHaveSoftDelete +{ + /// True when the entity should be treated as deleted by the host. + bool Deleted { get; } +} diff --git a/src/BuildingBlocks/Domain/IIdentity.cs b/src/BuildingBlocks/Domain/IIdentity.cs new file mode 100644 index 0000000..09c5d16 --- /dev/null +++ b/src/BuildingBlocks/Domain/IIdentity.cs @@ -0,0 +1,13 @@ +namespace BuildingBlocks.Domain; + +/// +/// Strongly-typed identity wrapping a primitive. Different aggregates use different identity types +/// so a ProductId cannot be passed where an OrderId is required. +/// +/// Underlying primitive (int, long, Guid, string). +public interface IIdentity + where TId : notnull +{ + /// The wrapped primitive value. + TId Value { get; } +} diff --git a/src/BuildingBlocks/Domain/Identity.cs b/src/BuildingBlocks/Domain/Identity.cs new file mode 100644 index 0000000..359280d --- /dev/null +++ b/src/BuildingBlocks/Domain/Identity.cs @@ -0,0 +1,32 @@ +namespace BuildingBlocks.Domain; + +/// +/// Strongly-typed identity wrapping a primitive . +/// Derive one type per aggregate or entity (for example OrderId : AggregateId<int>) +/// so ids cannot be mixed at compile time. Convert to the primitive only at persistence and HTTP boundaries. +/// +/// Underlying primitive type. +public abstract record Identity : IIdentity + where TId : notnull +{ + /// Stores the primitive. Derived types may constrain allowed values in their constructors. + /// Underlying primitive value. + protected Identity(TId value) + { + ArgumentNullException.ThrowIfNull(value); + Value = value; + } + + /// + public TId Value { get; init; } + + /// Converts to the underlying primitive for queries, EF mappings, and APIs. + public static implicit operator TId(Identity id) + { + ArgumentNullException.ThrowIfNull(id); + return id.Value; + } + + /// + public override string ToString() => $"{GetType().Name}[{Value}]"; +} diff --git a/src/BuildingBlocks/Domain/PACKAGE_README.md b/src/BuildingBlocks/Domain/PACKAGE_README.md new file mode 100644 index 0000000..4d77acb --- /dev/null +++ b/src/BuildingBlocks/Domain/PACKAGE_README.md @@ -0,0 +1,51 @@ +# BuildingBlocks.Domain +Focused DDD primitives for .NET 8/9/10: **Entity**, **AggregateRoot**, **ValueObject**, **Identity** / **AggregateId** / **EntityId**, **IBusinessRule**, **DomainException**. +No ORM, mediator, or messaging dependencies. For EF Core converters use `BuildingBlocks.Domain.EntityFrameworkCore`. + +## Install +Not published to nuget.org. Add a project reference: + +```bash +dotnet add reference path/to/BuildingBlocks.Domain.csproj +``` + +## Quick start +```csharp +using BuildingBlocks.Domain; + +public sealed record CustomerId : AggregateId +{ + public CustomerId(int value) : base(value) + { + if (value < 0) throw new DomainException("Invalid id."); + } + public static implicit operator CustomerId(int value) => new(value); +} + +public sealed class Customer : AggregateRoot +{ + public Email Email { get; private set; } = null!; + private Customer() { } // EF + public static Customer Create(CustomerId id, Email email) + { + return new Customer { Id = id, Email = email }; + } +} + +public sealed class Email : ValueObject +{ + public string Value { get; } + private Email(string value) => Value = value; + public static Email Create(string value) { /* validate */ return new Email(value.Trim()); } + protected override IEnumerable GetEqualityComponents() + { + yield return Value.ToLowerInvariant(); + } +} +``` + +## Design notes +- Prefer **private setters** and factories on host entities. +- `AggregateRoot` stores events in memory; your application layer dispatches them. +- `Entity.Id` is **protected set** — assign in constructors/factories only. +- Keep this package small — do not add repositories, UnitOfWork, or outbox here. diff --git a/src/BuildingBlocks/Domain/ValueObject.cs b/src/BuildingBlocks/Domain/ValueObject.cs new file mode 100644 index 0000000..46311cc --- /dev/null +++ b/src/BuildingBlocks/Domain/ValueObject.cs @@ -0,0 +1,45 @@ +namespace BuildingBlocks.Domain; + +/// +/// Immutable value compared by structure (), not by reference. +/// Prefer a static Create (or similar) factory that validates and returns the VO. +/// +public abstract class ValueObject : IEquatable +{ + /// Ordered components used for equality and hashing. + protected abstract IEnumerable GetEqualityComponents(); + + /// + public bool Equals(ValueObject? other) + { + if (other is null || GetType() != other.GetType()) + return false; + + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + /// + public override bool Equals(object? obj) => obj is ValueObject other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var component in GetEqualityComponents()) + hash.Add(component); + return hash.ToHashCode(); + } + + /// Structural equality (nulls are equal to each other). + public static bool operator ==(ValueObject? left, ValueObject? right) + { + if (left is null && right is null) + return true; + if (left is null || right is null) + return false; + return left.Equals(right); + } + + /// Structural inequality. + public static bool operator !=(ValueObject? left, ValueObject? right) => !(left == right); +} diff --git a/src/BuildingBlocks/Idempotency/AGENTS.md b/src/BuildingBlocks/Idempotency/AGENTS.md index efade0b..0fa175b 100644 --- a/src/BuildingBlocks/Idempotency/AGENTS.md +++ b/src/BuildingBlocks/Idempotency/AGENTS.md @@ -25,6 +25,7 @@ telemetry.AddSource("BuildingBlocks.Idempotency"); [Idempotent(useLock: true)] public async Task Create(...) { } +app.UseIdempotencyRequestBuffering(); // before endpoints — Minimal API binds before IEndpointFilter app.MapPost("/path", handler).WithIdempotency(useLock: true); ``` @@ -34,6 +35,7 @@ app.MapPost("/path", handler).WithIdempotency(useLock: true); - Cache all **2xx**; replay envelope + configurable replay header - Errors: ProblemDetails (`https://buildingblocks.dev/errors/idempotency/...`) - Fingerprint default **off** (Exp 3); on → method+path+body SHA-256 (Exp 12) +- Minimal API + fingerprint requires `UseIdempotencyRequestBuffering()` (endpoint filters run after `[FromBody]` binding) - Lock only around GetOrCreate when `UseLock` (Exp 4) - MVC ObjectResult body and cache envelope: System.Text.Json; Minimal API `IResult`: System.Text.Json - Telemetry optional; no cache-key tag by default; no BuildingBlocks.Telemetry package ref diff --git a/src/BuildingBlocks/Idempotency/AspNetCore/IdempotencyRequestBufferingMiddleware.cs b/src/BuildingBlocks/Idempotency/AspNetCore/IdempotencyRequestBufferingMiddleware.cs new file mode 100644 index 0000000..f22f329 --- /dev/null +++ b/src/BuildingBlocks/Idempotency/AspNetCore/IdempotencyRequestBufferingMiddleware.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace BuildingBlocks.Idempotency.AspNetCore; + +/// +/// Enables request body buffering early for requests that carry an +/// Idempotency-Key so Minimal API can fingerprint +/// the body after model binding has already read it. +/// +/// +/// MVC uses a resource filter that buffers before binding. +/// Minimal API endpoint filters run after binding; without early buffering the body stream is +/// empty when hashes it and fingerprint conflicts never fire. +/// +public static class IdempotencyApplicationBuilderExtensions +{ + /// + /// Buffers the request body when the configured idempotency header is present. + /// Call before endpoint routing (typically early in the pipeline, after exception handling). + /// + public static IApplicationBuilder UseIdempotencyRequestBuffering(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app.Use(async (context, next) => + { + var options = context.RequestServices.GetService>()?.CurrentValue + ?? context.RequestServices.GetService>()?.Value; + + if (options is not null + && context.Request.Headers.ContainsKey(options.HeaderName)) + { + context.Request.EnableBuffering(); + } + + await next().ConfigureAwait(false); + }); + } +} diff --git a/src/BuildingBlocks/Idempotency/AspNetCore/IdempotentEndpointFilter.cs b/src/BuildingBlocks/Idempotency/AspNetCore/IdempotentEndpointFilter.cs index 12bc3de..4030345 100644 --- a/src/BuildingBlocks/Idempotency/AspNetCore/IdempotentEndpointFilter.cs +++ b/src/BuildingBlocks/Idempotency/AspNetCore/IdempotentEndpointFilter.cs @@ -194,10 +194,13 @@ public static RouteHandlerBuilder WithIdempotency( return builder.AddEndpointFilter(async (context, next) => { var sp = context.HttpContext.RequestServices; + var options = sp.GetService>()?.CurrentValue + ?? sp.GetService>()?.Value + ?? new IdempotencyOptions(); var filter = new IdempotentEndpointFilter( sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetService>()?.Value ?? new IdempotencyOptions(), + options, endpoint, sp.GetService(), sp.GetService()); diff --git a/src/BuildingBlocks/Idempotency/Core/IdempotencyGate.cs b/src/BuildingBlocks/Idempotency/Core/IdempotencyGate.cs index 6259824..9645141 100644 --- a/src/BuildingBlocks/Idempotency/Core/IdempotencyGate.cs +++ b/src/BuildingBlocks/Idempotency/Core/IdempotencyGate.cs @@ -70,8 +70,14 @@ public async Task BeginAsync( if (request.Body.CanSeek) request.Body.Position = 0; + // Copy to memory so fingerprint works for Minimal API (pipe-backed bodies) + // as well as MVC, then rewind the request stream for model binding. + await using var buffered = new MemoryStream(); + await request.Body.CopyToAsync(buffered, cancellationToken).ConfigureAwait(false); + buffered.Position = 0; + requestFingerprint = await IdempotencyFingerprint - .ComputeAsync(request.Method, request.Path.Value ?? string.Empty, request.Body, cancellationToken) + .ComputeAsync(request.Method, request.Path.Value ?? string.Empty, buffered, cancellationToken) .ConfigureAwait(false); if (request.Body.CanSeek) diff --git a/src/BuildingBlocks/Idempotency/PACKAGE_README.md b/src/BuildingBlocks/Idempotency/PACKAGE_README.md index 0ac1804..901a539 100644 --- a/src/BuildingBlocks/Idempotency/PACKAGE_README.md +++ b/src/BuildingBlocks/Idempotency/PACKAGE_README.md @@ -53,7 +53,9 @@ builder.Services.AddBuildingBlocksIdempotency(o => [Idempotent(useLock: true)] public async Task> Create([FromBody] CreateOrder request) { ... } -// Minimal API +// Minimal API — call UseIdempotencyRequestBuffering() early in the pipeline so +// EnableRequestFingerprint can rewind the body after [FromBody] binding. +app.UseIdempotencyRequestBuffering(); app.MapPost("/orders", CreateAsync).WithIdempotency(useLock: true); ``` diff --git a/src/BuildingBlocks/Pagination.Dapper/README.md b/src/BuildingBlocks/Pagination.Dapper/README.md index bcfd160..74b3e74 100644 --- a/src/BuildingBlocks/Pagination.Dapper/README.md +++ b/src/BuildingBlocks/Pagination.Dapper/README.md @@ -1,6 +1,6 @@ # BuildingBlocks.Pagination.Dapper -In-repo **project only** — not a NuGet package. FeatureFusion showcases it at `POST /api/v2/Product/products-dapper`. +In-repo **project only** — not a NuGet package. FeatureFusion showcases it at `POST /api/v1/Product/products-dapper`. Uses the Pagination IR project (`IsPackable=false`). Hosts that want NuGet pagination should use **BuildingBlocks.Pagination.EntityFrameworkCore** (1.1.0: Npgsql row comparison of any width, `NULLS FIRST/LAST` on PG/Sqlite). diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/BuildingBlocks.Pagination.EntityFrameworkCore.csproj b/src/BuildingBlocks/Pagination.EntityFrameworkCore/BuildingBlocks.Pagination.EntityFrameworkCore.csproj index 2ce477a..da01a14 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/BuildingBlocks.Pagination.EntityFrameworkCore.csproj +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/BuildingBlocks.Pagination.EntityFrameworkCore.csproj @@ -44,16 +44,16 @@ - - + + - - + + - - + + diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md b/src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md index af36f53..e9b8442 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md @@ -204,9 +204,9 @@ The IR (`SortKey`, `CursorCodec`, `CursorPage`) is a non-packable sibling projec Same `GetProductsQuery` on the FeatureFusion PostgreSQL catalog (do not set `QueryHint.ReadUncommitted`): -- `GET /api/v2/products-page` — Minimal API (EF; POST kept) -- `POST /api/v2/Product/products` — MVC controller (EF) -- `POST /api/v2/Product/products-dapper` — MVC Dapper showcase (not packed) +- `GET /api/v1/products-page` — Minimal API (EF; POST kept) +- `POST /api/v1/Product/products` — Minimal API (EF) +- `POST /api/v1/Product/products-dapper` — Dapper showcase (not packed) - MCP `products.list` First page → `NextCursor` → `PreviousCursor`: see the [pagination docs](https://github.com/Maxofpower/FeatureFusion/blob/main/docs/building-blocks/pagination.md#runnable-showcase-featurefusion). diff --git a/src/Lab/FeatureFusion.AppHost/Program.cs b/src/Lab/FeatureFusion.AppHost/Program.cs index eaa6835..249fe49 100644 --- a/src/Lab/FeatureFusion.AppHost/Program.cs +++ b/src/Lab/FeatureFusion.AppHost/Program.cs @@ -13,7 +13,7 @@ builder.AddProject("featurefusion") .WithHttpEndpoint(port: 5141, name: "http") .WithEndpoint(7762, targetPort: 5002, scheme: "https", name: "featurefusion-https") - .WithUrl("/swagger/index.html?urls.primaryName=v2", "Swagger v2") + .WithUrl("/swagger/index.html?urls.primaryName=v1", "Swagger v1") .WithInfrastructure(infra) .WithSigNozOtlpExporter(signoz); diff --git a/src/Lab/FeatureFusion.ServiceDefaults/Extensions.cs b/src/Lab/FeatureFusion.ServiceDefaults/Extensions.cs index cb50204..4443cec 100644 --- a/src/Lab/FeatureFusion.ServiceDefaults/Extensions.cs +++ b/src/Lab/FeatureFusion.ServiceDefaults/Extensions.cs @@ -26,12 +26,11 @@ public static TBuilder AddServiceDefaults( => builder.AddServiceDefaults(configureOptions: null, configureTelemetry); /// - /// Adds Aspire service defaults with dynamic Telemetry options (food-delivery style) and optional builder hooks. + /// Adds Aspire service defaults with callback-configured telemetry options and optional builder hooks. /// /// Host builder. /// /// Options overrides: pillars, instrumentations, OTLP endpoint/protocol/headers, sampling. - /// Same idea as food-delivery AddCustomOpenTelemetry(Action<OpenTelemetryOptions>). /// Also bindable from Telemetry config / OTEL_* env without this callback. /// /// diff --git a/src/Lab/FeatureFusion/Apis/MinimalApiGreeting.cs b/src/Lab/FeatureFusion/Apis/MinimalApiGreeting.cs deleted file mode 100644 index ac67eed..0000000 --- a/src/Lab/FeatureFusion/Apis/MinimalApiGreeting.cs +++ /dev/null @@ -1,176 +0,0 @@ -using BuildingBlocks.Idempotency.AspNetCore; -using BuildingBlocks.Mcp; -using BuildingBlocks.Mcp.Hosting; -using Asp.Versioning.Conventions; -using FeatureFusion.Domain.Entities; -using FeatureFusion.Dtos; -using FeatureFusion.Infrastructure.Extensions; -using FeatureFusion.Services.ProductService; -using FluentValidation; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using Microsoft.FeatureManagement; -using System; - - -namespace FeatureFusion.API.V2 -{ - public static class FeatureMinimalsApi - { - public static RouteGroupBuilder MapGreetingApiV2(this IEndpointRouteBuilder app) - { - // Create a version set - var apiVersionSet = app.NewApiVersionSet() - .HasApiVersion(2.0) - .ReportApiVersions() - .Build(); - - var api = app.MapGroup("api/v{version:apiVersion}") - .WithApiVersionSet(apiVersionSet) - .MapToApiVersion(2.0); - - api.MapGet("/product-promotion", GetProductPromotion); - api.MapGet("/product-recommendation", GetProductRocemmendation); - - // to present manual Validation handling with dipendency injection - api.MapPost("/minimal-custom-greeting", GetCustomGreeting) - .Produces>() - .ProducesValidationProblem() - .Produces>(); - - - // Approach 1: Using a generic endpoint filter with `AddEndpointFilter` - // This approach applies validation by adding a generic endpoint filter to the route. - api.MapPost("/person-endpointfilter", HandleCreatePerson) - .AddEndpointFilter>(); - - // Approach 2: Using a generic endpoint extension method with `WithValidation` - // This approach applies validation fluently using a custom extension method. - api.MapPost("/person-builderextension", HandleCreatePerson) - .WithValidation(); - - // Approach 3: Using a custom route handler builder extension - // This approach encapsulates the endpoint definition and validation in a single method. - api.MapPostWithValidation("/person-genericendpoint", HandleCreatePerson); - - api.MapGet("/lab-ping", LabPing) - .WithName("LabPing") - .WithSummary("Minimal API ping (not a Mediator command). Same method is an MCP tool lab.ping.") - .WithMcp(app); - - // Smoke: BuildingBlocks.Idempotency Minimal API surface (not an Exp gate). - api.MapPost("/idempotency-smoke", () => Results.Ok(new { ok = true })) - .WithName("IdempotencySmoke") - .WithSummary("Minimal API Idempotency-Key smoke (WithIdempotency).") - .WithIdempotency(useLock: true); - - return api; - } - - public static async Task>, NotFound>> GetProductPromotion( - IProductService productService, - bool getFromMemCach = false) - { - try - { - var promotions = await productService.GetProductPromotionAsync(getFromMemCach); - - // Return TypedResults.Ok with a list of ProductPromotion - return TypedResults.Ok(promotions); - } - catch - { - // Return TypedResults.NotFound with an error message - return TypedResults.NotFound("An error occurred while fetching promotions."); - } - } - - public static async Task>, NotFound>> GetProductRocemmendation( - IProductService productService) - { - try - { - var recommedation = await productService.GetProductRocemmendationAsync(); - - return TypedResults.Ok(recommedation); - } - catch - { - return TypedResults.NotFound("An error occurred while fetching promotions."); - } - } - - // 1- to present manual Validation handling with dipendency injection - public static async Task, BadRequest, NotFound>> GetCustomGreeting - ([AsParameters] GreetingDto greeting, - GreetingValidator validator, - IFeatureManager featureManager, - ILogger _logger) - { - // Use the validator to validate the incoming model - var validationResult = await validator.ValidateWithResultAsync(greeting); - - if (!validationResult.IsValid) - { - _logger.LogWarning("validation error on {GreetingType}: {Errors}", - nameof(GreetingDto), validationResult.ProblemDetails!.Errors); - - // Return problem details if validation fails - return TypedResults.BadRequest(validationResult.ProblemDetails); - - } - - if (await featureManager.IsEnabledAsync("CustomGreeting")) - { - return TypedResults.Ok($"Hello VIP user {greeting.Fullname}, this is your custom greeting V2!"); - } - - return TypedResults.Ok("Hello Anonymous user!"); - } - - - // 2- to present dynamic Validation with generic endpoint filter - public static async Task, BadRequest, NotFound>> HandleCreatePerson - ([AsParameters] PersonDto person, - IFeatureManager featureManager) - { - - if (await featureManager.IsEnabledAsync("CustomGreeting")) - { - return TypedResults.Ok($"Hello VIP person {person.Name}, this is your custom greeting V2!"); - } - - return TypedResults.Ok($"Hello Guest person! {person.Name}"); - } - - // 3- to present dynamic Validation with generic ModelBinder - public static async Task, BadRequest, NotFound>> HandleCreatePerson2 - ([AsParameters] PersonDto person, - IFeatureManager featureManager) - { - - if (await featureManager.IsEnabledAsync("CustomGreeting")) - { - return TypedResults.Ok($"Hello VIP person {person.Name}, this is your custom greeting V2!"); - } - - return TypedResults.Ok($"Hello Guest person! {person.Name}"); - } - - /// - /// HTTP GET /api/v2/lab-ping and MCP tool lab.ping — same method, not a Mediator command. - /// - [McpTool("lab.ping", Description = "Minimal API ping (not a Mediator command)", Kind = McpToolKind.Query)] - public static string LabPing([AsParameters] LabPingRequest request) - => string.IsNullOrWhiteSpace(request.Name) ? "pong" : $"pong:{request.Name}"; - } - - public sealed class LabPingRequest - { - public string Name { get; set; } = default!; - } -} - - - - diff --git a/src/Lab/FeatureFusion/Controllers/V1/Authentication.cs b/src/Lab/FeatureFusion/Controllers/V1/Authentication.cs deleted file mode 100644 index 0702901..0000000 --- a/src/Lab/FeatureFusion/Controllers/V1/Authentication.cs +++ /dev/null @@ -1,38 +0,0 @@ -using FeatureFusion.Dtos; -using FeatureFusion.Services.Authentication; -using Microsoft.AspNetCore.Mvc; - -namespace FeatureFusion.Controllers.V1 - -{ - //[ApiVersion("1.0")] - [Route("api/v{version:apiVersion}/[controller]")] - [ApiController] - public class AuthController : ControllerBase - { - private readonly IAuthService _authService; - - public AuthController(IAuthService authService) - { - _authService = authService; - } - - [HttpPost("login")] - public IActionResult Login([FromBody] LoginDto login) - { - if (_authService.ValidateVipUser(login.Username, login.Password)) - { - var token = _authService.GenerateJwtToken(login.Username, isVip: true); - return Ok(new { token }); - } - else - { - var token = _authService.GenerateJwtToken(login.Username, isVip: false); - return Ok(new { token }); - } - - - } - - } -} \ No newline at end of file diff --git a/src/Lab/FeatureFusion/Controllers/V1/GreetingController.cs b/src/Lab/FeatureFusion/Controllers/V1/GreetingController.cs deleted file mode 100644 index bfb86d0..0000000 --- a/src/Lab/FeatureFusion/Controllers/V1/GreetingController.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.FeatureManagement; - -namespace FeatureFusion.Controllers.V1 - -{ - //[ApiVersion("1.0")] - [ApiController] - [Route("api/v{version:apiVersion}/[controller]")] - - public class GreetingController : ControllerBase - { - private readonly IFeatureManagerSnapshot _featureManager; - - public GreetingController(IFeatureManagerSnapshot featureManager) - { - _featureManager = featureManager; - } - // [MapToApiVersion("1.0")] - [HttpGet("custom-greeting")] - public async Task GetCustomGreeting() - { - if (await _featureManager.IsEnabledAsync("CustomGreeting")) - { - return Ok("Hello VIP user, this is your custom greeting!"); - } - - return Ok("Hello Anonymous user!"); - } - } -} \ No newline at end of file diff --git a/src/Lab/FeatureFusion/Controllers/V2/Authentication.cs b/src/Lab/FeatureFusion/Controllers/V2/Authentication.cs deleted file mode 100644 index 9fd8796..0000000 --- a/src/Lab/FeatureFusion/Controllers/V2/Authentication.cs +++ /dev/null @@ -1,37 +0,0 @@ -using FeatureFusion.Dtos; -using FeatureFusion.Services.Authentication; -using Microsoft.AspNetCore.Mvc; - -namespace FeatureFusion.Controllers.V2 -{ - //[ApiVersion("2.0")] - [Route("api/v{version:apiVersion}/[controller]")] - [ApiController] - public class AuthController : ControllerBase - { - private readonly IAuthService _authService; - - public AuthController(IAuthService authService) - { - _authService = authService; - } - - [HttpPost("login")] - public IActionResult Login([FromBody] LoginDto login) - { - if (_authService.ValidateVipUser(login.Username, login.Password)) - { - var token = _authService.GenerateJwtToken(login.Username, isVip: true); - return Ok(new { token }); - } - else - { - var token = _authService.GenerateJwtToken(login.Username, isVip: false); - return Ok(new { token }); - } - - - } - - } -} \ No newline at end of file diff --git a/src/Lab/FeatureFusion/Controllers/V2/GreetingController.cs b/src/Lab/FeatureFusion/Controllers/V2/GreetingController.cs deleted file mode 100644 index 2f15efd..0000000 --- a/src/Lab/FeatureFusion/Controllers/V2/GreetingController.cs +++ /dev/null @@ -1,65 +0,0 @@ -using FeatureFusion.Dtos; -using FeatureFusion.Infrastructure.Filters; -using FeatureFusion.Models; -using FeatureFusion.Services.FeatureToggleService; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using Microsoft.FeatureManagement; - -namespace FeatureFusion.Controllers.V2 - -{ - - [ApiController] - [Route("api/v{version:apiVersion}/[controller]")] - public class GreetingController : ControllerBase - { - private readonly IFeatureManagerSnapshot _featureManager; - private readonly GreetingValidator _validator; - private readonly IFeatureToggleService _featureToggleService; - - - public GreetingController(IFeatureManagerSnapshot featureManager, - GreetingValidator validator - , IFeatureToggleService featureToggleService) - { - - _featureManager = featureManager; - _validator = validator; - _featureToggleService = featureToggleService; - } - - //leveraging coR pattern with some static rules - [HttpPost("custom-greeting")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] // Ok - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ValidationProblemDetails))] // BadRequest - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(string))] // NotFound - public async Task, BadRequest, NotFound>> GetCustomGreeting(GreetingDto greeting) - { - - var validationResult = await _validator.ValidateWithResultAsync(greeting); - - if (!validationResult.IsValid) - { - - return TypedResults.BadRequest(validationResult.ProblemDetails); - } - - //for testing purpose - a static customer - var user = new UserDto("Admin", true, false); - - bool greetingAccess = await _featureToggleService.CanAccessFeatureAsync(user); // ? Evaluates all rules - - if (greetingAccess) - { - return TypedResults.Ok($"Hello VIP user {greeting.Fullname}, this is your custom greeting V2!"); - } - - return TypedResults.Ok("Hello Anonymous user V2!"); - } - - } - -} - - diff --git a/src/Lab/FeatureFusion/Controllers/V2/OrderController.cs b/src/Lab/FeatureFusion/Controllers/V2/OrderController.cs deleted file mode 100644 index c2511d3..0000000 --- a/src/Lab/FeatureFusion/Controllers/V2/OrderController.cs +++ /dev/null @@ -1,52 +0,0 @@ -using BuildingBlocks.Idempotency.AspNetCore; -using BuildingBlocks.Mediator; -using FeatureFusion.Models; -using FeatureFusion.Services.FeatureToggleService; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; -using FeatureFusion.Features.Orders.Commands; - - -namespace FeatureFusion.Controllers.V2 -{ - [ApiController] - [Route("api/v{version:apiVersion}/[controller]")] - public class OrderController : Controller - { - private readonly OrderRequestValidator _validator; - private readonly ISender _sender; - public OrderController(OrderRequestValidator validator, ISender sender) - { - _validator = validator; - _sender = sender; - } - - // to test idempotent-filter , validation , mediator , rabbitmq - [HttpPost("order")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(OrderResponse))] // Ok - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ValidationProblemDetails))] // BadRequest - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(string))] // NotFound - [Idempotent(useLock: true)] // Apply the Idempotent attribute - public async Task> CreateOrder([FromBody] CreateOrderCommand request) - { - // Validate the request - var validationResult = await _validator.ValidateWithResultAsync(request); - - if (!validationResult.IsValid) - { - return BadRequest(validationResult.ProblemDetails); - } - - var createOrderResult = await _sender.Send(request); - - // Unwrap Result so JSON serialization does not touch Result.Error on success. - return createOrderResult.Match>( - onSuccess: value => Ok(value), - onFailure: (error, statusCode) => StatusCode(statusCode, error)); - } - - } -} - - diff --git a/src/Lab/FeatureFusion/Controllers/V2/ProductController.cs b/src/Lab/FeatureFusion/Controllers/V2/ProductController.cs deleted file mode 100644 index d09f048..0000000 --- a/src/Lab/FeatureFusion/Controllers/V2/ProductController.cs +++ /dev/null @@ -1,109 +0,0 @@ -using FeatureFusion.Infrastructure.Filters; -using BuildingBlocks.Mediator; -using FeatureFusion.Models; -using FeatureFusion.Services.FeatureToggleService; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; -using FeatureFusion.Dtos; -using FeatureFusion.Infrastructure.CursorPagination; -using FeatureFusion.Dtos.Validator; -using FluentValidation; -using FeatureFusion.Features.Products.Queries; - - -namespace FeatureFusion.Controllers.V2 -{ - [ApiController] - [Route("api/v{version:apiVersion}/[controller]")] - public class ProductController : Controller - { - private readonly GetProductsCommandValidator _validator; - private readonly ISender _sender; - public ProductController(GetProductsCommandValidator validator, ISender sender) - { - _validator = validator; - _sender = sender; - } - - /// - /// Keyset catalog page (same as - /// GET /api/v2/products-page). Prefer the GET Minimal API for new clients. - /// - [HttpPost("products")] - [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] - public async Task>, - BadRequest, ProblemHttpResult>> GetProducts( - [FromQuery] GetProductsQuery command, - CancellationToken cancellationToken) - { - { - var validationResult = await _validator.ValidateWithResultAsync(command); - if (validationResult.HasErrors()) - { - return TypedResults.BadRequest(validationResult.ProblemDetails); - } - - var result = await _sender.Send(command, cancellationToken); - - return result.ToHttpResult(); - } - } - - /// Dapper showcase of the same products table (main list stays EF). - [HttpPost("products-dapper")] - [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] - public async Task>, - BadRequest, ProblemHttpResult>> GetProductsDapper( - [FromQuery] GetProductsQuery command, - [FromServices] FeatureFusion.Services.ProductService.IProductService products, - CancellationToken cancellationToken) - { - var validationResult = await _validator.ValidateWithResultAsync(command); - if (validationResult.HasErrors()) - { - return TypedResults.BadRequest(validationResult.ProblemDetails); - } - - var result = await products.GetProductsViaDapperAsync( - command.Limit, - command.SortBy, - command.SortDirection, - command.Cursor, - (BuildingBlocks.Pagination.PageDirection)command.PageDirection, - cancellationToken); - - return result.ToHttpResult(); - } - - } - - public static class ResultExtensions - { - public static Results, BadRequest, ProblemHttpResult> ToHttpResult(this Result result) - { - return result.Match, BadRequest, ProblemHttpResult>>( - success => TypedResults.Ok(success), - (error, statusCode) => - { - var errors = new Dictionary - { - { "General", new[] { error } } - }; - return TypedResults.BadRequest(new ValidationProblemDetails(errors) - { - Title = "Request Error", - Detail = error, - Status = statusCode - }); - }); - } - } -} - - - diff --git a/src/Lab/FeatureFusion/Domain/Carts/Cart.cs b/src/Lab/FeatureFusion/Domain/Carts/Cart.cs new file mode 100644 index 0000000..d9259bf --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Carts/Cart.cs @@ -0,0 +1,86 @@ +using BuildingBlocks.Domain; +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Customers; + +namespace FeatureFusion.Domain.Carts; + +/// One cart per customer. Does not own product prices. +public class Cart : AggregateRoot +{ + private readonly List _items = []; + + public CustomerId CustomerId { get; private set; } = null!; + public DateTime UpdatedAtUtc { get; private set; } + public IReadOnlyCollection Items => _items; + + private Cart() + { + } + + public static Cart Create(CustomerId customerId, DateTime updatedAtUtc, CartId? id = null) + { + ArgumentNullException.ThrowIfNull(customerId); + if (updatedAtUtc.Kind != DateTimeKind.Utc) + throw new DomainException("UpdatedAt must be UTC."); + + var cart = new Cart + { + CustomerId = customerId, + UpdatedAtUtc = updatedAtUtc + }; + if (id is not null) + cart.Id = id; + return cart; + } + + public void AddOrIncrement(ProductId productId, int quantity, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(productId); + if (quantity <= 0) + throw new DomainException("Quantity must be positive."); + Touch(utcNow); + + var existing = _items.FirstOrDefault(i => i.ProductId == productId); + if (existing is null) + _items.Add(CartItem.Create(Id ?? CartId.From(0), productId, quantity)); + else + existing.AddQuantity(quantity); + } + + public void SetQuantity(ProductId productId, int quantity, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(productId); + Touch(utcNow); + var existing = _items.FirstOrDefault(i => i.ProductId == productId) + ?? throw new DomainException("Cart item not found."); + if (quantity <= 0) + { + _items.Remove(existing); + return; + } + + existing.SetQuantity(quantity); + } + + public void Remove(ProductId productId, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(productId); + Touch(utcNow); + var existing = _items.FirstOrDefault(i => i.ProductId == productId); + if (existing is not null) + _items.Remove(existing); + } + + public void Clear(DateTime utcNow) + { + Touch(utcNow); + _items.Clear(); + } + + private void Touch(DateTime utcNow) + { + if (utcNow.Kind != DateTimeKind.Utc) + throw new DomainException("UpdatedAt must be UTC."); + UpdatedAtUtc = utcNow; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Carts/CartId.cs b/src/Lab/FeatureFusion/Domain/Carts/CartId.cs new file mode 100644 index 0000000..ef4b274 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Carts/CartId.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Carts; + +public sealed record CartId : AggregateId +{ + public CartId(int value) : base(value) + { + } + + public static CartId From(int value) => new(value); + public static implicit operator CartId(int value) => new(value); + public static explicit operator int(CartId id) => id.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Carts/CartItem.cs b/src/Lab/FeatureFusion/Domain/Carts/CartItem.cs new file mode 100644 index 0000000..eb6cc3c --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Carts/CartItem.cs @@ -0,0 +1,49 @@ +using BuildingBlocks.Domain; +using FeatureFusion.Domain.Catalog; + +namespace FeatureFusion.Domain.Carts; + +/// Cart line — quantity only; catalog price is never stored here. +public class CartItem : Entity +{ + public CartId CartId { get; private set; } = null!; + public Cart? Cart { get; private set; } + public ProductId ProductId { get; private set; } = null!; + public int Quantity { get; private set; } + + private CartItem() + { + } + + internal static CartItem Create(CartId cartId, ProductId productId, int quantity, CartItemId? id = null) + { + if (quantity <= 0) + throw new DomainException("Cart item quantity must be positive."); + var item = new CartItem + { + CartId = cartId, + ProductId = productId, + Quantity = quantity + }; + if (id is not null) + item.Id = id; + return item; + } + + internal void SetQuantity(int quantity) + { + if (quantity <= 0) + throw new DomainException("Cart item quantity must be positive."); + Quantity = quantity; + } + + internal void AddQuantity(int delta) + { + if (delta <= 0) + throw new DomainException("Quantity delta must be positive."); + var next = Quantity + delta; + if (next <= 0) + throw new DomainException("Cart item quantity overflow."); + Quantity = next; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Carts/CartItemId.cs b/src/Lab/FeatureFusion/Domain/Carts/CartItemId.cs new file mode 100644 index 0000000..413cd65 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Carts/CartItemId.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Carts; + +public sealed record CartItemId : EntityId +{ + public CartItemId(int value) : base(value) + { + } + + public static CartItemId From(int value) => new(value); + public static implicit operator CartItemId(int value) => new(value); + public static explicit operator int(CartItemId id) => id.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/Brand.cs b/src/Lab/FeatureFusion/Domain/Catalog/Brand.cs new file mode 100644 index 0000000..dbdd23e --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/Brand.cs @@ -0,0 +1,37 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Catalog brand used for listing filters and detail attribution. +public class Brand : Entity +{ + /// Display name. + public string Name { get; private set; } = ""; + + /// Stable public slug. + public Slug Slug { get; private set; } = null!; + + /// Optional logo path for listing chips and detail headers. + public string? LogoUrl { get; private set; } + + private Brand() + { + } + + /// Creates a brand. + public static Brand Create(string name, Slug? slug = null, string? logoUrl = null, BrandId? id = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Brand name is required."); + + var brand = new Brand + { + Name = name.Trim(), + Slug = slug ?? Slug.FromName(name), + LogoUrl = string.IsNullOrWhiteSpace(logoUrl) ? null : logoUrl.Trim() + }; + if (id is not null) + brand.Id = id; + return brand; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/BrandId.cs b/src/Lab/FeatureFusion/Domain/Catalog/BrandId.cs new file mode 100644 index 0000000..b8bbcc4 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/BrandId.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Strongly-typed brand identity (catalog bounded context). +public sealed record BrandId : EntityId +{ + /// Creates a brand id. Zero and negatives are allowed (EF temporary keys before insert). + public BrandId(int value) : base(value) + { + } + + /// Factory used by EF converters and domain code. + public static BrandId From(int value) => new(value); + + /// Implicit from the persistence primitive. + public static implicit operator BrandId(int value) => new(value); +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/Category.cs b/src/Lab/FeatureFusion/Domain/Catalog/Category.cs new file mode 100644 index 0000000..4bbb185 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/Category.cs @@ -0,0 +1,33 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Flat catalog category used for listing filters and detail breadcrumbs. +public class Category : Entity +{ + /// Display name. + public string Name { get; private set; } = ""; + + /// Stable public slug. + public Slug Slug { get; private set; } = null!; + + private Category() + { + } + + /// Creates a category. + public static Category Create(string name, Slug? slug = null, CategoryId? id = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Category name is required."); + + var category = new Category + { + Name = name.Trim(), + Slug = slug ?? Slug.FromName(name) + }; + if (id is not null) + category.Id = id; + return category; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/CategoryId.cs b/src/Lab/FeatureFusion/Domain/Catalog/CategoryId.cs new file mode 100644 index 0000000..ae89d4a --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/CategoryId.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Strongly-typed category identity (catalog bounded context). +public sealed record CategoryId : EntityId +{ + /// Creates a category id. Zero and negatives are allowed (EF temporary keys before insert). + public CategoryId(int value) : base(value) + { + } + + /// Factory used by EF converters and domain code. + public static CategoryId From(int value) => new(value); + + /// Implicit from the persistence primitive. + public static implicit operator CategoryId(int value) => new(value); +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/Product.cs b/src/Lab/FeatureFusion/Domain/Catalog/Product.cs new file mode 100644 index 0000000..f5d42a0 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/Product.cs @@ -0,0 +1,184 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// +/// Catalog product aggregate. Listing pages read name, price, primary image, brand, and category. +/// Detail pages also load gallery images and specifications. +/// +public class Product : AggregateRoot, IHaveSoftDelete +{ + private readonly List _images = []; + private readonly List _specifications = []; + + /// Display name. + public string Name { get; private set; } = ""; + + /// Unique stock-keeping unit. + public Sku Sku { get; private set; } = null!; + + /// Public detail-route slug. + public Slug Slug { get; private set; } = null!; + + /// One-line summary for listing cards. + public string? ShortDescription { get; private set; } + + /// Long copy for the detail page. + public string? FullDescription { get; private set; } + + /// Whether the product appears in listing and detail. + public bool Published { get; private set; } + + /// + public bool Deleted { get; private set; } + + /// Whether the product is sold as a standalone listing. + public bool VisibleIndividually { get; private set; } = true; + + /// Unit price in EUR. + public decimal Price { get; private set; } + + /// Available units. Zero means out of stock. + public int StockQuantity { get; private set; } + + /// Owning brand (required). Identity is the source of truth. + public BrandId BrandId { get; private set; } = null!; + + /// Loaded brand navigation; null when the query did not include it. + public Brand? Brand { get; private set; } + + /// Owning category (required). Identity is the source of truth. + public CategoryId CategoryId { get; private set; } = null!; + + /// Loaded category navigation; null when the query did not include it. + public Category? Category { get; private set; } + + /// UTC creation timestamp (listing sort). + public DateTime CreatedAt { get; private set; } + + /// Gallery owned by this product. + public IReadOnlyCollection Images => _images; + + /// Specification rows owned by this product. + public IReadOnlyCollection Specifications => _specifications; + + private Product() + { + } + + /// Creates a catalog product. + public static Product Create( + string name, + Sku sku, + decimal price, + int stockQuantity, + BrandId brandId, + CategoryId categoryId, + DateTime createdAtUtc, + Slug? slug = null, + string? shortDescription = null, + string? fullDescription = null, + bool published = true, + bool visibleIndividually = true, + ProductId? id = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Product name is required."); + ArgumentNullException.ThrowIfNull(sku); + ArgumentNullException.ThrowIfNull(brandId); + ArgumentNullException.ThrowIfNull(categoryId); + if (price < 0) + throw new DomainException("Product price cannot be negative."); + if (stockQuantity < 0) + throw new DomainException("Stock quantity cannot be negative."); + if (createdAtUtc.Kind != DateTimeKind.Utc) + throw new DomainException("CreatedAt must be UTC."); + + var product = new Product + { + Name = name.Trim(), + Sku = sku, + Slug = slug ?? Slug.FromName(name), + ShortDescription = string.IsNullOrWhiteSpace(shortDescription) ? null : shortDescription.Trim(), + FullDescription = fullDescription, + Price = price, + StockQuantity = stockQuantity, + BrandId = brandId, + CategoryId = categoryId, + Published = published, + Deleted = false, + VisibleIndividually = visibleIndividually, + CreatedAt = createdAtUtc + }; + if (id is not null) + product.Id = id; + return product; + } + + /// Adds a gallery image. The first primary wins; later primaries are demoted. + public ProductImage AddImage(string url, string altText, int displayOrder, bool isPrimary, ProductImageId? id = null) + { + var productId = Id ?? ProductId.From(0); + if (isPrimary) + { + foreach (var existing in _images) + existing.ClearPrimary(); + } + + var image = ProductImage.Create(productId, url, altText, displayOrder, isPrimary, id); + _images.Add(image); + return image; + } + + /// Adds a specification row. + public ProductSpecification AddSpecification(string name, string value, int displayOrder, ProductSpecificationId? id = null) + { + var productId = Id ?? ProductId.From(0); + var spec = ProductSpecification.Create(productId, name, value, displayOrder, id); + _specifications.Add(spec); + return spec; + } + + /// + /// Whether the product can be sold in the given quantity (published, not deleted, enough stock). + /// + public bool CanFulfill(int quantity) => + Published && !Deleted && quantity > 0 && StockQuantity >= quantity; + + /// + /// Decrements stock for a fulfilled sale. Never goes negative. + /// Bumps for optimistic concurrency. + /// + public bool TryDecrementStock(int quantity) + { + if (!CanFulfill(quantity)) + return false; + StockQuantity -= quantity; + OriginalVersion++; + return true; + } + + /// Updates catalog list price (does not rewrite historical order line snapshots). + public void ChangePrice(decimal newPrice) + { + if (newPrice < 0) + throw new DomainException("Product price cannot be negative."); + Price = newPrice; + OriginalVersion++; + } + + /// In-memory sample used by promotion demos (not persisted). + public static Product CreateDemo(string name, bool published, ProductId? id = null) + { + return Create( + name: name, + sku: Sku.Create($"SKU-DEMO-{(id?.Value ?? 0):D4}"), + price: 0m, + stockQuantity: 0, + brandId: BrandId.From(1), + categoryId: CategoryId.From(1), + createdAtUtc: DateTime.UtcNow, + published: published, + id: id); + } +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/ProductChildIds.cs b/src/Lab/FeatureFusion/Domain/Catalog/ProductChildIds.cs new file mode 100644 index 0000000..574933f --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/ProductChildIds.cs @@ -0,0 +1,33 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Strongly-typed product image identity. +public sealed record ProductImageId : EntityId +{ + /// Creates an image id. Zero and negatives are allowed (EF temporary keys before insert). + public ProductImageId(int value) : base(value) + { + } + + /// Factory used by converters and domain code. + public static ProductImageId From(int value) => new(value); + + /// Implicit from the persistence primitive. + public static implicit operator ProductImageId(int value) => new(value); +} + +/// Strongly-typed product specification identity. +public sealed record ProductSpecificationId : EntityId +{ + /// Creates a specification id. Zero and negatives are allowed (EF temporary keys before insert). + public ProductSpecificationId(int value) : base(value) + { + } + + /// Factory used by converters and domain code. + public static ProductSpecificationId From(int value) => new(value); + + /// Implicit from the persistence primitive. + public static implicit operator ProductSpecificationId(int value) => new(value); +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/ProductId.cs b/src/Lab/FeatureFusion/Domain/Catalog/ProductId.cs new file mode 100644 index 0000000..62feb2b --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/ProductId.cs @@ -0,0 +1,21 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Strongly-typed catalog product identity (catalog bounded context). +public sealed record ProductId : AggregateId +{ + /// Creates a product id. Zero and negatives are allowed (EF temporary keys before insert). + public ProductId(int value) : base(value) + { + } + + /// Factory used by EF converters and domain code. + public static ProductId From(int value) => new(value); + + /// Implicit from the persistence primitive. + public static implicit operator ProductId(int value) => new(value); + + /// To the persistence primitive. Required so keyset OrderBy can convert in expression trees. + public static explicit operator int(ProductId id) => id.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/ProductImage.cs b/src/Lab/FeatureFusion/Domain/Catalog/ProductImage.cs new file mode 100644 index 0000000..5c95226 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/ProductImage.cs @@ -0,0 +1,54 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Gallery image owned by a product (detail page carousel; listing uses the primary). +public class ProductImage : Entity +{ + /// Owning product. + public ProductId ProductId { get; private set; } = null!; + + /// Public media path or URL. + public string Url { get; private set; } = ""; + + /// Accessible alternative text. + public string AltText { get; private set; } = ""; + + /// Sort order within the gallery (ascending). + public int DisplayOrder { get; private set; } + + /// True when this image is the listing thumbnail. + public bool IsPrimary { get; private set; } + + private ProductImage() + { + } + + internal static ProductImage Create( + ProductId productId, + string url, + string altText, + int displayOrder, + bool isPrimary, + ProductImageId? id = null) + { + if (string.IsNullOrWhiteSpace(url)) + throw new DomainException("Image URL is required."); + if (displayOrder < 0) + throw new DomainException("Image display order cannot be negative."); + + var image = new ProductImage + { + ProductId = productId, + Url = url.Trim(), + AltText = string.IsNullOrWhiteSpace(altText) ? "" : altText.Trim(), + DisplayOrder = displayOrder, + IsPrimary = isPrimary + }; + if (id is not null) + image.Id = id; + return image; + } + + internal void ClearPrimary() => IsPrimary = false; +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/ProductSpecification.cs b/src/Lab/FeatureFusion/Domain/Catalog/ProductSpecification.cs new file mode 100644 index 0000000..4554163 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/ProductSpecification.cs @@ -0,0 +1,49 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Name/value specification row shown on the product detail page. +public class ProductSpecification : Entity +{ + /// Owning product. + public ProductId ProductId { get; private set; } = null!; + + /// Specification label (for example Display or Battery). + public string Name { get; private set; } = ""; + + /// Specification value. + public string Value { get; private set; } = ""; + + /// Sort order on the detail page (ascending). + public int DisplayOrder { get; private set; } + + private ProductSpecification() + { + } + + internal static ProductSpecification Create( + ProductId productId, + string name, + string value, + int displayOrder, + ProductSpecificationId? id = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Specification name is required."); + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException("Specification value is required."); + if (displayOrder < 0) + throw new DomainException("Specification display order cannot be negative."); + + var spec = new ProductSpecification + { + ProductId = productId, + Name = name.Trim(), + Value = value.Trim(), + DisplayOrder = displayOrder + }; + if (id is not null) + spec.Id = id; + return spec; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/Sku.cs b/src/Lab/FeatureFusion/Domain/Catalog/Sku.cs new file mode 100644 index 0000000..3e4ee39 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/Sku.cs @@ -0,0 +1,35 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// Stock-keeping unit. Unique per catalog product. +public sealed class Sku : ValueObject +{ + /// Normalized SKU text. + public string Value { get; } + + private Sku(string value) => Value = value; + + /// Creates a SKU (1–64 characters). + public static Sku Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException("SKU is required."); + var trimmed = value.Trim(); + if (trimmed.Length > 64) + throw new DomainException("SKU cannot exceed 64 characters."); + return new Sku(trimmed); + } + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Value.ToUpperInvariant(); + } + + /// + public override string ToString() => Value; + + /// Implicit to the persistence string. + public static implicit operator string(Sku sku) => sku.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Catalog/Slug.cs b/src/Lab/FeatureFusion/Domain/Catalog/Slug.cs new file mode 100644 index 0000000..6b3764a --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Catalog/Slug.cs @@ -0,0 +1,54 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Catalog; + +/// URL-safe catalog identifier used on listing and detail routes. +public sealed class Slug : ValueObject +{ + /// Lowercase hyphenated value. + public string Value { get; } + + private Slug(string value) => Value = value; + + /// Creates a slug (1–128 characters, [a-z0-9-]). + public static Slug Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException("Slug is required."); + var trimmed = value.Trim().ToLowerInvariant(); + if (trimmed.Length > 128) + throw new DomainException("Slug cannot exceed 128 characters."); + for (var i = 0; i < trimmed.Length; i++) + { + var c = trimmed[i]; + if (c is not (>= 'a' and <= 'z' or >= '0' and <= '9' or '-')) + throw new DomainException("Slug may contain only letters, digits, and hyphens."); + } + return new Slug(trimmed); + } + + /// Builds a slug from a display name. + public static Slug FromName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Name is required to build a slug."); + var chars = name.Trim().ToLowerInvariant().Select(c => + c is >= 'a' and <= 'z' or >= '0' and <= '9' ? c : '-').ToArray(); + var raw = new string(chars); + while (raw.Contains("--", StringComparison.Ordinal)) + raw = raw.Replace("--", "-", StringComparison.Ordinal); + return Create(raw.Trim('-')); + } + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + + /// + public override string ToString() => Value; + + /// Implicit conversion to the persisted string. + public static implicit operator string(Slug slug) => slug.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Customers/Customer.cs b/src/Lab/FeatureFusion/Domain/Customers/Customer.cs new file mode 100644 index 0000000..429aa8a --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Customers/Customer.cs @@ -0,0 +1,40 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Customers; + +/// Customer aggregate (identity for orders; not a storefront checkout surface). +public class Customer : AggregateRoot +{ + /// Unique email. + public Email Email { get; private set; } = null!; + + /// Display name. + public string DisplayName { get; private set; } = ""; + + /// UTC creation timestamp. + public DateTime CreatedAt { get; private set; } + + private Customer() + { + } + + /// Creates a customer. + public static Customer Create(Email email, string displayName, DateTime createdAtUtc, CustomerId? id = null) + { + ArgumentNullException.ThrowIfNull(email); + if (string.IsNullOrWhiteSpace(displayName)) + throw new DomainException("Customer display name is required."); + if (createdAtUtc.Kind != DateTimeKind.Utc) + throw new DomainException("CreatedAt must be UTC."); + + var customer = new Customer + { + Email = email, + DisplayName = displayName.Trim(), + CreatedAt = createdAtUtc + }; + if (id is not null) + customer.Id = id; + return customer; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Customers/CustomerId.cs b/src/Lab/FeatureFusion/Domain/Customers/CustomerId.cs new file mode 100644 index 0000000..b1ae759 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Customers/CustomerId.cs @@ -0,0 +1,21 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Customers; + +/// Strongly-typed customer identity (customer bounded context). +public sealed record CustomerId : AggregateId +{ + /// Creates a customer id. Zero and negatives are allowed (EF temporary keys before insert). + public CustomerId(int value) : base(value) + { + } + + /// Factory used by EF converters and domain code. + public static CustomerId From(int value) => new(value); + + /// Implicit from the persistence primitive. + public static implicit operator CustomerId(int value) => new(value); + + /// To the persistence primitive. Required so keyset OrderBy can convert in expression trees. + public static explicit operator int(CustomerId id) => id.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Customers/Email.cs b/src/Lab/FeatureFusion/Domain/Customers/Email.cs new file mode 100644 index 0000000..af6c3e7 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Customers/Email.cs @@ -0,0 +1,35 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Customers; + +/// Customer email address (customer bounded context). +public sealed class Email : ValueObject +{ + /// Normalized email text. + public string Value { get; } + + private Email(string value) => Value = value; + + /// Creates a validated email. + public static Email Create(string value) + { + if (string.IsNullOrWhiteSpace(value) || !value.Contains('@', StringComparison.Ordinal)) + throw new DomainException("Email must be a non-empty address."); + var trimmed = value.Trim(); + if (trimmed.Length > 256) + throw new DomainException("Email cannot exceed 256 characters."); + return new Email(trimmed); + } + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Value.ToLowerInvariant(); + } + + /// + public override string ToString() => Value; + + /// Implicit to the persistence string. + public static implicit operator string(Email email) => email.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Entities/Person.cs b/src/Lab/FeatureFusion/Domain/Entities/Person.cs index c7d227a..7e0b713 100644 --- a/src/Lab/FeatureFusion/Domain/Entities/Person.cs +++ b/src/Lab/FeatureFusion/Domain/Entities/Person.cs @@ -1,12 +1,10 @@ -using FeatureFusion.Models.Validator; using FluentValidation; -using Microsoft.AspNetCore.Mvc; -using System; + namespace FeatureFusion.Domain.Entities { public record Person : BaseEntity { - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public int Age { get; set; } }; public class PersonValidator : AbstractValidator diff --git a/src/Lab/FeatureFusion/Domain/Entities/Product.cs b/src/Lab/FeatureFusion/Domain/Entities/Product.cs deleted file mode 100644 index b8652aa..0000000 --- a/src/Lab/FeatureFusion/Domain/Entities/Product.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace FeatureFusion.Domain.Entities -{ - public record Product : BaseEntity - { - public string Name { get; init; } - public string FullDescription { get; set; } - public bool Published { get; init; } - public bool Deleted { get; init; } - public bool VisibleIndividually { get; init; } - public decimal Price { get; init; } - public DateTime CreatedAt { get; init; } - } -} diff --git a/src/Lab/FeatureFusion/Domain/Orders/Order.cs b/src/Lab/FeatureFusion/Domain/Orders/Order.cs new file mode 100644 index 0000000..86fd312 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Orders/Order.cs @@ -0,0 +1,117 @@ +using BuildingBlocks.Domain; +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Customers; + +namespace FeatureFusion.Domain.Orders; + +/// Order aggregate with price snapshots and controlled lifecycle transitions. +public class Order : AggregateRoot +{ + private readonly List _items = []; + + public OrderNumber OrderNumber { get; private set; } = null!; + public CustomerId CustomerId { get; private set; } = null!; + public Customer? Customer { get; private set; } + public OrderStatus Status { get; private set; } + + /// Sum of line totals (before tax/shipping). + public decimal Subtotal { get; private set; } + + /// Tax amount calculated at checkout (0 for direct CreateOrder). + public decimal TaxAmount { get; private set; } + + /// Shipping fee calculated at checkout (0 for direct CreateOrder). + public decimal ShippingAmount { get; private set; } + + /// Grand total = Subtotal + TaxAmount + ShippingAmount. + public decimal Total { get; private set; } + + public string Currency { get; private set; } = "EUR"; + public DateTime CreatedAt { get; private set; } + public OrderShipping Shipping { get; private set; } = OrderShipping.None(); + public IReadOnlyCollection Items => _items; + + private Order() + { + } + + /// + /// Creates an order with at least one line. + /// When tax/shipping are omitted, grand total equals line subtotal (CreateOrder / Exp path). + /// + public static Order Create( + OrderNumber orderNumber, + CustomerId customerId, + OrderStatus status, + string currency, + DateTime createdAtUtc, + IEnumerable<(ProductId ProductId, int Quantity, decimal UnitPrice, OrderItemId? Id)> lines, + OrderId? id = null, + decimal taxAmount = 0m, + decimal shippingAmount = 0m, + OrderShipping? shipping = null) + { + ArgumentNullException.ThrowIfNull(orderNumber); + ArgumentNullException.ThrowIfNull(customerId); + if (string.IsNullOrWhiteSpace(currency) || currency.Trim().Length != 3) + throw new DomainException("Currency must be a 3-letter code."); + if (createdAtUtc.Kind != DateTimeKind.Utc) + throw new DomainException("CreatedAt must be UTC."); + if (taxAmount < 0) + throw new DomainException("Tax amount cannot be negative."); + if (shippingAmount < 0) + throw new DomainException("Shipping amount cannot be negative."); + + var lineList = lines?.ToList() ?? throw new DomainException("Order lines are required."); + if (lineList.Count == 0) + throw new DomainException("Order must have at least one line."); + + var order = new Order + { + OrderNumber = orderNumber, + CustomerId = customerId, + Status = status, + Currency = currency.Trim().ToUpperInvariant(), + CreatedAt = createdAtUtc, + TaxAmount = decimal.Round(taxAmount, 2, MidpointRounding.AwayFromZero), + ShippingAmount = decimal.Round(shippingAmount, 2, MidpointRounding.AwayFromZero), + Shipping = shipping ?? OrderShipping.None() + }; + if (id is not null) + order.Id = id; + + var orderId = id ?? OrderId.From(0); + foreach (var line in lineList) + order._items.Add(OrderItem.Create(orderId, line.ProductId, line.Quantity, line.UnitPrice, line.Id)); + + order.Subtotal = decimal.Round(order._items.Sum(i => i.LineTotal), 2, MidpointRounding.AwayFromZero); + order.Total = decimal.Round( + order.Subtotal + order.TaxAmount + order.ShippingAmount, + 2, + MidpointRounding.AwayFromZero); + return order; + } + + public void MarkPlaced() + { + if (Status is not (OrderStatus.Pending or OrderStatus.PaymentFailed)) + throw new DomainException($"Cannot mark Placed from status {Status}."); + Status = OrderStatus.Placed; + } + + public void MarkPaymentFailed() + { + if (Status != OrderStatus.Pending) + throw new DomainException($"Cannot mark PaymentFailed from status {Status}."); + Status = OrderStatus.PaymentFailed; + } + + public void Cancel() + { + if (Status is OrderStatus.Cancelled) + return; + if (Status is not (OrderStatus.Pending or OrderStatus.Placed or OrderStatus.PaymentFailed)) + throw new DomainException($"Cannot cancel from status {Status}."); + Status = OrderStatus.Cancelled; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Orders/OrderId.cs b/src/Lab/FeatureFusion/Domain/Orders/OrderId.cs new file mode 100644 index 0000000..283febc --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Orders/OrderId.cs @@ -0,0 +1,21 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Orders; + +/// Strongly-typed order identity (orders bounded context). +public sealed record OrderId : AggregateId +{ + /// Creates an order id. Zero and negatives are allowed (EF temporary keys before insert). + public OrderId(int value) : base(value) + { + } + + /// Factory used by EF converters and domain code. + public static OrderId From(int value) => new(value); + + /// Implicit from the persistence primitive. + public static implicit operator OrderId(int value) => new(value); + + /// To the persistence primitive. Required so keyset OrderBy can convert in expression trees. + public static explicit operator int(OrderId id) => id.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Orders/OrderItem.cs b/src/Lab/FeatureFusion/Domain/Orders/OrderItem.cs new file mode 100644 index 0000000..31e87f8 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Orders/OrderItem.cs @@ -0,0 +1,57 @@ +using BuildingBlocks.Domain; +using FeatureFusion.Domain.Catalog; + +namespace FeatureFusion.Domain.Orders; + +/// Order line with unit price captured at placement time. +public class OrderItem : Entity +{ + /// Owning order. + public OrderId OrderId { get; private set; } = null!; + + /// Loaded order navigation. + public Order? Order { get; private set; } + + /// Catalog product identity. + public ProductId ProductId { get; private set; } = null!; + + /// Loaded product navigation. + public Product? Product { get; private set; } + + /// Quantity ordered. + public int Quantity { get; private set; } + + /// Unit price at order time. + public decimal UnitPrice { get; private set; } + + /// Line total (not persisted). + public decimal LineTotal => UnitPrice * Quantity; + + private OrderItem() + { + } + + internal static OrderItem Create( + OrderId orderId, + ProductId productId, + int quantity, + decimal unitPrice, + OrderItemId? id = null) + { + if (quantity <= 0) + throw new DomainException("Order item quantity must be positive."); + if (unitPrice < 0) + throw new DomainException("Unit price cannot be negative."); + + var item = new OrderItem + { + OrderId = orderId, + ProductId = productId, + Quantity = quantity, + UnitPrice = unitPrice + }; + if (id is not null) + item.Id = id; + return item; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Orders/OrderItemId.cs b/src/Lab/FeatureFusion/Domain/Orders/OrderItemId.cs new file mode 100644 index 0000000..67ab128 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Orders/OrderItemId.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Orders; + +/// Strongly-typed order-line identity (orders bounded context). +public sealed record OrderItemId : EntityId +{ + /// Creates an order-line id. Zero and negatives are allowed (EF temporary keys before insert). + public OrderItemId(int value) : base(value) + { + } + + /// Factory used by EF converters and domain code. + public static OrderItemId From(int value) => new(value); + + /// Implicit from the persistence primitive. + public static implicit operator OrderItemId(int value) => new(value); +} diff --git a/src/Lab/FeatureFusion/Domain/Orders/OrderNumber.cs b/src/Lab/FeatureFusion/Domain/Orders/OrderNumber.cs new file mode 100644 index 0000000..c21eca8 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Orders/OrderNumber.cs @@ -0,0 +1,35 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Orders; + +/// Public order number (business key, not the persistence id). +public sealed class OrderNumber : ValueObject +{ + /// Normalized order number. + public string Value { get; } + + private OrderNumber(string value) => Value = value; + + /// Creates an order number (1–32 characters). + public static OrderNumber Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException("Order number is required."); + var trimmed = value.Trim(); + if (trimmed.Length > 32) + throw new DomainException("Order number cannot exceed 32 characters."); + return new OrderNumber(trimmed); + } + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Value.ToUpperInvariant(); + } + + /// + public override string ToString() => Value; + + /// Implicit to the persistence string. + public static implicit operator string(OrderNumber number) => number.Value; +} diff --git a/src/Lab/FeatureFusion/Domain/Orders/OrderShipping.cs b/src/Lab/FeatureFusion/Domain/Orders/OrderShipping.cs new file mode 100644 index 0000000..18b1b30 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Orders/OrderShipping.cs @@ -0,0 +1,75 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Orders; + +/// Shipping snapshot captured at checkout (not a courier integration). +public sealed class OrderShipping : ValueObject +{ + public string RecipientName { get; private set; } = ""; + public string Line1 { get; private set; } = ""; + public string City { get; private set; } = ""; + public string PostalCode { get; private set; } = ""; + public string Country { get; private set; } = ""; + public string Method { get; private set; } = ""; + public ShippingStatus Status { get; private set; } + + private OrderShipping() + { + } + + public static OrderShipping None() => new() + { + Status = ShippingStatus.None, + Method = "", + RecipientName = "", + Line1 = "", + City = "", + PostalCode = "", + Country = "" + }; + + public static OrderShipping Create( + string recipientName, + string line1, + string city, + string postalCode, + string country, + string method, + ShippingStatus status = ShippingStatus.Pending) + { + if (string.IsNullOrWhiteSpace(recipientName)) + throw new DomainException("Shipping recipient is required."); + if (string.IsNullOrWhiteSpace(line1)) + throw new DomainException("Shipping address line is required."); + if (string.IsNullOrWhiteSpace(city)) + throw new DomainException("Shipping city is required."); + if (string.IsNullOrWhiteSpace(postalCode)) + throw new DomainException("Shipping postal code is required."); + if (string.IsNullOrWhiteSpace(country) || country.Trim().Length != 2) + throw new DomainException("Shipping country must be a 2-letter code."); + if (string.IsNullOrWhiteSpace(method)) + throw new DomainException("Shipping method is required."); + + return new OrderShipping + { + RecipientName = recipientName.Trim(), + Line1 = line1.Trim(), + City = city.Trim(), + PostalCode = postalCode.Trim(), + Country = country.Trim().ToUpperInvariant(), + Method = method.Trim(), + Status = status + }; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return RecipientName; + yield return Line1; + yield return City; + yield return PostalCode; + yield return Country; + yield return Method; + yield return Status; + } +} diff --git a/src/Lab/FeatureFusion/Domain/Orders/OrderStatus.cs b/src/Lab/FeatureFusion/Domain/Orders/OrderStatus.cs new file mode 100644 index 0000000..e3c0591 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Orders/OrderStatus.cs @@ -0,0 +1,17 @@ +namespace FeatureFusion.Domain.Orders; + +/// Minimal order lifecycle for Demo Commerce. +public enum OrderStatus +{ + /// Awaiting confirmation (seed / reserved flows). + Pending = 0, + + /// Accepted (CreateOrder and successful checkout). + Placed = 1, + + /// Cancelled after placement or while pending. + Cancelled = 2, + + /// Payment declined after a pending order was recorded (domain completeness). + PaymentFailed = 3 +} diff --git a/src/Lab/FeatureFusion/Domain/Orders/ShippingStatus.cs b/src/Lab/FeatureFusion/Domain/Orders/ShippingStatus.cs new file mode 100644 index 0000000..a841f34 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Orders/ShippingStatus.cs @@ -0,0 +1,9 @@ +namespace FeatureFusion.Domain.Orders; + +/// Minimal shipping lifecycle (no carrier integration). +public enum ShippingStatus +{ + None = 0, + Pending = 1, + Scheduled = 2 +} diff --git a/src/Lab/FeatureFusion/Domain/Payments/PaymentRecord.cs b/src/Lab/FeatureFusion/Domain/Payments/PaymentRecord.cs new file mode 100644 index 0000000..f44cc94 --- /dev/null +++ b/src/Lab/FeatureFusion/Domain/Payments/PaymentRecord.cs @@ -0,0 +1,63 @@ +using BuildingBlocks.Domain; + +namespace FeatureFusion.Domain.Payments; + +public sealed record PaymentRecordId : EntityId +{ + public PaymentRecordId(int value) : base(value) + { + } + + public static PaymentRecordId From(int value) => new(value); + public static implicit operator PaymentRecordId(int value) => new(value); + public static explicit operator int(PaymentRecordId id) => id.Value; +} + +public enum PaymentOutcome +{ + Approved = 1, + Declined = 2 +} + +/// Non-sensitive payment attempt recorded after checkout authorization. +public class PaymentRecord : Entity +{ + public Guid CorrelationOrderId { get; private set; } + public int? DomainOrderId { get; private set; } + public PaymentOutcome Outcome { get; private set; } + public decimal Amount { get; private set; } + public string Currency { get; private set; } = "EUR"; + public DateTime CreatedAtUtc { get; private set; } + + private PaymentRecord() + { + } + + public static PaymentRecord Create( + Guid correlationOrderId, + PaymentOutcome outcome, + decimal amount, + DateTime createdAtUtc, + int? domainOrderId = null, + string currency = "EUR") + { + if (correlationOrderId == Guid.Empty) + throw new DomainException("Correlation order id is required."); + if (amount < 0) + throw new DomainException("Payment amount cannot be negative."); + if (createdAtUtc.Kind != DateTimeKind.Utc) + throw new DomainException("CreatedAt must be UTC."); + + return new PaymentRecord + { + CorrelationOrderId = correlationOrderId, + DomainOrderId = domainOrderId, + Outcome = outcome, + Amount = decimal.Round(amount, 2, MidpointRounding.AwayFromZero), + Currency = currency.Trim().ToUpperInvariant(), + CreatedAtUtc = createdAtUtc + }; + } + + public void AttachDomainOrder(int domainOrderId) => DomainOrderId = domainOrderId; +} diff --git a/src/Lab/FeatureFusion/Dtos/LoginDto.cs b/src/Lab/FeatureFusion/Dtos/LoginDto.cs index a28506b..d1c4cd2 100644 --- a/src/Lab/FeatureFusion/Dtos/LoginDto.cs +++ b/src/Lab/FeatureFusion/Dtos/LoginDto.cs @@ -1,6 +1,6 @@ namespace FeatureFusion.Dtos; public class LoginDto { - public string Username { get; set; } - public string Password { get; set; } + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; } diff --git a/src/Lab/FeatureFusion/Dtos/PersonDto.cs b/src/Lab/FeatureFusion/Dtos/PersonDto.cs index 5e65cf7..94d8176 100644 --- a/src/Lab/FeatureFusion/Dtos/PersonDto.cs +++ b/src/Lab/FeatureFusion/Dtos/PersonDto.cs @@ -6,7 +6,7 @@ namespace FeatureFusion.Dtos { public record PersonDto { - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public int Age { get; set; } }; public class PersonDtoValidator : AbstractValidator diff --git a/src/Lab/FeatureFusion/Dtos/ProductDto.cs b/src/Lab/FeatureFusion/Dtos/ProductDto.cs index 95407ab..a46d8b6 100644 --- a/src/Lab/FeatureFusion/Dtos/ProductDto.cs +++ b/src/Lab/FeatureFusion/Dtos/ProductDto.cs @@ -4,6 +4,6 @@ public record ProductDto( int Id, string Name, decimal Price, - string Description, + string? Description, DateTime CreatedAt); } diff --git a/src/Lab/FeatureFusion/Dtos/ProductPromotionDto.cs b/src/Lab/FeatureFusion/Dtos/ProductPromotionDto.cs index 232f92f..1cd024f 100644 --- a/src/Lab/FeatureFusion/Dtos/ProductPromotionDto.cs +++ b/src/Lab/FeatureFusion/Dtos/ProductPromotionDto.cs @@ -7,7 +7,7 @@ public record ProductPromotionDto [JsonPropertyName("product_id")] public int ProductId { get; init; } [JsonPropertyName("product_name")] - public string Name { get; init; } + public string Name { get; init; } = string.Empty; [JsonPropertyName("manufacturer_id")] public int ManufacturerId { get; init; } [JsonPropertyName("is_featured")] diff --git a/src/Lab/FeatureFusion/Dtos/Validator/OrderRequestValidator.cs b/src/Lab/FeatureFusion/Dtos/Validator/OrderRequestValidator.cs deleted file mode 100644 index 448aa43..0000000 --- a/src/Lab/FeatureFusion/Dtos/Validator/OrderRequestValidator.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FeatureFusion.Models.Validator -{ - public class OrderRequestValidator - { - } -} diff --git a/src/Lab/FeatureFusion/Dtos/Validator/ValidationResultWrapper.cs b/src/Lab/FeatureFusion/Dtos/Validator/ValidationResultWrapper.cs index 8c3676f..5290932 100644 --- a/src/Lab/FeatureFusion/Dtos/Validator/ValidationResultWrapper.cs +++ b/src/Lab/FeatureFusion/Dtos/Validator/ValidationResultWrapper.cs @@ -3,9 +3,9 @@ public class ValidationResult { public bool IsValid { get; } - public ValidationProblemDetails ProblemDetails { get; } + public ValidationProblemDetails? ProblemDetails { get; } - private ValidationResult(bool isValid, ValidationProblemDetails problemDetails = null) + private ValidationResult(bool isValid, ValidationProblemDetails? problemDetails = null) { IsValid = isValid; ProblemDetails = problemDetails; diff --git a/src/Lab/FeatureFusion/FeatureFusion.csproj b/src/Lab/FeatureFusion/FeatureFusion.csproj index 838a2c0..b8d2e82 100644 --- a/src/Lab/FeatureFusion/FeatureFusion.csproj +++ b/src/Lab/FeatureFusion/FeatureFusion.csproj @@ -2,17 +2,11 @@ net10.0 - disable + enable + nullable enable - - - - - - - @@ -28,6 +22,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -44,6 +42,8 @@ + + diff --git a/src/Lab/FeatureFusion/Features/Admission/AdmissionDecision.cs b/src/Lab/FeatureFusion/Features/Admission/AdmissionDecision.cs new file mode 100644 index 0000000..633c933 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/AdmissionDecision.cs @@ -0,0 +1,29 @@ +namespace FeatureFusion.Features.Admission; + +/// Outcome of . +public abstract record AdmissionDecision +{ + private AdmissionDecision() { } + + public sealed record Allow : AdmissionDecision; + + public sealed record Deny(string Error, int StatusCode) : AdmissionDecision; + + public sealed record Defer(AdmissionPendingResponse Pending) : AdmissionDecision; +} + +/// Public pending payload returned to HTTP/MCP callers (no business effect yet). +public sealed record AdmissionPendingResponse( + Guid TicketId, + string CapabilityId, + string Status, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + string Outcome = "Pending"); + +public sealed record AdmissionReleaseResult( + bool Succeeded, + string? Error, + int StatusCode, + Guid? OrderId, + Guid TicketId); diff --git a/src/Lab/FeatureFusion/Features/Admission/CapabilityAdmissionOptions.cs b/src/Lab/FeatureFusion/Features/Admission/CapabilityAdmissionOptions.cs new file mode 100644 index 0000000..05c0d22 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/CapabilityAdmissionOptions.cs @@ -0,0 +1,19 @@ +namespace FeatureFusion.Features.Admission; + +/// Lab-only options for capability admission (not a policy engine). +public sealed class CapabilityAdmissionOptions +{ + public const string SectionName = "CapabilityAdmission"; + + /// + /// Capability ids that must Defer before ISender.Send. + /// Empty = Allow for all (existing Experiment / smoke behavior). + /// + public List DeferredCapabilities { get; set; } = []; + + /// How long a Pending ticket may be released. + public TimeSpan TicketTtl { get; set; } = TimeSpan.FromHours(1); + + public bool IsDeferred(string capabilityId) => + DeferredCapabilities.Any(c => string.Equals(c, capabilityId, StringComparison.Ordinal)); +} diff --git a/src/Lab/FeatureFusion/Features/Admission/CapabilityAdmissionService.cs b/src/Lab/FeatureFusion/Features/Admission/CapabilityAdmissionService.cs new file mode 100644 index 0000000..d0e9605 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/CapabilityAdmissionService.cs @@ -0,0 +1,262 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using FeatureFusion.Features.Orders.Commands; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FeatureFusion.Features.Admission; + +/// EF-backed admission gate (lab proof — not a NuGet package). +public sealed class CapabilityAdmissionService : ICapabilityAdmission +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private readonly CatalogDbContext _db; + private readonly CapabilityAdmissionOptions _options; + private readonly TimeProvider _time; + private readonly IReadOnlyDictionary _executors; + + public CapabilityAdmissionService( + CatalogDbContext db, + IOptions options, + IEnumerable executors, + TimeProvider? time = null) + { + _db = db; + _options = options.Value; + _time = time ?? TimeProvider.System; + _executors = executors.ToDictionary(e => e.CapabilityId, StringComparer.Ordinal); + } + + public async Task AdmitAsync( + string capabilityId, + string requestKey, + string intentPayloadJson, + string intentHash, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(capabilityId); + ArgumentException.ThrowIfNullOrWhiteSpace(intentPayloadJson); + ArgumentException.ThrowIfNullOrWhiteSpace(intentHash); + + if (!_options.IsDeferred(capabilityId)) + return new AdmissionDecision.Allow(); + + if (string.IsNullOrWhiteSpace(requestKey)) + { + return new AdmissionDecision.Deny( + $"Capability '{capabilityId}' requires a request key to defer (HTTP Idempotency-Key or MCP idempotencyKey).", + StatusCodes.Status400BadRequest); + } + + var now = _time.GetUtcNow(); + var existing = await _db.IntentTickets + .AsNoTracking() + .FirstOrDefaultAsync( + t => t.CapabilityId == capabilityId && t.RequestKey == requestKey, + cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + if (existing.Status == IntentTicketStatus.Pending && existing.ExpiresAt > now) + { + if (!string.Equals(existing.IntentHash, intentHash, StringComparison.Ordinal)) + { + return new AdmissionDecision.Deny( + "Request key was reused with a different intent payload while the ticket is Pending.", + StatusCodes.Status422UnprocessableEntity); + } + + return new AdmissionDecision.Defer(ToPending(existing)); + } + + if (existing.Status == IntentTicketStatus.Released) + { + return new AdmissionDecision.Deny( + $"Ticket '{existing.Id}' was already released for this request key.", + StatusCodes.Status409Conflict); + } + + await _db.IntentTickets + .Where(t => t.Id == existing.Id) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + var ticket = new IntentTicket + { + Id = Guid.NewGuid(), + CapabilityId = capabilityId, + RequestKey = requestKey, + IntentHash = intentHash, + IntentPayload = intentPayloadJson, + Status = IntentTicketStatus.Pending, + CreatedAt = now, + ExpiresAt = now + _options.TicketTtl + }; + + _db.IntentTickets.Add(ticket); + try + { + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException) + { + var winner = await _db.IntentTickets + .AsNoTracking() + .FirstAsync( + t => t.CapabilityId == capabilityId && t.RequestKey == requestKey, + cancellationToken) + .ConfigureAwait(false); + + if (winner.Status == IntentTicketStatus.Pending + && !string.Equals(winner.IntentHash, intentHash, StringComparison.Ordinal)) + { + return new AdmissionDecision.Deny( + "Request key was reused with a different intent payload while the ticket is Pending.", + StatusCodes.Status422UnprocessableEntity); + } + + if (winner.Status == IntentTicketStatus.Pending) + return new AdmissionDecision.Defer(ToPending(winner)); + + return new AdmissionDecision.Deny( + $"Ticket '{winner.Id}' is not Pending for this request key.", + StatusCodes.Status409Conflict); + } + + return new AdmissionDecision.Defer(ToPending(ticket)); + } + + public async Task ReleaseAsync( + Guid ticketId, + string? releasedBy, + CancellationToken cancellationToken) + { + var now = _time.GetUtcNow(); + var by = string.IsNullOrWhiteSpace(releasedBy) ? "human" : releasedBy.Trim(); + + var claimed = await _db.IntentTickets + .Where(t => t.Id == ticketId + && t.Status == IntentTicketStatus.Pending + && t.ExpiresAt > now) + .ExecuteUpdateAsync( + s => s + .SetProperty(t => t.Status, IntentTicketStatus.Released) + .SetProperty(t => t.ReleasedAt, now) + .SetProperty(t => t.ReleasedBy, by), + cancellationToken) + .ConfigureAwait(false); + + if (claimed != 1) + { + var current = await _db.IntentTickets + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == ticketId, cancellationToken) + .ConfigureAwait(false); + + if (current is null) + { + return new AdmissionReleaseResult( + false, $"Ticket '{ticketId}' was not found.", StatusCodes.Status404NotFound, null, ticketId); + } + + if (current.Status == IntentTicketStatus.Pending && current.ExpiresAt <= now) + { + await _db.IntentTickets + .Where(t => t.Id == ticketId && t.Status == IntentTicketStatus.Pending) + .ExecuteUpdateAsync( + s => s.SetProperty(t => t.Status, IntentTicketStatus.Expired), + cancellationToken) + .ConfigureAwait(false); + + return new AdmissionReleaseResult( + false, $"Ticket '{ticketId}' has expired.", StatusCodes.Status410Gone, null, ticketId); + } + + if (current.Status == IntentTicketStatus.Released) + { + return new AdmissionReleaseResult( + false, + $"Ticket '{ticketId}' was already released.", + StatusCodes.Status409Conflict, + current.ExecutionOrderId, + ticketId); + } + + return new AdmissionReleaseResult( + false, $"Ticket '{ticketId}' cannot be released (status={current.Status}).", + StatusCodes.Status409Conflict, null, ticketId); + } + + var ticket = await _db.IntentTickets + .AsNoTracking() + .FirstAsync(t => t.Id == ticketId, cancellationToken) + .ConfigureAwait(false); + + if (!_executors.TryGetValue(ticket.CapabilityId, out var executor)) + { + return new AdmissionReleaseResult( + false, $"Unsupported capability '{ticket.CapabilityId}'.", StatusCodes.Status400BadRequest, null, ticketId); + } + + // Failure boundary: ticket is already Released before Execute. + // Concurrent releases cannot both claim the ticket; a crash after claim may leave Released without an order. + var outcome = await executor.ExecuteAsync(ticket.IntentPayload, cancellationToken).ConfigureAwait(false); + if (!outcome.Success) + { + return new AdmissionReleaseResult( + false, outcome.Error, outcome.StatusCode, null, ticketId); + } + + await _db.IntentTickets + .Where(t => t.Id == ticketId) + .ExecuteUpdateAsync( + s => s.SetProperty(t => t.ExecutionOrderId, outcome.ExecutionId), + cancellationToken) + .ConfigureAwait(false); + + return new AdmissionReleaseResult(true, null, StatusCodes.Status200OK, outcome.ExecutionId, ticketId); + } + + /// Stable intent fingerprint for (orders.create). + public static string HashCreateOrderIntent(CreateOrderCommand command) + { + ArgumentNullException.ThrowIfNull(command); + string canonical; + if (command.Items is null || command.Items.Count == 0) + { + // Exp / HTTP flat-body contract. + canonical = $"{command.ProductId}|{command.Quantity}|{command.CustomerId}"; + } + else + { + var lines = command.Items + .OrderBy(l => l.ProductId) + .ThenBy(l => l.Quantity) + .Select(l => $"{l.ProductId}x{l.Quantity}"); + canonical = $"{command.CustomerId}|{string.Join(',', lines)}"; + } + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); + return Convert.ToHexString(hash); + } + + public static string SerializeCreateOrderIntent(CreateOrderCommand command) + => JsonSerializer.Serialize(command, JsonOptions); + + private static AdmissionPendingResponse ToPending(IntentTicket ticket) => + new( + ticket.Id, + ticket.CapabilityId, + nameof(IntentTicketStatus.Pending), + ticket.CreatedAt, + ticket.ExpiresAt); +} diff --git a/src/Lab/FeatureFusion/Features/Admission/CapabilityIds.cs b/src/Lab/FeatureFusion/Features/Admission/CapabilityIds.cs new file mode 100644 index 0000000..6ceb8ab --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/CapabilityIds.cs @@ -0,0 +1,7 @@ +namespace FeatureFusion.Features.Admission; + +/// Stable application capability identities (not MCP/agent/tool identities). +public static class CapabilityIds +{ + public const string OrdersCreate = "orders.create"; +} diff --git a/src/Lab/FeatureFusion/Features/Admission/CreateOrderCapabilityExecutor.cs b/src/Lab/FeatureFusion/Features/Admission/CreateOrderCapabilityExecutor.cs new file mode 100644 index 0000000..6bc3995 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/CreateOrderCapabilityExecutor.cs @@ -0,0 +1,68 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Features.Orders.Commands; +using System.Text.Json; + +namespace FeatureFusion.Features.Admission; + +/// +/// Capability-oriented execution after an IntentTicket is claimed (Released). +/// Admission core does not know CreateOrder — hosts register one executor per capability id. +/// +public interface ICapabilityExecutor +{ + string CapabilityId { get; } + + Task ExecuteAsync(string intentPayloadJson, CancellationToken cancellationToken); +} + +/// Result of executing a deferred capability intent. +public sealed record CapabilityExecutionOutcome( + bool Success, + string? Error, + int StatusCode, + Guid? ExecutionId); + +/// Executes deferred via Mediator (same command as HTTP/MCP). +public sealed class CreateOrderCapabilityExecutor : ICapabilityExecutor +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private readonly ISender _sender; + + public CreateOrderCapabilityExecutor(ISender sender) => _sender = sender; + + public string CapabilityId => CapabilityIds.OrdersCreate; + + public async Task ExecuteAsync( + string intentPayloadJson, + CancellationToken cancellationToken) + { + CreateOrderCommand command; + try + { + command = JsonSerializer.Deserialize(intentPayloadJson, JsonOptions) + ?? throw new JsonException("Intent payload deserialized to null."); + } + catch (JsonException ex) + { + return new CapabilityExecutionOutcome( + false, $"Invalid intent payload: {ex.Message}", StatusCodes.Status500InternalServerError, null); + } + + var result = await _sender.Send(command, cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess) + { + return new CapabilityExecutionOutcome( + false, + result.Error, + result.StatusCode == 0 ? StatusCodes.Status500InternalServerError : result.StatusCode, + null); + } + + return new CapabilityExecutionOutcome(true, null, StatusCodes.Status200OK, result.Value.OrderId); + } +} diff --git a/src/Lab/FeatureFusion/Features/Admission/Endpoints/AdmissionEndpoints.cs b/src/Lab/FeatureFusion/Features/Admission/Endpoints/AdmissionEndpoints.cs new file mode 100644 index 0000000..84c0523 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/Endpoints/AdmissionEndpoints.cs @@ -0,0 +1,87 @@ +using FeatureFusion.Features.Admission; +using FeatureFusion.Infrastructure.Context; +using FeatureFusion.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Admission.Endpoints; + +/// Trusted HTTP release surface for deferred capability tickets (not an MCP tool). +public static class AdmissionEndpoints +{ + public static RouteGroupBuilder MapAdmissionEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/admission") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Admission"); + + api.MapPost("/tickets/{ticketId:guid}/release", ReleaseAsync) + .WithName("ReleaseIntentTicket") + .WithSummary("Release a Pending intent ticket and execute the deferred capability (trusted HTTP only).") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status410Gone); + + api.MapGet("/tickets/{ticketId:guid}", GetAsync) + .WithName("GetIntentTicket") + .WithSummary("Inspect an intent ticket.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound); + + return api; + } + + private static async Task ReleaseAsync( + Guid ticketId, + ICapabilityAdmission admission, + [FromHeader(Name = "X-Released-By")] string? releasedBy, + CancellationToken cancellationToken) + { + var result = await admission.ReleaseAsync(ticketId, releasedBy, cancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + return Results.Problem( + detail: result.Error, + statusCode: result.StatusCode, + title: "Admission release failed"); + } + + return Results.Ok(new + { + ticketId = result.TicketId, + orderId = result.OrderId, + status = nameof(IntentTicketStatus.Released), + outcome = "Released" + }); + } + + private static async Task GetAsync( + Guid ticketId, + CatalogDbContext db, + CancellationToken cancellationToken) + { + var ticket = await db.IntentTickets + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == ticketId, cancellationToken) + .ConfigureAwait(false); + + if (ticket is null) + return Results.NotFound(); + + return Results.Ok(new + { + ticketId = ticket.Id, + capabilityId = ticket.CapabilityId, + status = ticket.Status.ToString(), + createdAt = ticket.CreatedAt, + expiresAt = ticket.ExpiresAt, + releasedAt = ticket.ReleasedAt, + releasedBy = ticket.ReleasedBy, + executionOrderId = ticket.ExecutionOrderId + }); + } +} diff --git a/src/Lab/FeatureFusion/Features/Admission/ICapabilityAdmission.cs b/src/Lab/FeatureFusion/Features/Admission/ICapabilityAdmission.cs new file mode 100644 index 0000000..625311d --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/ICapabilityAdmission.cs @@ -0,0 +1,20 @@ +namespace FeatureFusion.Features.Admission; + +/// +/// Application-owned capability admission. Lives in front of ISender.Send. +/// Surfaces (HTTP, MCP) call this; they do not own deferral policy. +/// +public interface ICapabilityAdmission +{ + Task AdmitAsync( + string capabilityId, + string requestKey, + string intentPayloadJson, + string intentHash, + CancellationToken cancellationToken); + + Task ReleaseAsync( + Guid ticketId, + string? releasedBy, + CancellationToken cancellationToken); +} diff --git a/src/Lab/FeatureFusion/Features/Admission/IntentTicket.cs b/src/Lab/FeatureFusion/Features/Admission/IntentTicket.cs new file mode 100644 index 0000000..a0de8af --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/IntentTicket.cs @@ -0,0 +1,43 @@ +namespace FeatureFusion.Features.Admission; + +public enum IntentTicketStatus +{ + Pending = 0, + Released = 1, + Expired = 2 +} + +/// +/// Durable deferred execution intent — not a workflow instance or approval platform record. +/// +public sealed class IntentTicket +{ + public Guid Id { get; set; } + + public string CapabilityId { get; set; } = ""; + + /// Caller request key (HTTP Idempotency-Key / MCP idempotencyKey). + public string RequestKey { get; set; } = ""; + + /// Canonical intent fingerprint for same-key payload conflict detection. + public string IntentHash { get; set; } = ""; + + /// JSON payload used to reconstruct the command on release. + public string IntentPayload { get; set; } = ""; + + public IntentTicketStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } + + public DateTimeOffset? ReleasedAt { get; set; } + + public string? ReleasedBy { get; set; } + + /// + /// Correlation id produced after a successful release (observation only). + /// For orders.create this is the EventBus/HTTP OrderResponse.OrderId Guid — not the EF int PK. + /// + public Guid? ExecutionOrderId { get; set; } +} diff --git a/src/Lab/FeatureFusion/Features/Admission/OrderCreateAdmissionGate.cs b/src/Lab/FeatureFusion/Features/Admission/OrderCreateAdmissionGate.cs new file mode 100644 index 0000000..b4ae7f6 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Admission/OrderCreateAdmissionGate.cs @@ -0,0 +1,47 @@ +using BuildingBlocks.Mcp; +using FeatureFusion.Features.Orders.Commands; +using Microsoft.AspNetCore.Http; + +namespace FeatureFusion.Features.Admission; + +/// +/// Shared pre-ISender.Send gate for . +/// Used by HTTP Order endpoints and MCP UseDispatcher. +/// +public static class OrderCreateAdmissionGate +{ + public static async Task AdmitCreateOrderAsync( + ICapabilityAdmission admission, + CreateOrderCommand command, + string? requestKey, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(admission); + ArgumentNullException.ThrowIfNull(command); + + var payload = CapabilityAdmissionService.SerializeCreateOrderIntent(command); + var hash = CapabilityAdmissionService.HashCreateOrderIntent(command); + return await admission.AdmitAsync( + CapabilityIds.OrdersCreate, + requestKey ?? "", + payload, + hash, + cancellationToken) + .ConfigureAwait(false); + } + + public static string? ResolveHttpRequestKey(HttpRequest request) + { + if (request.Headers.TryGetValue("Idempotency-Key", out var values)) + { + var key = values.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(key)) + return key; + } + + return null; + } + + public static string? ResolveMcpRequestKey(IMcpInvokeContextAccessor? accessor) + => accessor?.Current?.IdempotencyKey; +} diff --git a/src/Lab/FeatureFusion/Features/Auth/Endpoints/AuthEndpoints.cs b/src/Lab/FeatureFusion/Features/Auth/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..cc3be50 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Auth/Endpoints/AuthEndpoints.cs @@ -0,0 +1,35 @@ +using FeatureFusion.Dtos; +using FeatureFusion.Infrastructure.Extensions; +using FeatureFusion.Services.Authentication; +using Microsoft.AspNetCore.Mvc; + +namespace FeatureFusion.Features.Auth.Endpoints; + +/// JWT login for lab Feature Management demos (VIP claim). +public static class AuthEndpoints +{ + public static RouteGroupBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/Auth") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Auth"); + + api.MapPost("/login", LoginAsync) + .WithName("AuthLogin") + .WithSummary("Issue a JWT. vipuser/vippassword receives VIP=true for the Feature Management filter preview.") + .Accepts("application/json") + .Produces(StatusCodes.Status200OK); + + return api; + } + + private static IResult LoginAsync([FromBody] LoginDto login, IAuthService authService) + { + var isVip = authService.ValidateVipUser(login.Username, login.Password); + var token = authService.GenerateJwtToken(login.Username, isVip); + return Results.Ok(new { token }); + } +} diff --git a/src/Lab/FeatureFusion/Features/Carts/CartContracts.cs b/src/Lab/FeatureFusion/Features/Carts/CartContracts.cs new file mode 100644 index 0000000..9841a19 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Carts/CartContracts.cs @@ -0,0 +1,33 @@ +using BuildingBlocks.Mediator; + +namespace FeatureFusion.Features.Carts; + +/// Cart line — quantity only; catalog price is resolved at checkout. +public sealed record CartItemDto(int ProductId, int Quantity); + +/// Customer cart projection. +public sealed record CartDto(int CustomerId, IReadOnlyList Items); + +/// HTTP body for adding a cart line. +public sealed record AddCartItemRequest(int ProductId, int Quantity); + +/// HTTP body for setting a cart line quantity. +public sealed record UpdateCartItemQuantityRequest(int Quantity); + +/// Get-or-create cart for a customer. +public sealed record GetCartQuery(int CustomerId) : IQuery>; + +/// Add or increment a product line on the customer's cart. +public sealed record AddCartItemCommand(int CustomerId, int ProductId, int Quantity) + : ICommand>; + +/// Set quantity for a cart line (quantity ≤ 0 removes the line). +public sealed record UpdateCartItemQuantityCommand(int CustomerId, int ProductId, int Quantity) + : ICommand>; + +/// Remove a product line from the cart. +public sealed record RemoveCartItemCommand(int CustomerId, int ProductId) + : ICommand>; + +/// Clear all lines from the customer's cart. +public sealed record ClearCartCommand(int CustomerId) : ICommand>; diff --git a/src/Lab/FeatureFusion/Features/Carts/CartEndpoints.cs b/src/Lab/FeatureFusion/Features/Carts/CartEndpoints.cs new file mode 100644 index 0000000..77fa9c0 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Carts/CartEndpoints.cs @@ -0,0 +1,114 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace FeatureFusion.Features.Carts; + +/// Demo Commerce cart HTTP surface nested under customers. +public static class CartEndpoints +{ + public static RouteGroupBuilder MapCartEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/customers") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Customers"); + + api.MapGet("/{id:int}/cart", GetCartAsync) + .WithName("GetCart") + .WithSummary("Get-or-create the customer's cart.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + api.MapPost("/{id:int}/cart/items", AddCartItemAsync) + .WithName("AddCartItem") + .WithSummary("Add or increment a cart line.") + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + api.MapPut("/{id:int}/cart/items/{productId:int}", UpdateCartItemAsync) + .WithName("UpdateCartItemQuantity") + .WithSummary("Set quantity for a cart line (≤0 removes).") + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + api.MapDelete("/{id:int}/cart/items/{productId:int}", RemoveCartItemAsync) + .WithName("RemoveCartItem") + .WithSummary("Remove a product line from the cart.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + api.MapDelete("/{id:int}/cart", ClearCartAsync) + .WithName("ClearCart") + .WithSummary("Clear all lines from the customer's cart.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + return api; + } + + private static async Task GetCartAsync( + int id, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(new GetCartQuery(id), cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task AddCartItemAsync( + int id, + [FromBody] AddCartItemRequest body, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send( + new AddCartItemCommand(id, body.ProductId, body.Quantity), + cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task UpdateCartItemAsync( + int id, + int productId, + [FromBody] UpdateCartItemQuantityRequest body, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send( + new UpdateCartItemQuantityCommand(id, productId, body.Quantity), + cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task RemoveCartItemAsync( + int id, + int productId, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send( + new RemoveCartItemCommand(id, productId), + cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task ClearCartAsync( + int id, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(new ClearCartCommand(id), cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } +} diff --git a/src/Lab/FeatureFusion/Features/Carts/CartHandlers.cs b/src/Lab/FeatureFusion/Features/Carts/CartHandlers.cs new file mode 100644 index 0000000..d7fa805 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Carts/CartHandlers.cs @@ -0,0 +1,258 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Domain.Carts; +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Customers; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Carts; + +public sealed class GetCartQueryHandler : IQueryHandler> +{ + private readonly CatalogDbContext _db; + private readonly TimeProvider _time; + + public GetCartQueryHandler(CatalogDbContext db, TimeProvider? time = null) + { + _db = db; + _time = time ?? TimeProvider.System; + } + + public async Task> Handle(GetCartQuery request, CancellationToken cancellationToken) + { + var cart = await GetOrCreateCartAsync(request.CustomerId, cancellationToken).ConfigureAwait(false); + if (cart is null) + return Result.Failure("Customer not found.", StatusCodes.Status404NotFound); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Success(ToDto(cart)); + } + + private async Task GetOrCreateCartAsync(int customerId, CancellationToken cancellationToken) + { + var cart = await LoadCartAsync(customerId, cancellationToken).ConfigureAwait(false); + if (cart is not null) + return cart; + + var customerExists = await _db.Customers.AsNoTracking() + .AnyAsync(c => (int)c.Id == customerId, cancellationToken) + .ConfigureAwait(false); + if (!customerExists) + return null; + + var now = UtcNow(); + cart = Cart.Create(CustomerId.From(customerId), now); + _db.Carts.Add(cart); + return cart; + } + + private Task LoadCartAsync(int customerId, CancellationToken cancellationToken) => + _db.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => (int)c.CustomerId == customerId, cancellationToken); + + private DateTime UtcNow() + { + var now = _time.GetUtcNow().UtcDateTime; + return now.Kind == DateTimeKind.Utc ? now : DateTime.SpecifyKind(now, DateTimeKind.Utc); + } + + private static CartDto ToDto(Cart cart) => + new( + (int)cart.CustomerId, + cart.Items + .OrderBy(i => (int)i.ProductId) + .Select(i => new CartItemDto((int)i.ProductId, i.Quantity)) + .ToList()); +} + +public sealed class AddCartItemCommandHandler : ICommandHandler> +{ + private readonly CatalogDbContext _db; + private readonly TimeProvider _time; + + public AddCartItemCommandHandler(CatalogDbContext db, TimeProvider? time = null) + { + _db = db; + _time = time ?? TimeProvider.System; + } + + public async Task> Handle(AddCartItemCommand request, CancellationToken cancellationToken) + { + var customerExists = await _db.Customers.AsNoTracking() + .AnyAsync(c => (int)c.Id == request.CustomerId, cancellationToken) + .ConfigureAwait(false); + if (!customerExists) + return Result.Failure("Customer not found.", StatusCodes.Status404NotFound); + + var product = await _db.Product.AsNoTracking() + .FirstOrDefaultAsync(p => (int)p.Id == request.ProductId, cancellationToken) + .ConfigureAwait(false); + if (product is null) + return Result.Failure($"Product '{request.ProductId}' not found.", StatusCodes.Status404NotFound); + if (!product.Published || product.Deleted) + return Result.Failure( + $"Product '{request.ProductId}' is not available for sale.", + StatusCodes.Status409Conflict); + + var cart = await GetOrCreateCartAsync(request.CustomerId, cancellationToken).ConfigureAwait(false); + cart.AddOrIncrement(ProductId.From(request.ProductId), request.Quantity, UtcNow()); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Success(ToDto(cart)); + } + + private async Task GetOrCreateCartAsync(int customerId, CancellationToken cancellationToken) + { + var cart = await _db.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => (int)c.CustomerId == customerId, cancellationToken) + .ConfigureAwait(false); + if (cart is not null) + return cart; + + cart = Cart.Create(CustomerId.From(customerId), UtcNow()); + _db.Carts.Add(cart); + return cart; + } + + private DateTime UtcNow() + { + var now = _time.GetUtcNow().UtcDateTime; + return now.Kind == DateTimeKind.Utc ? now : DateTime.SpecifyKind(now, DateTimeKind.Utc); + } + + private static CartDto ToDto(Cart cart) => + new( + (int)cart.CustomerId, + cart.Items + .OrderBy(i => (int)i.ProductId) + .Select(i => new CartItemDto((int)i.ProductId, i.Quantity)) + .ToList()); +} + +public sealed class UpdateCartItemQuantityCommandHandler + : ICommandHandler> +{ + private readonly CatalogDbContext _db; + private readonly TimeProvider _time; + + public UpdateCartItemQuantityCommandHandler(CatalogDbContext db, TimeProvider? time = null) + { + _db = db; + _time = time ?? TimeProvider.System; + } + + public async Task> Handle( + UpdateCartItemQuantityCommand request, + CancellationToken cancellationToken) + { + var cart = await _db.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => (int)c.CustomerId == request.CustomerId, cancellationToken) + .ConfigureAwait(false); + if (cart is null) + return Result.Failure("Cart not found.", StatusCodes.Status404NotFound); + + var productId = ProductId.From(request.ProductId); + if (cart.Items.All(i => i.ProductId != productId)) + return Result.Failure("Cart item not found.", StatusCodes.Status404NotFound); + + cart.SetQuantity(productId, request.Quantity, UtcNow()); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Success(ToDto(cart)); + } + + private DateTime UtcNow() + { + var now = _time.GetUtcNow().UtcDateTime; + return now.Kind == DateTimeKind.Utc ? now : DateTime.SpecifyKind(now, DateTimeKind.Utc); + } + + private static CartDto ToDto(Cart cart) => + new( + (int)cart.CustomerId, + cart.Items + .OrderBy(i => (int)i.ProductId) + .Select(i => new CartItemDto((int)i.ProductId, i.Quantity)) + .ToList()); +} + +public sealed class RemoveCartItemCommandHandler : ICommandHandler> +{ + private readonly CatalogDbContext _db; + private readonly TimeProvider _time; + + public RemoveCartItemCommandHandler(CatalogDbContext db, TimeProvider? time = null) + { + _db = db; + _time = time ?? TimeProvider.System; + } + + public async Task> Handle(RemoveCartItemCommand request, CancellationToken cancellationToken) + { + var cart = await _db.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => (int)c.CustomerId == request.CustomerId, cancellationToken) + .ConfigureAwait(false); + if (cart is null) + return Result.Failure("Cart not found.", StatusCodes.Status404NotFound); + + cart.Remove(ProductId.From(request.ProductId), UtcNow()); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Success(ToDto(cart)); + } + + private DateTime UtcNow() + { + var now = _time.GetUtcNow().UtcDateTime; + return now.Kind == DateTimeKind.Utc ? now : DateTime.SpecifyKind(now, DateTimeKind.Utc); + } + + private static CartDto ToDto(Cart cart) => + new( + (int)cart.CustomerId, + cart.Items + .OrderBy(i => (int)i.ProductId) + .Select(i => new CartItemDto((int)i.ProductId, i.Quantity)) + .ToList()); +} + +public sealed class ClearCartCommandHandler : ICommandHandler> +{ + private readonly CatalogDbContext _db; + private readonly TimeProvider _time; + + public ClearCartCommandHandler(CatalogDbContext db, TimeProvider? time = null) + { + _db = db; + _time = time ?? TimeProvider.System; + } + + public async Task> Handle(ClearCartCommand request, CancellationToken cancellationToken) + { + var cart = await _db.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => (int)c.CustomerId == request.CustomerId, cancellationToken) + .ConfigureAwait(false); + if (cart is null) + return Result.Failure("Cart not found.", StatusCodes.Status404NotFound); + + cart.Clear(UtcNow()); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return Result.Success(ToDto(cart)); + } + + private DateTime UtcNow() + { + var now = _time.GetUtcNow().UtcDateTime; + return now.Kind == DateTimeKind.Utc ? now : DateTime.SpecifyKind(now, DateTimeKind.Utc); + } + + private static CartDto ToDto(Cart cart) => + new( + (int)cart.CustomerId, + cart.Items + .OrderBy(i => (int)i.ProductId) + .Select(i => new CartItemDto((int)i.ProductId, i.Quantity)) + .ToList()); +} diff --git a/src/Lab/FeatureFusion/Features/Carts/CartValidators.cs b/src/Lab/FeatureFusion/Features/Carts/CartValidators.cs new file mode 100644 index 0000000..a14d009 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Carts/CartValidators.cs @@ -0,0 +1,43 @@ +using FluentValidation; + +namespace FeatureFusion.Features.Carts; + +public sealed class GetCartQueryValidator : AbstractValidator +{ + public GetCartQueryValidator() => + RuleFor(x => x.CustomerId).GreaterThan(0).WithMessage("Customer id is required."); +} + +public sealed class AddCartItemCommandValidator : AbstractValidator +{ + public AddCartItemCommandValidator() + { + RuleFor(x => x.CustomerId).GreaterThan(0).WithMessage("Customer id is required."); + RuleFor(x => x.ProductId).GreaterThan(0).WithMessage("Product id is required."); + RuleFor(x => x.Quantity).GreaterThan(0).WithMessage("Quantity must be greater than 0."); + } +} + +public sealed class UpdateCartItemQuantityCommandValidator : AbstractValidator +{ + public UpdateCartItemQuantityCommandValidator() + { + RuleFor(x => x.CustomerId).GreaterThan(0).WithMessage("Customer id is required."); + RuleFor(x => x.ProductId).GreaterThan(0).WithMessage("Product id is required."); + } +} + +public sealed class RemoveCartItemCommandValidator : AbstractValidator +{ + public RemoveCartItemCommandValidator() + { + RuleFor(x => x.CustomerId).GreaterThan(0).WithMessage("Customer id is required."); + RuleFor(x => x.ProductId).GreaterThan(0).WithMessage("Product id is required."); + } +} + +public sealed class ClearCartCommandValidator : AbstractValidator +{ + public ClearCartCommandValidator() => + RuleFor(x => x.CustomerId).GreaterThan(0).WithMessage("Customer id is required."); +} diff --git a/src/Lab/FeatureFusion/Features/Catalog/CatalogContracts.cs b/src/Lab/FeatureFusion/Features/Catalog/CatalogContracts.cs new file mode 100644 index 0000000..4ba4483 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/CatalogContracts.cs @@ -0,0 +1,92 @@ +using BuildingBlocks.Mcp; +using BuildingBlocks.Mediator; + +namespace FeatureFusion.Features.Catalog; + +/// Listing card for the product grid. +public sealed record CatalogProductListItemDto( + int Id, + string Name, + string Slug, + string Sku, + decimal Price, + int StockQuantity, + bool InStock, + string BrandName, + string BrandSlug, + string CategoryName, + string CategorySlug, + string? PrimaryImageUrl, + string? ShortDescription); + +/// Paged listing response. +public sealed record CatalogProductListDto( + IReadOnlyList Items, + int Page, + int PageSize, + int TotalCount); + +/// Gallery image on the detail page. +public sealed record CatalogProductImageDto(string Url, string AltText, bool IsPrimary, int DisplayOrder); + +/// Specification row on the detail page. +public sealed record CatalogProductSpecDto(string Name, string Value, int DisplayOrder); + +/// Related product chip on the detail page. +public sealed record CatalogRelatedProductDto(int Id, string Name, string Slug, decimal Price, string? PrimaryImageUrl); + +/// Full product detail. +public sealed record CatalogProductDetailDto( + int Id, + string Name, + string Slug, + string Sku, + decimal Price, + int StockQuantity, + bool InStock, + string? ShortDescription, + string? FullDescription, + string BrandName, + string BrandSlug, + string CategoryName, + string CategorySlug, + IReadOnlyList Images, + IReadOnlyList Specifications, + IReadOnlyList Related); + +/// Brand chip for listing filters. +public sealed record CatalogBrandDto(int Id, string Name, string Slug, string? LogoUrl, int ProductCount); + +/// Category chip for listing filters. +public sealed record CatalogCategoryDto(int Id, string Name, string Slug, int ProductCount); + +/// Listing query with optional brand/category slugs. +[McpTool("catalog.products.list", Description = "List Demo Commerce storefront products (OFFSET, brand/category filters).")] +public sealed record ListCatalogProductsQuery : IQuery> +{ + /// Brand slug filter. + public string? Brand { get; init; } + + /// Category slug filter. + public string? Category { get; init; } + + /// 1-based page. + public int Page { get; init; } = 1; + + /// Page size (1–48). + public int PageSize { get; init; } = 24; +} + +/// Detail query by public slug (HTTP + MCP share this Mediator message). +[McpTool("catalog.product.get", Description = "Get a Demo Commerce storefront product by slug (gallery, specs, related).")] +public sealed record GetCatalogProductBySlugQuery(string Slug) : IQuery>; + +/// Related products in the same category (deterministic Name, Id order). +public sealed record ListRelatedCatalogProductsQuery(string Slug, int Limit = 8) + : IQuery>>; + +/// All brands that currently have published products. +public sealed record ListCatalogBrandsQuery : IQuery>>; + +/// All categories that currently have published products. +public sealed record ListCatalogCategoriesQuery : IQuery>>; diff --git a/src/Lab/FeatureFusion/Features/Catalog/CatalogEndpoints.cs b/src/Lab/FeatureFusion/Features/Catalog/CatalogEndpoints.cs new file mode 100644 index 0000000..dda1299 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/CatalogEndpoints.cs @@ -0,0 +1,114 @@ +using Asp.Versioning; +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace FeatureFusion.Features.Catalog; + +/// +/// Demo Commerce storefront catalog (OFFSET listing + product detail). +/// Distinct from the Pagination lab: GET/POST /api/v1/products-page and POST /api/v1/Product/products. +/// +public static class CatalogEndpoints +{ + public static RouteGroupBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/catalog") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Catalog"); + + api.MapGet("/products", ListProductsAsync) + .WithName("ListCatalogProducts") + .WithSummary("Storefront product listing (brand/category slug filters). OFFSET paging — not keyset.") + .WithDescription("Same Mediator query as MCP tool catalog.products.list.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest); + + api.MapGet("/products/{slug}", GetProductAsync) + .WithName("GetCatalogProductBySlug") + .WithSummary("Storefront product detail by slug, including gallery, specs, and related products.") + .WithDescription("Same Mediator query as MCP tool catalog.product.get.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + api.MapGet("/products/{slug}/related", ListRelatedAsync) + .WithName("ListRelatedCatalogProducts") + .WithSummary("Same-category related products (deterministic Name, Id). Not a recommendation engine.") + .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + api.MapGet("/brands", ListBrandsAsync) + .WithName("ListCatalogBrands") + .WithSummary("Brands for listing filters.") + .Produces>(StatusCodes.Status200OK); + + api.MapGet("/categories", ListCategoriesAsync) + .WithName("ListCatalogCategories") + .WithSummary("Categories for listing filters.") + .Produces>(StatusCodes.Status200OK); + + return api; + } + + private static async Task ListProductsAsync( + ISender sender, + CancellationToken cancellationToken, + [FromQuery] string? brand = null, + [FromQuery] string? category = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 24) + { + var result = await sender.Send( + new ListCatalogProductsQuery + { + Brand = brand, + Category = category, + Page = page, + PageSize = pageSize + }, + cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task GetProductAsync( + string slug, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(new GetCatalogProductBySlugQuery(slug), cancellationToken) + .ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task ListRelatedAsync( + string slug, + ISender sender, + CancellationToken cancellationToken, + [FromQuery] int limit = 8) + { + var result = await sender.Send(new ListRelatedCatalogProductsQuery(slug, limit), cancellationToken) + .ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task ListBrandsAsync( + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(new ListCatalogBrandsQuery(), cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task ListCategoriesAsync( + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(new ListCatalogCategoriesQuery(), cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } +} diff --git a/src/Lab/FeatureFusion/Features/Catalog/CatalogProjections.cs b/src/Lab/FeatureFusion/Features/Catalog/CatalogProjections.cs new file mode 100644 index 0000000..be76472 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/CatalogProjections.cs @@ -0,0 +1,107 @@ +using System.Linq.Expressions; +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Catalog; + +/// +/// EF-translatable catalog projections. HTTP DTOs stay primitives; aggregates stay in the domain. +/// +public static class CatalogProjections +{ + public static readonly Expression> ListItem = + p => new CatalogProductListItemDto( + (int)p.Id, + p.Name, + p.Slug.Value, + p.Sku.Value, + p.Price, + p.StockQuantity, + p.StockQuantity > 0, + p.Brand!.Name, + p.Brand.Slug.Value, + p.Category!.Name, + p.Category.Slug.Value, + p.Images + .OrderByDescending(i => i.IsPrimary) + .ThenBy(i => i.DisplayOrder) + .Select(i => i.Url) + .FirstOrDefault(), + p.ShortDescription); + + internal static readonly Expression> Detail = + p => new CatalogProductDetailRow( + (int)p.Id, + p.Name, + p.Slug.Value, + p.Sku.Value, + p.Price, + p.StockQuantity, + p.StockQuantity > 0, + p.ShortDescription, + p.FullDescription, + p.Brand!.Name, + p.Brand.Slug.Value, + p.Category!.Name, + p.Category.Slug.Value, + p.Images + .OrderBy(i => i.DisplayOrder) + .Select(i => new CatalogProductImageDto(i.Url, i.AltText, i.IsPrimary, i.DisplayOrder)) + .ToList(), + p.Specifications + .OrderBy(s => s.DisplayOrder) + .Select(s => new CatalogProductSpecDto(s.Name, s.Value, s.DisplayOrder)) + .ToList(), + p.CategoryId); + + public static readonly Expression> RelatedItem = + p => new CatalogRelatedProductDto( + (int)p.Id, + p.Name, + p.Slug.Value, + p.Price, + p.Images + .OrderByDescending(i => i.IsPrimary) + .ThenBy(i => i.DisplayOrder) + .Select(i => i.Url) + .FirstOrDefault()); + + public static IQueryable Brands(CatalogDbContext db) => + db.Brands.AsNoTracking() + .OrderBy(b => b.Name) + .Select(b => new CatalogBrandDto( + (int)b.Id, + b.Name, + b.Slug.Value, + b.LogoUrl, + db.Product.Count(p => p.BrandId == b.Id && p.Published && !p.Deleted))); + + public static IQueryable Categories(CatalogDbContext db) => + db.Categories.AsNoTracking() + .OrderBy(c => c.Name) + .Select(c => new CatalogCategoryDto( + (int)c.Id, + c.Name, + c.Slug.Value, + db.Product.Count(p => p.CategoryId == c.Id && p.Published && !p.Deleted))); +} + +/// Detail projection without related products (loaded in a second query). +internal sealed record CatalogProductDetailRow( + int Id, + string Name, + string Slug, + string Sku, + decimal Price, + int StockQuantity, + bool InStock, + string? ShortDescription, + string? FullDescription, + string BrandName, + string BrandSlug, + string CategoryName, + string CategorySlug, + IReadOnlyList Images, + IReadOnlyList Specifications, + CategoryId CategoryId); diff --git a/src/Lab/FeatureFusion/Features/Catalog/CatalogSlug.cs b/src/Lab/FeatureFusion/Features/Catalog/CatalogSlug.cs new file mode 100644 index 0000000..10af4cd --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/CatalogSlug.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Domain; +using FeatureFusion.Domain.Catalog; + +namespace FeatureFusion.Features.Catalog; + +internal static class CatalogSlug +{ + public static bool TryCreate(string? value, [NotNullWhen(true)] out Slug? slug) + { + slug = null; + if (string.IsNullOrWhiteSpace(value)) + return false; + try + { + slug = Slug.Create(value); + return true; + } + catch (DomainException) + { + return false; + } + } +} diff --git a/src/Lab/FeatureFusion/Features/Catalog/GetCatalogProductBySlugQueryHandler.cs b/src/Lab/FeatureFusion/Features/Catalog/GetCatalogProductBySlugQueryHandler.cs new file mode 100644 index 0000000..7765338 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/GetCatalogProductBySlugQueryHandler.cs @@ -0,0 +1,56 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Catalog; + +public sealed class GetCatalogProductBySlugQueryHandler + : IQueryHandler> +{ + private readonly CatalogDbContext _db; + + public GetCatalogProductBySlugQueryHandler(CatalogDbContext db) => _db = db; + + public async Task> Handle( + GetCatalogProductBySlugQuery request, + CancellationToken cancellationToken) + { + if (!CatalogSlug.TryCreate(request.Slug, out var slug)) + return Result.Failure("Slug is required.", StatusCodes.Status400BadRequest); + + var row = await _db.Product.AsNoTracking() + .Where(p => p.Slug == slug && p.Published && !p.Deleted) + .Select(CatalogProjections.Detail) + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (row is null) + return Result.Failure("Product not found.", StatusCodes.Status404NotFound); + + var related = await _db.Product.AsNoTracking() + .Where(p => p.Published && !p.Deleted && p.CategoryId == row.CategoryId && (int)p.Id != row.Id) + .OrderBy(p => p.Name) + .Take(4) + .Select(CatalogProjections.RelatedItem) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return Result.Success(new CatalogProductDetailDto( + row.Id, + row.Name, + row.Slug, + row.Sku, + row.Price, + row.StockQuantity, + row.InStock, + row.ShortDescription, + row.FullDescription, + row.BrandName, + row.BrandSlug, + row.CategoryName, + row.CategorySlug, + row.Images, + row.Specifications, + related)); + } +} diff --git a/src/Lab/FeatureFusion/Features/Catalog/ListCatalogBrandsQueryHandler.cs b/src/Lab/FeatureFusion/Features/Catalog/ListCatalogBrandsQueryHandler.cs new file mode 100644 index 0000000..c75973c --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/ListCatalogBrandsQueryHandler.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Catalog; + +public sealed class ListCatalogBrandsQueryHandler + : IQueryHandler>> +{ + private readonly CatalogDbContext _db; + + public ListCatalogBrandsQueryHandler(CatalogDbContext db) => _db = db; + + public async Task>> Handle( + ListCatalogBrandsQuery request, + CancellationToken cancellationToken) + { + IReadOnlyList rows = await CatalogProjections.Brands(_db) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return Result>.Success(rows); + } +} diff --git a/src/Lab/FeatureFusion/Features/Catalog/ListCatalogCategoriesQueryHandler.cs b/src/Lab/FeatureFusion/Features/Catalog/ListCatalogCategoriesQueryHandler.cs new file mode 100644 index 0000000..22879b6 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/ListCatalogCategoriesQueryHandler.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Catalog; + +public sealed class ListCatalogCategoriesQueryHandler + : IQueryHandler>> +{ + private readonly CatalogDbContext _db; + + public ListCatalogCategoriesQueryHandler(CatalogDbContext db) => _db = db; + + public async Task>> Handle( + ListCatalogCategoriesQuery request, + CancellationToken cancellationToken) + { + IReadOnlyList rows = await CatalogProjections.Categories(_db) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return Result>.Success(rows); + } +} diff --git a/src/Lab/FeatureFusion/Features/Catalog/ListCatalogProductsQueryHandler.cs b/src/Lab/FeatureFusion/Features/Catalog/ListCatalogProductsQueryHandler.cs new file mode 100644 index 0000000..dc84e0b --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/ListCatalogProductsQueryHandler.cs @@ -0,0 +1,71 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Catalog; + +public sealed class ListCatalogProductsQueryHandler + : IQueryHandler> +{ + private readonly CatalogDbContext _db; + + public ListCatalogProductsQueryHandler(CatalogDbContext db) => _db = db; + + public async Task> Handle( + ListCatalogProductsQuery request, + CancellationToken cancellationToken) + { + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 48 ? 24 : request.PageSize; + + var query = _db.Product.AsNoTracking() + .Where(p => p.Published && !p.Deleted && p.VisibleIndividually); + + if (!string.IsNullOrWhiteSpace(request.Brand)) + { + if (!CatalogSlug.TryCreate(request.Brand, out var brandSlug)) + return EmptyList(page, pageSize); + + var brandId = await _db.Brands.AsNoTracking() + .Where(b => b.Slug == brandSlug) + .Select(b => b.Id) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + if (brandId is null) + return EmptyList(page, pageSize); + + query = query.Where(p => p.BrandId == brandId); + } + + if (!string.IsNullOrWhiteSpace(request.Category)) + { + if (!CatalogSlug.TryCreate(request.Category, out var categorySlug)) + return EmptyList(page, pageSize); + + var categoryId = await _db.Categories.AsNoTracking() + .Where(c => c.Slug == categorySlug) + .Select(c => c.Id) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + if (categoryId is null) + return EmptyList(page, pageSize); + + query = query.Where(p => p.CategoryId == categoryId); + } + + var total = await query.CountAsync(cancellationToken).ConfigureAwait(false); + var items = await query + .OrderBy(p => p.Name) + .ThenBy(p => (int)p.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(CatalogProjections.ListItem) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return Result.Success(new CatalogProductListDto(items, page, pageSize, total)); + } + + private static Result EmptyList(int page, int pageSize) => + Result.Success(new CatalogProductListDto([], page, pageSize, 0)); +} diff --git a/src/Lab/FeatureFusion/Features/Catalog/ListRelatedCatalogProductsQueryHandler.cs b/src/Lab/FeatureFusion/Features/Catalog/ListRelatedCatalogProductsQueryHandler.cs new file mode 100644 index 0000000..26ef31d --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Catalog/ListRelatedCatalogProductsQueryHandler.cs @@ -0,0 +1,56 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Catalog; + +/// +/// Same-category related products for the storefront. Not a recommendation engine — +/// deterministic Name, then Id order. +/// +public sealed class ListRelatedCatalogProductsQueryHandler + : IQueryHandler>> +{ + private readonly CatalogDbContext _db; + + public ListRelatedCatalogProductsQueryHandler(CatalogDbContext db) => _db = db; + + public async Task>> Handle( + ListRelatedCatalogProductsQuery request, + CancellationToken cancellationToken) + { + if (!CatalogSlug.TryCreate(request.Slug, out var slug)) + return Result>.Failure( + "Slug is required.", + StatusCodes.Status400BadRequest); + + var limit = request.Limit is < 1 or > 24 ? 8 : request.Limit; + + var source = await _db.Product.AsNoTracking() + .Where(p => p.Slug == slug && p.Published && !p.Deleted) + .Select(p => new { Id = (int)p.Id, p.CategoryId }) + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (source is null) + return Result>.Failure( + "Product not found.", + StatusCodes.Status404NotFound); + + var related = await _db.Product.AsNoTracking() + .Where(p => + p.Published + && !p.Deleted + && p.VisibleIndividually + && p.CategoryId == source.CategoryId + && (int)p.Id != source.Id) + .OrderBy(p => p.Name) + .ThenBy(p => (int)p.Id) + .Take(limit) + .Select(CatalogProjections.RelatedItem) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return Result>.Success(related); + } +} diff --git a/src/Lab/FeatureFusion/Features/Checkout/CheckoutCommand.cs b/src/Lab/FeatureFusion/Features/Checkout/CheckoutCommand.cs new file mode 100644 index 0000000..6fedd3c --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Checkout/CheckoutCommand.cs @@ -0,0 +1,57 @@ +using BuildingBlocks.Mcp; +using BuildingBlocks.Mediator; +using FluentValidation; + +namespace FeatureFusion.Features.Checkout; + +/// +/// Checkout orchestration intent: tax + shipping + payment, then CreateOrderCommand. +/// Clients never send prices or payment card data. +/// +[McpTool( + "orders.checkout", + Description = "Checkout the customer's cart (tax, shipping, demo payment, create order)", + Idempotent = true, + RequireConfirmation = true)] +public sealed class CheckoutCommand : ICommand> +{ + public int CustomerId { get; set; } + public string RecipientName { get; set; } = string.Empty; + public string Line1 { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string PostalCode { get; set; } = string.Empty; + + /// ISO 3166-1 alpha-2 country code. + public string Country { get; set; } = string.Empty; +} + +/// FluentValidation for checkout intent — enforced by ValidationBehavior. +public sealed class CheckoutCommandValidator : AbstractValidator +{ + public CheckoutCommandValidator() + { + RuleFor(x => x.CustomerId).GreaterThan(0).WithMessage("Customer id is required."); + RuleFor(x => x.RecipientName).NotEmpty().WithMessage("Recipient name is required."); + RuleFor(x => x.Line1).NotEmpty().WithMessage("Shipping address line is required."); + RuleFor(x => x.City).NotEmpty().WithMessage("Shipping city is required."); + RuleFor(x => x.PostalCode).NotEmpty().WithMessage("Shipping postal code is required."); + RuleFor(x => x.Country) + .NotEmpty() + .Must(c => c.Trim().Length == 2) + .WithMessage("Country must be a 2-letter code."); + } +} + +/// Successful checkout projection (order + totals + payment outcome). +public sealed record CheckoutResponse( + Guid OrderId, + int DomainOrderId, + string OrderNumber, + string Status, + decimal Subtotal, + decimal TaxAmount, + decimal ShippingAmount, + decimal GrandTotal, + string Currency, + string PaymentDecision, + DateTime OrderDate); diff --git a/src/Lab/FeatureFusion/Features/Checkout/CheckoutCommandHandler.cs b/src/Lab/FeatureFusion/Features/Checkout/CheckoutCommandHandler.cs new file mode 100644 index 0000000..b9bbb8e --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Checkout/CheckoutCommandHandler.cs @@ -0,0 +1,197 @@ +using System.Diagnostics; +using BuildingBlocks.Mediator; +using FeatureFusion.Domain.Payments; +using FeatureFusion.Features.Orders.Commands; +using FeatureFusion.Features.Payments; +using FeatureFusion.Features.Shipping; +using FeatureFusion.Features.Tax; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Checkout; + +/// +/// Orchestrates cart → totals → payment → . +/// Does not duplicate order aggregate write logic. +/// +public sealed class CheckoutCommandHandler : ICommandHandler> +{ + public static readonly ActivitySource ActivitySource = new("FeatureFusion.Checkout"); + + private readonly CatalogDbContext _db; + private readonly ISender _sender; + private readonly ITaxCalculator _tax; + private readonly IShippingPolicy _shipping; + private readonly IPaymentProcessor _payments; + private readonly TimeProvider _time; + + public CheckoutCommandHandler( + CatalogDbContext db, + ISender sender, + ITaxCalculator tax, + IShippingPolicy shipping, + IPaymentProcessor payments, + TimeProvider? time = null) + { + _db = db; + _sender = sender; + _tax = tax; + _shipping = shipping; + _payments = payments; + _time = time ?? TimeProvider.System; + } + + public async Task> Handle( + CheckoutCommand request, + CancellationToken cancellationToken) + { + using var activity = ActivitySource.StartActivity("checkout"); + activity?.SetTag("commerce.customer_id", request.CustomerId); + + var cart = await _db.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => (int)c.CustomerId == request.CustomerId, cancellationToken) + .ConfigureAwait(false); + + if (cart is null || cart.Items.Count == 0) + return Result.Failure("Cart is empty.", StatusCodes.Status400BadRequest); + + var productIds = cart.Items.Select(i => (int)i.ProductId).Distinct().ToList(); + var products = await _db.Product + .AsNoTracking() + .Where(p => productIds.Contains((int)p.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (products.Count != productIds.Count) + { + var found = products.Select(p => (int)p.Id).ToHashSet(); + var missing = productIds.First(id => !found.Contains(id)); + return Result.Failure( + $"Product '{missing}' not found.", + StatusCodes.Status404NotFound); + } + + var byId = products.ToDictionary(p => (int)p.Id); + foreach (var item in cart.Items) + { + var productId = (int)item.ProductId; + var product = byId[productId]; + if (!product.CanFulfill(item.Quantity)) + { + if (!product.Published || product.Deleted) + return Result.Failure( + $"Product '{productId}' is not available for sale.", + StatusCodes.Status409Conflict); + + return Result.Failure( + $"Product '{productId}' does not have enough stock for quantity {item.Quantity}.", + StatusCodes.Status409Conflict); + } + } + + var subtotal = decimal.Round( + cart.Items.Sum(i => byId[(int)i.ProductId].Price * i.Quantity), + 2, + MidpointRounding.AwayFromZero); + + var tax = _tax.Calculate(subtotal); + + ShippingQuote shipping; + try + { + shipping = _shipping.Quote( + request.RecipientName, + request.Line1, + request.City, + request.PostalCode, + request.Country); + } + catch (BuildingBlocks.Domain.DomainException ex) + { + return Result.Failure(ex.Message, StatusCodes.Status400BadRequest); + } + + var grand = decimal.Round( + subtotal + tax.TaxAmount + shipping.Fee, + 2, + MidpointRounding.AwayFromZero); + + activity?.SetTag("commerce.subtotal", (double)subtotal); + activity?.SetTag("commerce.grand_total", (double)grand); + + var charge = await _payments.ChargeAsync( + new PaymentChargeRequest(grand, "EUR", request.CustomerId), + cancellationToken).ConfigureAwait(false); + + activity?.SetTag("commerce.payment_decision", charge.Decision.ToString()); + + var now = UtcNow(); + + if (charge.Decision == PaymentDecision.Declined) + { + _db.PaymentRecords.Add(PaymentRecord.Create( + Guid.NewGuid(), + PaymentOutcome.Declined, + grand, + now)); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return Result.Failure( + charge.Reason, + StatusCodes.Status402PaymentRequired); + } + + var createResult = await _sender.Send( + new CreateOrderCommand + { + CustomerId = request.CustomerId, + Items = cart.Items + .Select(i => new CreateOrderLineDto + { + ProductId = (int)i.ProductId, + Quantity = i.Quantity + }) + .ToList(), + TaxAmount = tax.TaxAmount, + ShippingAmount = shipping.Fee, + Shipping = shipping.Details + }, + cancellationToken).ConfigureAwait(false); + + if (!createResult.IsSuccess) + return Result.Failure(createResult.Error, createResult.StatusCode); + + var order = createResult.Value; + _db.PaymentRecords.Add(PaymentRecord.Create( + order.OrderId, + PaymentOutcome.Approved, + grand, + now, + order.DomainOrderId)); + + cart.Clear(now); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + activity?.SetTag("commerce.domain_order_id", order.DomainOrderId); + + return Result.Success(new CheckoutResponse( + order.OrderId, + order.DomainOrderId, + order.OrderNumber, + order.Status, + order.Subtotal, + order.TaxAmount, + order.ShippingAmount, + order.TotalAmount, + "EUR", + charge.Decision.ToString(), + order.OrderDate)); + } + + private DateTime UtcNow() + { + var now = _time.GetUtcNow().UtcDateTime; + return now.Kind == DateTimeKind.Utc ? now : DateTime.SpecifyKind(now, DateTimeKind.Utc); + } +} diff --git a/src/Lab/FeatureFusion/Features/Checkout/CheckoutEndpoints.cs b/src/Lab/FeatureFusion/Features/Checkout/CheckoutEndpoints.cs new file mode 100644 index 0000000..4a3b3e0 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Checkout/CheckoutEndpoints.cs @@ -0,0 +1,48 @@ +using BuildingBlocks.Idempotency.AspNetCore; +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace FeatureFusion.Features.Checkout; + +/// Checkout HTTP surface nested under customers (idempotent). +public static class CheckoutEndpoints +{ + public static RouteGroupBuilder MapCheckoutEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/customers") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Orders"); + + api.MapPost("/{id:int}/checkout", CheckoutAsync) + .WithName("Checkout") + .WithSummary("Checkout the customer's cart (tax, shipping, demo payment, create order).") + .WithDescription( + "Orchestrates ITaxCalculator + IShippingPolicy + IPaymentProcessor, then Mediator CreateOrderCommand. " + + "BuildingBlocks.Idempotency WithIdempotency(useLock: true). Same command as MCP orders.checkout. " + + "Declined payment returns 402 without creating an order.") + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status402PaymentRequired) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithIdempotency(useLock: true); + + return api; + } + + private static async Task CheckoutAsync( + int id, + [FromBody] CheckoutCommand request, + ISender sender, + CancellationToken cancellationToken) + { + request.CustomerId = id; + var result = await sender.Send(request, cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } +} diff --git a/src/Lab/FeatureFusion/Features/Customers/CustomerContracts.cs b/src/Lab/FeatureFusion/Features/Customers/CustomerContracts.cs new file mode 100644 index 0000000..23ebd3d --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Customers/CustomerContracts.cs @@ -0,0 +1,72 @@ +using BuildingBlocks.Mcp; +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.CursorPagination; +using FluentValidation; + +namespace FeatureFusion.Features.Customers; + +/// Customer list card. +public sealed record CustomerListItemDto( + int Id, + string Email, + string DisplayName, + DateTime CreatedAt); + +/// Customer detail. +public sealed record CustomerDetailDto( + int Id, + string Email, + string DisplayName, + DateTime CreatedAt); + +/// Order summary on a customer orders page. +public sealed record CustomerOrderListItemDto( + int Id, + string OrderNumber, + string Status, + decimal Total, + string Currency, + DateTime CreatedAt, + int LineCount); + +/// OFFSET page of customer orders. +public sealed record CustomerOrderListDto( + IReadOnlyList Items, + int Page, + int PageSize, + int TotalCount); + +/// Keyset list customers (BuildingBlocks.Pagination showcase). +[McpTool("customers.list", Description = "List customers (keyset pagination)")] +public sealed record ListCustomersQuery : IQuery>> +{ + /// Page size (1–50). Default 20. + public int Limit { get; init; } = 20; + + /// Opaque cursor from a previous response. + public string Cursor { get; init; } = string.Empty; +} + +/// Customer detail by id. +[McpTool("customers.get", Description = "Get a customer by id")] +public sealed record GetCustomerQuery(int Id) : IQuery>; + +/// Orders for a customer (OFFSET storefront-style paging). +public sealed record ListCustomerOrdersQuery : IQuery> +{ + public required int CustomerId { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} + +public sealed class GetCustomerQueryValidator : AbstractValidator +{ + public GetCustomerQueryValidator() => + RuleFor(x => x.Id).GreaterThan(0).WithMessage("Customer id is required."); +} + +public sealed class ListCustomerOrdersQueryValidator : AbstractValidator +{ + public ListCustomerOrdersQueryValidator() => + RuleFor(x => x.CustomerId).GreaterThan(0).WithMessage("Customer id is required."); +} diff --git a/src/Lab/FeatureFusion/Features/Customers/CustomerEndpoints.cs b/src/Lab/FeatureFusion/Features/Customers/CustomerEndpoints.cs new file mode 100644 index 0000000..09f04cd --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Customers/CustomerEndpoints.cs @@ -0,0 +1,86 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.CursorPagination; +using FeatureFusion.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace FeatureFusion.Features.Customers; + +/// +/// Demo Commerce customer reads. List uses BuildingBlocks.Pagination keyset; +/// customer orders use OFFSET (storefront-style nested list). +/// +public static class CustomerEndpoints +{ + public static RouteGroupBuilder MapCustomerEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/customers") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Customers"); + + api.MapGet("/", ListCustomersAsync) + .WithName("ListCustomers") + .WithSummary("List customers (keyset / BuildingBlocks.Pagination). Newest first.") + .WithDescription( + "Uses ToCursorPageAsync with CreatedAt+Id. Distinct from storefront OFFSET catalog listing.") + .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest); + + api.MapGet("/{id:int}", GetCustomerAsync) + .WithName("GetCustomer") + .WithSummary("Customer detail by id.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + api.MapGet("/{id:int}/orders", ListCustomerOrdersAsync) + .WithName("ListCustomerOrders") + .WithSummary("Orders for a customer (OFFSET paging, newest first).") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + return api; + } + + private static async Task ListCustomersAsync( + ISender sender, + CancellationToken cancellationToken, + [FromQuery] int limit = 20, + [FromQuery] string? cursor = null) + { + var result = await sender.Send( + new ListCustomersQuery { Limit = limit, Cursor = cursor ?? string.Empty }, + cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task GetCustomerAsync( + int id, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(new GetCustomerQuery(id), cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task ListCustomerOrdersAsync( + int id, + ISender sender, + CancellationToken cancellationToken, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var result = await sender.Send( + new ListCustomerOrdersQuery + { + CustomerId = id, + Page = page, + PageSize = pageSize + }, + cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } +} diff --git a/src/Lab/FeatureFusion/Features/Customers/CustomerSortKeys.cs b/src/Lab/FeatureFusion/Features/Customers/CustomerSortKeys.cs new file mode 100644 index 0000000..555eab6 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Customers/CustomerSortKeys.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Pagination; +using FeatureFusion.Domain.Customers; + +namespace FeatureFusion.Features.Customers; + +/// Keyset sort keys for Demo Commerce customer listing. +public static class CustomerSortKeys +{ + /// CreatedAt descending, unique Id descending (newest first). + public static readonly SortKey CreatedAtDesc = + SortKey.For() + .ByDescending(c => c.CreatedAt, sql: "CreatedAt") + .ThenByUniqueDescending(c => (int)c.Id, sql: "Id"); +} diff --git a/src/Lab/FeatureFusion/Features/Customers/GetCustomerQueryHandler.cs b/src/Lab/FeatureFusion/Features/Customers/GetCustomerQueryHandler.cs new file mode 100644 index 0000000..82c9000 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Customers/GetCustomerQueryHandler.cs @@ -0,0 +1,33 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Customers; + +public sealed class GetCustomerQueryHandler + : IQueryHandler> +{ + private readonly CatalogDbContext _db; + + public GetCustomerQueryHandler(CatalogDbContext db) => _db = db; + + public async Task> Handle( + GetCustomerQuery request, + CancellationToken cancellationToken) + { + var customer = await _db.Customers.AsNoTracking() + .Where(c => (int)c.Id == request.Id) + .Select(c => new CustomerDetailDto( + (int)c.Id, + c.Email.Value, + c.DisplayName, + c.CreatedAt)) + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (customer is null) + return Result.Failure("Customer not found.", StatusCodes.Status404NotFound); + + return Result.Success(customer); + } +} diff --git a/src/Lab/FeatureFusion/Features/Customers/ListCustomerOrdersQueryHandler.cs b/src/Lab/FeatureFusion/Features/Customers/ListCustomerOrdersQueryHandler.cs new file mode 100644 index 0000000..1d4d7ab --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Customers/ListCustomerOrdersQueryHandler.cs @@ -0,0 +1,52 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Customers; + +public sealed class ListCustomerOrdersQueryHandler + : IQueryHandler> +{ + private readonly CatalogDbContext _db; + + public ListCustomerOrdersQueryHandler(CatalogDbContext db) => _db = db; + + public async Task> Handle( + ListCustomerOrdersQuery request, + CancellationToken cancellationToken) + { + var exists = await _db.Customers.AsNoTracking() + .AnyAsync(c => (int)c.Id == request.CustomerId, cancellationToken) + .ConfigureAwait(false); + if (!exists) + return Result.Failure( + "Customer not found.", + StatusCodes.Status404NotFound); + + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 50 ? 20 : request.PageSize; + + var query = _db.Orders.AsNoTracking() + .Where(o => (int)o.CustomerId == request.CustomerId); + + var total = await query.CountAsync(cancellationToken).ConfigureAwait(false); + var items = await query + .OrderByDescending(o => o.CreatedAt) + .ThenByDescending(o => (int)o.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(o => new CustomerOrderListItemDto( + (int)o.Id, + o.OrderNumber.Value, + o.Status.ToString(), + o.Total, + o.Currency, + o.CreatedAt, + o.Items.Count)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return Result.Success( + new CustomerOrderListDto(items, page, pageSize, total)); + } +} diff --git a/src/Lab/FeatureFusion/Features/Customers/ListCustomersQueryHandler.cs b/src/Lab/FeatureFusion/Features/Customers/ListCustomersQueryHandler.cs new file mode 100644 index 0000000..2d80e6f --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Customers/ListCustomersQueryHandler.cs @@ -0,0 +1,54 @@ +using BuildingBlocks.Mediator; +using BuildingBlocks.Pagination; +using BuildingBlocks.Pagination.EntityFrameworkCore; +using FeatureFusion.Infrastructure.Context; +using FeatureFusion.Infrastructure.CursorPagination; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Customers; + +/// +/// Customer list via — +/// Demo Commerce showcase of BuildingBlocks.Pagination (distinct from storefront OFFSET catalog). +/// +public sealed class ListCustomersQueryHandler + : IQueryHandler>> +{ + private readonly CatalogDbContext _db; + + public ListCustomersQueryHandler(CatalogDbContext db) => _db = db; + + public async Task>> Handle( + ListCustomersQuery request, + CancellationToken cancellationToken) + { + var limit = request.Limit is < 1 or > 50 ? 20 : request.Limit; + var firstPage = string.IsNullOrWhiteSpace(request.Cursor); + + try + { + var page = await _db.Customers + .AsNoTracking() + .TagWith("customers.list") + .ToCursorPageAsync( + new CursorRequest(request.Cursor, limit), + CustomerSortKeys.CreatedAtDesc, + c => new CustomerListItemDto( + (int)c.Id, + c.Email.Value, + c.DisplayName, + c.CreatedAt), + new PaginationOptions { IncludeTotalCount = firstPage }, + cancellationToken) + .ConfigureAwait(false); + + return Result>.Success(page.ToPagedResult()); + } + catch (PaginationException ex) + { + return Result>.Failure( + ex.Message, + StatusCodes.Status400BadRequest); + } + } +} diff --git a/src/Lab/FeatureFusion/Features/Lab/Endpoints/FeatureFilterPreviewEndpoints.cs b/src/Lab/FeatureFusion/Features/Lab/Endpoints/FeatureFilterPreviewEndpoints.cs new file mode 100644 index 0000000..62bed76 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Lab/Endpoints/FeatureFilterPreviewEndpoints.cs @@ -0,0 +1,44 @@ +using FeatureFusion.Infrastructure.Extensions; +using Microsoft.FeatureManagement; + +namespace FeatureFusion.Features.Lab.Endpoints; + +/// +/// Dedicated Feature Management showcase: CustomGreeting flag + UseGreeting filter (VIP claim). +/// +public static class FeatureFilterPreviewEndpoints +{ + private const string Description = + "Feature Management lab preview (not a storefront API). " + + "Evaluates feature flag **CustomGreeting** via filter alias **UseGreeting**, which returns true when the " + + "JWT has claim VIP=true. Obtain a token from POST /api/v1/Auth/login (vipuser/vippassword for VIP). " + + "Anonymous or non-VIP callers get the anonymous message. " + + "Capabilities demonstrated: Microsoft.FeatureManagement feature flags, custom IFeatureFilter, JWT claim evaluation."; + + public static RouteGroupBuilder MapFeatureFilterPreviewEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/lab") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Lab — Feature Management"); + + api.MapGet("/feature-filter-preview", PreviewAsync) + .WithName("FeatureFilterPreview") + .WithSummary("Feature filter preview: CustomGreeting + UseGreeting (VIP claim).") + .WithDescription(Description) + .Produces(StatusCodes.Status200OK); + + return api; + } + + private static async Task PreviewAsync(IFeatureManager featureManager, HttpContext httpContext) + { + var name = httpContext.User.Identity?.Name ?? "caller"; + if (await featureManager.IsEnabledAsync("CustomGreeting").ConfigureAwait(false)) + return Results.Ok($"Hello VIP user {name}, CustomGreeting is enabled via UseGreeting filter."); + + return Results.Ok($"Hello Anonymous user {name}, CustomGreeting is disabled for this caller."); + } +} diff --git a/src/Lab/FeatureFusion/Features/Lab/Endpoints/LabEndpoints.cs b/src/Lab/FeatureFusion/Features/Lab/Endpoints/LabEndpoints.cs new file mode 100644 index 0000000..e6b625e --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Lab/Endpoints/LabEndpoints.cs @@ -0,0 +1,133 @@ +using BuildingBlocks.Idempotency.AspNetCore; +using BuildingBlocks.Mcp; +using BuildingBlocks.Mcp.Hosting; +using FeatureFusion.Dtos; +using FeatureFusion.Infrastructure.Extensions; +using FeatureFusion.Services.ProductService; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.FeatureManagement; + +namespace FeatureFusion.Features.Lab.Endpoints; + +/// Lab Minimal APIs (validation samples, cache demos, ping, idempotency smoke). +public static class LabEndpoints +{ + public static RouteGroupBuilder MapLabEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Lab"); + + api.MapGet("/product-promotion", GetProductPromotion) + .WithName("GetProductPromotion") + .WithSummary("Lab cache demo: product promotions (not Feature Management showcase)."); + + api.MapGet("/product-recommendation", GetProductRecommendation) + .WithName("GetProductRecommendation") + .WithSummary("Lab cache middleware demo: product recommendations (not Feature Management showcase)."); + + api.MapPost("/minimal-custom-greeting", GetCustomGreeting) + .WithName("MinimalCustomGreeting") + .WithSummary("Lab FluentValidation demo (GreetingDto). Not the Feature Management filter preview.") + .WithDescription( + "Validates Fullname via GreetingValidator. For CustomGreeting / UseGreeting filter behavior, " + + "use GET /api/v1/lab/feature-filter-preview.") + .Produces>() + .ProducesValidationProblem() + .Produces>(); + + api.MapPost("/person-endpointfilter", HandleCreatePerson) + .WithName("PersonEndpointFilter") + .WithSummary("Lab validation via AddEndpointFilter (not Feature Management showcase).") + .AddEndpointFilter>(); + + api.MapPost("/person-builderextension", HandleCreatePerson) + .WithName("PersonBuilderExtension") + .WithSummary("Lab validation via WithValidation extension (not Feature Management showcase).") + .WithValidation(); + + api.MapPostWithValidation("/person-genericendpoint", HandleCreatePerson) + .WithName("PersonGenericEndpoint") + .WithSummary("Lab validation via MapPostWithValidation (not Feature Management showcase)."); + + api.MapGet("/lab-ping", LabPing) + .WithName("LabPing") + .WithSummary("Minimal API ping (not a Mediator command). Same method is MCP tool lab.ping.") + .WithMcp(app); + + api.MapPost("/idempotency-smoke", () => Results.Ok(new { ok = true })) + .WithName("IdempotencySmoke") + .WithSummary("Minimal API Idempotency-Key smoke (WithIdempotency).") + .WithIdempotency(useLock: true); + + return api; + } + + public static async Task>, NotFound>> GetProductPromotion( + IProductService productService, + bool getFromMemCach = false) + { + try + { + var promotions = await productService.GetProductPromotionAsync(getFromMemCach); + return TypedResults.Ok(promotions); + } + catch + { + return TypedResults.NotFound("An error occurred while fetching promotions."); + } + } + + public static async Task>, NotFound>> GetProductRecommendation( + IProductService productService) + { + try + { + var recommendation = await productService.GetProductRocemmendationAsync(); + return TypedResults.Ok(recommendation); + } + catch + { + return TypedResults.NotFound("An error occurred while fetching promotions."); + } + } + + public static async Task, BadRequest, NotFound>> GetCustomGreeting( + [AsParameters] GreetingDto greeting, + GreetingValidator validator, + ILogger logger) + { + var validationResult = await validator.ValidateWithResultAsync(greeting); + if (!validationResult.IsValid) + { + logger.LogWarning("validation error on {GreetingType}: {Errors}", + nameof(GreetingDto), validationResult.ProblemDetails!.Errors); + return TypedResults.BadRequest(validationResult.ProblemDetails); + } + + return TypedResults.Ok($"Hello {greeting.Fullname}, greeting validated."); + } + + public static Task, BadRequest, NotFound>> HandleCreatePerson( + [AsParameters] PersonDto person) + { + return Task.FromResult, BadRequest, NotFound>>( + TypedResults.Ok($"Hello {person.Name}")); + } + + /// + /// HTTP GET /api/v1/lab-ping and MCP tool lab.ping — same method, not a Mediator command. + /// + [McpTool("lab.ping", Description = "Minimal API ping (not a Mediator command)", Kind = McpToolKind.Query)] + public static string LabPing([AsParameters] LabPingRequest request) + => string.IsNullOrWhiteSpace(request.Name) ? "pong" : $"pong:{request.Name}"; +} + +public sealed class LabPingRequest +{ + public string Name { get; set; } = default!; +} diff --git a/src/Lab/FeatureFusion/Features/MediatorDemo/Endpoints/MediatorDemoEndpoints.cs b/src/Lab/FeatureFusion/Features/MediatorDemo/Endpoints/MediatorDemoEndpoints.cs index c05029e..d2f6d61 100644 --- a/src/Lab/FeatureFusion/Features/MediatorDemo/Endpoints/MediatorDemoEndpoints.cs +++ b/src/Lab/FeatureFusion/Features/MediatorDemo/Endpoints/MediatorDemoEndpoints.cs @@ -1,7 +1,7 @@ -using Asp.Versioning; using BuildingBlocks.Mediator; using FeatureFusion.Features.MediatorDemo.Commands; using FeatureFusion.Features.MediatorDemo.Queries; +using FeatureFusion.Infrastructure.Extensions; using Microsoft.AspNetCore.Mvc; namespace FeatureFusion.Features.MediatorDemo.Endpoints; @@ -14,22 +14,16 @@ public static class MediatorDemoEndpoints { public static RouteGroupBuilder MapMediatorDemoEndpoints(this IEndpointRouteBuilder app) { - var v2 = new ApiVersion(2, 0); - var apiVersionSet = app.NewApiVersionSet() - .HasApiVersion(v2) - .ReportApiVersions() - .Build(); + var apiVersionSet = app.CreateLabApiVersionSet(); var api = app.MapGroup("api/v{version:apiVersion}/mediator-demo") .WithApiVersionSet(apiVersionSet) - .MapToApiVersion(v2) + .MapToApiVersion(ApiVersioningExtensions.Current) .WithTags("MediatorDemo"); - // EchoCommandValidator (FluentValidation) runs via ValidationBehavior in the mediator pipeline. - // Empty/too-long Message → ValidationException → ValidationExceptionHandler → 400 ValidationProblemDetails. api.MapPost("/echo", EchoAsync) .WithName("MediatorDemoEcho") - .WithSummary("Send EchoCommand through BuildingBlocks.Mediator (FluentValidation via ValidationBehavior). Use message=__throw__ to force a handler fault (500).") + .WithSummary("Echo a message through the mediator pipeline. Use message=__throw__ to force a handler fault (500).") .Accepts("application/json") .Produces(StatusCodes.Status200OK) .ProducesValidationProblem() @@ -38,7 +32,7 @@ public static RouteGroupBuilder MapMediatorDemoEndpoints(this IEndpointRouteBuil api.MapGet("/status", StatusAsync) .WithName("MediatorDemoStatus") - .WithSummary("Send GetEchoStatusQuery through BuildingBlocks.Mediator") + .WithSummary("Mediator query sample: echo pipeline status.") .Produces(StatusCodes.Status200OK); return api; @@ -50,9 +44,7 @@ private static async Task EchoAsync( CancellationToken cancellationToken) { var result = await sender.Send(command, cancellationToken).ConfigureAwait(false); - return result.Match( - onSuccess: value => Results.Ok(value), - onFailure: (error, statusCode) => Results.Problem(detail: error, statusCode: statusCode)); + return result.ToApiResult(); } private static async Task StatusAsync( @@ -60,8 +52,6 @@ private static async Task StatusAsync( CancellationToken cancellationToken) { var result = await sender.Send(new GetEchoStatusQuery(), cancellationToken).ConfigureAwait(false); - return result.Match( - onSuccess: value => Results.Ok(value), - onFailure: (error, statusCode) => Results.Problem(detail: error, statusCode: statusCode)); + return result.ToApiResult(); } } diff --git a/src/Lab/FeatureFusion/Features/MediatorDemo/Queries/GetEchoStatusQuery.cs b/src/Lab/FeatureFusion/Features/MediatorDemo/Queries/GetEchoStatusQuery.cs index a8cdafc..4a21f6f 100644 --- a/src/Lab/FeatureFusion/Features/MediatorDemo/Queries/GetEchoStatusQuery.cs +++ b/src/Lab/FeatureFusion/Features/MediatorDemo/Queries/GetEchoStatusQuery.cs @@ -18,7 +18,7 @@ public Task> Handle( var response = new EchoStatusResponse( Status: "ready", ActivitySource: "BuildingBlocks.Mediator", - Hint: "In Aspire Dashboard, filter traces by source BuildingBlocks.Mediator after calling POST /api/v2/mediator-demo/echo."); + Hint: "In Aspire Dashboard, filter traces by source BuildingBlocks.Mediator after calling POST /api/v1/mediator-demo/echo."); return Task.FromResult(Result.Success(response)); } diff --git a/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommand.cs b/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommand.cs index 6542c45..7533186 100644 --- a/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommand.cs +++ b/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommand.cs @@ -1,58 +1,119 @@ -using FeatureFusion.Dtos; +using System.Text.Json.Serialization; using BuildingBlocks.Mcp; using BuildingBlocks.Mediator; +using FeatureFusion.Dtos; +using FeatureFusion.Domain.Orders; using FeatureFusion.Models.Validator; using FluentValidation; using Microsoft.AspNetCore.Mvc; -using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; +namespace FeatureFusion.Features.Orders.Commands; -namespace FeatureFusion.Features.Orders.Commands +/// +/// Create-order intent. Experiments 1–20 use flat productId/quantity/customerId. +/// Optional supports multi-line Demo Commerce creates; when omitted, the flat fields form a single line. +/// Clients never send unit price, total, order number, or status. +/// Checkout may set ignored tax/shipping fields via Mediator only. +/// +[McpTool("orders.create", Description = "Create an order from catalog products", Idempotent = true, RequireConfirmation = true)] +public class CreateOrderCommand : ICommand> { - [McpTool("orders.create", Description = "Create an order", Idempotent = true, RequireConfirmation = true)] - public class CreateOrderCommand : ICommand> + public int ProductId { get; set; } + public int Quantity { get; set; } + public int CustomerId { get; set; } + + /// Optional multi-line payload. When present and non-empty, replaces the flat product/quantity line. + public List? Items { get; set; } + + /// Set only by CheckoutCommandHandler (not HTTP/MCP JSON). + [JsonIgnore] + public decimal TaxAmount { get; set; } + + /// Set only by CheckoutCommandHandler (not HTTP/MCP JSON). + [JsonIgnore] + public decimal ShippingAmount { get; set; } + + /// Set only by CheckoutCommandHandler (not HTTP/MCP JSON). + [JsonIgnore] + public OrderShipping? Shipping { get; set; } + + /// Normalized order lines for hashing, validation, and persistence. + public IReadOnlyList ResolveLines() { - public int ProductId { get; set; } - public int Quantity { get; set; } - public int CustomerId { get; set; } + if (Items is { Count: > 0 }) + return Items; + + return [new CreateOrderLineDto { ProductId = ProductId, Quantity = Quantity }]; } - public class OrderRequestValidator : BaseValidator +} +/// One requested line (intent only — no client price). +public sealed class CreateOrderLineDto +{ + public int ProductId { get; set; } + public int Quantity { get; set; } +} + +public class OrderRequestValidator : BaseValidator +{ + private readonly ILogger _logger; + + public OrderRequestValidator(ILogger logger) { - private readonly ILogger _logger; + _logger = logger; - public OrderRequestValidator(ILogger logger) - { - _logger = logger; - RuleFor(x => x.Quantity) - .GreaterThan(0).WithMessage("Quantity must be greater than 0"); - } - public async Task ValidateWithResultAsync(CreateOrderCommand item) - { - var validationResult = await ValidateAsync(item); + RuleFor(x => x.CustomerId) + .GreaterThan(0).WithMessage("CustomerId must be greater than 0"); - if (!validationResult.IsValid) - { - var validationErrors = validationResult.Errors - .GroupBy(e => e.PropertyName) - .ToDictionary( - group => group.Key, - group => group.Select(e => e.ErrorMessage).ToArray() - ); + RuleFor(x => x.TaxAmount) + .GreaterThanOrEqualTo(0).WithMessage("Tax cannot be negative."); + + RuleFor(x => x.ShippingAmount) + .GreaterThanOrEqualTo(0).WithMessage("Shipping cannot be negative."); - _logger.LogError($"validation error on {nameof(GreetingDto)}: {validationErrors}"); + RuleFor(x => x) + .Custom((cmd, ctx) => + { + var lines = cmd.ResolveLines(); + if (lines.Count == 0) + { + ctx.AddFailure("Items", "At least one order item is required"); + return; + } - var problemDetails = new ValidationProblemDetails + for (var i = 0; i < lines.Count; i++) { - Status = StatusCodes.Status400BadRequest, - Title = "One or more validation errors occurred.", - Errors = validationErrors - }; + if (lines[i].ProductId <= 0) + ctx.AddFailure($"Items[{i}].ProductId", "ProductId must be greater than 0"); + if (lines[i].Quantity <= 0) + ctx.AddFailure($"Items[{i}].Quantity", "Quantity must be greater than 0"); + } + }); + } - return ValidationResult.Failure(problemDetails); - } + public async Task ValidateWithResultAsync(CreateOrderCommand item) + { + var validationResult = await ValidateAsync(item); - return ValidationResult.Success(); + if (!validationResult.IsValid) + { + var validationErrors = validationResult.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + group => group.Key, + group => group.Select(e => e.ErrorMessage).ToArray()); + + _logger.LogError("validation error on {Command}: {Errors}", nameof(CreateOrderCommand), validationErrors); + + var problemDetails = new ValidationProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "One or more validation errors occurred.", + Errors = validationErrors + }; + + return ValidationResult.Failure(problemDetails); } - } + return ValidationResult.Success(); + } } diff --git a/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandHandler.cs b/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandHandler.cs index 8224f11..f3766cc 100644 --- a/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandHandler.cs +++ b/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandHandler.cs @@ -1,83 +1,232 @@ using BuildingBlocks.Mediator; -using FeatureFusion.Models; -using static FeatureFusion.Controllers.V2.OrderController; -using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; - +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Customers; +using FeatureFusion.Domain.Orders; using FeatureFusion.Features.Order.IntegrationEvents; using FeatureFusion.Features.Order.IntegrationEvents.Events; using FeatureFusion.Infrastructure.Context; -using FeatureFusion.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using OrderEntity = FeatureFusion.Domain.Orders.Order; + +namespace FeatureFusion.Features.Orders.Commands; -namespace FeatureFusion.Features.Orders.Commands +/// +/// Persists a real with catalog price snapshots and stock decrement. +/// Surfaces (HTTP / MCP / Admission release / Checkout) must only call . +/// +public sealed class CreateOrderCommandHandler : ICommandHandler> { - public class CreateOrderCommandHandler : ICommandHandler> + private const int MaxConcurrencyRetries = 5; + + private readonly CatalogDbContext _db; + private readonly IIntegrationEventService _integrationEvents; + private readonly TimeProvider _time; + + public CreateOrderCommandHandler( + CatalogDbContext db, + IIntegrationEventService integrationEvents, + TimeProvider? time = null) + { + _db = db; + _integrationEvents = integrationEvents; + _time = time ?? TimeProvider.System; + } + + public async Task> Handle(CreateOrderCommand request, CancellationToken cancellationToken) { - private readonly IServiceProvider _serviceProvider; - private readonly CatalogDbContext _catalogDbContext; + var merged = request.ResolveLines() + .GroupBy(l => l.ProductId) + .Select(g => (ProductId: g.Key, Quantity: g.Sum(x => x.Quantity))) + .ToList(); - public CreateOrderCommandHandler(IServiceProvider serviceProvider, - CatalogDbContext catalogdbContext) - { - _serviceProvider = serviceProvider; - _catalogDbContext = catalogdbContext; - } + var customer = await _db.Customers.AsNoTracking() + .FirstOrDefaultAsync(c => (int)c.Id == request.CustomerId, cancellationToken) + .ConfigureAwait(false); + if (customer is null) + return Result.Failure("Customer not found.", StatusCodes.Status404NotFound); + + var productIds = merged.Select(m => m.ProductId).ToList(); - public async Task> Handle(CreateOrderCommand request, CancellationToken cancellationToken) + List? loadedProducts = null; + OrderEntity? pendingOrder = null; + + for (var attempt = 0; attempt < MaxConcurrencyRetries; attempt++) { + if (attempt > 0) + { + // Discard only this attempt's mutations — never ChangeTracker.Clear() + // (Checkout / Admission may still track Cart / IntentTicket on the same scoped DbContext). + await DiscardAttemptAsync(loadedProducts, pendingOrder, cancellationToken).ConfigureAwait(false); + loadedProducts = null; + pendingOrder = null; + } + + var products = await _db.Product + .Where(p => productIds.Contains((int)p.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + loadedProducts = products; - // Static in-memory product - var product = new Product + if (products.Count != productIds.Count) { - Name = "Smartphone", - Published = true, - Deleted = false, - VisibleIndividually = true, - Price = 599.99m - }; - - // Static in-memory customer - var customer = new Person + var found = products.Select(p => (int)p.Id).ToHashSet(); + var missing = productIds.First(id => !found.Contains(id)); + return Result.Failure($"Product '{missing}' not found.", StatusCodes.Status404NotFound); + } + + var byId = products.ToDictionary(p => (int)p.Id); + + foreach (var (productId, quantity) in merged) { - Name = "John Doe", - Age = 11111, - }; + var product = byId[productId]; + if (!product.CanFulfill(quantity)) + { + if (!product.Published || product.Deleted) + return Result.Failure( + $"Product '{productId}' is not available for sale.", + StatusCodes.Status409Conflict); - var orderId = Guid.NewGuid(); + return Result.Failure( + $"Product '{productId}' does not have enough stock for quantity {quantity}.", + StatusCodes.Status409Conflict); + } + } - var orderTotal = product.Price * request.Quantity; + foreach (var (productId, quantity) in merged) + { + if (!byId[productId].TryDecrementStock(quantity)) + { + return Result.Failure( + $"Product '{productId}' does not have enough stock for quantity {quantity}.", + StatusCodes.Status409Conflict); + } + } + + var now = _time.GetUtcNow().UtcDateTime; + if (now.Kind != DateTimeKind.Utc) + now = DateTime.SpecifyKind(now, DateTimeKind.Utc); - var response = new OrderResponse + var orderLines = merged.Select(m => { - OrderId = orderId, - CustomerName = customer.Name, - ProductName = product.Name, - Quantity = request.Quantity, - TotalAmount = orderTotal, - OrderDate = DateTime.UtcNow, - Message = "Order created successfully." - }; - var evt = new OrderCreatedIntegrationEvent(orderId, orderTotal); + var product = byId[m.ProductId]; + return ( + ProductId: ProductId.From(m.ProductId), + m.Quantity, + UnitPrice: product.Price, + (OrderItemId?)null); + }).ToList(); - using var scope = _serviceProvider.CreateScope(); - var integrationService = scope.ServiceProvider.GetRequiredService(); + var orderNumber = OrderNumber.Create($"ORD-{Ulid.NewUlid()}"); + var order = OrderEntity.Create( + orderNumber, + CustomerId.From(request.CustomerId), + OrderStatus.Placed, + currency: "EUR", + createdAtUtc: now, + lines: orderLines, + taxAmount: request.TaxAmount, + shippingAmount: request.ShippingAmount, + shipping: request.Shipping); + pendingOrder = order; + _db.Orders.Add(order); - // currently it will be added to catalog , i need to setup table order - _catalogDbContext.Product.Add(product); - await integrationService.PublishThroughEventBusAsync(evt); + var correlationId = Guid.NewGuid(); + var evt = new OrderCreatedIntegrationEvent(correlationId, order.Total); - return Result.Success(response); + try + { + await _integrationEvents.PublishThroughEventBusAsync(evt).ConfigureAwait(false); + } + catch (DbUpdateConcurrencyException) when (attempt < MaxConcurrencyRetries - 1) + { + continue; + } + catch (DbUpdateConcurrencyException) + { + await DiscardAttemptAsync(loadedProducts, pendingOrder, cancellationToken).ConfigureAwait(false); + return Result.Failure( + "Stock changed concurrently; please retry.", + StatusCodes.Status409Conflict); + } + + var primary = merged[0]; + var primaryProduct = byId[primary.ProductId]; + + return Result.Success(new OrderResponse + { + OrderId = correlationId, + DomainOrderId = (int)order.Id, + OrderNumber = order.OrderNumber.Value, + Status = order.Status.ToString(), + CustomerName = customer.DisplayName, + ProductName = primaryProduct.Name, + Quantity = merged.Sum(m => m.Quantity), + TotalAmount = order.Total, + Subtotal = order.Subtotal, + TaxAmount = order.TaxAmount, + ShippingAmount = order.ShippingAmount, + OrderDate = order.CreatedAt, + Message = "Order created successfully." + }); + } + + return Result.Failure( + "Stock changed concurrently; please retry.", + StatusCodes.Status409Conflict); + } + /// + /// Rolls back in-memory stock mutations (reload from DB) and detaches an uncommitted order + /// without wiping unrelated tracked entities on the shared scoped . + /// + private async Task DiscardAttemptAsync( + List? products, + OrderEntity? order, + CancellationToken cancellationToken) + { + if (order is not null) + { + foreach (var item in order.Items.ToList()) + { + var itemEntry = _db.Entry(item); + if (itemEntry.State != EntityState.Detached) + itemEntry.State = EntityState.Detached; + } + + var orderEntry = _db.Entry(order); + if (orderEntry.State != EntityState.Detached) + orderEntry.State = EntityState.Detached; } - public class OrderResponse + if (products is null) + return; + + foreach (var product in products) { - public Guid OrderId { get; set; } - public string CustomerName { get; set; } - public string ProductName { get; set; } - public int Quantity { get; set; } - public decimal TotalAmount { get; set; } - public DateTime OrderDate { get; set; } - public string Message { get; set; } + var entry = _db.Entry(product); + if (entry.State == EntityState.Detached) + continue; + + // Reload restores StockQuantity + OriginalVersion from the database after a failed SaveChanges. + await entry.ReloadAsync(cancellationToken).ConfigureAwait(false); } } } + +/// Create-order HTTP/MCP response (Exp-compatible Guid OrderId + Demo Commerce fields). +public sealed class OrderResponse +{ + public Guid OrderId { get; set; } + public int DomainOrderId { get; set; } + public string OrderNumber { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public string ProductName { get; set; } = string.Empty; + public int Quantity { get; set; } + public decimal TotalAmount { get; set; } + public decimal Subtotal { get; set; } + public decimal TaxAmount { get; set; } + public decimal ShippingAmount { get; set; } + public DateTime OrderDate { get; set; } + public string Message { get; set; } = string.Empty; +} diff --git a/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoid.cs b/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoid.cs deleted file mode 100644 index a831f1e..0000000 --- a/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoid.cs +++ /dev/null @@ -1,55 +0,0 @@ -using FeatureFusion.Dtos; -using BuildingBlocks.Mediator; -using FeatureFusion.Models.Validator; -using FluentValidation; -using Microsoft.AspNetCore.Mvc; - -namespace FeatureFusion.Features.Orders.Commands -{ - public class CreateOrderCommandVoid : ICommand - { - public int ProductId { get; set; } - public int Quantity { get; set; } - public int CustomerId { get; set; } - } - - public class CreateOrderCommandVoidValidator : BaseValidator - { - private readonly ILogger _logger; - - public CreateOrderCommandVoidValidator(ILogger logger) - { - _logger = logger; - RuleFor(x => x.Quantity) - .GreaterThan(0).WithMessage("Quantity must be greater than 0"); - } - - public async Task ValidateWithResultAsync(CreateOrderCommandVoid item) - { - var validationResult = await ValidateAsync(item); - - if (!validationResult.IsValid) - { - var validationErrors = validationResult.Errors - .GroupBy(e => e.PropertyName) - .ToDictionary( - group => group.Key, - group => group.Select(e => e.ErrorMessage).ToArray() - ); - - _logger.LogError("validation error on {Command}: {Errors}", nameof(CreateOrderCommandVoid), validationErrors); - - var problemDetails = new ValidationProblemDetails - { - Status = StatusCodes.Status400BadRequest, - Title = "One or more validation errors occurred.", - Errors = validationErrors - }; - - return ValidationResult.Failure(problemDetails); - } - - return ValidationResult.Success(); - } - } -} diff --git a/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoidHandler.cs b/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoidHandler.cs deleted file mode 100644 index 3b09d28..0000000 --- a/src/Lab/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoidHandler.cs +++ /dev/null @@ -1,60 +0,0 @@ -using BuildingBlocks.Mediator; -using FeatureFusion.Models; -using static FeatureFusion.Controllers.V2.OrderController; -using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; -using StackExchange.Redis; -using FeatureFusion.Domain.Entities; -namespace FeatureFusion.Features.Orders.Commands -{ - public class CreateOrderCommandVoidHandler : ICommandHandler - { - public Task Handle(CreateOrderCommandVoid command, CancellationToken cancellationToken) - { - // Static in-memory product - var product = new Product - { - Id = 12345, - Name = "Smartphone", - Published = true, - Deleted = false, - VisibleIndividually = true, - Price = 599.99m - }; - - // Static in-memory customer - var customer = new Person - { - Name = "John Doe", - Age = 11111, - }; - - var orderId = Ulid.NewUlid(); - - var orderTotal = product.Price * command.Quantity; - - var response = new OrderResponse - { - OrderId = orderId, - CustomerName = customer.Name, - ProductName = product.Name, - Quantity = command.Quantity, - TotalAmount = orderTotal, - OrderDate = DateTime.UtcNow, - Message = "Order created successfully." - }; - return Task.CompletedTask; - - } - - public class OrderResponse - { - public Ulid OrderId { get; set; } - public string CustomerName { get; set; } - public string ProductName { get; set; } - public int Quantity { get; set; } - public decimal TotalAmount { get; set; } - public DateTime OrderDate { get; set; } - public string Message { get; set; } - } - } -} diff --git a/src/Lab/FeatureFusion/Features/Orders/Endpoints/OrderEndpoints.cs b/src/Lab/FeatureFusion/Features/Orders/Endpoints/OrderEndpoints.cs new file mode 100644 index 0000000..641d4e2 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Orders/Endpoints/OrderEndpoints.cs @@ -0,0 +1,84 @@ +using BuildingBlocks.Idempotency.AspNetCore; +using BuildingBlocks.Mediator; +using FeatureFusion.Features.Admission; +using FeatureFusion.Features.Orders.Commands; +using FeatureFusion.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace FeatureFusion.Features.Orders.Endpoints; + +/// +/// Order create HTTP surface (idempotency + admission + mediator). +/// Path kept as POST /api/v1/Order/order for experiment contracts. +/// +public static class OrderEndpoints +{ + public static RouteGroupBuilder MapOrderEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/Order") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Orders"); + + api.MapPost("/order", CreateOrderAsync) + .WithName("CreateOrder") + .WithSummary("Create a catalog order. Requires Idempotency-Key; may return 202 when admission defers.") + .WithDescription( + "FluentValidation → capability admission (Allow / Defer / Deny) → Mediator CreateOrderCommand. " + + "BuildingBlocks.Idempotency WithIdempotency(useLock: true). Same command as MCP orders.create. " + + "Persists Domain.Orders.Order with catalog price snapshots (not a checkout).") + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status202Accepted) + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithIdempotency(useLock: true); + + return api; + } + + private static async Task CreateOrderAsync( + [FromBody] CreateOrderCommand request, + OrderRequestValidator validator, + ISender sender, + ICapabilityAdmission admission, + HttpRequest httpRequest, + CancellationToken cancellationToken) + { + var validationResult = await validator.ValidateWithResultAsync(request).ConfigureAwait(false); + if (!validationResult.IsValid) + return Results.BadRequest(validationResult.ProblemDetails); + + var requestKey = OrderCreateAdmissionGate.ResolveHttpRequestKey(httpRequest); + var decision = await OrderCreateAdmissionGate.AdmitCreateOrderAsync( + admission, + request, + requestKey, + cancellationToken).ConfigureAwait(false); + + switch (decision) + { + case AdmissionDecision.Defer defer: + return Results.Accepted(value: defer.Pending); + case AdmissionDecision.Deny deny: + return Results.Problem( + title: "Admission denied", + detail: deny.Error, + statusCode: deny.StatusCode); + case AdmissionDecision.Allow: + break; + default: + return Results.Problem("Unknown admission decision.", statusCode: StatusCodes.Status500InternalServerError); + } + + var createOrderResult = await sender.Send(request, cancellationToken).ConfigureAwait(false); + return createOrderResult.Match( + onSuccess: value => Results.Ok(value), + onFailure: (error, statusCode) => Results.Problem( + detail: error, + statusCode: statusCode is >= 400 and < 600 ? statusCode : StatusCodes.Status400BadRequest)); + } +} diff --git a/src/Lab/FeatureFusion/Features/Orders/GetOrderQueryHandler.cs b/src/Lab/FeatureFusion/Features/Orders/GetOrderQueryHandler.cs new file mode 100644 index 0000000..8d5befa --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Orders/GetOrderQueryHandler.cs @@ -0,0 +1,67 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Orders; + +public sealed class GetOrderQueryHandler + : IQueryHandler> +{ + private readonly CatalogDbContext _db; + + public GetOrderQueryHandler(CatalogDbContext db) => _db = db; + + public async Task> Handle( + GetOrderQuery request, + CancellationToken cancellationToken) + { + var order = await _db.Orders.AsNoTracking() + .Where(o => (int)o.Id == request.Id) + .Select(o => new + { + Id = (int)o.Id, + OrderNumber = o.OrderNumber.Value, + CustomerId = (int)o.CustomerId, + CustomerEmail = o.Customer != null ? o.Customer.Email.Value : null, + CustomerDisplayName = o.Customer != null ? o.Customer.DisplayName : null, + Status = o.Status.ToString(), + o.Subtotal, + o.TaxAmount, + o.ShippingAmount, + o.Total, + o.Currency, + o.CreatedAt, + Lines = o.Items + .OrderBy(i => (int)i.Id) + .Select(i => new OrderLineDto( + (int)i.ProductId, + i.Product != null ? i.Product.Name : null, + i.Product != null ? i.Product.Slug.Value : null, + i.Product != null ? i.Product.Sku.Value : null, + i.Quantity, + i.UnitPrice, + i.UnitPrice * i.Quantity)) + .ToList() + }) + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (order is null) + return Result.Failure("Order not found.", StatusCodes.Status404NotFound); + + return Result.Success(new OrderDetailDto( + order.Id, + order.OrderNumber, + order.CustomerId, + order.CustomerEmail, + order.CustomerDisplayName, + order.Status, + order.Subtotal, + order.TaxAmount, + order.ShippingAmount, + order.Total, + order.Currency, + order.CreatedAt, + order.Lines)); + } +} diff --git a/src/Lab/FeatureFusion/Features/Orders/IntegrationEvents/IntegrationEventService.cs b/src/Lab/FeatureFusion/Features/Orders/IntegrationEvents/IntegrationEventService.cs index e04d178..48b3541 100644 --- a/src/Lab/FeatureFusion/Features/Orders/IntegrationEvents/IntegrationEventService.cs +++ b/src/Lab/FeatureFusion/Features/Orders/IntegrationEvents/IntegrationEventService.cs @@ -18,14 +18,14 @@ public async Task PublishThroughEventBusAsync(IntegrationEvent evt) await ResilientTransaction.New(catalogContext).ExecuteAsync(async () => { await catalogContext.SaveChangesAsync(); - await eventBus.PublishAsync(evt, catalogContext.Database.CurrentTransaction); + await eventBus.PublishAsync(evt, catalogContext.Database.CurrentTransaction!); }); } catch (Exception ex) { logger.LogError(ex, "Error Publishing integration event: {IntegrationEventId} - ({@IntegrationEvent})", evt.Id, evt); - + throw; } } diff --git a/src/Lab/FeatureFusion/Features/Orders/ListOrdersQueryHandler.cs b/src/Lab/FeatureFusion/Features/Orders/ListOrdersQueryHandler.cs new file mode 100644 index 0000000..2b490ef --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Orders/ListOrdersQueryHandler.cs @@ -0,0 +1,58 @@ +using BuildingBlocks.Mediator; +using BuildingBlocks.Pagination; +using BuildingBlocks.Pagination.EntityFrameworkCore; +using FeatureFusion.Infrastructure.Context; +using FeatureFusion.Infrastructure.CursorPagination; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Features.Orders; + +/// +/// Order list via keyset pagination — Demo Commerce + BuildingBlocks.Pagination. +/// Distinct from lab POST /api/v1/Order/order create and from storefront OFFSET catalog. +/// +public sealed class ListOrdersQueryHandler + : IQueryHandler>> +{ + private readonly CatalogDbContext _db; + + public ListOrdersQueryHandler(CatalogDbContext db) => _db = db; + + public async Task>> Handle( + ListOrdersQuery request, + CancellationToken cancellationToken) + { + var limit = request.Limit is < 1 or > 50 ? 20 : request.Limit; + var firstPage = string.IsNullOrWhiteSpace(request.Cursor); + + try + { + var page = await _db.Orders + .AsNoTracking() + .TagWith("orders.list") + .ToCursorPageAsync( + new CursorRequest(request.Cursor, limit), + OrderSortKeys.CreatedAtDesc, + o => new OrderListItemDto( + (int)o.Id, + o.OrderNumber.Value, + (int)o.CustomerId, + o.Status.ToString(), + o.Total, + o.Currency, + o.CreatedAt, + o.Items.Count), + new PaginationOptions { IncludeTotalCount = firstPage }, + cancellationToken) + .ConfigureAwait(false); + + return Result>.Success(page.ToPagedResult()); + } + catch (PaginationException ex) + { + return Result>.Failure( + ex.Message, + StatusCodes.Status400BadRequest); + } + } +} diff --git a/src/Lab/FeatureFusion/Features/Orders/OrderContracts.cs b/src/Lab/FeatureFusion/Features/Orders/OrderContracts.cs new file mode 100644 index 0000000..c5ea93e --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Orders/OrderContracts.cs @@ -0,0 +1,61 @@ +using BuildingBlocks.Mcp; +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.CursorPagination; +using FluentValidation; + +namespace FeatureFusion.Features.Orders; + +/// Order list row. +public sealed record OrderListItemDto( + int Id, + string OrderNumber, + int CustomerId, + string Status, + decimal Total, + string Currency, + DateTime CreatedAt, + int LineCount); + +/// Order line on detail. +public sealed record OrderLineDto( + int ProductId, + string? ProductName, + string? ProductSlug, + string? ProductSku, + int Quantity, + decimal UnitPrice, + decimal LineTotal); + +/// Order detail with lines. +public sealed record OrderDetailDto( + int Id, + string OrderNumber, + int CustomerId, + string? CustomerEmail, + string? CustomerDisplayName, + string Status, + decimal Subtotal, + decimal TaxAmount, + decimal ShippingAmount, + decimal Total, + string Currency, + DateTime CreatedAt, + IReadOnlyList Lines); + +/// Keyset list orders (BuildingBlocks.Pagination). +[McpTool("orders.list", Description = "List orders (keyset pagination)")] +public sealed record ListOrdersQuery : IQuery>> +{ + public int Limit { get; init; } = 20; + public string Cursor { get; init; } = string.Empty; +} + +/// Order detail by persistence id. +[McpTool("orders.get", Description = "Get an order by id")] +public sealed record GetOrderQuery(int Id) : IQuery>; + +public sealed class GetOrderQueryValidator : AbstractValidator +{ + public GetOrderQueryValidator() => + RuleFor(x => x.Id).GreaterThan(0).WithMessage("Order id is required."); +} diff --git a/src/Lab/FeatureFusion/Features/Orders/OrderQueryEndpoints.cs b/src/Lab/FeatureFusion/Features/Orders/OrderQueryEndpoints.cs new file mode 100644 index 0000000..9284388 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Orders/OrderQueryEndpoints.cs @@ -0,0 +1,59 @@ +using BuildingBlocks.Mediator; +using FeatureFusion.Infrastructure.CursorPagination; +using FeatureFusion.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace FeatureFusion.Features.Orders; + +/// +/// Demo Commerce order reads under /api/v1/orders. +/// Create remains at POST /api/v1/Order/order (experiment path). +/// +public static class OrderQueryEndpoints +{ + public static RouteGroupBuilder MapOrderQueryEndpoints(this IEndpointRouteBuilder app) + { + var apiVersionSet = app.CreateLabApiVersionSet(); + + var api = app.MapGroup("api/v{version:apiVersion}/orders") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(ApiVersioningExtensions.Current) + .WithTags("Orders"); + + api.MapGet("/", ListOrdersAsync) + .WithName("ListOrders") + .WithSummary("List orders (keyset / BuildingBlocks.Pagination). Newest first.") + .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest); + + api.MapGet("/{id:int}", GetOrderAsync) + .WithName("GetOrder") + .WithSummary("Order detail with lines and product references.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + return api; + } + + private static async Task ListOrdersAsync( + ISender sender, + CancellationToken cancellationToken, + [FromQuery] int limit = 20, + [FromQuery] string? cursor = null) + { + var result = await sender.Send( + new ListOrdersQuery { Limit = limit, Cursor = cursor ?? string.Empty }, + cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } + + private static async Task GetOrderAsync( + int id, + ISender sender, + CancellationToken cancellationToken) + { + var result = await sender.Send(new GetOrderQuery(id), cancellationToken).ConfigureAwait(false); + return result.ToApiResult(); + } +} diff --git a/src/Lab/FeatureFusion/Features/Orders/OrderSortKeys.cs b/src/Lab/FeatureFusion/Features/Orders/OrderSortKeys.cs new file mode 100644 index 0000000..615d74a --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Orders/OrderSortKeys.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Pagination; +using OrderEntity = FeatureFusion.Domain.Orders.Order; + +namespace FeatureFusion.Features.Orders; + +/// Keyset sort keys for Demo Commerce order listing. +public static class OrderSortKeys +{ + /// CreatedAt descending, unique Id descending (newest first). + public static readonly SortKey CreatedAtDesc = + SortKey.For() + .ByDescending(o => o.CreatedAt, sql: "CreatedAt") + .ThenByUniqueDescending(o => (int)o.Id, sql: "Id"); +} diff --git a/src/Lab/FeatureFusion/Features/Orders/Types/Results.cs b/src/Lab/FeatureFusion/Features/Orders/Types/Results.cs index 690e5e9..ddb644f 100644 --- a/src/Lab/FeatureFusion/Features/Orders/Types/Results.cs +++ b/src/Lab/FeatureFusion/Features/Orders/Types/Results.cs @@ -1,7 +1,10 @@ -public readonly struct Result +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +public readonly struct Result { - private readonly T _value; - private readonly string _error = string.Empty; + private readonly T? _value; + private readonly string? _error; private readonly int _statusCode; [System.Text.Json.Serialization.JsonIgnore] @@ -34,4 +37,33 @@ public TResult Match( Func onSuccess, Func onFailure) => IsSuccess ? onSuccess(_value!) : onFailure(_error!, _statusCode); -} \ No newline at end of file +} + +public static class ResultHttpExtensions +{ + public static IResult ToApiResult(this Result result) => + result.Match( + onSuccess: static value => Results.Ok(value), + onFailure: static (error, statusCode) => Results.Problem( + detail: error, + statusCode: statusCode is >= 400 and < 600 ? statusCode : StatusCodes.Status400BadRequest)); + + public static Results, BadRequest, ProblemHttpResult> ToHttpResult(this Result result) + { + return result.Match, BadRequest, ProblemHttpResult>>( + success => TypedResults.Ok(success), + (error, statusCode) => + { + var errors = new Dictionary + { + { "General", new[] { error } } + }; + return TypedResults.BadRequest(new ValidationProblemDetails(errors) + { + Title = "Request Error", + Detail = error, + Status = statusCode + }); + }); + } +} diff --git a/src/Lab/FeatureFusion/Features/Payments/IPaymentProcessor.cs b/src/Lab/FeatureFusion/Features/Payments/IPaymentProcessor.cs new file mode 100644 index 0000000..74cd643 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Payments/IPaymentProcessor.cs @@ -0,0 +1,39 @@ +namespace FeatureFusion.Features.Payments; + +public enum PaymentDecision +{ + Approved = 1, + Declined = 2 +} + +public sealed record PaymentChargeRequest(decimal Amount, string Currency, int CustomerId); + +public sealed record PaymentChargeResult(PaymentDecision Decision, string Reason); + +/// Demo payment boundary — not a real PSP. +public interface IPaymentProcessor +{ + Task ChargeAsync(PaymentChargeRequest request, CancellationToken cancellationToken); +} + +/// +/// Deterministic demo processor: declines when the amount's cent fraction equals 13 (e.g. 10.13, 1199.13). +/// +public sealed class DemoPaymentProcessor : IPaymentProcessor +{ + public Task ChargeAsync(PaymentChargeRequest request, CancellationToken cancellationToken) + { + var rounded = decimal.Round(request.Amount, 2, MidpointRounding.AwayFromZero); + var cents = (int)(rounded * 100m) % 100; + if (cents == 13) + { + return Task.FromResult(new PaymentChargeResult( + PaymentDecision.Declined, + "DemoPaymentProcessor declined amounts ending in .13.")); + } + + return Task.FromResult(new PaymentChargeResult( + PaymentDecision.Approved, + "DemoPaymentProcessor approved.")); + } +} diff --git a/src/Lab/FeatureFusion/Features/Products/Endpoints/ProductPaginationEndpoints.cs b/src/Lab/FeatureFusion/Features/Products/Endpoints/ProductPaginationEndpoints.cs index d114f7e..52d5d12 100644 --- a/src/Lab/FeatureFusion/Features/Products/Endpoints/ProductPaginationEndpoints.cs +++ b/src/Lab/FeatureFusion/Features/Products/Endpoints/ProductPaginationEndpoints.cs @@ -1,63 +1,81 @@ -using Asp.Versioning; using BuildingBlocks.Mediator; -using FeatureFusion.Controllers.V2; using FeatureFusion.Dtos; using FeatureFusion.Dtos.Validator; using FeatureFusion.Features.Products.Queries; using FeatureFusion.Infrastructure.CursorPagination; +using FeatureFusion.Infrastructure.Extensions; +using FeatureFusion.Services.ProductService; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; namespace FeatureFusion.Features.Products.Endpoints; /// -/// Minimal API surface for keyset pagination (same as -/// POST /api/v2/Product/products and MCP products.list). +/// Pagination lab keyset surface (same as +/// POST /api/v1/Product/products and MCP products.list). +/// Distinct from Demo Commerce storefront GET /api/v1/catalog/products. /// public static class ProductPaginationEndpoints { private const string CatalogDescription = - "Keyset (cursor) pagination over the PostgreSQL product catalog via BuildingBlocks.Pagination. " + + "Pagination lab: keyset (cursor) paging over products. " + + "Not the Demo Commerce storefront (GET /api/v1/catalog/products). " + "Query: limit (1–100, default 20), sortBy (Id | Name | Price | CreatedAt), " + "sortDirection (Ascending | Descending), optional opaque cursor, optional pageDirection (Forward | Backward). " + - "Cursors are opaque — send NextCursor or PreviousCursor back unchanged; do not construct cursor contents. " + - "Empty cursor + Forward (default) is the first page (includes TotalCount). " + - "Empty cursor + pageDirection=Backward is the last page. " + - "Same GetProductsQuery as POST /api/v2/Product/products (MVC EF), POST /api/v2/Product/products-dapper, and MCP products.list."; + "Empty cursor + Forward is the first page (includes TotalCount). " + + "Same query as POST /api/v1/Product/products and POST /api/v1/Product/products-dapper."; - public static RouteGroupBuilder MapProductPaginationEndpoints(this IEndpointRouteBuilder app) + public static IEndpointRouteBuilder MapProductPaginationEndpoints(this IEndpointRouteBuilder app) { - var v2 = new ApiVersion(2, 0); - var apiVersionSet = app.NewApiVersionSet() - .HasApiVersion(v2) - .ReportApiVersions() - .Build(); + var apiVersionSet = app.CreateLabApiVersionSet(); + var v1 = ApiVersioningExtensions.Current; - var api = app.MapGroup("api/v{version:apiVersion}") + var root = app.MapGroup("api/v{version:apiVersion}") .WithApiVersionSet(apiVersionSet) - .MapToApiVersion(v2) + .MapToApiVersion(v1) .WithTags("Products"); - api.MapGet("/products-page", ListAsync) + root.MapGet("/products-page", ListEfAsync) .WithName("GetProductsPage") - .WithSummary("GET catalog page (keyset / cursor). Same GetProductsQuery as MVC, Dapper, and MCP.") + .WithSummary("GET products page (keyset / cursor). Pagination lab, not storefront catalog.") .WithDescription(CatalogDescription) .Produces>(StatusCodes.Status200OK) .ProducesValidationProblem() .ProducesProblem(StatusCodes.Status500InternalServerError); - api.MapPost("/products-page", ListAsync) + root.MapPost("/products-page", ListEfAsync) .WithName("ProductsPage") - .WithSummary("POST catalog page (same query as GET /api/v2/products-page; kept for compatibility).") + .WithSummary("POST products page (same query as GET /api/v1/products-page).") .WithDescription(CatalogDescription) .Produces>(StatusCodes.Status200OK) .ProducesValidationProblem() .ProducesProblem(StatusCodes.Status500InternalServerError); - return api; + var product = app.MapGroup("api/v{version:apiVersion}/Product") + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(v1) + .WithTags("Products"); + + product.MapPost("/products", ListEfAsync) + .WithName("PostProductProducts") + .WithSummary("Pagination lab: keyset page (same GetProductsQuery as GET /api/v1/products-page).") + .WithDescription(CatalogDescription) + .Produces>(StatusCodes.Status200OK) + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status500InternalServerError); + + product.MapPost("/products-dapper", ListDapperAsync) + .WithName("PostProductProductsDapper") + .WithSummary("Pagination lab: same products table via Dapper (EF is the main list path).") + .WithDescription(CatalogDescription) + .Produces>(StatusCodes.Status200OK) + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status500InternalServerError); + + return app; } - private static async Task>, BadRequest, ProblemHttpResult>> ListAsync( + private static async Task>, BadRequest, ProblemHttpResult>> ListEfAsync( GetProductsCommandValidator validator, ISender sender, CancellationToken cancellationToken, @@ -67,22 +85,53 @@ private static async Task>, BadRequest>, BadRequest, ProblemHttpResult>> ListDapperAsync( + GetProductsCommandValidator validator, + IProductService products, + CancellationToken cancellationToken, + [FromQuery] int limit = 20, + [FromQuery] string cursor = "", + [FromQuery] ProductSortField sortBy = ProductSortField.Id, + [FromQuery] SortDirection sortDirection = SortDirection.Ascending, + [FromQuery] PageDirection pageDirection = PageDirection.Forward) + { + var query = BuildQuery(limit, cursor, sortBy, sortDirection, pageDirection); + var validationResult = await validator.ValidateWithResultAsync(query).ConfigureAwait(false); + if (validationResult.HasErrors()) + return TypedResults.BadRequest(validationResult.ProblemDetails); + + var result = await products.GetProductsViaDapperAsync( + query.Limit, + query.SortBy, + query.SortDirection, + query.Cursor, + (BuildingBlocks.Pagination.PageDirection)query.PageDirection, + cancellationToken).ConfigureAwait(false); + + return result.ToHttpResult(); + } + + private static GetProductsQuery BuildQuery( + int limit, + string? cursor, + ProductSortField sortBy, + SortDirection sortDirection, + PageDirection pageDirection) => + new() + { + Limit = limit, + Cursor = cursor ?? string.Empty, + SortBy = sortBy, + SortDirection = sortDirection, + PageDirection = pageDirection + }; } diff --git a/src/Lab/FeatureFusion/Features/Shipping/IShippingPolicy.cs b/src/Lab/FeatureFusion/Features/Shipping/IShippingPolicy.cs new file mode 100644 index 0000000..c7b0ded --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Shipping/IShippingPolicy.cs @@ -0,0 +1,30 @@ +using FeatureFusion.Domain.Orders; + +namespace FeatureFusion.Features.Shipping; + +public sealed record ShippingQuote(decimal Fee, OrderShipping Details); + +/// Deterministic flat-rate demo shipping. +public interface IShippingPolicy +{ + ShippingQuote Quote(string recipientName, string line1, string city, string postalCode, string country); +} + +public sealed class DemoShippingPolicy : IShippingPolicy +{ + public const decimal FlatFee = 4.99m; + public const string MethodName = "Demo Standard"; + + public ShippingQuote Quote(string recipientName, string line1, string city, string postalCode, string country) + { + var details = OrderShipping.Create( + recipientName, + line1, + city, + postalCode, + country, + MethodName, + ShippingStatus.Pending); + return new ShippingQuote(FlatFee, details); + } +} diff --git a/src/Lab/FeatureFusion/Features/Tax/ITaxCalculator.cs b/src/Lab/FeatureFusion/Features/Tax/ITaxCalculator.cs new file mode 100644 index 0000000..79d6f09 --- /dev/null +++ b/src/Lab/FeatureFusion/Features/Tax/ITaxCalculator.cs @@ -0,0 +1,24 @@ +using FeatureFusion.Domain.Orders; + +namespace FeatureFusion.Features.Tax; + +public sealed record TaxCalculation(decimal TaxAmount); + +/// Deterministic demo tax — not a tax engine. +public interface ITaxCalculator +{ + TaxCalculation Calculate(decimal lineSubtotal); +} + +public sealed class DemoTaxCalculator : ITaxCalculator +{ + public const decimal Rate = 0.10m; + + public TaxCalculation Calculate(decimal lineSubtotal) + { + if (lineSubtotal < 0) + throw new ArgumentOutOfRangeException(nameof(lineSubtotal)); + var tax = decimal.Round(lineSubtotal * Rate, 2, MidpointRounding.AwayFromZero); + return new TaxCalculation(tax); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Caching/CacheKeyService.cs b/src/Lab/FeatureFusion/Infrastructure/Caching/CacheKeyService.cs index 124a2a2..ed327ab 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Caching/CacheKeyService.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Caching/CacheKeyService.cs @@ -6,8 +6,6 @@ using System.Security.Cryptography; using System.Text; -// from nopcommerce caching logic -// https://github.com/nopSolutions/nopCommerce namespace FeatureFusion.Infrastructure.Caching { /// @@ -150,7 +148,7 @@ public static string CreateHash(byte[] data, string hashAlgorithm, int trimByteC if (string.IsNullOrEmpty(hashAlgorithm)) throw new ArgumentNullException(nameof(hashAlgorithm)); - var algorithm = (HashAlgorithm)CryptoConfig.CreateFromName(hashAlgorithm); + var algorithm = (HashAlgorithm?)CryptoConfig.CreateFromName(hashAlgorithm); if (algorithm == null) throw new ArgumentException("Unrecognized hash name"); diff --git a/src/Lab/FeatureFusion/Infrastructure/Caching/IStaticCacheManager.cs b/src/Lab/FeatureFusion/Infrastructure/Caching/IStaticCacheManager.cs index 0986d60..6c29a21 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Caching/IStaticCacheManager.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Caching/IStaticCacheManager.cs @@ -18,7 +18,7 @@ public interface IStaticCacheManager : IDisposable /// A task that represents the asynchronous operation /// The task result contains the cached value associated with the specified key /// - Task GetAsync(CacheKey key, Func> acquire); + Task GetAsync(CacheKey key, Func> acquire); /// /// Get a cached item. @@ -29,7 +29,7 @@ public interface IStaticCacheManager : IDisposable /// A task that represents the asynchronous operation /// The task result contains the cached value associated with the specified key /// - Task TryGetAsync(CacheKey key); + Task TryGetAsync(CacheKey key); /// /// Get a cached item. If it's not in the cache yet, then load and cache it /// @@ -40,7 +40,7 @@ public interface IStaticCacheManager : IDisposable /// A task that represents the asynchronous operation /// The task result contains the cached value associated with the specified key /// - Task GetAsync(CacheKey key, Func acquire); + Task GetAsync(CacheKey key, Func acquire); /// /// Get a cached item. If it's not in the cache yet, then load and cache it @@ -49,7 +49,7 @@ public interface IStaticCacheManager : IDisposable /// Cache key /// Function to load item if it's not in the cache yet /// The cached value associated with the specified key - T Get(CacheKey key, Func acquire); + T? Get(CacheKey key, Func acquire); /// /// Remove the value with the specified key from the cache @@ -65,7 +65,7 @@ public interface IStaticCacheManager : IDisposable /// Key of cached item /// Value for caching /// A task that represents the asynchronous operation - Task SetAsync(CacheKey key, object data); + Task SetAsync(CacheKey key, object? data); /// /// Remove items by cache key prefix diff --git a/src/Lab/FeatureFusion/Infrastructure/Caching/MemoryCacheManager.cs b/src/Lab/FeatureFusion/Infrastructure/Caching/MemoryCacheManager.cs index 87ed1fc..fec4a01 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Caching/MemoryCacheManager.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Caching/MemoryCacheManager.cs @@ -73,9 +73,9 @@ private void Remove(CacheKey cacheKey, params object[] cacheKeyParameters) /// /// Key of cached item /// Value for caching - private void Set(CacheKey key, object data) + private void Set(CacheKey key, object? data) { - if ((key?.CacheTime ?? 0) <= 0 || data == null) + if (key.CacheTime <= 0 || data == null) return; _memoryCache.Set(key.Key, data, PrepareEntryOptions(key)); @@ -108,12 +108,12 @@ public Task RemoveAsync(CacheKey cacheKey, params object[] cacheKeyParameters) /// A task that represents the asynchronous operation /// The task result contains the cached value associated with the specified key /// - public async Task GetAsync(CacheKey key, Func> acquire) + public async Task GetAsync(CacheKey key, Func> acquire) { - if ((key?.CacheTime ?? 0) <= 0) + if (key.CacheTime <= 0) return await acquire(); - if (_memoryCache.TryGetValue(key.Key, out T result)) + if (_memoryCache.TryGetValue(key.Key, out T? result)) { Console.WriteLine($"==> Cache hit for key: {key.Key}"); return result; @@ -139,19 +139,19 @@ public async Task GetAsync(CacheKey key, Func> acquire) /// A task that represents the asynchronous operation /// The task result contains the cached value associated with the specified key /// - public Task TryGetAsync(CacheKey key) + public Task TryGetAsync(CacheKey key) { - if ((key?.CacheTime ?? 0) <= 0) - return Task.FromResult(default); // Return null for invalid cache time + if (key.CacheTime <= 0) + return Task.FromResult(default); - if (_memoryCache.TryGetValue(key.Key, out T result)) + if (_memoryCache.TryGetValue(key.Key, out T? result)) { Console.WriteLine($"==> Cache hit for key: {key.Key}"); return Task.FromResult(result); } Console.WriteLine($"==> Cache miss for key: {key.Key}. Loading data..."); - return Task.FromResult(default); + return Task.FromResult(default); } /// @@ -164,9 +164,9 @@ public Task TryGetAsync(CacheKey key) /// A task that represents the asynchronous operation /// The task result contains the cached value associated with the specified key /// - public async Task GetAsync(CacheKey key, Func acquire) + public async Task GetAsync(CacheKey key, Func acquire) { - if ((key?.CacheTime ?? 0) <= 0) + if (key.CacheTime <= 0) return acquire(); var result = _memoryCache.GetOrCreate(key.Key, entry => @@ -190,12 +190,12 @@ public async Task GetAsync(CacheKey key, Func acquire) /// Cache key /// Function to load item if it's not in the cache yet /// The cached value associated with the specified key - public T Get(CacheKey key, Func acquire) + public T? Get(CacheKey key, Func acquire) { - if ((key?.CacheTime ?? 0) <= 0) + if (key.CacheTime <= 0) return acquire(); - if (_memoryCache.TryGetValue(key.Key, out T result)) + if (_memoryCache.TryGetValue(key.Key, out T? result)) { Console.WriteLine($" ==> Cache hit for key: {key.Key}"); return result; @@ -219,7 +219,7 @@ public T Get(CacheKey key, Func acquire) /// Key of cached item /// Value for caching /// A task that represents the asynchronous operation - public Task SetAsync(CacheKey key, object data) + public Task SetAsync(CacheKey key, object? data) { Set(key, data); diff --git a/src/Lab/FeatureFusion/Infrastructure/Caching/RedisCacheManager.cs b/src/Lab/FeatureFusion/Infrastructure/Caching/RedisCacheManager.cs index 38ef906..36c80e8 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Caching/RedisCacheManager.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Caching/RedisCacheManager.cs @@ -22,7 +22,7 @@ public async Task GetValueOrCreateAsync(CacheKey key, Func> acquir { var database = await _connectionWrapper.GetDatabaseAsync(); - string redisKey = key.ToString(); + string redisKey = key.Key; // Lua script: Atomically get existing value or set if missing var luaScript = @" @@ -34,11 +34,11 @@ public async Task GetValueOrCreateAsync(CacheKey key, Func> acquir return ARGV[1] end"; - var cachedData = (string)await database.ScriptEvaluateAsync(luaScript, + var cachedData = (string?)(await database.ScriptEvaluateAsync(luaScript, new RedisKey[] { redisKey }, - new RedisValue[] { JsonSerializer.Serialize(await acquire()), key.CacheTime * 60 }); + new RedisValue[] { JsonSerializer.Serialize(await acquire()), key.CacheTime * 60 })); - return cachedData is not null ? JsonSerializer.Deserialize(cachedData) : default!; + return cachedData is not null ? JsonSerializer.Deserialize(cachedData)! : default!; } public async Task RefreshCacheAsync(string key, Func> fetchFromDb, int cacheMinutes) diff --git a/src/Lab/FeatureFusion/Infrastructure/Caching/RedisConnectionWrapper.cs b/src/Lab/FeatureFusion/Infrastructure/Caching/RedisConnectionWrapper.cs index 894b993..4251861 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Caching/RedisConnectionWrapper.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Caching/RedisConnectionWrapper.cs @@ -10,7 +10,7 @@ public partial class RedisConnectionWrapper : IRedisConnectionWrapper #region Fields protected readonly SemaphoreSlim _connectionLock = new(1, 1); - protected volatile IConnectionMultiplexer _connection; + protected volatile IConnectionMultiplexer? _connection; protected readonly RedisCacheOptions _options; #endregion @@ -39,7 +39,7 @@ protected virtual async Task ConnectAsync() if (_options.ConfigurationOptions is not null) connection = await ConnectionMultiplexer.ConnectAsync(_options.ConfigurationOptions); else - connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration); + connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration!); } else { @@ -61,7 +61,7 @@ protected virtual IConnectionMultiplexer Connect() IConnectionMultiplexer connection; if (_options.ConnectionMultiplexerFactory is null) - connection = _options.ConfigurationOptions is not null ? ConnectionMultiplexer.Connect(_options.ConfigurationOptions) : ConnectionMultiplexer.Connect(_options.Configuration); + connection = _options.ConfigurationOptions is not null ? ConnectionMultiplexer.Connect(_options.ConfigurationOptions) : ConnectionMultiplexer.Connect(_options.Configuration!); else connection = _options.ConnectionMultiplexerFactory().GetAwaiter().GetResult(); @@ -78,13 +78,13 @@ protected virtual IConnectionMultiplexer Connect() protected virtual async Task GetConnectionAsync() { if (_connection?.IsConnected == true) - return _connection; + return _connection!; await _connectionLock.WaitAsync(); try { if (_connection?.IsConnected == true) - return _connection; + return _connection!; //Connection disconnected. Disposing connection... _connection?.Dispose(); @@ -97,7 +97,7 @@ protected virtual async Task GetConnectionAsync() _connectionLock.Release(); } - return _connection; + return _connection!; } /// @@ -107,13 +107,13 @@ protected virtual async Task GetConnectionAsync() protected virtual IConnectionMultiplexer GetConnection() { if (_connection?.IsConnected == true) - return _connection; + return _connection!; _connectionLock.Wait(); try { if (_connection?.IsConnected == true) - return _connection; + return _connection!; //Connection disconnected. Disposing connection... _connection?.Dispose(); @@ -126,7 +126,7 @@ protected virtual IConnectionMultiplexer GetConnection() _connectionLock.Release(); } - return _connection; + return _connection!; } #endregion diff --git a/src/Lab/FeatureFusion/Infrastructure/Caching/RedisOptions.cs b/src/Lab/FeatureFusion/Infrastructure/Caching/RedisOptions.cs index 3a5f49f..1d81ec1 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Caching/RedisOptions.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Caching/RedisOptions.cs @@ -5,7 +5,7 @@ public class RedisSettings public class RedisOptions { [Required(ErrorMessage = "Redis connection string is required.")] - public string ConnectionString { get; set; } + public string ConnectionString { get; set; } = string.Empty; public string InstanceName { get; set; } = "MyApp:"; } diff --git a/src/Lab/FeatureFusion/Infrastructure/Dapper/CatalogDapperTypeHandlers.cs b/src/Lab/FeatureFusion/Infrastructure/Dapper/CatalogDapperTypeHandlers.cs new file mode 100644 index 0000000..9b2b0c9 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Dapper/CatalogDapperTypeHandlers.cs @@ -0,0 +1,56 @@ +using System.Data; +using BuildingBlocks.Domain; +using Dapper; +using FeatureFusion.Domain.Catalog; + +namespace FeatureFusion.Infrastructure.Dapper; + +/// Registers Dapper type handlers for catalog identities and value objects. +public static class CatalogDapperTypeHandlers +{ + private static int _registered; + + /// Idempotent registration for process lifetime. + public static void Register() + { + if (Interlocked.Exchange(ref _registered, 1) == 1) + return; + + SqlMapper.AddTypeHandler(new IntIdentityHandler(ProductId.From)); + SqlMapper.AddTypeHandler(new IntIdentityHandler(BrandId.From)); + SqlMapper.AddTypeHandler(new IntIdentityHandler(CategoryId.From)); + SqlMapper.AddTypeHandler(new StringValueHandler(Sku.Create)); + SqlMapper.AddTypeHandler(new StringValueHandler(Slug.Create)); + } + + private sealed class IntIdentityHandler : SqlMapper.TypeHandler + where T : IIdentity + { + private readonly Func _factory; + + public IntIdentityHandler(Func factory) => _factory = factory; + + public override T Parse(object value) => _factory(Convert.ToInt32(value)); + + public override void SetValue(IDbDataParameter parameter, T? value) + { + parameter.DbType = DbType.Int32; + parameter.Value = value is null ? DBNull.Value : value.Value; + } + } + + private sealed class StringValueHandler : SqlMapper.TypeHandler + { + private readonly Func _factory; + + public StringValueHandler(Func factory) => _factory = factory; + + public override T Parse(object value) => _factory(Convert.ToString(value) ?? ""); + + public override void SetValue(IDbDataParameter parameter, T? value) + { + parameter.DbType = DbType.String; + parameter.Value = value is null ? DBNull.Value : value.ToString(); + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/DbContext/CatalogDContextSeed.cs b/src/Lab/FeatureFusion/Infrastructure/DbContext/CatalogDContextSeed.cs index 2e46238..1a79736 100644 --- a/src/Lab/FeatureFusion/Infrastructure/DbContext/CatalogDContextSeed.cs +++ b/src/Lab/FeatureFusion/Infrastructure/DbContext/CatalogDContextSeed.cs @@ -1,10 +1,7 @@ -using FeatureFusion.Domain.Entities; using FeatureFusion.Infrastructure.Extensions; -using FeatureFusion.Models; +using FeatureFusion.Infrastructure.Seeding; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; using Npgsql; -using System.Text.Json; namespace FeatureFusion.Infrastructure.Context; @@ -14,46 +11,11 @@ public partial class CatalogDContextSeed( { public async Task SeedAsync(CatalogDbContext context) { - var contentRootPath = env.ContentRootPath; - var picturePath = env.WebRootPath; - - + _ = env; + context.Database.OpenConnection(); ((NpgsqlConnection)context.Database.GetDbConnection()).ReloadTypes(); - - - if (!context.Product.Any()) - { - - var sourcePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Setup", "catalog.json"); - var sourceJson = File.ReadAllText(sourcePath); - var sourceItems = JsonSerializer.Deserialize(sourceJson); - - await context.SaveChangesAsync(); - var catalogItems = sourceItems.Select(source => new Product - { - Id = source.Id, - Name = source.Name, - Price = source.Price, - CreatedAt=source.CreatedAt - - }).ToArray(); - - await context.Product.AddRangeAsync(catalogItems); - logger.LogInformation("Seeded catalog with {NumItems} items", context.Product.Count()); - await context.SaveChangesAsync(); - } - } - - private class CatalogSourceEntry - { - public int Id { get; set; } - public string Type { get; set; } - public string Brand { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public decimal Price { get; set; } - public DateTime CreatedAt { get; set; } + await DemoCommerceSeed.SeedAsync(context, logger).ConfigureAwait(false); } } diff --git a/src/Lab/FeatureFusion/Infrastructure/DbContext/CatalogDbContext.cs b/src/Lab/FeatureFusion/Infrastructure/DbContext/CatalogDbContext.cs index b11030d..00184f4 100644 --- a/src/Lab/FeatureFusion/Infrastructure/DbContext/CatalogDbContext.cs +++ b/src/Lab/FeatureFusion/Infrastructure/DbContext/CatalogDbContext.cs @@ -1,29 +1,53 @@ -using FeatureFusion.Infrastructure.EntitiyConfiguration; +using FeatureFusion.Infrastructure.EntitiyConfiguration; using Microsoft.EntityFrameworkCore; using EventBusRabbitMQ.Extensions; using EventBusRabbitMQ.Domain; using EventBusRabbitMQ.Infrastructure.Context; -using System.ComponentModel.DataAnnotations.Schema; -using FeatureFusion.Domain.Entities; +using FeatureFusion.Domain.Carts; +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Customers; +using FeatureFusion.Domain.Orders; +using FeatureFusion.Domain.Payments; +using FeatureFusion.Features.Admission; namespace FeatureFusion.Infrastructure.Context; - public class CatalogDbContext : DbContext, IEventStoreDbContext { - public CatalogDbContext(DbContextOptions options, IConfiguration configuration) - : base(options) + public CatalogDbContext(DbContextOptions options) + : base(options) { } + public DbSet OutboxMessages { get; set; } public DbSet InboxMessages { get; set; } public DbSet ProcessedMessages { get; set; } public DbSet InboxSubscriber { get; set; } public DbSet Product { get; set; } + public DbSet Brands { get; set; } + public DbSet Categories { get; set; } + public DbSet Customers { get; set; } + public DbSet Orders { get; set; } + public DbSet OrderItems { get; set; } + public DbSet Carts { get; set; } + public DbSet CartItems { get; set; } + public DbSet PaymentRecords { get; set; } + public DbSet IntentTickets { get; set; } protected override void OnModelCreating(ModelBuilder builder) { builder.ApplyConfiguration(new ProductEntityTypeConfiguration()); - builder.UseEventStore(); + builder.ApplyConfiguration(new BrandEntityTypeConfiguration()); + builder.ApplyConfiguration(new CategoryEntityTypeConfiguration()); + builder.ApplyConfiguration(new CustomerEntityTypeConfiguration()); + builder.ApplyConfiguration(new OrderEntityTypeConfiguration()); + builder.ApplyConfiguration(new OrderItemEntityTypeConfiguration()); + builder.ApplyConfiguration(new ProductImageEntityTypeConfiguration()); + builder.ApplyConfiguration(new ProductSpecificationEntityTypeConfiguration()); + builder.ApplyConfiguration(new IntentTicketEntityTypeConfiguration()); + builder.ApplyConfiguration(new CartEntityTypeConfiguration()); + builder.ApplyConfiguration(new CartItemEntityTypeConfiguration()); + builder.ApplyConfiguration(new PaymentRecordEntityTypeConfiguration()); + builder.UseEventStore(); } } diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/BrandEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/BrandEntityTypeConfiguration.cs new file mode 100644 index 0000000..20f70a7 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/BrandEntityTypeConfiguration.cs @@ -0,0 +1,26 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class BrandEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("brands"); + builder.HasKey(b => b.Id); + builder.Property(b => b.Id) + .HasIdentityConversion(v => new BrandId(v)) + .ValueGeneratedOnAdd(); + builder.Property(b => b.Name).IsRequired().HasMaxLength(128); + builder.Property(b => b.Slug) + .HasValueObjectConversion(s => s.Value, v => Slug.Create(v)) + .HasMaxLength(128) + .IsRequired(); + builder.Property(b => b.LogoUrl).HasMaxLength(512); + builder.HasIndex(b => b.Name).IsUnique(); + builder.HasIndex(b => b.Slug).IsUnique(); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CartEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CartEntityTypeConfiguration.cs new file mode 100644 index 0000000..a7c69c8 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CartEntityTypeConfiguration.cs @@ -0,0 +1,42 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Carts; +using FeatureFusion.Domain.Customers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class CartEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("carts"); + builder.HasKey(c => c.Id); + builder.Ignore(c => c.DomainEvents); + builder.Ignore(c => c.OriginalVersion); + + builder.Property(c => c.Id) + .HasIdentityConversion(v => new CartId(v)) + .ValueGeneratedOnAdd(); + + builder.Property(c => c.CustomerId) + .HasIdentityConversion(v => new CustomerId(v)) + .IsRequired(); + + builder.Property(c => c.UpdatedAtUtc) + .HasConversion(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + + builder.HasIndex(c => c.CustomerId) + .IsUnique() + .HasDatabaseName("IX_carts_customer_id"); + + builder.HasMany(c => c.Items) + .WithOne(i => i.Cart) + .HasForeignKey(i => i.CartId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(c => c.Items) + .HasField("_items") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CartItemEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CartItemEntityTypeConfiguration.cs new file mode 100644 index 0000000..d0743e8 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CartItemEntityTypeConfiguration.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Carts; +using FeatureFusion.Domain.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class CartItemEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("cart_items"); + builder.HasKey(i => i.Id); + + builder.Property(i => i.Id) + .HasIdentityConversion(v => new CartItemId(v)) + .ValueGeneratedOnAdd(); + + builder.Property(i => i.CartId) + .HasIdentityConversion(v => new CartId(v)); + + builder.Property(i => i.ProductId) + .HasIdentityConversion(v => new ProductId(v)); + + builder.HasIndex(i => new { i.CartId, i.ProductId }) + .IsUnique() + .HasDatabaseName("IX_cart_items_cart_product"); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CategoryEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CategoryEntityTypeConfiguration.cs new file mode 100644 index 0000000..8124ee0 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CategoryEntityTypeConfiguration.cs @@ -0,0 +1,25 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class CategoryEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("categories"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id) + .HasIdentityConversion(v => new CategoryId(v)) + .ValueGeneratedOnAdd(); + builder.Property(c => c.Name).IsRequired().HasMaxLength(128); + builder.Property(c => c.Slug) + .HasValueObjectConversion(s => s.Value, v => Slug.Create(v)) + .HasMaxLength(128) + .IsRequired(); + builder.HasIndex(c => c.Name).IsUnique(); + builder.HasIndex(c => c.Slug).IsUnique(); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CustomerEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CustomerEntityTypeConfiguration.cs new file mode 100644 index 0000000..c4de6f1 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/CustomerEntityTypeConfiguration.cs @@ -0,0 +1,33 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Customers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class CustomerEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("customers"); + builder.HasKey(c => c.Id); + builder.Ignore(c => c.DomainEvents); + builder.Ignore(c => c.OriginalVersion); + + builder.Property(c => c.Id) + .HasIdentityConversion(v => new CustomerId(v)) + .ValueGeneratedOnAdd(); + + builder.Property(c => c.DisplayName).IsRequired().HasMaxLength(200); + builder.Property(c => c.CreatedAt) + .HasConversion(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + + builder.Property(c => c.Email) + .HasValueObjectConversion(e => e.Value, v => Email.Create(v)) + .HasColumnName("Email") + .HasMaxLength(256) + .IsRequired(); + + builder.HasIndex("Email").IsUnique().HasDatabaseName("IX_customers_email"); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/IntentTicketEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/IntentTicketEntityTypeConfiguration.cs new file mode 100644 index 0000000..0997979 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/IntentTicketEntityTypeConfiguration.cs @@ -0,0 +1,45 @@ +using FeatureFusion.Features.Admission; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class IntentTicketEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("intent_tickets"); + builder.HasKey(t => t.Id); + + builder.Property(t => t.CapabilityId) + .IsRequired() + .HasMaxLength(128); + + builder.Property(t => t.RequestKey) + .IsRequired() + .HasMaxLength(256); + + builder.Property(t => t.IntentHash) + .IsRequired() + .HasMaxLength(128); + + builder.Property(t => t.IntentPayload) + .IsRequired() + .HasColumnType("text"); + + builder.Property(t => t.Status) + .IsRequired() + .HasConversion() + .HasMaxLength(32); + + builder.Property(t => t.ReleasedBy) + .HasMaxLength(128); + + builder.HasIndex(t => new { t.CapabilityId, t.RequestKey }) + .IsUnique() + .HasDatabaseName("IX_intent_tickets_capability_request_key"); + + builder.HasIndex(t => t.Status) + .HasDatabaseName("IX_intent_tickets_status"); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/OrderEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/OrderEntityTypeConfiguration.cs new file mode 100644 index 0000000..a977c4d --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/OrderEntityTypeConfiguration.cs @@ -0,0 +1,69 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Customers; +using FeatureFusion.Domain.Orders; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class OrderEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("orders"); + builder.HasKey(o => o.Id); + builder.Ignore(o => o.DomainEvents); + builder.Ignore(o => o.OriginalVersion); + + builder.Property(o => o.Id) + .HasIdentityConversion(v => new OrderId(v)) + .ValueGeneratedOnAdd(); + + builder.Property(o => o.OrderNumber) + .HasValueObjectConversion(n => n.Value, v => OrderNumber.Create(v)) + .IsRequired() + .HasMaxLength(32); + builder.Property(o => o.Currency).IsRequired().HasMaxLength(3); + builder.Property(o => o.Subtotal).HasPrecision(18, 2); + builder.Property(o => o.TaxAmount).HasPrecision(18, 2); + builder.Property(o => o.ShippingAmount).HasPrecision(18, 2); + builder.Property(o => o.Total).HasPrecision(18, 2); + builder.Property(o => o.Status).HasConversion().HasMaxLength(32); + builder.Property(o => o.CreatedAt) + .HasConversion(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + + builder.OwnsOne(o => o.Shipping, ship => + { + ship.Property(s => s.RecipientName).HasColumnName("shipping_recipient").HasMaxLength(128); + ship.Property(s => s.Line1).HasColumnName("shipping_line1").HasMaxLength(256); + ship.Property(s => s.City).HasColumnName("shipping_city").HasMaxLength(128); + ship.Property(s => s.PostalCode).HasColumnName("shipping_postal").HasMaxLength(32); + ship.Property(s => s.Country).HasColumnName("shipping_country").HasMaxLength(2); + ship.Property(s => s.Method).HasColumnName("shipping_method").HasMaxLength(64); + ship.Property(s => s.Status).HasColumnName("shipping_status").HasConversion().HasMaxLength(32); + }); + builder.Navigation(o => o.Shipping).IsRequired(); + + builder.Property(o => o.CustomerId) + .HasIdentityConversion(v => new CustomerId(v)); + + builder.HasOne(o => o.Customer) + .WithMany() + .HasForeignKey(o => o.CustomerId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(o => o.Items) + .WithOne(i => i.Order) + .HasForeignKey(i => i.OrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(o => o.Items) + .HasField("_items") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasIndex(o => o.OrderNumber).IsUnique(); + builder.HasIndex(o => o.CustomerId).HasDatabaseName("IX_orders_customer_id"); + builder.HasIndex(o => o.Status).HasDatabaseName("IX_orders_status"); + builder.HasIndex(o => o.CreatedAt).HasDatabaseName("IX_orders_created_at"); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/OrderItemEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/OrderItemEntityTypeConfiguration.cs new file mode 100644 index 0000000..47b71ed --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/OrderItemEntityTypeConfiguration.cs @@ -0,0 +1,37 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Orders; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class OrderItemEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("order_items"); + builder.HasKey(i => i.Id); + builder.Ignore(i => i.LineTotal); + + builder.Property(i => i.Id) + .HasIdentityConversion(v => new OrderItemId(v)) + .ValueGeneratedOnAdd(); + + builder.Property(i => i.OrderId) + .HasIdentityConversion(v => new OrderId(v)); + + builder.Property(i => i.ProductId) + .HasIdentityConversion(v => new ProductId(v)); + + builder.Property(i => i.UnitPrice).HasPrecision(18, 2); + + builder.HasOne(i => i.Product) + .WithMany() + .HasForeignKey(i => i.ProductId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(i => i.OrderId).HasDatabaseName("IX_order_items_order_id"); + builder.HasIndex(i => i.ProductId).HasDatabaseName("IX_order_items_product_id"); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/PaymentRecordEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/PaymentRecordEntityTypeConfiguration.cs new file mode 100644 index 0000000..01d794f --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/PaymentRecordEntityTypeConfiguration.cs @@ -0,0 +1,28 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Payments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class PaymentRecordEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("payment_records"); + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasIdentityConversion(v => new PaymentRecordId(v)) + .ValueGeneratedOnAdd(); + + builder.Property(p => p.Outcome).HasConversion().HasMaxLength(32); + builder.Property(p => p.Amount).HasPrecision(18, 2); + builder.Property(p => p.Currency).HasMaxLength(3).IsRequired(); + builder.Property(p => p.CreatedAtUtc) + .HasConversion(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + + builder.HasIndex(p => p.CorrelationOrderId) + .HasDatabaseName("IX_payment_records_correlation"); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs index 711ab72..bea1e48 100644 --- a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs @@ -1,57 +1,128 @@ -using BuildingBlocks.Pagination.EntityFrameworkCore; -using FeatureFusion.Domain.Entities; -using FeatureFusion.Infrastructure.Pagination; +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Catalog; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace FeatureFusion.Infrastructure.EntitiyConfiguration; -class ProductEntityTypeConfiguration - : IEntityTypeConfiguration +internal sealed class ProductEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("products"); builder.HasKey(p => p.Id); + builder.Ignore(p => p.DomainEvents); + // AggregateRoot.OriginalVersion is the optimistic concurrency token (IHaveAggregateVersion). + builder.Property(p => p.OriginalVersion).IsConcurrencyToken(); builder.Property(p => p.Id) - .ValueGeneratedOnAdd(); + .HasIdentityConversion(v => new ProductId(v)) + .ValueGeneratedOnAdd(); - builder.Property(ci => ci.Name); + builder.Property(p => p.Name) + .IsRequired() + .HasMaxLength(256); + + builder.Property(p => p.Sku) + .HasValueObjectConversion(s => s.Value, v => Sku.Create(v)) + .HasMaxLength(64) + .IsRequired(); + + builder.Property(p => p.Slug) + .HasValueObjectConversion(s => s.Value, v => Slug.Create(v)) + .HasMaxLength(128) + .IsRequired(); + + builder.Property(p => p.ShortDescription).HasMaxLength(512); + + builder.Property(p => p.FullDescription); + + builder.Property(p => p.Price) + .HasPrecision(18, 2); + + builder.Property(p => p.BrandId) + .HasIdentityConversion(v => new BrandId(v)) + .IsRequired(); + + builder.Property(p => p.CategoryId) + .HasIdentityConversion(v => new CategoryId(v)) + .IsRequired(); builder.Property(p => p.CreatedAt) - .HasConversion( - v => v, - v => DateTime.SpecifyKind(v, DateTimeKind.Utc) - ); + .HasConversion( + v => v, + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); - builder.HasIndex(ci => ci.Name) - .HasDatabaseName("IX_products_name"); ; + builder.HasOne(p => p.Brand) + .WithMany() + .HasForeignKey(p => p.BrandId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(p => p.Category) + .WithMany() + .HasForeignKey(p => p.CategoryId) + .OnDelete(DeleteBehavior.Restrict); - builder.HasIndex(ci => ci.CreatedAt) + builder.HasIndex(p => p.Sku) + .IsUnique() + .HasDatabaseName("IX_products_sku"); + + builder.HasIndex(p => p.Slug) + .IsUnique() + .HasDatabaseName("IX_products_slug"); + + builder.HasIndex(p => p.BrandId) + .HasDatabaseName("IX_products_brand_id"); + + builder.HasIndex(p => p.CategoryId) + .HasDatabaseName("IX_products_category_id"); + + builder.HasIndex(p => p.Name) + .HasDatabaseName("IX_products_name"); + + builder.HasIndex(p => p.CreatedAt) .IsDescending(false) .HasDatabaseName("IX_products_created_at_asc"); - builder.HasIndex(ci => ci.CreatedAt) + builder.HasIndex(p => p.CreatedAt) .IsDescending(true) .HasDatabaseName("IX_products_created_at_desc"); - builder.HasKeysetIndex(ProductSortKeys.PriceAsc) + builder.HasIndex(["Price", "Id"], "IX_Product_Price_Id_AA") .HasDatabaseName("IX_products_price_id"); - builder.HasKeysetIndex(ProductSortKeys.PriceDesc) + builder.HasIndex(["Price", "Id"], "IX_Product_Price_Id_DA") + .IsDescending(true, false) .HasDatabaseName("IX_products_price_id_desc"); - builder.HasKeysetIndex(ProductSortKeys.CreatedAtAsc) + builder.HasIndex(["CreatedAt", "Id"], "IX_Product_CreatedAt_Id_AA") .HasDatabaseName("IX_products_created_at_id"); - builder.HasKeysetIndex(ProductSortKeys.CreatedAtDesc) + builder.HasIndex(["CreatedAt", "Id"], "IX_Product_CreatedAt_Id_DA") + .IsDescending(true, false) .HasDatabaseName("IX_products_created_at_id_desc"); - builder.HasKeysetIndex(ProductSortKeys.NameAsc) + builder.HasIndex(["Name", "Id"], "IX_Product_Name_Id_AA") .HasDatabaseName("IX_products_name_id"); - builder.HasKeysetIndex(ProductSortKeys.NameDesc) + builder.HasIndex(["Name", "Id"], "IX_Product_Name_Id_DA") + .IsDescending(true, false) .HasDatabaseName("IX_products_name_id_desc"); - builder.HasKeysetIndex(ProductSortKeys.NameThenPriceAsc) + builder.HasIndex(["Name", "Price", "Id"], "IX_Product_Name_Price_Id_AAA") .HasDatabaseName("IX_products_name_price_id"); - builder.HasKeysetIndex(ProductSortKeys.NameThenPriceDesc) + builder.HasIndex(["Name", "Price", "Id"], "IX_Product_Name_Price_Id_DDD") + .IsDescending() .HasDatabaseName("IX_products_name_price_id_desc"); + + builder.HasMany(p => p.Images) + .WithOne() + .HasForeignKey(i => i.ProductId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(p => p.Images) + .HasField("_images") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(p => p.Specifications) + .WithOne() + .HasForeignKey(s => s.ProductId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(p => p.Specifications) + .HasField("_specifications") + .UsePropertyAccessMode(PropertyAccessMode.Field); } } diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductImageEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductImageEntityTypeConfiguration.cs new file mode 100644 index 0000000..149ecbf --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductImageEntityTypeConfiguration.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class ProductImageEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("product_images"); + builder.HasKey(i => i.Id); + builder.Property(i => i.Id) + .HasIdentityConversion(v => new ProductImageId(v)) + .ValueGeneratedOnAdd(); + builder.Property(i => i.ProductId) + .HasIdentityConversion(v => new ProductId(v)); + builder.Property(i => i.Url).IsRequired().HasMaxLength(512); + builder.Property(i => i.AltText).IsRequired().HasMaxLength(256); + builder.HasIndex(i => i.ProductId).HasDatabaseName("IX_product_images_product_id"); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductSpecificationEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductSpecificationEntityTypeConfiguration.cs new file mode 100644 index 0000000..ea34ff2 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductSpecificationEntityTypeConfiguration.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Domain.EntityFrameworkCore; +using FeatureFusion.Domain.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FeatureFusion.Infrastructure.EntitiyConfiguration; + +internal sealed class ProductSpecificationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("product_specifications"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Id) + .HasIdentityConversion(v => new ProductSpecificationId(v)) + .ValueGeneratedOnAdd(); + builder.Property(s => s.ProductId) + .HasIdentityConversion(v => new ProductId(v)); + builder.Property(s => s.Name).IsRequired().HasMaxLength(128); + builder.Property(s => s.Value).IsRequired().HasMaxLength(256); + builder.HasIndex(s => s.ProductId).HasDatabaseName("IX_product_specifications_product_id"); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Extensions/ApiVersioningExtensions.cs b/src/Lab/FeatureFusion/Infrastructure/Extensions/ApiVersioningExtensions.cs new file mode 100644 index 0000000..4122b1c --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Extensions/ApiVersioningExtensions.cs @@ -0,0 +1,16 @@ +using Asp.Versioning; +using Asp.Versioning.Builder; + +namespace FeatureFusion.Infrastructure.Extensions; + +/// Single lab API version (1.0) shared by all endpoint groups. +public static class ApiVersioningExtensions +{ + public static readonly ApiVersion Current = new(1, 0); + + public static ApiVersionSet CreateLabApiVersionSet(this IEndpointRouteBuilder app) => + app.NewApiVersionSet() + .HasApiVersion(Current) + .ReportApiVersions() + .Build(); +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Extensions/BuilderExtensions.cs b/src/Lab/FeatureFusion/Infrastructure/Extensions/BuilderExtensions.cs index f1686ca..3900bf8 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Extensions/BuilderExtensions.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Extensions/BuilderExtensions.cs @@ -1,16 +1,20 @@ using Asp.Versioning; using Asp.Versioning.ApiExplorer; -using Asp.Versioning.Conventions; using BuildingBlocks.Idempotency.DependencyInjection; using BuildingBlocks.Pagination.EntityFrameworkCore; +using FeatureFusion.Features.Admission; using FeatureFusion.Features.Order.IntegrationEvents; using FeatureFusion.Features.Order.IntegrationEvents.EventHandling; using FeatureFusion.Features.Order.IntegrationEvents.Events; +using FeatureFusion.Features.Payments; +using FeatureFusion.Features.Shipping; +using FeatureFusion.Features.Tax; using FeatureFusion.Infrastructure.Caching; using FeatureFusion.Infrastructure.Context; +using FeatureFusion.Infrastructure.Dapper; using FeatureFusion.Infrastructure.Filters; +using FeatureFusion.Infrastructure.Swagger; using FeatureFusion.Infrastructure.ValidationProvider; -using FeatureFusion.Models; using FeatureFusion.Infrastructure.Initializers; using FeatureFusion.Models.Validator; using FeatureFusion.Services.Authentication; @@ -32,7 +36,6 @@ using System.Reflection; using System.Text; using System.Text.Json; -using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; namespace FeatureFusion.Infrastructure.Extensions { @@ -88,10 +91,12 @@ public static void AddSwaggerConfiguration(this IServiceCollection services) Title = "API", Version = description.ApiVersion.ToString() }); - c.UseAllOfToExtendReferenceSchemas(); - c.SchemaFilter(); } + c.UseAllOfToExtendReferenceSchemas(); + c.SchemaFilter(); + c.DocumentFilter(); + // Add JWT Authentication to Swagger c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { @@ -159,13 +164,13 @@ public static void AddCacheWithRedis(this IServiceCollection services, IConfigur - // Generic method for API versioning + // Single Asp.Versioning version (1.0). Do not use VersionByNamespaceConvention. public static void AddApiVersioningWithReader(this IServiceCollection services) { services.AddApiVersioning(options => { - //options.AssumeDefaultVersionWhenUnspecified = true; options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; options.ApiVersionReader = ApiVersionReader.Combine( new QueryStringApiVersionReader("v"), @@ -178,17 +183,12 @@ public static void AddApiVersioningWithReader(this IServiceCollection services) options.GroupNameFormat = "'v'V"; options.SubstituteApiVersionInUrl = true; }) - .AddMvc( - options => - { - // automatically applies an api version namespace onventions - options.Conventions.Add(new VersionByNamespaceConvention()); - }); - + .AddMvc(); } public static void RegisterServices(this IServiceCollection services) { + CatalogDapperTypeHandlers.Register(); services.AddProblemDetails(); @@ -209,6 +209,10 @@ public static void RegisterServices(this IServiceCollection services) services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + // FluentValidation: dual-register for IValidatorProvider (non-generic) // and host ValidationBehavior (closed IValidator). services.AddFluentValidationAutoValidation(); @@ -224,6 +228,13 @@ public static void RegisterServices(this IServiceCollection services) .UseRedisLock() .UseTelemetry(); + // Application-owned capability admission (Defer-before-Send). Lab proof — not a NuGet package. + services.AddOptions() + .BindConfiguration(CapabilityAdmissionOptions.SectionName); + services.AddSingleton(TimeProvider.System); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); // AppInitializer is registered in AddApplicationServices after DB migrations. diff --git a/src/Lab/FeatureFusion/Infrastructure/Extensions/MigrateDbContextExtensions.cs b/src/Lab/FeatureFusion/Infrastructure/Extensions/MigrateDbContextExtensions.cs index 476568c..7f1e5d1 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Extensions/MigrateDbContextExtensions.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Extensions/MigrateDbContextExtensions.cs @@ -35,7 +35,7 @@ private static async Task MigrateDbContextAsync(this IServiceProvider using var scope = services.CreateScope(); var scopeServices = scope.ServiceProvider; var logger = scopeServices.GetRequiredService>(); - var context = scopeServices.GetService(); + var context = scopeServices.GetRequiredService(); using var activity = ActivitySource.StartActivity($"Migration operation {typeof(TContext).Name}"); @@ -69,7 +69,7 @@ private static async Task InvokeSeeder(Func new( - product.Id, + product.Id.Value, product.Name, product.Price, product.FullDescription, diff --git a/src/Lab/FeatureFusion/Infrastructure/Filters/Evaluation.cs b/src/Lab/FeatureFusion/Infrastructure/Filters/Evaluation.cs index dc0bb9d..dacdef4 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Filters/Evaluation.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Filters/Evaluation.cs @@ -1,11 +1,10 @@ using Microsoft.FeatureManagement; /// -/// Feature filter that enables greeting features for users with a VIP claim. +/// Feature filter that enables CustomGreeting when the caller has JWT claim VIP=true. /// /// -/// Part of the Feature Management demos in this lab. See README and docs/linkedin-posts.md. -/// Filter alias: UseGreeting. +/// Lab Feature Management preview (GET /api/v1/lab/feature-filter-preview). Filter alias: UseGreeting. /// [FilterAlias("UseGreeting")] public class UseGreetingFilter : IFeatureFilter diff --git a/src/Lab/FeatureFusion/Infrastructure/Filters/ValidationFilter.cs b/src/Lab/FeatureFusion/Infrastructure/Filters/ValidationFilter.cs index 8ea7c99..194d040 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Filters/ValidationFilter.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Filters/ValidationFilter.cs @@ -11,7 +11,7 @@ public ValidationFilter(IValidatorProvider validatorProvider) { _validatorProvider = validatorProvider; } - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { // Find the validator for the request model var validator = _validatorProvider.GetValidator(); diff --git a/src/Lab/FeatureFusion/Infrastructure/Middleware/MiddlewareCache.cs b/src/Lab/FeatureFusion/Infrastructure/Middleware/MiddlewareCache.cs index 8a70b5d..9b7bfb8 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Middleware/MiddlewareCache.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Middleware/MiddlewareCache.cs @@ -17,7 +17,7 @@ public RecommendationCacheMiddleware(RequestDelegate next, IStaticCacheManager c public async Task InvokeAsync(HttpContext context) { // Cache only GET requests - if (context.Request.Path.Equals("/api/v2/product-recommendation") + if (context.Request.Path.Equals("/api/v1/product-recommendation") && context.Request.Method == HttpMethods.Get) { // Dynamically generate a cache key based on user-specific data diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20250502133232_CatalogInitMigration.Designer.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20250502133232_CatalogInitMigration.Designer.cs index 2be8769..cfede01 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Migrations/20250502133232_CatalogInitMigration.Designer.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20250502133232_CatalogInitMigration.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using FeatureFusion.Infrastructure.Context; using Microsoft.EntityFrameworkCore; @@ -202,7 +202,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("processed_messages", (string)null); }); - modelBuilder.Entity("FeatureFusion.Domain.Entities.Product", b => + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => { b.Property("Id") .ValueGeneratedOnAdd() diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904205214_DemoCommerceFoundation.Designer.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904205214_DemoCommerceFoundation.Designer.cs new file mode 100644 index 0000000..ff2ac5d --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904205214_DemoCommerceFoundation.Designer.cs @@ -0,0 +1,584 @@ +// +using System; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260904205214_DemoCommerceFoundation")] + partial class DemoCommerceFoundation + { + /// + 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("EventBusRabbitMQ.Domain.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsProcessed") + .HasColumnType("boolean"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastAttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubscriberName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("MessageId", "SubscriberName") + .IsUnique(); + + b.ToTable("inbox_subscribers", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("event_type"); + + b.PrimitiveCollection("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamptz") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_outbox_messages_unprocessed") + .HasFilter("processed_at IS NULL"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAt"), new[] { "Id", "EventType", "Payload" }); + + b.HasIndex("Status", "RetryCount", "CreatedAt") + .HasDatabaseName("ix_outbox_messages_status_retry_created") + .HasFilter("status IN ('Pending', 'Failed')"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Status", "RetryCount", "CreatedAt"), new[] { "Id", "EventType", "Payload" }); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.ProcessedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("processed_messages", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("brands", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Customers.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("IX_customers_email"); + + b.ToTable("customers", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.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("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_orders_created_at"); + + b.HasIndex("CustomerId") + .HasDatabaseName("IX_orders_customer_id"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.HasIndex("Status") + .HasDatabaseName("IX_orders_status"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .HasDatabaseName("IX_order_items_order_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_order_items_product_id"); + + b.ToTable("order_items", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("FullDescription") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("VisibleIndividually") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BrandId") + .HasDatabaseName("IX_products_brand_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("IX_products_category_id"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("IX_products_created_at_desc"); + + b.HasIndex("Name") + .HasDatabaseName("IX_products_name"); + + b.HasIndex("Sku") + .IsUnique() + .HasDatabaseName("IX_products_sku") + .HasFilter("\"Sku\" IS NOT NULL"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_AA") + .HasDatabaseName("IX_products_created_at_id"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_created_at_id_desc"); + + b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_AA") + .HasDatabaseName("IX_products_name_id"); + + b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_name_id_desc"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_AAA") + .HasDatabaseName("IX_products_name_price_id"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_DDD") + .IsDescending() + .HasDatabaseName("IX_products_name_price_id_desc"); + + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_AA") + .HasDatabaseName("IX_products_price_id"); + + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_price_id_desc"); + + b.ToTable("products", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Features.Admission.IntentTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionOrderId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntentHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IntentPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequestKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_intent_tickets_status"); + + b.HasIndex("CapabilityId", "RequestKey") + .IsUnique() + .HasDatabaseName("IX_intent_tickets_capability_request_key"); + + b.ToTable("intent_tickets", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => + { + b.HasOne("EventBusRabbitMQ.Domain.InboxMessage", "Message") + .WithMany("Subscribers") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.HasOne("FeatureFusion.Domain.Customers.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.HasOne("FeatureFusion.Domain.Orders.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FeatureFusion.Domain.Catalog.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Brand"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxMessage", b => + { + b.Navigation("Subscribers"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904205214_DemoCommerceFoundation.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904205214_DemoCommerceFoundation.cs new file mode 100644 index 0000000..3d58cab --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904205214_DemoCommerceFoundation.cs @@ -0,0 +1,312 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + /// + public partial class DemoCommerceFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Price", + table: "products", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + oldClrType: typeof(decimal), + oldType: "numeric"); + + migrationBuilder.AlterColumn( + name: "Name", + table: "products", + type: "character varying(256)", + maxLength: 256, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "BrandId", + table: "products", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CategoryId", + table: "products", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "Sku", + table: "products", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "StockQuantity", + table: "products", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "brands", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_brands", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "categories", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "customers", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_customers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "orders", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrderNumber = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CustomerId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Total = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + Currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_orders", x => x.Id); + table.ForeignKey( + name: "FK_orders_customers_CustomerId", + column: x => x.CustomerId, + principalTable: "customers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "order_items", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrderId = table.Column(type: "integer", nullable: false), + ProductId = table.Column(type: "integer", nullable: false), + Quantity = table.Column(type: "integer", nullable: false), + UnitPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_order_items", x => x.Id); + table.ForeignKey( + name: "FK_order_items_orders_OrderId", + column: x => x.OrderId, + principalTable: "orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_order_items_products_ProductId", + column: x => x.ProductId, + principalTable: "products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_products_brand_id", + table: "products", + column: "BrandId"); + + migrationBuilder.CreateIndex( + name: "IX_products_category_id", + table: "products", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_products_sku", + table: "products", + column: "Sku", + unique: true, + filter: "\"Sku\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_brands_Name", + table: "brands", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_categories_Name", + table: "categories", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_customers_email", + table: "customers", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_order_items_order_id", + table: "order_items", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_order_items_product_id", + table: "order_items", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_orders_created_at", + table: "orders", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_orders_customer_id", + table: "orders", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_orders_OrderNumber", + table: "orders", + column: "OrderNumber", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_orders_status", + table: "orders", + column: "Status"); + + migrationBuilder.AddForeignKey( + name: "FK_products_brands_BrandId", + table: "products", + column: "BrandId", + principalTable: "brands", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_products_categories_CategoryId", + table: "products", + column: "CategoryId", + principalTable: "categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_products_brands_BrandId", + table: "products"); + + migrationBuilder.DropForeignKey( + name: "FK_products_categories_CategoryId", + table: "products"); + + migrationBuilder.DropTable( + name: "brands"); + + migrationBuilder.DropTable( + name: "categories"); + + migrationBuilder.DropTable( + name: "order_items"); + + migrationBuilder.DropTable( + name: "orders"); + + migrationBuilder.DropTable( + name: "customers"); + + migrationBuilder.DropIndex( + name: "IX_products_brand_id", + table: "products"); + + migrationBuilder.DropIndex( + name: "IX_products_category_id", + table: "products"); + + migrationBuilder.DropIndex( + name: "IX_products_sku", + table: "products"); + + migrationBuilder.DropColumn( + name: "BrandId", + table: "products"); + + migrationBuilder.DropColumn( + name: "CategoryId", + table: "products"); + + migrationBuilder.DropColumn( + name: "Sku", + table: "products"); + + migrationBuilder.DropColumn( + name: "StockQuantity", + table: "products"); + + migrationBuilder.AlterColumn( + name: "Price", + table: "products", + type: "numeric", + nullable: false, + oldClrType: typeof(decimal), + oldType: "numeric(18,2)", + oldPrecision: 18, + oldScale: 2); + + migrationBuilder.AlterColumn( + name: "Name", + table: "products", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256); + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904220000_IntentTickets.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904220000_IntentTickets.cs new file mode 100644 index 0000000..0b0b216 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904220000_IntentTickets.cs @@ -0,0 +1,56 @@ +using System; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260904220000_IntentTickets")] + public partial class IntentTickets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "intent_tickets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CapabilityId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + RequestKey = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + IntentHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + IntentPayload = table.Column(type: "text", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + ReleasedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReleasedBy = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + ExecutionOrderId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_intent_tickets", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_intent_tickets_capability_request_key", + table: "intent_tickets", + columns: new[] { "CapabilityId", "RequestKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_intent_tickets_status", + table: "intent_tickets", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "intent_tickets"); + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904222603_CatalogStorefront.Designer.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904222603_CatalogStorefront.Designer.cs new file mode 100644 index 0000000..8c55bce --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904222603_CatalogStorefront.Designer.cs @@ -0,0 +1,711 @@ +// +using System; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260904222603_CatalogStorefront")] + partial class CatalogStorefront + { + /// + 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("EventBusRabbitMQ.Domain.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsProcessed") + .HasColumnType("boolean"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastAttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubscriberName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("MessageId", "SubscriberName") + .IsUnique(); + + b.ToTable("inbox_subscribers", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("event_type"); + + b.PrimitiveCollection("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamptz") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_outbox_messages_unprocessed") + .HasFilter("processed_at IS NULL"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAt"), new[] { "Id", "EventType", "Payload" }); + + b.HasIndex("Status", "RetryCount", "CreatedAt") + .HasDatabaseName("ix_outbox_messages_status_retry_created") + .HasFilter("status IN ('Pending', 'Failed')"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Status", "RetryCount", "CreatedAt"), new[] { "Id", "EventType", "Payload" }); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.ProcessedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("processed_messages", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("brands", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Customers.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("IX_customers_email"); + + b.ToTable("customers", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.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("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_orders_created_at"); + + b.HasIndex("CustomerId") + .HasDatabaseName("IX_orders_customer_id"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.HasIndex("Status") + .HasDatabaseName("IX_orders_status"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .HasDatabaseName("IX_order_items_order_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_order_items_product_id"); + + b.ToTable("order_items", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("FullDescription") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ShortDescription") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("VisibleIndividually") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BrandId") + .HasDatabaseName("IX_products_brand_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("IX_products_category_id"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("IX_products_created_at_desc"); + + b.HasIndex("Name") + .HasDatabaseName("IX_products_name"); + + b.HasIndex("Sku") + .IsUnique() + .HasDatabaseName("IX_products_sku"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("IX_products_slug"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_AA") + .HasDatabaseName("IX_products_created_at_id"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_created_at_id_desc"); + + b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_AA") + .HasDatabaseName("IX_products_name_id"); + + b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_name_id_desc"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_AAA") + .HasDatabaseName("IX_products_name_price_id"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_DDD") + .IsDescending() + .HasDatabaseName("IX_products_name_price_id_desc"); + + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_AA") + .HasDatabaseName("IX_products_price_id"); + + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_price_id_desc"); + + b.ToTable("products", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AltText") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_product_images_product_id"); + + b.ToTable("product_images", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductSpecification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_product_specifications_product_id"); + + b.ToTable("product_specifications", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Features.Admission.IntentTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionOrderId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntentHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IntentPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequestKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_intent_tickets_status"); + + b.HasIndex("CapabilityId", "RequestKey") + .IsUnique() + .HasDatabaseName("IX_intent_tickets_capability_request_key"); + + b.ToTable("intent_tickets", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => + { + b.HasOne("EventBusRabbitMQ.Domain.InboxMessage", "Message") + .WithMany("Subscribers") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.HasOne("FeatureFusion.Domain.Customers.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.HasOne("FeatureFusion.Domain.Orders.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Brand"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductImage", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Product", null) + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductSpecification", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Product", null) + .WithMany("Specifications") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxMessage", b => + { + b.Navigation("Subscribers"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Navigation("Images"); + + b.Navigation("Specifications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904222603_CatalogStorefront.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904222603_CatalogStorefront.cs new file mode 100644 index 0000000..ff1a8b0 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904222603_CatalogStorefront.cs @@ -0,0 +1,286 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + /// + public partial class CatalogStorefront : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_products_sku", + table: "products"); + + // Leftover CatalogInit products have NULL BrandId/CategoryId. EF's AlterColumn + // backfill uses 0, which is not a real brand/category (FK 23503). + migrationBuilder.Sql( + """ + INSERT INTO categories ("Name") + SELECT 'Uncategorized' + WHERE EXISTS (SELECT 1 FROM products WHERE "CategoryId" IS NULL) + AND NOT EXISTS (SELECT 1 FROM categories); + + INSERT INTO brands ("Name") + SELECT 'Unknown' + WHERE EXISTS (SELECT 1 FROM products WHERE "BrandId" IS NULL) + AND NOT EXISTS (SELECT 1 FROM brands); + + UPDATE products + SET "CategoryId" = (SELECT MIN("Id") FROM categories) + WHERE "CategoryId" IS NULL; + + UPDATE products + SET "BrandId" = (SELECT MIN("Id") FROM brands) + WHERE "BrandId" IS NULL; + + UPDATE products + SET "Sku" = 'SKU-LEGACY-' || "Id"::text + WHERE "Sku" IS NULL OR BTRIM("Sku") = ''; + """); + + migrationBuilder.AlterColumn( + name: "Sku", + table: "products", + type: "character varying(64)", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "products", + type: "integer", + nullable: false, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "BrandId", + table: "products", + type: "integer", + nullable: false, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "ShortDescription", + table: "products", + type: "character varying(512)", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "Slug", + table: "products", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Slug", + table: "categories", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "LogoUrl", + table: "brands", + type: "character varying(512)", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "Slug", + table: "brands", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.Sql( + """ + UPDATE products + SET "Slug" = 'product-' || "Id"::text + WHERE "Slug" IS NULL OR BTRIM("Slug") = ''; + + UPDATE categories + SET "Slug" = 'category-' || "Id"::text + WHERE "Slug" IS NULL OR BTRIM("Slug") = ''; + + UPDATE brands + SET "Slug" = 'brand-' || "Id"::text + WHERE "Slug" IS NULL OR BTRIM("Slug") = ''; + """); + + migrationBuilder.CreateTable( + name: "product_images", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProductId = table.Column(type: "integer", nullable: false), + Url = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + AltText = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + DisplayOrder = table.Column(type: "integer", nullable: false), + IsPrimary = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_product_images", x => x.Id); + table.ForeignKey( + name: "FK_product_images_products_ProductId", + column: x => x.ProductId, + principalTable: "products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "product_specifications", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProductId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Value = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + DisplayOrder = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_product_specifications", x => x.Id); + table.ForeignKey( + name: "FK_product_specifications_products_ProductId", + column: x => x.ProductId, + principalTable: "products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_products_sku", + table: "products", + column: "Sku", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_products_slug", + table: "products", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_categories_Slug", + table: "categories", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_brands_Slug", + table: "brands", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_product_images_product_id", + table: "product_images", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_product_specifications_product_id", + table: "product_specifications", + column: "ProductId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "product_images"); + + migrationBuilder.DropTable( + name: "product_specifications"); + + migrationBuilder.DropIndex( + name: "IX_products_sku", + table: "products"); + + migrationBuilder.DropIndex( + name: "IX_products_slug", + table: "products"); + + migrationBuilder.DropIndex( + name: "IX_categories_Slug", + table: "categories"); + + migrationBuilder.DropIndex( + name: "IX_brands_Slug", + table: "brands"); + + migrationBuilder.DropColumn( + name: "ShortDescription", + table: "products"); + + migrationBuilder.DropColumn( + name: "Slug", + table: "products"); + + migrationBuilder.DropColumn( + name: "Slug", + table: "categories"); + + migrationBuilder.DropColumn( + name: "LogoUrl", + table: "brands"); + + migrationBuilder.DropColumn( + name: "Slug", + table: "brands"); + + migrationBuilder.AlterColumn( + name: "Sku", + table: "products", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "products", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AlterColumn( + name: "BrandId", + table: "products", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.CreateIndex( + name: "IX_products_sku", + table: "products", + column: "Sku", + unique: true, + filter: "\"Sku\" IS NOT NULL"); + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908091824_DemoCommerceCheckout.Designer.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908091824_DemoCommerceCheckout.Designer.cs new file mode 100644 index 0000000..a1c378b --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908091824_DemoCommerceCheckout.Designer.cs @@ -0,0 +1,891 @@ +// +using System; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260908091824_DemoCommerceCheckout")] + partial class DemoCommerceCheckout + { + /// + 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("EventBusRabbitMQ.Domain.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsProcessed") + .HasColumnType("boolean"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastAttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubscriberName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("MessageId", "SubscriberName") + .IsUnique(); + + b.ToTable("inbox_subscribers", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("event_type"); + + b.PrimitiveCollection("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamptz") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_outbox_messages_unprocessed") + .HasFilter("processed_at IS NULL"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAt"), new[] { "Id", "EventType", "Payload" }); + + b.HasIndex("Status", "RetryCount", "CreatedAt") + .HasDatabaseName("ix_outbox_messages_status_retry_created") + .HasFilter("status IN ('Pending', 'Failed')"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Status", "RetryCount", "CreatedAt"), new[] { "Id", "EventType", "Payload" }); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.ProcessedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("processed_messages", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.Cart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("IX_carts_customer_id"); + + b.ToTable("carts", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CartId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CartId", "ProductId") + .IsUnique() + .HasDatabaseName("IX_cart_items_cart_product"); + + b.ToTable("cart_items", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("brands", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("FullDescription") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ShortDescription") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("VisibleIndividually") + .HasColumnType("boolean"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("BrandId") + .HasDatabaseName("IX_products_brand_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("IX_products_category_id"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("IX_products_created_at_desc"); + + b.HasIndex("Name") + .HasDatabaseName("IX_products_name"); + + b.HasIndex("Sku") + .IsUnique() + .HasDatabaseName("IX_products_sku"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("IX_products_slug"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_AA") + .HasDatabaseName("IX_products_created_at_id"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_created_at_id_desc"); + + b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_AA") + .HasDatabaseName("IX_products_name_id"); + + b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_name_id_desc"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_AAA") + .HasDatabaseName("IX_products_name_price_id"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_DDD") + .IsDescending() + .HasDatabaseName("IX_products_name_price_id_desc"); + + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_AA") + .HasDatabaseName("IX_products_price_id"); + + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_price_id_desc"); + + b.ToTable("products", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AltText") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_product_images_product_id"); + + b.ToTable("product_images", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductSpecification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_product_specifications_product_id"); + + b.ToTable("product_specifications", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Customers.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("IX_customers_email"); + + b.ToTable("customers", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.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("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShippingAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subtotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_orders_created_at"); + + b.HasIndex("CustomerId") + .HasDatabaseName("IX_orders_customer_id"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.HasIndex("Status") + .HasDatabaseName("IX_orders_status"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .HasDatabaseName("IX_order_items_order_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_order_items_product_id"); + + b.ToTable("order_items", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Payments.PaymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CorrelationOrderId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("DomainOrderId") + .HasColumnType("integer"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationOrderId") + .HasDatabaseName("IX_payment_records_correlation"); + + b.ToTable("payment_records", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Features.Admission.IntentTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionOrderId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntentHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IntentPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequestKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_intent_tickets_status"); + + b.HasIndex("CapabilityId", "RequestKey") + .IsUnique() + .HasDatabaseName("IX_intent_tickets_capability_request_key"); + + b.ToTable("intent_tickets", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => + { + b.HasOne("EventBusRabbitMQ.Domain.InboxMessage", "Message") + .WithMany("Subscribers") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.CartItem", b => + { + b.HasOne("FeatureFusion.Domain.Carts.Cart", "Cart") + .WithMany("Items") + .HasForeignKey("CartId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cart"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Brand"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductImage", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Product", null) + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductSpecification", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Product", null) + .WithMany("Specifications") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.HasOne("FeatureFusion.Domain.Customers.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("FeatureFusion.Domain.Orders.OrderShipping", "Shipping", b1 => + { + b1.Property("OrderId") + .HasColumnType("integer"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("shipping_city"); + + b1.Property("Country") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("shipping_country"); + + b1.Property("Line1") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("shipping_line1"); + + b1.Property("Method") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("shipping_method"); + + b1.Property("PostalCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("shipping_postal"); + + b1.Property("RecipientName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("shipping_recipient"); + + b1.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("shipping_status"); + + b1.HasKey("OrderId"); + + b1.ToTable("orders"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Customer"); + + b.Navigation("Shipping") + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.HasOne("FeatureFusion.Domain.Orders.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxMessage", b => + { + b.Navigation("Subscribers"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.Cart", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Navigation("Images"); + + b.Navigation("Specifications"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908091824_DemoCommerceCheckout.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908091824_DemoCommerceCheckout.cs new file mode 100644 index 0000000..f996c51 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908091824_DemoCommerceCheckout.cs @@ -0,0 +1,234 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + /// + public partial class DemoCommerceCheckout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "xmin", + table: "products", + type: "xid", + rowVersion: true, + nullable: false, + defaultValue: 0u); + + migrationBuilder.AddColumn( + name: "ShippingAmount", + table: "orders", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "Subtotal", + table: "orders", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "TaxAmount", + table: "orders", + type: "numeric(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "shipping_city", + table: "orders", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "shipping_country", + table: "orders", + type: "character varying(2)", + maxLength: 2, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "shipping_line1", + table: "orders", + type: "character varying(256)", + maxLength: 256, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "shipping_method", + table: "orders", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "shipping_postal", + table: "orders", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "shipping_recipient", + table: "orders", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "shipping_status", + table: "orders", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "carts", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CustomerId = table.Column(type: "integer", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_carts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "payment_records", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CorrelationOrderId = table.Column(type: "uuid", nullable: false), + DomainOrderId = table.Column(type: "integer", nullable: true), + Outcome = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + Currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_payment_records", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "cart_items", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CartId = table.Column(type: "integer", nullable: false), + ProductId = table.Column(type: "integer", nullable: false), + Quantity = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_cart_items", x => x.Id); + table.ForeignKey( + name: "FK_cart_items_carts_CartId", + column: x => x.CartId, + principalTable: "carts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_cart_items_cart_product", + table: "cart_items", + columns: new[] { "CartId", "ProductId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_carts_customer_id", + table: "carts", + column: "CustomerId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_payment_records_correlation", + table: "payment_records", + column: "CorrelationOrderId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cart_items"); + + migrationBuilder.DropTable( + name: "payment_records"); + + migrationBuilder.DropTable( + name: "carts"); + + migrationBuilder.DropColumn( + name: "xmin", + table: "products"); + + migrationBuilder.DropColumn( + name: "ShippingAmount", + table: "orders"); + + migrationBuilder.DropColumn( + name: "Subtotal", + table: "orders"); + + migrationBuilder.DropColumn( + name: "TaxAmount", + table: "orders"); + + migrationBuilder.DropColumn( + name: "shipping_city", + table: "orders"); + + migrationBuilder.DropColumn( + name: "shipping_country", + table: "orders"); + + migrationBuilder.DropColumn( + name: "shipping_line1", + table: "orders"); + + migrationBuilder.DropColumn( + name: "shipping_method", + table: "orders"); + + migrationBuilder.DropColumn( + name: "shipping_postal", + table: "orders"); + + migrationBuilder.DropColumn( + name: "shipping_recipient", + table: "orders"); + + migrationBuilder.DropColumn( + name: "shipping_status", + table: "orders"); + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908092920_ProductOriginalVersionConcurrency.Designer.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908092920_ProductOriginalVersionConcurrency.Designer.cs new file mode 100644 index 0000000..ad6dd8a --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908092920_ProductOriginalVersionConcurrency.Designer.cs @@ -0,0 +1,889 @@ +// +using System; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260908092920_ProductOriginalVersionConcurrency")] + partial class ProductOriginalVersionConcurrency + { + /// + 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("EventBusRabbitMQ.Domain.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsProcessed") + .HasColumnType("boolean"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastAttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubscriberName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("MessageId", "SubscriberName") + .IsUnique(); + + b.ToTable("inbox_subscribers", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("event_type"); + + b.PrimitiveCollection("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamptz") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_outbox_messages_unprocessed") + .HasFilter("processed_at IS NULL"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAt"), new[] { "Id", "EventType", "Payload" }); + + b.HasIndex("Status", "RetryCount", "CreatedAt") + .HasDatabaseName("ix_outbox_messages_status_retry_created") + .HasFilter("status IN ('Pending', 'Failed')"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Status", "RetryCount", "CreatedAt"), new[] { "Id", "EventType", "Payload" }); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.ProcessedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("processed_messages", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.Cart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("IX_carts_customer_id"); + + b.ToTable("carts", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CartId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CartId", "ProductId") + .IsUnique() + .HasDatabaseName("IX_cart_items_cart_product"); + + b.ToTable("cart_items", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("brands", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("FullDescription") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ShortDescription") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("VisibleIndividually") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BrandId") + .HasDatabaseName("IX_products_brand_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("IX_products_category_id"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("IX_products_created_at_desc"); + + b.HasIndex("Name") + .HasDatabaseName("IX_products_name"); + + b.HasIndex("Sku") + .IsUnique() + .HasDatabaseName("IX_products_sku"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("IX_products_slug"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_AA") + .HasDatabaseName("IX_products_created_at_id"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_created_at_id_desc"); + + b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_AA") + .HasDatabaseName("IX_products_name_id"); + + b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_name_id_desc"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_AAA") + .HasDatabaseName("IX_products_name_price_id"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_DDD") + .IsDescending() + .HasDatabaseName("IX_products_name_price_id_desc"); + + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_AA") + .HasDatabaseName("IX_products_price_id"); + + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_DA") + .IsDescending(true, false) + .HasDatabaseName("IX_products_price_id_desc"); + + b.ToTable("products", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AltText") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_product_images_product_id"); + + b.ToTable("product_images", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductSpecification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_product_specifications_product_id"); + + b.ToTable("product_specifications", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Customers.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("IX_customers_email"); + + b.ToTable("customers", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.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("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShippingAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subtotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_orders_created_at"); + + b.HasIndex("CustomerId") + .HasDatabaseName("IX_orders_customer_id"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.HasIndex("Status") + .HasDatabaseName("IX_orders_status"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .HasDatabaseName("IX_order_items_order_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_order_items_product_id"); + + b.ToTable("order_items", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Payments.PaymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CorrelationOrderId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("DomainOrderId") + .HasColumnType("integer"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationOrderId") + .HasDatabaseName("IX_payment_records_correlation"); + + b.ToTable("payment_records", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Features.Admission.IntentTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionOrderId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntentHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IntentPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequestKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_intent_tickets_status"); + + b.HasIndex("CapabilityId", "RequestKey") + .IsUnique() + .HasDatabaseName("IX_intent_tickets_capability_request_key"); + + b.ToTable("intent_tickets", (string)null); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => + { + b.HasOne("EventBusRabbitMQ.Domain.InboxMessage", "Message") + .WithMany("Subscribers") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.CartItem", b => + { + b.HasOne("FeatureFusion.Domain.Carts.Cart", "Cart") + .WithMany("Items") + .HasForeignKey("CartId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cart"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Brand"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductImage", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Product", null) + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductSpecification", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Product", null) + .WithMany("Specifications") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.HasOne("FeatureFusion.Domain.Customers.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("FeatureFusion.Domain.Orders.OrderShipping", "Shipping", b1 => + { + b1.Property("OrderId") + .HasColumnType("integer"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("shipping_city"); + + b1.Property("Country") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("shipping_country"); + + b1.Property("Line1") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("shipping_line1"); + + b1.Property("Method") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("shipping_method"); + + b1.Property("PostalCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("shipping_postal"); + + b1.Property("RecipientName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("shipping_recipient"); + + b1.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("shipping_status"); + + b1.HasKey("OrderId"); + + b1.ToTable("orders"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Customer"); + + b.Navigation("Shipping") + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.HasOne("FeatureFusion.Domain.Orders.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxMessage", b => + { + b.Navigation("Subscribers"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.Cart", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Navigation("Images"); + + b.Navigation("Specifications"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908092920_ProductOriginalVersionConcurrency.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908092920_ProductOriginalVersionConcurrency.cs new file mode 100644 index 0000000..d6a8cb1 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260908092920_ProductOriginalVersionConcurrency.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + /// + public partial class ProductOriginalVersionConcurrency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "xmin", + table: "products"); + + migrationBuilder.AddColumn( + name: "OriginalVersion", + table: "products", + type: "bigint", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginalVersion", + table: "products"); + + migrationBuilder.AddColumn( + name: "xmin", + table: "products", + type: "xid", + rowVersion: true, + nullable: false, + defaultValue: 0u); + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/CatalogDbContextModelSnapshot.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/CatalogDbContextModelSnapshot.cs index 0c3a08d..0cb7946 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Migrations/CatalogDbContextModelSnapshot.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/CatalogDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using FeatureFusion.Infrastructure.Context; using Microsoft.EntityFrameworkCore; @@ -146,7 +146,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(256)") .HasColumnName("event_type"); - b.Property("Payload") + b.PrimitiveCollection("Payload") .IsRequired() .HasColumnType("jsonb") .HasColumnName("payload"); @@ -199,7 +199,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("processed_messages", (string)null); }); - modelBuilder.Entity("FeatureFusion.Domain.Entities.Product", b => + modelBuilder.Entity("FeatureFusion.Domain.Carts.Cart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("IX_carts_customer_id"); + + b.ToTable("carts", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CartId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CartId", "ProductId") + .IsUnique() + .HasDatabaseName("IX_cart_items_cart_product"); + + b.ToTable("cart_items", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Brand", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -207,6 +256,74 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("LogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("brands", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -217,26 +334,64 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("Name") - .HasColumnType("text"); + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); b.Property("Price") - .HasColumnType("numeric"); + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); b.Property("Published") .HasColumnType("boolean"); + b.Property("ShortDescription") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + b.Property("VisibleIndividually") .HasColumnType("boolean"); b.HasKey("Id"); - b.HasIndex("CreatedAt") - .HasDatabaseName("IX_products_created_at_asc"); + b.HasIndex("BrandId") + .HasDatabaseName("IX_products_brand_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("IX_products_category_id"); b.HasIndex("CreatedAt") .IsDescending() .HasDatabaseName("IX_products_created_at_desc"); + b.HasIndex("Name") + .HasDatabaseName("IX_products_name"); + + b.HasIndex("Sku") + .IsUnique() + .HasDatabaseName("IX_products_sku"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("IX_products_slug"); + b.HasIndex(new[] { "CreatedAt", "Id" }, "IX_Product_CreatedAt_Id_AA") .HasDatabaseName("IX_products_created_at_id"); @@ -244,9 +399,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsDescending(true, false) .HasDatabaseName("IX_products_created_at_id_desc"); - b.HasIndex("Name") - .HasDatabaseName("IX_products_name"); - b.HasIndex(new[] { "Name", "Id" }, "IX_Product_Name_Id_AA") .HasDatabaseName("IX_products_name_id"); @@ -254,6 +406,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsDescending(true, false) .HasDatabaseName("IX_products_name_id_desc"); + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_AAA") + .HasDatabaseName("IX_products_name_price_id"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_DDD") + .IsDescending() + .HasDatabaseName("IX_products_name_price_id_desc"); + b.HasIndex(new[] { "Price", "Id" }, "IX_Product_Price_Id_AA") .HasDatabaseName("IX_products_price_id"); @@ -261,14 +420,296 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsDescending(true, false) .HasDatabaseName("IX_products_price_id_desc"); - b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_AAA") - .HasDatabaseName("IX_products_name_price_id"); + b.ToTable("products", (string)null); + }); - b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_DDD") - .IsDescending(true, true, true) - .HasDatabaseName("IX_products_name_price_id_desc"); + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); - b.ToTable("products", (string)null); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AltText") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_product_images_product_id"); + + b.ToTable("product_images", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductSpecification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_product_specifications_product_id"); + + b.ToTable("product_specifications", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Customers.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("IX_customers_email"); + + b.ToTable("customers", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.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("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("OrderNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShippingAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Subtotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_orders_created_at"); + + b.HasIndex("CustomerId") + .HasDatabaseName("IX_orders_customer_id"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.HasIndex("Status") + .HasDatabaseName("IX_orders_status"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .HasDatabaseName("IX_order_items_order_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("IX_order_items_product_id"); + + b.ToTable("order_items", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Payments.PaymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CorrelationOrderId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("DomainOrderId") + .HasColumnType("integer"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationOrderId") + .HasDatabaseName("IX_payment_records_correlation"); + + b.ToTable("payment_records", (string)null); + }); + + modelBuilder.Entity("FeatureFusion.Features.Admission.IntentTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionOrderId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntentHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IntentPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequestKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_intent_tickets_status"); + + b.HasIndex("CapabilityId", "RequestKey") + .IsUnique() + .HasDatabaseName("IX_intent_tickets_capability_request_key"); + + b.ToTable("intent_tickets", (string)null); }); modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxSubscriber", b => @@ -282,10 +723,163 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Message"); }); + modelBuilder.Entity("FeatureFusion.Domain.Carts.CartItem", b => + { + b.HasOne("FeatureFusion.Domain.Carts.Cart", "Cart") + .WithMany("Items") + .HasForeignKey("CartId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cart"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Brand"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductImage", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Product", null) + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.ProductSpecification", b => + { + b.HasOne("FeatureFusion.Domain.Catalog.Product", null) + .WithMany("Specifications") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.HasOne("FeatureFusion.Domain.Customers.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("FeatureFusion.Domain.Orders.OrderShipping", "Shipping", b1 => + { + b1.Property("OrderId") + .HasColumnType("integer"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("shipping_city"); + + b1.Property("Country") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("shipping_country"); + + b1.Property("Line1") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("shipping_line1"); + + b1.Property("Method") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("shipping_method"); + + b1.Property("PostalCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("shipping_postal"); + + b1.Property("RecipientName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("shipping_recipient"); + + b1.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("shipping_status"); + + b1.HasKey("OrderId"); + + b1.ToTable("orders"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Customer"); + + b.Navigation("Shipping") + .IsRequired(); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.OrderItem", b => + { + b.HasOne("FeatureFusion.Domain.Orders.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FeatureFusion.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Product"); + }); + modelBuilder.Entity("EventBusRabbitMQ.Domain.InboxMessage", b => { b.Navigation("Subscribers"); }); + + modelBuilder.Entity("FeatureFusion.Domain.Carts.Cart", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Catalog.Product", b => + { + b.Navigation("Images"); + + b.Navigation("Specifications"); + }); + + modelBuilder.Entity("FeatureFusion.Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Lab/FeatureFusion/Infrastructure/Pagination/ProductSortKeys.cs b/src/Lab/FeatureFusion/Infrastructure/Pagination/ProductSortKeys.cs index cd4dbd5..d88358e 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Pagination/ProductSortKeys.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Pagination/ProductSortKeys.cs @@ -1,5 +1,5 @@ using BuildingBlocks.Pagination; -using FeatureFusion.Domain.Entities; +using FeatureFusion.Domain.Catalog; using FeatureFusion.Features.Products.Queries; using SortDirection = FeatureFusion.Features.Products.Queries.SortDirection; @@ -12,40 +12,40 @@ namespace FeatureFusion.Infrastructure.Pagination; public static class ProductSortKeys { public static readonly SortKey IdAsc = - SortKey.For().ThenByUnique(p => p.Id, sql: "Id"); + SortKey.For().ThenByUnique(p => (int)p.Id, sql: "Id"); public static readonly SortKey IdDesc = - SortKey.For().ThenByUniqueDescending(p => p.Id, sql: "Id"); + SortKey.For().ThenByUniqueDescending(p => (int)p.Id, sql: "Id"); public static readonly SortKey NameAsc = - SortKey.For().By(p => p.Name, sql: "Name").ThenByUnique(p => p.Id, sql: "Id"); + SortKey.For().By(p => p.Name, sql: "Name").ThenByUnique(p => (int)p.Id, sql: "Id"); public static readonly SortKey NameDesc = - SortKey.For().ByDescending(p => p.Name, sql: "Name").ThenByUnique(p => p.Id, sql: "Id"); + SortKey.For().ByDescending(p => p.Name, sql: "Name").ThenByUnique(p => (int)p.Id, sql: "Id"); public static readonly SortKey PriceAsc = - SortKey.For().By(p => p.Price, sql: "Price").ThenByUnique(p => p.Id, sql: "Id"); + SortKey.For().By(p => p.Price, sql: "Price").ThenByUnique(p => (int)p.Id, sql: "Id"); public static readonly SortKey PriceDesc = - SortKey.For().ByDescending(p => p.Price, sql: "Price").ThenByUnique(p => p.Id, sql: "Id"); + SortKey.For().ByDescending(p => p.Price, sql: "Price").ThenByUnique(p => (int)p.Id, sql: "Id"); public static readonly SortKey CreatedAtAsc = - SortKey.For().By(p => p.CreatedAt, sql: "CreatedAt").ThenByUnique(p => p.Id, sql: "Id"); + SortKey.For().By(p => p.CreatedAt, sql: "CreatedAt").ThenByUnique(p => (int)p.Id, sql: "Id"); public static readonly SortKey CreatedAtDesc = - SortKey.For().ByDescending(p => p.CreatedAt, sql: "CreatedAt").ThenByUnique(p => p.Id, sql: "Id"); + SortKey.For().ByDescending(p => p.CreatedAt, sql: "CreatedAt").ThenByUnique(p => (int)p.Id, sql: "Id"); public static readonly SortKey NameThenPriceAsc = SortKey.For() .By(p => p.Name, sql: "Name") .ThenBy(p => p.Price, sql: "Price") - .ThenByUnique(p => p.Id, sql: "Id"); + .ThenByUnique(p => (int)p.Id, sql: "Id"); public static readonly SortKey NameThenPriceDesc = SortKey.For() .ByDescending(p => p.Name, sql: "Name") .ThenByDescending(p => p.Price, sql: "Price") - .ThenByUniqueDescending(p => p.Id, sql: "Id"); + .ThenByUniqueDescending(p => (int)p.Id, sql: "Id"); public static readonly SortKeyRegistry Ascending = new SortKeyRegistry() .Add(ProductSortField.Id, IdAsc) diff --git a/src/Lab/FeatureFusion/Infrastructure/Seeding/DemoCommerceSeed.cs b/src/Lab/FeatureFusion/Infrastructure/Seeding/DemoCommerceSeed.cs new file mode 100644 index 0000000..c0b7a71 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Seeding/DemoCommerceSeed.cs @@ -0,0 +1,424 @@ +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Customers; +using FeatureFusion.Domain.Orders; +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; + +namespace FeatureFusion.Infrastructure.Seeding; + +/// +/// Deterministic storefront seed: catalog (listing + detail), customers, and orders. +/// Keeps 1000 products so existing pagination experiments remain valid. +/// Does not emit outbox or integration events. +/// +public static class DemoCommerceSeed +{ + public const int ExpectedBrandCount = 9; + public const int ExpectedCategoryCount = 7; + public const int ExpectedProductCount = 1000; + public const int ExpectedCustomerCount = 25; + public const int ExpectedOrderCount = 50; + /// Sellable catalog stock pool so Exp CreateOrder storms cannot exhaust SKUs. + public const int SellableStockPool = 100_000; + public const decimal DuplicatePrice = 199.00m; + public static readonly DateTime DuplicateCreatedAt = new(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc); + public const string HighValueOrderNumber = "ORD-HIGH-001"; + public const string OutOfStockSku = "SKU-OOS-001"; + /// + /// Qty 1 checkout of this SKU yields grand total ending in .13 (DemoPaymentProcessor decline). + /// Formula: round(4.67 × 1.10, 2) + 4.99 shipping = 10.13. + /// + public const string PaymentDeclineSku = "SKU-PAY-013"; + public const decimal PaymentDeclinePrice = 4.67m; + public const string PowerCustomerEmail = "alex.power@example.com"; + public const string FlagshipSlug = "iphone-15-pro"; + public const string FlagshipSku = "SKU-APL-IP15"; + public const string FlagshipBrandSlug = "apple"; + public const string FlagshipCategorySlug = "smartphones"; + + /// + /// Seed fixtures use ORD-PWR-*, ORD-HIGH-001, or ORD-####. + /// Runtime CreateOrder uses ORD-{Ulid} and must not be counted as seed. + /// + public static bool IsSeedOrderNumber(string? orderNumber) + { + if (string.IsNullOrEmpty(orderNumber) || !orderNumber.StartsWith("ORD-", StringComparison.Ordinal)) + return false; + if (orderNumber.StartsWith("ORD-PWR-", StringComparison.Ordinal)) + return true; + if (orderNumber == HighValueOrderNumber) + return true; + return orderNumber.Length == 8 + && char.IsDigit(orderNumber[4]) + && char.IsDigit(orderNumber[5]) + && char.IsDigit(orderNumber[6]) + && char.IsDigit(orderNumber[7]); + } + + private static readonly string[] BrandNames = + [ + "Apple", "Samsung", "Google", "Sony", "Dell", "Lenovo", "Bose", "Logitech", "ASUS" + ]; + + private static readonly string[] CategoryNames = + [ + "Smartphones", "Laptops", "Tablets", "Audio", "Accessories", "Monitors", "Wearables" + ]; + + public static async Task SeedAsync(CatalogDbContext context, ILogger logger, CancellationToken cancellationToken = default) + { + if (await context.Product.AnyAsync(cancellationToken).ConfigureAwait(false)) + { + logger.LogInformation("Demo Commerce seed skipped — catalog already present."); + return; + } + + if (await context.Brands.AnyAsync(cancellationToken).ConfigureAwait(false)) + { + context.Brands.RemoveRange(context.Brands); + context.Categories.RemoveRange(context.Categories); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + var brands = BrandNames.Select((name, i) => + { + var slug = Slug.FromName(name); + return Brand.Create( + name, + slug, + logoUrl: $"/media/brands/{slug.Value}.webp", + id: new BrandId(i + 1)); + }).ToList(); + + var categories = CategoryNames.Select((name, i) => + Category.Create(name, Slug.FromName(name), new CategoryId(i + 1))).ToList(); + + await context.Brands.AddRangeAsync(brands, cancellationToken).ConfigureAwait(false); + await context.Categories.AddRangeAsync(categories, cancellationToken).ConfigureAwait(false); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var products = BuildProducts(brands, categories); + await context.Product.AddRangeAsync(products, cancellationToken).ConfigureAwait(false); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var customers = BuildCustomers(); + await context.Customers.AddRangeAsync(customers, cancellationToken).ConfigureAwait(false); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + var orders = BuildOrders(customers, products); + await context.Orders.AddRangeAsync(orders, cancellationToken).ConfigureAwait(false); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + await ResetIdentityAsync(context, "brands", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "categories", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "products", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "product_images", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "product_specifications", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "customers", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "orders", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "order_items", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "carts", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "cart_items", cancellationToken).ConfigureAwait(false); + await ResetIdentityAsync(context, "payment_records", cancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Demo Commerce seeded: {Brands} brands, {Categories} categories, {Products} products, {Customers} customers, {Orders} orders.", + brands.Count, categories.Count, products.Count, customers.Count, orders.Count); + } + + private static async Task ResetIdentityAsync(CatalogDbContext context, string table, CancellationToken cancellationToken) + { + // Table names are fixed seed identifiers, not user input. +#pragma warning disable EF1002 + await context.Database.ExecuteSqlRawAsync( + $"SELECT setval(pg_get_serial_sequence('\"{table}\"', 'Id'), COALESCE((SELECT MAX(\"Id\") FROM \"{table}\"), 1));", + cancellationToken).ConfigureAwait(false); +#pragma warning restore EF1002 + } + + private static List BuildProducts(List brands, List categories) + { + var products = new List(ExpectedProductCount); + var flagships = new (string Sku, string Name, string Brand, string Category, decimal Price, int Stock, string Short, string Full)[] + { + (FlagshipSku, "iPhone 15 Pro", "Apple", "Smartphones", 1199m, SellableStockPool, + "Titanium smartphone with a pro camera system.", + "Flagship smartphone for listing and detail pages: gallery, specifications, and related products in the same category."), + ("SKU-SAM-S24", "Galaxy S24 Ultra", "Samsung", "Smartphones", 1299m, SellableStockPool, + "S Pen smartphone with a 200 MP camera.", + "Android flagship used for brand and category filters on the listing page."), + ("SKU-GOO-PX8", "Pixel 8 Pro", "Google", "Smartphones", 999m, SellableStockPool, + "Google Tensor phone with computational photography.", + "Mid-high smartphone used as a related product on other smartphone detail pages."), + ("SKU-APL-MBP14", "MacBook Pro 14", "Apple", "Laptops", 1999m, SellableStockPool, + "14-inch professional laptop.", + "Laptop flagship for category filters and high-value order lines."), + ("SKU-DEL-XPS15", "XPS 15 OLED", "Dell", "Laptops", 1899m, SellableStockPool, + "OLED creator laptop.", + "Windows laptop flagship with a full gallery and specification table."), + ("SKU-LEN-X1", "ThinkPad X1 Carbon", "Lenovo", "Laptops", 1749m, SellableStockPool, + "Ultralight business laptop.", + "Business laptop used for listing cards and related-product chips."), + ("SKU-ASU-Z13", "Zenbook 14 OLED", "ASUS", "Laptops", 1299m, SellableStockPool, + "Thin OLED ultrabook.", + "Everyday laptop with stock depth for pagination volume around the flagships."), + ("SKU-APL-IPAD", "iPad Pro 12.9", "Apple", "Tablets", 1099m, SellableStockPool, + "12.9-inch tablet for creative work.", + "Tablet flagship for category browsing and detail-page related products."), + ("SKU-SAM-TAB", "Galaxy Tab S9", "Samsung", "Tablets", 899m, SellableStockPool, + "Android tablet with an included stylus.", + "Tablet used to keep the tablets category populated on listing pages."), + ("SKU-SON-WH1000", "WH-1000XM5", "Sony", "Audio", 399m, SellableStockPool, + "Over-ear noise-cancelling headphones.", + "Audio flagship for listing filters and out-of-stock contrast with Pixel Buds."), + ("SKU-BOS-QC45", "QuietComfort 45", "Bose", "Audio", 329m, SellableStockPool, + "Comfort-focused wireless headphones.", + "Audio product with a complete detail gallery."), + ("SKU-LOG-MX3", "MX Master 3S", "Logitech", "Accessories", 99m, SellableStockPool, + "Precision wireless mouse.", + "Accessory flagship used as a low-price listing card."), + ("SKU-DEL-U2723", "UltraSharp 27 USB-C", "Dell", "Monitors", 549m, SellableStockPool, + "27-inch USB-C monitor.", + "Monitor flagship for category filters and specification rows."), + ("SKU-APL-AW9", "Apple Watch Series 9", "Apple", "Wearables", 429m, SellableStockPool, + "GPS smartwatch with a double tap gesture.", + "Wearable flagship for listing chips and detail copy."), + ("SKU-SAM-GW6", "Galaxy Watch 6", "Samsung", "Wearables", 349m, SellableStockPool, + "Wear OS smartwatch with body composition.", + "Wearable used as a related product on other wearables."), + (OutOfStockSku, "Pixel Buds Pro (Clearance)", "Google", "Audio", 189m, 0, + "Clearance earbuds — currently out of stock.", + "Out-of-stock fixture for listing availability badges."), + ("SKU-LOW-001", "Logitech Webcam C920e", "Logitech", "Accessories", 79m, 2, + "1080p webcam with low remaining stock.", + "Low-stock accessory fixture."), + ("SKU-LOW-002", "Sony WF-1000XM5", "Sony", "Audio", 279m, 1, + "In-ear noise-cancelling earbuds.", + "Low-stock audio fixture."), + ("SKU-LOW-003", "ASUS Portable Monitor", "ASUS", "Monitors", 249m, 3, + "USB-C portable display.", + "Low-stock monitor fixture."), + (PaymentDeclineSku, "Demo cable (payment-decline fixture)", "Logitech", "Accessories", PaymentDeclinePrice, SellableStockPool, + "Priced so a qty-1 checkout grand total ends in .13.", + "Deterministic DemoPaymentProcessor decline fixture. Not a real SKU."), + }; + + var brandMap = brands.ToDictionary(b => b.Name, b => b); + var categoryMap = categories.ToDictionary(c => c.Name, c => c); + var id = 1; + + foreach (var f in flagships) + { + var brand = brandMap[f.Brand]; + var category = categoryMap[f.Category]; + var product = Product.Create( + name: f.Name, + sku: Sku.Create(f.Sku), + price: f.Price, + stockQuantity: f.Stock, + brandId: brand.Id, + categoryId: category.Id, + createdAtUtc: DuplicateCreatedAt.AddDays(-(id % 20)), + slug: Slug.FromName(f.Name), + shortDescription: f.Short, + fullDescription: f.Full, + id: new ProductId(id++)); + AttachMedia(product, f.Brand, f.Category, flagship: true); + products.Add(product); + } + + for (var i = 0; i < 20; i++) + { + var brand = brands[i % brands.Count]; + var category = categories[i % categories.Count]; + var product = Product.Create( + name: $"{brand.Name} Essentials {category.Name} {i + 1}", + sku: Sku.Create($"SKU-DUP-PRICE-{i + 1:D2}"), + price: DuplicatePrice, + stockQuantity: SellableStockPool, + brandId: brand.Id, + categoryId: category.Id, + createdAtUtc: DuplicateCreatedAt.AddHours(i), + shortDescription: "Shared-price listing fixture.", + fullDescription: "Shared-price pagination fixture.", + id: new ProductId(id++)); + AttachMedia(product, brand.Name, category.Name, flagship: false); + products.Add(product); + } + + for (var i = 0; i < 12; i++) + { + var brand = brands[(i + 3) % brands.Count]; + var category = categories[(i + 2) % categories.Count]; + var product = Product.Create( + name: $"{brand.Name} Studio Line {i + 1}", + sku: Sku.Create($"SKU-DUP-DATE-{i + 1:D2}"), + price: 149.50m + i, + stockQuantity: SellableStockPool, + brandId: brand.Id, + categoryId: category.Id, + createdAtUtc: DuplicateCreatedAt, + shortDescription: "Shared created-at listing fixture.", + fullDescription: "Shared-CreatedAt pagination fixture.", + id: new ProductId(id++)); + AttachMedia(product, brand.Name, category.Name, flagship: false); + products.Add(product); + } + + while (products.Count < ExpectedProductCount) + { + var n = products.Count + 1; + var brand = brands[n % brands.Count]; + var category = categories[n % categories.Count]; + var price = 49.99m + (n % 150) + (n % 7) * 0.13m; + var product = Product.Create( + name: $"{brand.Name} {category.Name} Model {n}", + sku: Sku.Create($"SKU-CAT-{n:D4}"), + price: Math.Round(price, 2), + stockQuantity: SellableStockPool, + brandId: brand.Id, + categoryId: category.Id, + createdAtUtc: DuplicateCreatedAt.AddDays(-(n % 400)).AddMinutes(n % 60), + shortDescription: $"{category.Name} from {brand.Name}.", + fullDescription: $"Catalog filler #{n} for pagination volume.", + id: new ProductId(id++)); + AttachMedia(product, brand.Name, category.Name, flagship: false); + products.Add(product); + } + + return products; + } + + private static void AttachMedia(Product product, string brandName, string categoryName, bool flagship) + { + var slug = product.Slug.Value; + product.AddImage($"/media/catalog/{slug}-1.webp", product.Name, 0, isPrimary: true); + if (flagship) + { + product.AddImage($"/media/catalog/{slug}-2.webp", $"{product.Name} — side", 1, isPrimary: false); + product.AddImage($"/media/catalog/{slug}-3.webp", $"{product.Name} — detail", 2, isPrimary: false); + product.AddSpecification("Brand", brandName, 0); + product.AddSpecification("Category", categoryName, 1); + product.AddSpecification("SKU", product.Sku.Value, 2); + product.AddSpecification("Warranty", "24 months", 3); + product.AddSpecification("Ships from", "EU warehouse", 4); + } + else + { + product.AddSpecification("Brand", brandName, 0); + product.AddSpecification("Category", categoryName, 1); + } + } + + private static List BuildCustomers() + { + var names = new[] + { + ("Alex Power", PowerCustomerEmail), + ("Jordan Lee", "jordan.lee@example.com"), + ("Sam Rivera", "sam.rivera@example.com"), + ("Casey Nguyen", "casey.nguyen@example.com"), + ("Riley Patel", "riley.patel@example.com"), + ("Morgan Chen", "morgan.chen@example.com"), + ("Avery Brooks", "avery.brooks@example.com"), + ("Quinn Morales", "quinn.morales@example.com"), + ("Taylor Kim", "taylor.kim@example.com"), + ("Jamie Ortiz", "jamie.ortiz@example.com"), + ("Drew Hassan", "drew.hassan@example.com"), + ("Cameron Blake", "cameron.blake@example.com"), + ("Reese Alvarez", "reese.alvarez@example.com"), + ("Parker Singh", "parker.singh@example.com"), + ("Skyler Diaz", "skyler.diaz@example.com"), + ("Hayden Cole", "hayden.cole@example.com"), + ("Rowan West", "rowan.west@example.com"), + ("Emerson Shaw", "emerson.shaw@example.com"), + ("Finley Cross", "finley.cross@example.com"), + ("Kendall Frost", "kendall.frost@example.com"), + ("Peyton Vale", "peyton.vale@example.com"), + ("Blake Monroe", "blake.monroe@example.com"), + ("Charlie Dunn", "charlie.dunn@example.com"), + ("Dana Pierce", "dana.pierce@example.com"), + ("Elliot Nash", "elliot.nash@example.com"), + }; + + return names.Select((n, i) => Customer.Create( + Email.Create(n.Item2), + n.Item1, + DuplicateCreatedAt.AddDays(-i * 3), + new CustomerId(i + 1))).ToList(); + } + + private static List BuildOrders(List customers, List products) + { + var orders = new List(); + var rng = new Random(42); + var orderId = 1; + var itemId = 1; + var power = customers.First(c => c.Email.Value == PowerCustomerEmail); + + for (var i = 0; i < 6; i++) + { + orders.Add(MakeOrder( + ref orderId, ref itemId, $"ORD-PWR-{i + 1:D3}", power, products, rng, + i == 5 ? OrderStatus.Cancelled : OrderStatus.Placed, + lineCount: 2 + (i % 3))); + } + + var highProducts = products.Where(p => p.Price >= 900m && p.StockQuantity > 0).Take(5).ToList(); + var highLines = highProducts.Select(p => + { + var qty = p.Price > 1500m ? 1 : 2; + return (p.Id, qty, p.Price, (OrderItemId?)new OrderItemId(itemId++)); + }).ToList(); + + orders.Add(Order.Create( + OrderNumber.Create(HighValueOrderNumber), + customers[1].Id, + OrderStatus.Pending, + "EUR", + DuplicateCreatedAt.AddDays(-2), + highLines, + new OrderId(orderId++))); + + var otherCustomers = customers.Where(c => c.Id != power.Id).ToList(); + while (orders.Count < ExpectedOrderCount) + { + var customer = otherCustomers[rng.Next(otherCustomers.Count)]; + var status = (OrderStatus)(orders.Count % 3); + orders.Add(MakeOrder( + ref orderId, ref itemId, $"ORD-{orderId:D4}", customer, products, rng, status, + lineCount: 2 + (orders.Count % 3))); + } + + return orders; + } + + private static Order MakeOrder( + ref int orderId, + ref int itemId, + string number, + Customer customer, + List products, + Random rng, + OrderStatus status, + int lineCount) + { + var id = new OrderId(orderId++); + var sellable = products.Where(p => p.StockQuantity > 0 && p.Price > 0).ToList(); + var lines = new List<(ProductId, int, decimal, OrderItemId?)>(); + for (var i = 0; i < lineCount; i++) + { + var p = sellable[rng.Next(sellable.Count)]; + lines.Add((p.Id, 1 + rng.Next(3), p.Price, new OrderItemId(itemId++))); + } + + return Order.Create( + OrderNumber.Create(number), + customer.Id, + status, + "EUR", + DuplicateCreatedAt.AddDays(-(id.Value % 90)), + lines, + id); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Swagger/SwaggerTagDocumentFilter.cs b/src/Lab/FeatureFusion/Infrastructure/Swagger/SwaggerTagDocumentFilter.cs new file mode 100644 index 0000000..adf9cba --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Swagger/SwaggerTagDocumentFilter.cs @@ -0,0 +1,34 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace FeatureFusion.Infrastructure.Swagger; + +/// Stable Swagger tag order and descriptions for the lab HTTP surface. +public sealed class SwaggerTagDocumentFilter : IDocumentFilter +{ + private static readonly OpenApiTag[] OrderedTags = + [ + new() { Name = "Catalog", Description = "Storefront listing and detail: filters by brand/category slug, gallery, specifications." }, + new() { Name = "Products", Description = "Keyset (cursor) catalog paging used by pagination experiments." }, + new() { Name = "Orders", Description = "Lab order commands (idempotency, admission). Not storefront checkout." }, + new() { Name = "Admission", Description = "Deferred capability tickets for order create." }, + new() { Name = "Auth", Description = "JWT login for feature-toggle greeting demos." }, + new() { Name = "Greeting", Description = "Feature-toggle greeting samples." }, + new() { Name = "MediatorDemo", Description = "Mediator pipeline sample (command + query)." }, + new() { Name = "Lab", Description = "Miscellaneous lab endpoints (promotions, validation samples, ping)." } + ]; + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var present = swaggerDoc.Paths + .SelectMany(p => p.Value.Operations.Values) + .SelectMany(op => op.Tags ?? []) + .Select(t => t.Name) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .ToHashSet(StringComparer.Ordinal); + + swaggerDoc.Tags = OrderedTags.Where(t => present.Contains(t.Name)).ToList(); + foreach (var leftover in present.Except(swaggerDoc.Tags.Select(t => t.Name), StringComparer.Ordinal)) + swaggerDoc.Tags.Add(new OpenApiTag { Name = leftover }); + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/ValidatorProvider/IValidatorProvider.cs b/src/Lab/FeatureFusion/Infrastructure/ValidatorProvider/IValidatorProvider.cs index 5b96fdd..1fdd3ec 100644 --- a/src/Lab/FeatureFusion/Infrastructure/ValidatorProvider/IValidatorProvider.cs +++ b/src/Lab/FeatureFusion/Infrastructure/ValidatorProvider/IValidatorProvider.cs @@ -9,13 +9,13 @@ public interface IValidatorProvider /// /// The type of the model to validate. /// An instance of for the specified model type. - IValidator GetValidator(); + IValidator? GetValidator(); /// /// Retrieves a validator for the specified model type. /// /// The type of the model to validate. /// An instance of for the specified model type. - IValidator GetValidatorForType(Type type); + IValidator? GetValidatorForType(Type type); } } diff --git a/src/Lab/FeatureFusion/Infrastructure/ValidatorProvider/ValidatorProvider.cs b/src/Lab/FeatureFusion/Infrastructure/ValidatorProvider/ValidatorProvider.cs index fb86811..34835ae 100644 --- a/src/Lab/FeatureFusion/Infrastructure/ValidatorProvider/ValidatorProvider.cs +++ b/src/Lab/FeatureFusion/Infrastructure/ValidatorProvider/ValidatorProvider.cs @@ -27,14 +27,23 @@ public ValidatorProvider(IEnumerable registeredValidators) /// /// The model type. /// The corresponding validator. - public IValidator GetValidator() => GetValidatorForType(typeof(TModel)); + public IValidator? GetValidator() => GetValidatorForType(typeof(TModel)); /// /// Gets a validator for a given model type. /// /// The model type. /// The corresponding validator. - public IValidator GetValidatorForType(Type modelType) => _validatorCache.GetOrAdd(modelType, LocateValidatorForType); + public IValidator? GetValidatorForType(Type modelType) + { + if (_validatorCache.TryGetValue(modelType, out var cached)) + return cached; + + var located = LocateValidatorForType(modelType); + if (located is not null) + _validatorCache[modelType] = located; + return located; + } /// /// Finds a validator for the given model type. @@ -42,7 +51,7 @@ public ValidatorProvider(IEnumerable registeredValidators) /// The model type. /// The validator or null if none found. /// Thrown if multiple validators exist for the same type. - private IValidator LocateValidatorForType(Type modelType) + private IValidator? LocateValidatorForType(Type modelType) { var validatorType = CreateValidatorTypeForModel(modelType); diff --git a/src/Lab/FeatureFusion/Program.cs b/src/Lab/FeatureFusion/Program.cs index 9807cfe..67ea0d3 100644 --- a/src/Lab/FeatureFusion/Program.cs +++ b/src/Lab/FeatureFusion/Program.cs @@ -1,4 +1,5 @@ using Asp.Versioning.ApiExplorer; +using BuildingBlocks.Idempotency.AspNetCore; using BuildingBlocks.Mcp; using BuildingBlocks.Mcp.Hosting; using BuildingBlocks.Mediator; @@ -6,12 +7,21 @@ using Enyim.Caching; using Enyim.Caching.Configuration; using EventBusRabbitMQ; +using FeatureFusion.Features.Admission; +using FeatureFusion.Features.Admission.Endpoints; +using FeatureFusion.Features.Auth.Endpoints; +using FeatureFusion.Features.Catalog; +using FeatureFusion.Features.Carts; +using FeatureFusion.Features.Checkout; +using FeatureFusion.Features.Customers; +using FeatureFusion.Features.Lab.Endpoints; using FeatureFusion.Features.MediatorDemo.Endpoints; +using FeatureFusion.Features.Orders.Endpoints; +using FeatureFusion.Features.Orders; using FeatureFusion.Features.Products.Endpoints; using FeatureFusion.Infrastructure.Behaviors; using FeatureFusion.Infrastructure.Exceptions; using FeatureFusion.Infrastructure.Extensions; -using FeatureFusion.API.V2; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Caching.Distributed; using Microsoft.FeatureManagement; @@ -39,6 +49,7 @@ { telemetry.AddSource("DbMigrations"); telemetry.AddSource("BuildingBlocks.Idempotency"); + telemetry.AddSource("FeatureFusion.Checkout"); telemetry.ConfigureTracing(t => t .AddEntityFrameworkCoreInstrumentation() .AddRedisInstrumentation()); @@ -76,7 +87,26 @@ }).UseDispatcher(async (sp, msg, ct) => { await using var scope = sp.CreateAsyncScope(); - return await scope.ServiceProvider.GetRequiredService().Send(msg, ct); + var services = scope.ServiceProvider; + if (msg is FeatureFusion.Features.Orders.Commands.CreateOrderCommand createOrder) + { + var admission = services.GetRequiredService(); + var mcpContext = services.GetService(); + var decision = await FeatureFusion.Features.Admission.OrderCreateAdmissionGate.AdmitCreateOrderAsync( + admission, + createOrder, + FeatureFusion.Features.Admission.OrderCreateAdmissionGate.ResolveMcpRequestKey(mcpContext), + ct); + switch (decision) + { + case FeatureFusion.Features.Admission.AdmissionDecision.Defer defer: + return Result.Success(defer.Pending); + case FeatureFusion.Features.Admission.AdmissionDecision.Deny deny: + return Result.Failure(deny.Error, deny.StatusCode); + } + } + + return await services.GetRequiredService().Send(msg, ct); }); } @@ -164,23 +194,35 @@ void ConfigureRequestPipeline(WebApplication app) // after this — it turns handled ValidationExceptions into opaque 500s in Development/tests. app.UseExceptionHandler(); + // Minimal API binds [FromBody] before IEndpointFilter; buffer Idempotency-Key + // requests so WithIdempotency fingerprinting can rewind after binding (Exp 12). + app.UseIdempotencyRequestBuffering(); + // Cursor HTTP MCP uses http://localhost:5141/mcp. HTTPS redirection would 307 to // https://localhost:7226/mcp and the MCP client hangs on the Kestrel dev cert. app.UseWhen( ctx => !ctx.Request.Path.StartsWithSegments("/mcp"), branch => branch.UseHttpsRedirection()); + app.UseAuthentication(); app.UseAuthorization(); - // Map API controllers and versioned routes - app.MapControllers(); if (app.Environment.IsDevelopment()) app.MapBuildingBlocksMcp(); } #endregion -app.MapGreetingApiV2(); +app.MapLabEndpoints(); +app.MapFeatureFilterPreviewEndpoints(); +app.MapAuthEndpoints(); +app.MapOrderEndpoints(); +app.MapOrderQueryEndpoints(); +app.MapCustomerEndpoints(); +app.MapCartEndpoints(); +app.MapCheckoutEndpoints(); app.MapMediatorDemoEndpoints(); app.MapProductPaginationEndpoints(); +app.MapCatalogEndpoints(); +app.MapAdmissionEndpoints(); #region memchached prestart up validation if enabled // Pre-startup validation for memcached and redis diff --git a/src/Lab/FeatureFusion/Services/Authentication/AuthService.cs b/src/Lab/FeatureFusion/Services/Authentication/AuthService.cs index ef54a7a..32648ad 100644 --- a/src/Lab/FeatureFusion/Services/Authentication/AuthService.cs +++ b/src/Lab/FeatureFusion/Services/Authentication/AuthService.cs @@ -22,7 +22,7 @@ public bool ValidateVipUser(string username, string password) public string GenerateJwtToken(string username, bool isVip) { - var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]); + var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]!); var claims = new List { new Claim(JwtRegisteredClaimNames.Sub, username), diff --git a/src/Lab/FeatureFusion/Services/Product/ProductService.cs b/src/Lab/FeatureFusion/Services/Product/ProductService.cs index dae92f4..20b5e22 100644 --- a/src/Lab/FeatureFusion/Services/Product/ProductService.cs +++ b/src/Lab/FeatureFusion/Services/Product/ProductService.cs @@ -1,6 +1,7 @@ using BuildingBlocks.Pagination; using BuildingBlocks.Pagination.Dapper; using BuildingBlocks.Pagination.EntityFrameworkCore; +using FeatureFusion.Domain.Catalog; using FeatureFusion.Domain.Entities; using FeatureFusion.Dtos; using FeatureFusion.Features.Products.Queries; @@ -68,7 +69,7 @@ public async Task> GetProductPromotionAsync(bool getF { var products = await GenerateSampleData(); return products; - }); + }) ?? productPromotion; } else { @@ -78,7 +79,7 @@ public async Task> GetProductPromotionAsync(bool getF // Static data representing products and their manufacturer promotions var products = await GenerateSampleData(); return products; - }); + }) ?? productPromotion; } // i return result here for debug purpose , for production appInitilizer there is no need to return data @@ -98,9 +99,11 @@ public Task> GetProductRocemmendationAsync(Cancellatio // Static data representing products and their manufacturer promotions var products = - new List{new Product { Id=1, Name = "Laptop", Published = true, Deleted = false, VisibleIndividually = true }, - new Product { Id=2,Name = "Phone", Published = true, Deleted = false, VisibleIndividually = true }, - new Product { Id=3, Name = "Headphones", Published = false, Deleted = false, VisibleIndividually = true } + new List + { + Product.CreateDemo("Laptop", published: true, id: 1), + Product.CreateDemo("Phone", published: true, id: 2), + Product.CreateDemo("Headphones", published: false, id: 3) }; var productManufacturers = new List @@ -112,12 +115,12 @@ public Task> GetProductRocemmendationAsync(Cancellatio // Filtering and projecting product promotions based on the static data var query = from p in products - join pm in productManufacturers on p.Id equals pm.ProductId + join pm in productManufacturers on p.Id.Value equals pm.ProductId where p.Published && !p.Deleted && p.VisibleIndividually && pm.IsFeaturedProduct select new ProductPromotionDto { - ProductId = p.Id, + ProductId = p.Id.Value, Name = p.Name, ManufacturerId = pm.ManufacturerId, IsFeatured = pm.IsFeaturedProduct @@ -137,9 +140,9 @@ public ValueTask> GenerateSampleData() // Static data representing products and their manufacturer promotions var products = new List { - new Product { Name = "Laptop", Published = true, Deleted = false, VisibleIndividually = true }, - new Product { Name = "Phone", Published = true, Deleted = false, VisibleIndividually = true }, - new Product { Name = "Headphones", Published = false, Deleted = false, VisibleIndividually = true } + Product.CreateDemo("Laptop", published: true), + Product.CreateDemo("Phone", published: true), + Product.CreateDemo("Headphones", published: false) }; var productManufacturers = new List @@ -151,12 +154,12 @@ public ValueTask> GenerateSampleData() // Filtering and projecting product promotions based on the static data IList query = (from p in products - join pm in productManufacturers on p.Id equals pm.ProductId + join pm in productManufacturers on p.Id.Value equals pm.ProductId where p.Published && !p.Deleted && p.VisibleIndividually && pm.IsFeaturedProduct select new ProductPromotionDto { - ProductId = p.Id, + ProductId = p.Id.Value, Name = p.Name, ManufacturerId = pm.ManufacturerId, IsFeatured = pm.IsFeaturedProduct @@ -182,7 +185,7 @@ public async Task>> GetProductsAsync( .ToCursorPageAsync( new CursorRequest(cursor, limit, pageDirection), sortKey, - p => new ProductDto(p.Id, p.Name, p.Price, p.FullDescription, p.CreatedAt), + p => new ProductDto((int)p.Id, p.Name, p.Price, p.FullDescription, p.CreatedAt), new PaginationOptions { IncludeTotalCount = firstPage }, cancellationToken); @@ -223,7 +226,8 @@ public async Task>> GetProductsViaDapperAsync( const string sql = """ -- Isolation/hints stay in host SQL (PostgreSQL: session, not SQL Server NOLOCK). - SELECT "Id", "Name", "Price", "FullDescription", "CreatedAt", "Published", "Deleted", "VisibleIndividually" + SELECT "Id", "Name", "Sku", "Slug", "Price", "StockQuantity", "BrandId", "CategoryId", + "ShortDescription", "FullDescription", "CreatedAt", "Published", "Deleted", "VisibleIndividually" FROM products """; diff --git a/src/Lab/FeatureFusion/appsettings.Development.json b/src/Lab/FeatureFusion/appsettings.Development.json index 8e15edc..e7213db 100644 --- a/src/Lab/FeatureFusion/appsettings.Development.json +++ b/src/Lab/FeatureFusion/appsettings.Development.json @@ -54,6 +54,10 @@ "RetryCount": 10 }, + "CapabilityAdmission": { + "DeferredCapabilities": [ "orders.create" ], + "TicketTtl": "01:00:00" + }, "Aspire": { "Npgsql": { "EntityFrameworkCore": { diff --git a/src/src.sln b/src/src.sln new file mode 100644 index 0000000..62b6192 --- /dev/null +++ b/src/src.sln @@ -0,0 +1,144 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildingBlocks", "BuildingBlocks", "{8BFC2AEB-3317-442F-BD45-3886AA383300}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Aspire.Hosting.SigNoz", "BuildingBlocks\Aspire.Hosting.SigNoz\BuildingBlocks.Aspire.Hosting.SigNoz.csproj", "{3E18D055-A425-4248-8368-5EF9ECEE7AC0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Domain", "BuildingBlocks\Domain\BuildingBlocks.Domain.csproj", "{FFFEDE65-EA60-4AB0-988D-02101366BE5D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Domain.EntityFrameworkCore", "BuildingBlocks\Domain.EntityFrameworkCore\BuildingBlocks.Domain.EntityFrameworkCore.csproj", "{F6F64D16-D380-49A1-825B-05423E8C1D36}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Idempotency", "BuildingBlocks\Idempotency\BuildingBlocks.Idempotency.csproj", "{302F24C7-28FB-4EA5-8FA3-63C6FB775742}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Mcp", "BuildingBlocks\Mcp\BuildingBlocks.Mcp.csproj", "{E3656E06-761B-431D-B720-48A75404917A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Mcp.Analyzers", "BuildingBlocks\Mcp.Analyzers\BuildingBlocks.Mcp.Analyzers.csproj", "{897EA924-831C-454B-A2E9-48C9680D07C1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Mediator", "BuildingBlocks\Mediator\BuildingBlocks.Mediator.csproj", "{91968900-6E28-4C26-8FA7-EF50D5BCA7F5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Mediator.Analyzers", "BuildingBlocks\Mediator.Analyzers\BuildingBlocks.Mediator.Analyzers.csproj", "{31242BA7-E7E9-4E82-BFC6-DA7CB9F24FFF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Pagination", "BuildingBlocks\Pagination\BuildingBlocks.Pagination.csproj", "{BD3B55B7-0EDE-497B-B246-EA236E599942}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Pagination.Dapper", "BuildingBlocks\Pagination.Dapper\BuildingBlocks.Pagination.Dapper.csproj", "{37FDDB1F-F2DD-4B30-94CB-19086A5D6FE1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Pagination.EntityFrameworkCore", "BuildingBlocks\Pagination.EntityFrameworkCore\BuildingBlocks.Pagination.EntityFrameworkCore.csproj", "{AD41F73D-9778-4757-9935-8F36358466FE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Telemetry", "BuildingBlocks\Telemetry\BuildingBlocks.Telemetry.csproj", "{D98BE591-B979-405C-9274-74DB39ED52C4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lab", "Lab", "{2F8ACBF2-D4CC-4156-9D7C-CB9AA125FF76}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventBus", "Lab\EventBus\EventBus.csproj", "{B596E108-3D36-4F0E-B949-BDE4E27CA02F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeatureFusion", "Lab\FeatureFusion\FeatureFusion.csproj", "{BE6720A9-A5E4-450D-BF59-1355730F4444}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeatureFusion.ApiGateway", "Lab\FeatureFusion.ApiGateway\FeatureFusion.ApiGateway.csproj", "{CC21AA95-0D84-4C12-8594-861BD4AD1E08}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeatureFusion.AppHost", "Lab\FeatureFusion.AppHost\FeatureFusion.AppHost.csproj", "{D3FEFDF3-AB45-4D06-87A7-9FFB6F038712}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeatureFusion.AppHost.ServiceDefaults", "Lab\FeatureFusion.ServiceDefaults\FeatureFusion.AppHost.ServiceDefaults.csproj", "{690E541F-2945-4A11-90AA-E4DD6673350B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3E18D055-A425-4248-8368-5EF9ECEE7AC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E18D055-A425-4248-8368-5EF9ECEE7AC0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E18D055-A425-4248-8368-5EF9ECEE7AC0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E18D055-A425-4248-8368-5EF9ECEE7AC0}.Release|Any CPU.Build.0 = Release|Any CPU + {FFFEDE65-EA60-4AB0-988D-02101366BE5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFFEDE65-EA60-4AB0-988D-02101366BE5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFFEDE65-EA60-4AB0-988D-02101366BE5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFFEDE65-EA60-4AB0-988D-02101366BE5D}.Release|Any CPU.Build.0 = Release|Any CPU + {F6F64D16-D380-49A1-825B-05423E8C1D36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6F64D16-D380-49A1-825B-05423E8C1D36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6F64D16-D380-49A1-825B-05423E8C1D36}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6F64D16-D380-49A1-825B-05423E8C1D36}.Release|Any CPU.Build.0 = Release|Any CPU + {302F24C7-28FB-4EA5-8FA3-63C6FB775742}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {302F24C7-28FB-4EA5-8FA3-63C6FB775742}.Debug|Any CPU.Build.0 = Debug|Any CPU + {302F24C7-28FB-4EA5-8FA3-63C6FB775742}.Release|Any CPU.ActiveCfg = Release|Any CPU + {302F24C7-28FB-4EA5-8FA3-63C6FB775742}.Release|Any CPU.Build.0 = Release|Any CPU + {E3656E06-761B-431D-B720-48A75404917A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3656E06-761B-431D-B720-48A75404917A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3656E06-761B-431D-B720-48A75404917A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3656E06-761B-431D-B720-48A75404917A}.Release|Any CPU.Build.0 = Release|Any CPU + {897EA924-831C-454B-A2E9-48C9680D07C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {897EA924-831C-454B-A2E9-48C9680D07C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {897EA924-831C-454B-A2E9-48C9680D07C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {897EA924-831C-454B-A2E9-48C9680D07C1}.Release|Any CPU.Build.0 = Release|Any CPU + {91968900-6E28-4C26-8FA7-EF50D5BCA7F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91968900-6E28-4C26-8FA7-EF50D5BCA7F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91968900-6E28-4C26-8FA7-EF50D5BCA7F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91968900-6E28-4C26-8FA7-EF50D5BCA7F5}.Release|Any CPU.Build.0 = Release|Any CPU + {31242BA7-E7E9-4E82-BFC6-DA7CB9F24FFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31242BA7-E7E9-4E82-BFC6-DA7CB9F24FFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31242BA7-E7E9-4E82-BFC6-DA7CB9F24FFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31242BA7-E7E9-4E82-BFC6-DA7CB9F24FFF}.Release|Any CPU.Build.0 = Release|Any CPU + {BD3B55B7-0EDE-497B-B246-EA236E599942}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD3B55B7-0EDE-497B-B246-EA236E599942}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD3B55B7-0EDE-497B-B246-EA236E599942}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD3B55B7-0EDE-497B-B246-EA236E599942}.Release|Any CPU.Build.0 = Release|Any CPU + {37FDDB1F-F2DD-4B30-94CB-19086A5D6FE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37FDDB1F-F2DD-4B30-94CB-19086A5D6FE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37FDDB1F-F2DD-4B30-94CB-19086A5D6FE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37FDDB1F-F2DD-4B30-94CB-19086A5D6FE1}.Release|Any CPU.Build.0 = Release|Any CPU + {AD41F73D-9778-4757-9935-8F36358466FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD41F73D-9778-4757-9935-8F36358466FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD41F73D-9778-4757-9935-8F36358466FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD41F73D-9778-4757-9935-8F36358466FE}.Release|Any CPU.Build.0 = Release|Any CPU + {D98BE591-B979-405C-9274-74DB39ED52C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D98BE591-B979-405C-9274-74DB39ED52C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D98BE591-B979-405C-9274-74DB39ED52C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D98BE591-B979-405C-9274-74DB39ED52C4}.Release|Any CPU.Build.0 = Release|Any CPU + {B596E108-3D36-4F0E-B949-BDE4E27CA02F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B596E108-3D36-4F0E-B949-BDE4E27CA02F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B596E108-3D36-4F0E-B949-BDE4E27CA02F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B596E108-3D36-4F0E-B949-BDE4E27CA02F}.Release|Any CPU.Build.0 = Release|Any CPU + {BE6720A9-A5E4-450D-BF59-1355730F4444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE6720A9-A5E4-450D-BF59-1355730F4444}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE6720A9-A5E4-450D-BF59-1355730F4444}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE6720A9-A5E4-450D-BF59-1355730F4444}.Release|Any CPU.Build.0 = Release|Any CPU + {CC21AA95-0D84-4C12-8594-861BD4AD1E08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC21AA95-0D84-4C12-8594-861BD4AD1E08}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC21AA95-0D84-4C12-8594-861BD4AD1E08}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC21AA95-0D84-4C12-8594-861BD4AD1E08}.Release|Any CPU.Build.0 = Release|Any CPU + {D3FEFDF3-AB45-4D06-87A7-9FFB6F038712}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3FEFDF3-AB45-4D06-87A7-9FFB6F038712}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3FEFDF3-AB45-4D06-87A7-9FFB6F038712}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3FEFDF3-AB45-4D06-87A7-9FFB6F038712}.Release|Any CPU.Build.0 = Release|Any CPU + {690E541F-2945-4A11-90AA-E4DD6673350B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {690E541F-2945-4A11-90AA-E4DD6673350B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {690E541F-2945-4A11-90AA-E4DD6673350B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {690E541F-2945-4A11-90AA-E4DD6673350B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {3E18D055-A425-4248-8368-5EF9ECEE7AC0} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {FFFEDE65-EA60-4AB0-988D-02101366BE5D} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {F6F64D16-D380-49A1-825B-05423E8C1D36} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {302F24C7-28FB-4EA5-8FA3-63C6FB775742} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {E3656E06-761B-431D-B720-48A75404917A} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {897EA924-831C-454B-A2E9-48C9680D07C1} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {91968900-6E28-4C26-8FA7-EF50D5BCA7F5} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {31242BA7-E7E9-4E82-BFC6-DA7CB9F24FFF} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {BD3B55B7-0EDE-497B-B246-EA236E599942} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {37FDDB1F-F2DD-4B30-94CB-19086A5D6FE1} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {AD41F73D-9778-4757-9935-8F36358466FE} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {D98BE591-B979-405C-9274-74DB39ED52C4} = {8BFC2AEB-3317-442F-BD45-3886AA383300} + {B596E108-3D36-4F0E-B949-BDE4E27CA02F} = {2F8ACBF2-D4CC-4156-9D7C-CB9AA125FF76} + {BE6720A9-A5E4-450D-BF59-1355730F4444} = {2F8ACBF2-D4CC-4156-9D7C-CB9AA125FF76} + {CC21AA95-0D84-4C12-8594-861BD4AD1E08} = {2F8ACBF2-D4CC-4156-9D7C-CB9AA125FF76} + {D3FEFDF3-AB45-4D06-87A7-9FFB6F038712} = {2F8ACBF2-D4CC-4156-9D7C-CB9AA125FF76} + {690E541F-2945-4A11-90AA-E4DD6673350B} = {2F8ACBF2-D4CC-4156-9D7C-CB9AA125FF76} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {44F35FA8-68D3-4AE9-A753-1D44385CBE56} + EndGlobalSection +EndGlobal diff --git a/tests/BuildingBlocks/Domain.Tests/BuildingBlocks.Domain.Tests.csproj b/tests/BuildingBlocks/Domain.Tests/BuildingBlocks.Domain.Tests.csproj new file mode 100644 index 0000000..58eb459 --- /dev/null +++ b/tests/BuildingBlocks/Domain.Tests/BuildingBlocks.Domain.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/BuildingBlocks/Domain.Tests/DomainTests.cs b/tests/BuildingBlocks/Domain.Tests/DomainTests.cs new file mode 100644 index 0000000..95a4b37 --- /dev/null +++ b/tests/BuildingBlocks/Domain.Tests/DomainTests.cs @@ -0,0 +1,117 @@ +using BuildingBlocks.Domain; +using FluentAssertions; +using Xunit; + +namespace BuildingBlocks.Domain.Tests; + +public sealed class ValueObjectTests +{ + [Fact] + public void Equal_when_components_match() + { + var a = new Money(10m, "EUR"); + var b = new Money(10m, "EUR"); + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + [Fact] + public void Not_equal_when_components_differ() + { + new Money(10m, "EUR").Should().NotBe(new Money(11m, "EUR")); + new Money(10m, "EUR").Should().NotBe(new Money(10m, "USD")); + } +} + +public sealed class IdentityTests +{ + [Fact] + public void Same_type_and_value_are_equal() + { + new SampleId(7).Should().Be(new SampleId(7)); + ((int)new SampleId(7)).Should().Be(7); + } + [Fact] + public void Different_identity_types_are_not_equal() + { + object a = new SampleId(1); + object b = new OtherId(1); + a.Equals(b).Should().BeFalse(); + } +} + +public sealed class EntityTests +{ + [Fact] + public void Same_id_and_type_are_equal() + { + var a = new SampleEntity(new SampleId(1)); + var b = new SampleEntity(new SampleId(1)); + a.Should().Be(b); + } + [Fact] + public void CheckRule_throws_when_broken() + { + var entity = new SampleEntity(new SampleId(1)); + var act = () => entity.CheckRule(new AlwaysBroken()); + act.Should().Throw() + .Which.BrokenRule.Should().BeOfType(); + } +} + +public sealed class AggregateRootTests +{ + [Fact] + public void Raise_and_clear_domain_events() + { + var order = new SampleAggregate(new SampleId(5)); + order.RaiseSomething(); + order.DomainEvents.Should().ContainSingle(); + order.ClearDomainEvents(); + order.DomainEvents.Should().BeEmpty(); + } +} + +file sealed class Money : ValueObject +{ + public decimal Amount { get; } + public string Currency { get; } + public Money(decimal amount, string currency) + { + Amount = amount; + Currency = currency; + } + protected override IEnumerable GetEqualityComponents() + { + yield return Amount; + yield return Currency; + } +} + +file sealed record SampleId : AggregateId +{ + public SampleId(int value) : base(value) { } +} + +file sealed record OtherId : EntityId +{ + public OtherId(int value) : base(value) { } +} + +file sealed class SampleEntity : Entity +{ + public SampleEntity(SampleId id) : base(id) { } +} + +file sealed class SampleAggregate : AggregateRoot +{ + public SampleAggregate(SampleId id) : base(id) { } + public void RaiseSomething() => Raise(new SampleHappened()); +} + +file sealed record SampleHappened : DomainEvent; + +file sealed class AlwaysBroken : IBusinessRule +{ + public string Message => "broken"; + public bool IsBroken() => true; +} diff --git a/tests/BuildingBlocks/Mcp.Analyzers.Tests/AnalyzerTestHelper.cs b/tests/BuildingBlocks/Mcp.Analyzers.Tests/AnalyzerTestHelper.cs index 7765c07..945239f 100644 --- a/tests/BuildingBlocks/Mcp.Analyzers.Tests/AnalyzerTestHelper.cs +++ b/tests/BuildingBlocks/Mcp.Analyzers.Tests/AnalyzerTestHelper.cs @@ -33,7 +33,9 @@ internal static async Task VerifyAsync( var test = new CSharpAnalyzerTest { TestCode = source, - ReferenceAssemblies = ReferenceAssemblies.Net.Net90, + // Analyzer.Testing 1.1.2 maps Net90 to Microsoft.NETCore.App.Ref 9.0.0-preview.*, + // which fails to extract under a shared package cache. + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, }; test.TestState.Sources.Add(("McpToolAttribute.cs", StubAttribute)); test.ExpectedDiagnostics.AddRange(expected); diff --git a/tests/BuildingBlocks/Mediator.Analyzers.Tests/AnalyzerTestHelper.cs b/tests/BuildingBlocks/Mediator.Analyzers.Tests/AnalyzerTestHelper.cs index fe105cd..b333f62 100644 --- a/tests/BuildingBlocks/Mediator.Analyzers.Tests/AnalyzerTestHelper.cs +++ b/tests/BuildingBlocks/Mediator.Analyzers.Tests/AnalyzerTestHelper.cs @@ -31,7 +31,9 @@ internal static async Task VerifyAsync( var test = new CSharpAnalyzerTest { TestCode = source, - ReferenceAssemblies = ReferenceAssemblies.Net.Net90, + // Analyzer.Testing 1.1.2 maps Net90 to Microsoft.NETCore.App.Ref 9.0.0-preview.*, + // which fails to extract under a shared package cache. + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, }; // Separate compilation unit so test sources may use `using BuildingBlocks.Mediator`. diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.SqlServer.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.SqlServer.Tests.csproj b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.SqlServer.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.SqlServer.Tests.csproj index b5d180f..fb87022 100644 --- a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.SqlServer.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.SqlServer.Tests.csproj +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.SqlServer.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.SqlServer.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj index 781fbb8..c70e2b3 100644 --- a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj @@ -9,20 +9,20 @@ - - + + - - + + - - + + diff --git a/tests/Lab/IntegrationTests/Admission/CreateOrderAdmissionPersistenceTests.cs b/tests/Lab/IntegrationTests/Admission/CreateOrderAdmissionPersistenceTests.cs new file mode 100644 index 0000000..4b51317 --- /dev/null +++ b/tests/Lab/IntegrationTests/Admission/CreateOrderAdmissionPersistenceTests.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FeatureFusion.Features.Admission; +using FeatureFusion.Infrastructure.Context; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Orders; +using IntegrationTests.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace IntegrationTests.Admission; + +/// +/// CreateOrder + Admission persistence: Defer creates no Order; Release creates one Order; +/// concurrent release still single-winner. +/// +[Collection(AspireCollection.Name)] +public sealed class CreateOrderAdmissionPersistenceTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly AspireFixture _fixture; + private readonly HttpClient _http; + + public CreateOrderAdmissionPersistenceTests(AspireFixture fixture) + { + _fixture = fixture; + var host = fixture.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.PostConfigure(o => + { + o.DeferredCapabilities.Clear(); + o.DeferredCapabilities.Add(CapabilityIds.OrdersCreate); + o.TicketTtl = TimeSpan.FromHours(1); + }); + }); + }); + _http = host.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + } + + [Fact] + public async Task Defer_does_not_insert_domain_order() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var before = await db.Orders.CountAsync(); + + using var capture = new InProcessActivityCapture(); + var result = await HttpOrderCreate.PostAsync(_http, capture, Ulid.NewUlid().ToString(), quantity: 1); + result.HttpStatus.Should().Be((int)HttpStatusCode.Accepted); + + (await db.Orders.CountAsync()).Should().Be(before); + } + + [Fact] + public async Task Release_persists_exactly_one_domain_order() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var before = await db.Orders.CountAsync(); + + using var capture = new InProcessActivityCapture(); + var create = await HttpOrderCreate.PostAsync(_http, capture, Ulid.NewUlid().ToString(), quantity: 1); + var ticketId = JsonSerializer.Deserialize(create.Body, JsonOptions)!.TicketId; + + using var release = await _http.PostAsync($"/api/v1/admission/tickets/{ticketId}/release", null); + release.StatusCode.Should().Be(HttpStatusCode.OK); + var released = await release.Content.ReadFromJsonAsync(JsonOptions); + released.Should().NotBeNull(); + released!.OrderId.Should().NotBeEmpty(); + + (await db.Orders.CountAsync()).Should().Be(before + 1); + + var createdId = await db.Orders.AsNoTracking() + .OrderByDescending(o => (int)o.Id) + .Select(o => (int)o.Id) + .FirstAsync(); + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, createdId); + } + + [Fact] + public async Task Concurrent_release_persists_single_order() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var before = await db.Orders.CountAsync(); + + using var capture = new InProcessActivityCapture(); + var create = await HttpOrderCreate.PostAsync(_http, capture, Ulid.NewUlid().ToString(), quantity: 1); + var ticketId = JsonSerializer.Deserialize(create.Body, JsonOptions)!.TicketId; + + var tasks = Enumerable.Range(0, 2) + .Select(_ => _http.PostAsync($"/api/v1/admission/tickets/{ticketId}/release", null)) + .ToArray(); + var results = await Task.WhenAll(tasks); + var statuses = results.Select(r => r.StatusCode).ToList(); + statuses.Should().Contain(HttpStatusCode.OK); + statuses.Should().Contain(HttpStatusCode.Conflict); + + (await db.Orders.CountAsync()).Should().Be(before + 1); + + var createdId = await db.Orders.AsNoTracking() + .OrderByDescending(o => (int)o.Id) + .Select(o => (int)o.Id) + .FirstAsync(); + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, createdId); + } + + private sealed record PendingDto(Guid TicketId); + private sealed record ReleaseDto(Guid? OrderId, Guid TicketId); +} diff --git a/tests/Lab/IntegrationTests/Admission/OrderCreateAdmissionTests.cs b/tests/Lab/IntegrationTests/Admission/OrderCreateAdmissionTests.cs new file mode 100644 index 0000000..4ced224 --- /dev/null +++ b/tests/Lab/IntegrationTests/Admission/OrderCreateAdmissionTests.cs @@ -0,0 +1,408 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FeatureFusion.Features.Admission; +using FeatureFusion.Features.Order.IntegrationEvents.Events; +using FeatureFusion.Features.Orders.Commands; +using FeatureFusion.Infrastructure.Context; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Async; +using IntegrationTests.Infrastructure.Mcp; +using IntegrationTests.Infrastructure.Orders; +using IntegrationTests.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; +using static IntegrationTests.Infrastructure.Telemetry.LabTrace; + +namespace IntegrationTests.Admission; + +/// +/// Product-slice proof: application-owned Defer-before-Send for orders.create. +/// Not Exp 21 — separate from the closed MCP/EventBus research line. +/// +[Collection(AspireCollection.Name)] +public sealed class OrderCreateAdmissionTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly AspireFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly WebApplicationFactory _deferHost; + private readonly HttpClient _http; + + public OrderCreateAdmissionTests(AspireFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + _deferHost = fixture.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.PostConfigure(o => + { + o.DeferredCapabilities.Clear(); + o.DeferredCapabilities.Add(CapabilityIds.OrdersCreate); + o.TicketTtl = TimeSpan.FromHours(1); + }); + }); + }); + _http = _deferHost.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task Http_create_defers_without_send_order_or_outbox() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var outboxBefore = await CountOrderCreatedOutboxAsync(); + + var result = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 2); + var pending = ParsePending(result.Body); + + result.HttpStatus.Should().Be((int)HttpStatusCode.Accepted); + pending.TicketId.Should().NotBeEmpty(); + pending.CapabilityId.Should().Be(CapabilityIds.OrdersCreate); + pending.Status.Should().Be("Pending"); + result.OrderId.Should().Be(Guid.Empty); + + CreateOrderMediatorCount(result.Spans).Should().Be(0, "Defer must not call ISender.Send"); + (await CountOrderCreatedOutboxAsync()).Should().Be(outboxBefore, "no outbox on Defer"); + (await GetTicketAsync(pending.TicketId))!.Status.Should().Be("Pending"); + } + + [Fact] + public async Task Mcp_confirmed_create_still_defers_without_send_order_or_outbox() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var outboxBefore = await CountOrderCreatedOutboxAsync(); + + await using var mcp = await LabMcpClient.CreateAsync(_http); + capture.Clear(); + var call = await mcp.CallToolAsync( + "orders.create", + new Dictionary + { + ["productId"] = 1, + ["quantity"] = 2, + ["customerId"] = 1, + ["confirmed"] = true, + ["idempotencyKey"] = key + }); + + (call.IsError ?? false).Should().BeFalse("confirmed=true must not be treated as release"); + var pending = ParsePendingFromMcp(call); + pending.TicketId.Should().NotBeEmpty(); + pending.Status.Should().Be("Pending"); + + capture.All.Count(IsMediator).Should().Be(0, "no Mediator Send on Defer"); + capture.All.Count(s => s.DisplayName == "mcp.tool").Should().BeGreaterThan(0, + "MCP InvokeCore still runs; admission is inside the dispatcher"); + (await CountOrderCreatedOutboxAsync()).Should().Be(outboxBefore); + } + + [Fact] + public async Task Http_and_mcp_share_the_same_admission_tickets() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + + var http = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 3); + var httpPending = ParsePending(http.Body); + + await using var mcp = await LabMcpClient.CreateAsync(_http); + var mcpCall = await mcp.CallToolAsync( + "orders.create", + new Dictionary + { + ["productId"] = 1, + ["quantity"] = 3, + ["customerId"] = 1, + ["confirmed"] = true, + ["idempotencyKey"] = key + }); + var mcpPending = ParsePendingFromMcp(mcpCall); + + mcpPending.TicketId.Should().Be(httpPending.TicketId, + "same request key must resolve to the same durable ticket across HTTP and MCP"); + } + + [Fact] + public async Task Repeated_request_while_pending_returns_same_ticket_no_order() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var outboxBefore = await CountOrderCreatedOutboxAsync(); + + var first = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 2); + var second = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 2); + var a = ParsePending(first.Body); + var b = ParsePending(second.Body); + + a.TicketId.Should().Be(b.TicketId); + first.OrderId.Should().Be(Guid.Empty); + second.OrderId.Should().Be(Guid.Empty); + (await CountOrderCreatedOutboxAsync()).Should().Be(outboxBefore); + + await using var scope = _deferHost.Services.CreateAsyncScope(); + var count = await scope.ServiceProvider.GetRequiredService() + .IntentTickets.CountAsync(t => t.RequestKey == key); + count.Should().Be(1, "identity: CapabilityId + RequestKey → one Pending ticket"); + } + + [Fact] + public async Task Same_request_key_different_payload_is_rejected_at_admission_when_admit_runs() + { + await using var scope = _deferHost.Services.CreateAsyncScope(); + var admission = scope.ServiceProvider.GetRequiredService(); + var key = Ulid.NewUlid().ToString(); + + var cmdA = new CreateOrderCommand { ProductId = 1, Quantity = 2, CustomerId = 1 }; + var cmdB = new CreateOrderCommand { ProductId = 1, Quantity = 9, CustomerId = 1 }; + + var first = await admission.AdmitAsync( + CapabilityIds.OrdersCreate, + key, + CapabilityAdmissionService.SerializeCreateOrderIntent(cmdA), + CapabilityAdmissionService.HashCreateOrderIntent(cmdA), + CancellationToken.None); + first.Should().BeOfType(); + + var conflict = await admission.AdmitAsync( + CapabilityIds.OrdersCreate, + key, + CapabilityAdmissionService.SerializeCreateOrderIntent(cmdB), + CapabilityAdmissionService.HashCreateOrderIntent(cmdB), + CancellationToken.None); + + var deny = conflict.Should().BeOfType().Subject; + deny.StatusCode.Should().Be(422); + + _output.WriteLine( + "Identity semantics: ticket dedup key = CapabilityId + RequestKey; " + + "IntentHash must match while Pending. HTTP fingerprint-off / MCP memory store may " + + "replay the first Pending response without re-entering Admit."); + } + + [Fact] + public async Task Human_release_executes_existing_handler_once_with_outbox() + { + await _fixture.ResetLabObservationAsync(); + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + + var create = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 2); + var pending = ParsePending(create.Body); + CreateOrderMediatorCount(create.Spans).Should().Be(0); + + capture.Clear(); + using var releaseRequest = new HttpRequestMessage( + HttpMethod.Post, + $"/api/v1/admission/tickets/{pending.TicketId}/release"); + releaseRequest.Headers.TryAddWithoutValidation("X-Released-By", "lab-human"); + using var releaseResponse = await _http.SendAsync(releaseRequest); + var releaseBody = await releaseResponse.Content.ReadAsStringAsync(); + _output.WriteLine(releaseBody); + + releaseResponse.StatusCode.Should().Be(HttpStatusCode.OK); + using var doc = JsonDocument.Parse(releaseBody); + var orderId = doc.RootElement.GetProperty("orderId").GetGuid(); + orderId.Should().NotBeEmpty(); + + capture.All.Count(IsMediator).Should().BeGreaterThan(0, "release must Send CreateOrderCommand"); + var outbox = await OrderOutboxObserver.WaitUntilExistsAsync(_deferHost.Services, orderId); + outbox.OrderId.Should().Be(orderId); + + await Wait.UntilAsync( + () => _fixture.ProcessedEvents.Any(e => e.OrderId == orderId), + TimeSpan.FromSeconds(20)); + + (await GetTicketAsync(pending.TicketId))!.Status.Should().Be("Released"); + } + + [Fact] + public async Task Concurrent_release_only_one_winner_one_order() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var create = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 1); + var ticketId = ParsePending(create.Body).TicketId; + + async Task<(HttpStatusCode Status, string Body)> ReleaseOnce() + { + using var req = new HttpRequestMessage( + HttpMethod.Post, + $"/api/v1/admission/tickets/{ticketId}/release"); + using var res = await _http.SendAsync(req); + return (res.StatusCode, await res.Content.ReadAsStringAsync()); + } + + var results = await Task.WhenAll(ReleaseOnce(), ReleaseOnce(), ReleaseOnce()); + var wins = results.Where(r => r.Status == HttpStatusCode.OK).ToList(); + var conflicts = results.Where(r => r.Status == HttpStatusCode.Conflict).ToList(); + + wins.Should().HaveCount(1, "atomic Pending→Released claim admits one execution"); + conflicts.Should().HaveCount(2); + + using var doc = JsonDocument.Parse(wins[0].Body); + var orderId = doc.RootElement.GetProperty("orderId").GetGuid(); + (await OrderOutboxObserver.FindByOrderIdAsync(_deferHost.Services, orderId)) + .Should().HaveCount(1); + } + + [Fact] + public async Task Replay_after_release_does_not_create_second_order_via_admit() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var create = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 1); + var ticketId = ParsePending(create.Body).TicketId; + + using var releaseReq = new HttpRequestMessage( + HttpMethod.Post, + $"/api/v1/admission/tickets/{ticketId}/release"); + using var releaseRes = await _http.SendAsync(releaseReq); + releaseRes.StatusCode.Should().Be(HttpStatusCode.OK); + var orderId = (await releaseRes.Content.ReadFromJsonAsync()).GetProperty("orderId").GetGuid(); + + var outboxBefore = (await OrderOutboxObserver.FindByOrderIdAsync(_deferHost.Services, orderId)).Count; + + var replay = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 1); + replay.HttpStatus.Should().Be((int)HttpStatusCode.Accepted); + replay.CachedResponseHeader.Should().BeTrue("surface idempotency replays Pending envelope"); + ParsePending(replay.Body).TicketId.Should().Be(ticketId); + + (await OrderOutboxObserver.FindByOrderIdAsync(_deferHost.Services, orderId)) + .Should().HaveCount(outboxBefore); + + await using var scope = _deferHost.Services.CreateAsyncScope(); + var admission = scope.ServiceProvider.GetRequiredService(); + var cmd = new CreateOrderCommand { ProductId = 1, Quantity = 1, CustomerId = 1 }; + var decision = await admission.AdmitAsync( + CapabilityIds.OrdersCreate, + key, + CapabilityAdmissionService.SerializeCreateOrderIntent(cmd), + CapabilityAdmissionService.HashCreateOrderIntent(cmd), + CancellationToken.None); + decision.Should().BeOfType(); + } + + [Fact] + public async Task Expired_ticket_cannot_be_released() + { + var shortTtlHost = _fixture.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.PostConfigure(o => + { + o.DeferredCapabilities.Clear(); + o.DeferredCapabilities.Add(CapabilityIds.OrdersCreate); + o.TicketTtl = TimeSpan.FromMilliseconds(30); + }); + }); + }); + var http = shortTtlHost.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var create = await HttpOrderCreate.PostAsync(http, capture, key, quantity: 1); + var ticketId = ParsePending(create.Body).TicketId; + var outboxBefore = await CountOrderCreatedOutboxAsync(shortTtlHost.Services); + + await Task.Delay(80); + + using var releaseReq = new HttpRequestMessage( + HttpMethod.Post, + $"/api/v1/admission/tickets/{ticketId}/release"); + using var releaseRes = await http.SendAsync(releaseReq); + releaseRes.StatusCode.Should().Be(HttpStatusCode.Gone); + + (await CountOrderCreatedOutboxAsync(shortTtlHost.Services)).Should().Be(outboxBefore); + await using var scope = shortTtlHost.Services.CreateAsyncScope(); + var ticket = await scope.ServiceProvider.GetRequiredService() + .IntentTickets.AsNoTracking().FirstAsync(t => t.Id == ticketId); + ticket.ExecutionOrderId.Should().BeNull(); + ticket.Status.Should().BeOneOf(IntentTicketStatus.Expired, IntentTicketStatus.Pending); + } + + [Fact] + public async Task Control_without_defer_policy_allows_existing_send() + { + var http = _fixture.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var result = await HttpOrderCreate.PostAsync(http, capture, key, quantity: 1); + + result.HttpStatus.Should().Be((int)HttpStatusCode.OK); + result.OrderId.Should().NotBeEmpty(); + CreateOrderMediatorCount(result.Spans).Should().BeGreaterThan(0); + } + + private async Task CountOrderCreatedOutboxAsync(IServiceProvider? services = null) + { + await using var scope = (services ?? _deferHost.Services).CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.OutboxMessages.CountAsync(m => + m.EventType == nameof(OrderCreatedIntegrationEvent)); + } + + private async Task GetTicketAsync(Guid ticketId) + { + using var res = await _http.GetAsync($"/api/v1/admission/tickets/{ticketId}"); + if (res.StatusCode == HttpStatusCode.NotFound) + return null; + res.EnsureSuccessStatusCode(); + return await res.Content.ReadFromJsonAsync(JsonOptions); + } + + private static int CreateOrderMediatorCount(IReadOnlyList spans) => + spans.Count(s => IsMediator(s) + && s.DisplayName.Contains("CreateOrderCommand", StringComparison.Ordinal)); + + private static AdmissionPendingResponse ParsePending(string body) + { + var pending = JsonSerializer.Deserialize(body, JsonOptions); + pending.Should().NotBeNull(); + return pending!; + } + + private static AdmissionPendingResponse ParsePendingFromMcp(ModelContextProtocol.Protocol.CallToolResult result) + { + result.StructuredContent.Should().NotBeNull(); + var structured = result.StructuredContent!.Value; + if (structured.ValueKind == JsonValueKind.Object + && structured.TryGetProperty("value", out var value)) + { + return JsonSerializer.Deserialize(value.GetRawText(), JsonOptions)!; + } + + return JsonSerializer.Deserialize(structured.GetRawText(), JsonOptions)!; + } + + private sealed record TicketView( + Guid TicketId, + string CapabilityId, + string Status, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + DateTimeOffset? ReleasedAt, + string? ReleasedBy, + Guid? ExecutionOrderId); +} diff --git a/tests/Lab/IntegrationTests/Api/CartCheckoutApiTests.cs b/tests/Lab/IntegrationTests/Api/CartCheckoutApiTests.cs new file mode 100644 index 0000000..9369e70 --- /dev/null +++ b/tests/Lab/IntegrationTests/Api/CartCheckoutApiTests.cs @@ -0,0 +1,358 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FeatureFusion.Features.Shipping; +using FeatureFusion.Infrastructure.Context; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Orders; +using IntegrationTests.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace IntegrationTests.Api; + +[Collection(AspireCollection.Name)] +public sealed class CartCheckoutApiTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly AspireFixture _fixture; + private readonly HttpClient _http; + + public CartCheckoutApiTests(AspireFixture fixture) + { + _fixture = fixture; + _http = fixture.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + } + + [Fact] + public async Task Cart_add_update_remove_clear_roundtrip() + { + const int customerId = 3; + await ClearCartAsync(customerId); + + var empty = await _http.GetFromJsonAsync($"/api/v1/customers/{customerId}/cart", JsonOptions); + empty!.Items.Should().BeEmpty(); + + var add = await _http.PostAsJsonAsync( + $"/api/v1/customers/{customerId}/cart/items", + new { productId = 12, quantity = 2 }); + add.StatusCode.Should().Be(HttpStatusCode.OK); + var afterAdd = await add.Content.ReadFromJsonAsync(JsonOptions); + afterAdd!.Items.Should().ContainSingle(i => i.ProductId == 12 && i.Quantity == 2); + + var update = await _http.PutAsJsonAsync( + $"/api/v1/customers/{customerId}/cart/items/12", + new { quantity = 5 }); + update.StatusCode.Should().Be(HttpStatusCode.OK); + (await update.Content.ReadFromJsonAsync(JsonOptions))! + .Items.Should().ContainSingle(i => i.ProductId == 12 && i.Quantity == 5); + + var remove = await _http.DeleteAsync($"/api/v1/customers/{customerId}/cart/items/12"); + remove.StatusCode.Should().Be(HttpStatusCode.OK); + (await remove.Content.ReadFromJsonAsync(JsonOptions))!.Items.Should().BeEmpty(); + + await _http.PostAsJsonAsync( + $"/api/v1/customers/{customerId}/cart/items", + new { productId = 12, quantity = 1 }); + var clear = await _http.DeleteAsync($"/api/v1/customers/{customerId}/cart"); + clear.StatusCode.Should().Be(HttpStatusCode.OK); + (await clear.Content.ReadFromJsonAsync(JsonOptions))!.Items.Should().BeEmpty(); + } + + [Fact] + public async Task Checkout_success_computes_totals_decrements_stock_and_clears_cart() + { + const int customerId = 4; + const int productId = 12; // MX Master 99.00 + await ClearCartAsync(customerId); + + int stockBefore; + await using (var scope = _fixture.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + stockBefore = (await db.Product.AsNoTracking().SingleAsync(p => (int)p.Id == productId)).StockQuantity; + } + + (await _http.PostAsJsonAsync( + $"/api/v1/customers/{customerId}/cart/items", + new { productId, quantity = 2 })).EnsureSuccessStatusCode(); + + var key = Ulid.NewUlid().ToString(); + using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/customers/{customerId}/checkout"); + request.Headers.TryAddWithoutValidation("Idempotency-Key", key); + request.Content = JsonContent.Create(new + { + recipientName = "Dana Pierce", + line1 = "1 Demo St", + city = "Berlin", + postalCode = "10115", + country = "DE" + }); + + using var response = await _http.SendAsync(request); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body.Should().NotBeNull(); + body!.Subtotal.Should().Be(198.00m); + body.TaxAmount.Should().Be(19.80m); // 10% + body.ShippingAmount.Should().Be(DemoShippingPolicy.FlatFee); + body.GrandTotal.Should().Be(198.00m + 19.80m + DemoShippingPolicy.FlatFee); + body.Status.Should().Be("Placed"); + body.PaymentDecision.Should().Be("Approved"); + + var cart = await _http.GetFromJsonAsync($"/api/v1/customers/{customerId}/cart", JsonOptions); + cart!.Items.Should().BeEmpty(); + + await using (var scope = _fixture.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stockAfter = (await db.Product.AsNoTracking().SingleAsync(p => (int)p.Id == productId)).StockQuantity; + stockAfter.Should().Be(stockBefore - 2); + + var order = await db.Orders.AsNoTracking() + .Include(o => o.Items) + .SingleAsync(o => (int)o.Id == body.DomainOrderId); + order.Subtotal.Should().Be(198.00m); + order.TaxAmount.Should().Be(19.80m); + order.ShippingAmount.Should().Be(DemoShippingPolicy.FlatFee); + order.Total.Should().Be(body.GrandTotal); + order.Items.Single().UnitPrice.Should().Be(99.00m); + + var outbox = await db.OutboxMessages.AsNoTracking() + .OrderByDescending(o => o.CreatedAt) + .FirstAsync(); + outbox.EventType.Should().Contain("OrderCreated"); + + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, body.DomainOrderId); + } + } + + [Fact] + public async Task Checkout_empty_cart_returns_bad_request() + { + const int customerId = 5; + await ClearCartAsync(customerId); + using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/customers/{customerId}/checkout"); + request.Headers.TryAddWithoutValidation("Idempotency-Key", Ulid.NewUlid().ToString()); + request.Content = JsonContent.Create(DefaultAddress()); + using var response = await _http.SendAsync(request); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Checkout_payment_decline_leaves_cart_and_creates_no_order() + { + // Craft grand total ending in .13: need subtotal+tax+ship where cents%100==13. + // Use quantity that produces grand ending .13 via DemoTax 10% + 4.99 shipping. + // Easier approach: product with price such that (price*1.1 + 4.99) ends with .13 + // For price P: round(P*1.1,2)+4.99 ends with .13 + // Use LOW product carefully — instead force via known math: + // Find: Demo decline when (grand*100)%100==13. + // With FlatFee 4.99 and 10% tax: grand = round(sub*1.1,2)+4.99 + // Pick subtotal 10.127... wait use product 99 and qty that works, or add item + // of price 4.6727... Simpler: use customer cart with product priced so + // grand cents == 13. Solve: round(S*0.1,2)+S+4.99 = x.13 + // Try S=4.67 → tax 0.47 → 5.14+4.99=10.13 ✓ + // No seed product at 4.67. Use ChangePrice temporarily on product 12 for this customer test. + + const int customerId = 6; + const int productId = 12; + await ClearCartAsync(customerId); + + decimal originalPrice; + await using (var scope = _fixture.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var product = await db.Product.SingleAsync(p => (int)p.Id == productId); + originalPrice = product.Price; + product.ChangePrice(4.67m); + await db.SaveChangesAsync(); + } + + try + { + (await _http.PostAsJsonAsync( + $"/api/v1/customers/{customerId}/cart/items", + new { productId, quantity = 1 })).EnsureSuccessStatusCode(); + + var ordersBefore = await CountOrdersAsync(); + using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/customers/{customerId}/checkout"); + request.Headers.TryAddWithoutValidation("Idempotency-Key", Ulid.NewUlid().ToString()); + request.Content = JsonContent.Create(DefaultAddress()); + using var response = await _http.SendAsync(request); + response.StatusCode.Should().Be(HttpStatusCode.PaymentRequired); + + (await CountOrdersAsync()).Should().Be(ordersBefore); + var cart = await _http.GetFromJsonAsync($"/api/v1/customers/{customerId}/cart", JsonOptions); + cart!.Items.Should().ContainSingle(i => i.ProductId == productId); + } + finally + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var product = await db.Product.SingleAsync(p => (int)p.Id == productId); + product.ChangePrice(originalPrice); + await db.SaveChangesAsync(); + await ClearCartAsync(customerId); + } + } + + [Fact] + public async Task Checkout_idempotent_replay_does_not_duplicate_order() + { + const int customerId = 7; + await ClearCartAsync(customerId); + (await _http.PostAsJsonAsync( + $"/api/v1/customers/{customerId}/cart/items", + new { productId = 11, quantity = 1 })).EnsureSuccessStatusCode(); + + var key = Ulid.NewUlid().ToString(); + async Task SendAsync() + { + var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/customers/{customerId}/checkout"); + req.Headers.TryAddWithoutValidation("Idempotency-Key", key); + req.Content = JsonContent.Create(DefaultAddress()); + return await _http.SendAsync(req); + } + + using var first = await SendAsync(); + first.StatusCode.Should().Be(HttpStatusCode.OK); + var firstBody = await first.Content.ReadFromJsonAsync(JsonOptions); + + // Cart cleared — replay must return cached response without second create. + using var second = await SendAsync(); + second.StatusCode.Should().Be(HttpStatusCode.OK); + var secondBody = await second.Content.ReadFromJsonAsync(JsonOptions); + secondBody!.OrderId.Should().Be(firstBody!.OrderId); + secondBody.DomainOrderId.Should().Be(firstBody.DomainOrderId); + + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, firstBody.DomainOrderId); + } + + [Fact] + public async Task CreateOrder_duplicate_lines_merge_and_decrement_stock_once() + { + using var capture = new InProcessActivityCapture(); + int stockBefore; + await using (var scope = _fixture.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + stockBefore = (await db.Product.AsNoTracking().SingleAsync(p => (int)p.Id == 10)).StockQuantity; + } + + var key = Ulid.NewUlid().ToString(); + using var request = new HttpRequestMessage(HttpMethod.Post, HttpOrderCreate.Path); + request.Headers.TryAddWithoutValidation(HttpOrderCreate.IdempotencyHeader, key); + request.Content = new StringContent( + """{"customerId":8,"items":[{"productId":10,"quantity":1},{"productId":10,"quantity":2}]}""", + Encoding.UTF8, + "application/json"); + + using var response = await _http.SendAsync(request); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body!.Quantity.Should().Be(3); + + await using (var scope = _fixture.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var order = await db.Orders.AsNoTracking().Include(o => o.Items) + .SingleAsync(o => (int)o.Id == body.DomainOrderId); + order.Items.Should().ContainSingle(); + order.Items.Single().Quantity.Should().Be(3); + var stockAfter = (await db.Product.AsNoTracking().SingleAsync(p => (int)p.Id == 10)).StockQuantity; + stockAfter.Should().Be(stockBefore - 3); + } + + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, body.DomainOrderId); + } + + [Fact] + public async Task Concurrent_create_on_low_stock_never_goes_negative() + { + // Use LOW-002 stock=1 (product id 18 in seed order: 1..19 flagships, LOW-002 is 18) + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var low = (await db.Product.AsNoTracking().ToListAsync()) + .Single(p => p.Sku.Value == "SKU-LOW-002"); + var productId = low.Id.Value; + low.StockQuantity.Should().Be(1); + + using var capture = new InProcessActivityCapture(); + var tasks = Enumerable.Range(0, 4).Select(async i => + { + var client = _fixture.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + return await HttpOrderCreate.PostAsync( + client, capture, Ulid.NewUlid().ToString(), quantity: 1, productId: productId, customerId: 9); + }).ToArray(); + + var results = await Task.WhenAll(tasks); + results.Count(r => r.HttpStatus == 200).Should().Be(1); + results.Count(r => r.HttpStatus == 409).Should().Be(3); + + var stock = (await db.Product.AsNoTracking().SingleAsync(p => (int)p.Id == productId)).StockQuantity; + stock.Should().Be(0); + + foreach (var ok in results.Where(r => r.HttpStatus == 200)) + { + var created = JsonSerializer.Deserialize(ok.Body, JsonOptions); + if (created is not null) + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, created.DomainOrderId); + } + + // Restore low-stock fixture for other suite tests. + await db.Database.ExecuteSqlRawAsync( + """UPDATE products SET "StockQuantity" = 1 WHERE "Id" = {0}""", + productId); + } + + private async Task ClearCartAsync(int customerId) + { + await _http.DeleteAsync($"/api/v1/customers/{customerId}/cart"); + } + + private async Task CountOrdersAsync() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Orders.CountAsync(); + } + + private static object DefaultAddress() => new + { + recipientName = "Test User", + line1 = "2 Test Ave", + city = "Munich", + postalCode = "80331", + country = "DE" + }; + + private sealed record CartDto(int CustomerId, List Items); + private sealed record CartItemDto(int ProductId, int Quantity); + private sealed record CheckoutDto( + Guid OrderId, + int DomainOrderId, + string OrderNumber, + string Status, + decimal Subtotal, + decimal TaxAmount, + decimal ShippingAmount, + decimal GrandTotal, + string Currency, + string PaymentDecision, + DateTime OrderDate); + private sealed record CreateOrderResponse( + Guid OrderId, + int DomainOrderId, + string OrderNumber, + string Status, + int Quantity, + decimal TotalAmount); +} diff --git a/tests/Lab/IntegrationTests/Api/CatalogProductHttpMcpConvergenceTests.cs b/tests/Lab/IntegrationTests/Api/CatalogProductHttpMcpConvergenceTests.cs new file mode 100644 index 0000000..0cd21fe --- /dev/null +++ b/tests/Lab/IntegrationTests/Api/CatalogProductHttpMcpConvergenceTests.cs @@ -0,0 +1,105 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FeatureFusion.Infrastructure.Seeding; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Mcp; +using Microsoft.AspNetCore.Mvc.Testing; +using ModelContextProtocol.Protocol; + +namespace IntegrationTests.Api; + +/// +/// Proves Demo Commerce product detail shares one Mediator query across HTTP and MCP. +/// +[Collection(AspireCollection.Name)] +public sealed class CatalogProductHttpMcpConvergenceTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _http; + + public CatalogProductHttpMcpConvergenceTests(AspireFixture fixture) + { + _http = fixture.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task Http_and_mcp_list_catalog_products() + { + var httpResponse = await _http.GetAsync("/api/v1/catalog/products?page=1&pageSize=5"); + httpResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + await using var mcp = await LabMcpClient.CreateAsync(_http); + var mcpResult = await mcp.CallToolAsync( + "catalog.products.list", + new Dictionary { ["page"] = 1, ["pageSize"] = 5 }); + + (mcpResult.IsError ?? false).Should().BeFalse(); + mcpResult.StructuredContent.Should().NotBeNull(); + } + + [Fact] + public async Task Http_and_mcp_return_same_flagship_product() + { + var httpResponse = await _http.GetAsync($"/api/v1/catalog/products/{DemoCommerceSeed.FlagshipSlug}"); + httpResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var httpDetail = await httpResponse.Content.ReadFromJsonAsync(JsonOptions); + httpDetail.Should().NotBeNull(); + httpDetail!.Slug.Should().Be(DemoCommerceSeed.FlagshipSlug); + httpDetail.Sku.Should().Be(DemoCommerceSeed.FlagshipSku); + + await using var mcp = await LabMcpClient.CreateAsync(_http); + var mcpResult = await mcp.CallToolAsync( + "catalog.product.get", + new Dictionary { ["slug"] = DemoCommerceSeed.FlagshipSlug }); + + (mcpResult.IsError ?? false).Should().BeFalse(); + mcpResult.StructuredContent.Should().NotBeNull(); + var mcpJson = mcpResult.StructuredContent!.Value.GetRawText(); + var mcpDetail = JsonSerializer.Deserialize(mcpJson, JsonOptions); + mcpDetail.Should().NotBeNull(); + + mcpDetail!.Id.Should().Be(httpDetail.Id); + mcpDetail.Slug.Should().Be(httpDetail.Slug); + mcpDetail.Sku.Should().Be(httpDetail.Sku); + mcpDetail.Price.Should().Be(httpDetail.Price); + mcpDetail.BrandSlug.Should().Be(httpDetail.BrandSlug); + mcpDetail.CategorySlug.Should().Be(httpDetail.CategorySlug); + mcpDetail.Images.Should().HaveCount(httpDetail.Images.Count); + mcpDetail.Specifications.Should().HaveCount(httpDetail.Specifications.Count); + } + + [Fact] + public async Task Mcp_unknown_slug_is_error() + { + await using var mcp = await LabMcpClient.CreateAsync(_http); + var result = await mcp.CallToolAsync( + "catalog.product.get", + new Dictionary { ["slug"] = "does-not-exist" }); + + result.IsError.Should().BeTrue(); + var text = string.Join("\n", result.Content.OfType().Select(b => b.Text)); + text.Should().Match(t => + t.Contains("not found", StringComparison.OrdinalIgnoreCase) + || t.Contains("404", StringComparison.OrdinalIgnoreCase)); + } + + private sealed record ProductDetail( + int Id, + string Name, + string Slug, + string Sku, + decimal Price, + string BrandSlug, + string CategorySlug, + List Images, + List Specifications); +} diff --git a/tests/Lab/IntegrationTests/Api/CatalogStorefrontTests.cs b/tests/Lab/IntegrationTests/Api/CatalogStorefrontTests.cs new file mode 100644 index 0000000..d4a05a1 --- /dev/null +++ b/tests/Lab/IntegrationTests/Api/CatalogStorefrontTests.cs @@ -0,0 +1,219 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FeatureFusion.Infrastructure.Seeding; +using FluentAssertions; +using IntegrationTests.Aspire; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace IntegrationTests.Api; + +/// +/// Storefront catalog listing and detail (OFFSET paging at /api/v1/catalog/*). +/// Distinct from keyset Pagination lab routes (/api/v1/products-page, POST /api/v1/Product/products). +/// +[Collection(AspireCollection.Name)] +public sealed class CatalogStorefrontTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _client; + + public CatalogStorefrontTests(AspireFixture fixture) + { + _client = fixture.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task List_products_returns_first_page() + { + var response = await _client.GetAsync("/api/v1/catalog/products"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync(JsonOptions); + page.Should().NotBeNull(); + page!.Page.Should().Be(1); + page.PageSize.Should().Be(24); + page.TotalCount.Should().Be(DemoCommerceSeed.ExpectedProductCount); + page.Items.Should().HaveCount(24); + page.Items.Should().OnlyContain(i => + !string.IsNullOrWhiteSpace(i.Slug) + && !string.IsNullOrWhiteSpace(i.Sku) + && !string.IsNullOrWhiteSpace(i.BrandSlug) + && !string.IsNullOrWhiteSpace(i.CategorySlug) + && !string.IsNullOrWhiteSpace(i.PrimaryImageUrl)); + } + + [Fact] + public async Task List_products_filters_by_brand_slug() + { + var response = await _client.GetAsync($"/api/v1/catalog/products?brand={DemoCommerceSeed.FlagshipBrandSlug}&pageSize=48"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync(JsonOptions); + page.Should().NotBeNull(); + page!.TotalCount.Should().BeGreaterThan(0); + page.Items.Should().NotBeEmpty(); + page.Items.Should().OnlyContain(i => i.BrandSlug == DemoCommerceSeed.FlagshipBrandSlug); + } + + [Fact] + public async Task List_products_filters_by_category_slug() + { + var response = await _client.GetAsync($"/api/v1/catalog/products?category={DemoCommerceSeed.FlagshipCategorySlug}&pageSize=48"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync(JsonOptions); + page.Should().NotBeNull(); + page!.Items.Should().NotBeEmpty(); + page.Items.Should().OnlyContain(i => i.CategorySlug == DemoCommerceSeed.FlagshipCategorySlug); + } + + [Fact] + public async Task List_products_unknown_brand_returns_empty_page() + { + var response = await _client.GetAsync("/api/v1/catalog/products?brand=no-such-brand"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync(JsonOptions); + page.Should().NotBeNull(); + page!.TotalCount.Should().Be(0); + page.Items.Should().BeEmpty(); + page.Page.Should().Be(1); + page.PageSize.Should().Be(24); + } + + [Fact] + public async Task List_products_clamps_oversized_page_size() + { + var response = await _client.GetAsync("/api/v1/catalog/products?pageSize=999"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync(JsonOptions); + page.Should().NotBeNull(); + page!.PageSize.Should().Be(24); + page.Items.Should().HaveCount(24); + } + + [Fact] + public async Task List_related_products_same_category_excludes_source() + { + var response = await _client.GetAsync($"/api/v1/catalog/products/{DemoCommerceSeed.FlagshipSlug}/related"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var related = await response.Content.ReadFromJsonAsync>(JsonOptions); + related.Should().NotBeNull(); + related!.Should().NotBeEmpty(); + related.Should().OnlyContain(r => r.Slug != DemoCommerceSeed.FlagshipSlug); + related.Should().BeInAscendingOrder(r => r.Name); + } + + [Fact] + public async Task List_related_unknown_slug_returns_not_found() + { + var response = await _client.GetAsync("/api/v1/catalog/products/does-not-exist/related"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Get_product_by_slug_returns_gallery_and_specs() + { + var response = await _client.GetAsync($"/api/v1/catalog/products/{DemoCommerceSeed.FlagshipSlug}"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var detail = await response.Content.ReadFromJsonAsync(JsonOptions); + detail.Should().NotBeNull(); + detail!.Slug.Should().Be(DemoCommerceSeed.FlagshipSlug); + detail.Sku.Should().Be(DemoCommerceSeed.FlagshipSku); + detail.BrandSlug.Should().Be(DemoCommerceSeed.FlagshipBrandSlug); + detail.CategorySlug.Should().Be(DemoCommerceSeed.FlagshipCategorySlug); + detail.Images.Should().HaveCountGreaterThanOrEqualTo(3); + detail.Images.Should().ContainSingle(i => i.IsPrimary); + detail.Specifications.Should().HaveCountGreaterThanOrEqualTo(3); + detail.Related.Should().NotBeEmpty(); + detail.Related.Should().OnlyContain(r => r.Slug != detail.Slug); + } + + [Fact] + public async Task Get_product_unknown_slug_returns_not_found() + { + var response = await _client.GetAsync("/api/v1/catalog/products/does-not-exist"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task List_brands_matches_seed() + { + var response = await _client.GetAsync("/api/v1/catalog/brands"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var brands = await response.Content.ReadFromJsonAsync>(JsonOptions); + brands.Should().NotBeNull(); + brands!.Should().HaveCount(DemoCommerceSeed.ExpectedBrandCount); + brands.Should().OnlyContain(b => !string.IsNullOrWhiteSpace(b.Slug) && b.ProductCount > 0); + brands.Should().Contain(b => b.Slug == DemoCommerceSeed.FlagshipBrandSlug && !string.IsNullOrWhiteSpace(b.LogoUrl)); + } + + [Fact] + public async Task List_categories_matches_seed() + { + var response = await _client.GetAsync("/api/v1/catalog/categories"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var categories = await response.Content.ReadFromJsonAsync>(JsonOptions); + categories.Should().NotBeNull(); + categories!.Should().HaveCount(DemoCommerceSeed.ExpectedCategoryCount); + categories.Should().OnlyContain(c => !string.IsNullOrWhiteSpace(c.Slug) && c.ProductCount > 0); + } + + private sealed record ListResponse(List Items, int Page, int PageSize, int TotalCount); + + private sealed record ListItem( + int Id, + string Name, + string Slug, + string Sku, + decimal Price, + int StockQuantity, + bool InStock, + string BrandName, + string BrandSlug, + string CategoryName, + string CategorySlug, + string? PrimaryImageUrl, + string? ShortDescription); + + private sealed record DetailResponse( + int Id, + string Name, + string Slug, + string Sku, + decimal Price, + int StockQuantity, + bool InStock, + string? ShortDescription, + string? FullDescription, + string BrandName, + string BrandSlug, + string CategoryName, + string CategorySlug, + List Images, + List Specifications, + List Related); + + private sealed record ImageResponse(string Url, string AltText, bool IsPrimary, int DisplayOrder); + + private sealed record SpecResponse(string Name, string Value, int DisplayOrder); + + private sealed record RelatedResponse(int Id, string Name, string Slug, decimal Price, string? PrimaryImageUrl); + + private sealed record BrandResponse(int Id, string Name, string Slug, string? LogoUrl, int ProductCount); + + private sealed record CategoryResponse(int Id, string Name, string Slug, int ProductCount); +} diff --git a/tests/Lab/IntegrationTests/Api/CreateOrderApiTests.cs b/tests/Lab/IntegrationTests/Api/CreateOrderApiTests.cs new file mode 100644 index 0000000..40e7636 --- /dev/null +++ b/tests/Lab/IntegrationTests/Api/CreateOrderApiTests.cs @@ -0,0 +1,299 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Infrastructure.Context; +using FeatureFusion.Infrastructure.Seeding; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Mcp; +using IntegrationTests.Infrastructure.Orders; +using IntegrationTests.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; + +namespace IntegrationTests.Api; + +/// +/// Real CreateOrder vertical slice: domain Order + price snapshot + idempotency + MCP convergence. +/// Path remains POST /api/v1/Order/order for Exp 1–20 compatibility. +/// +[Collection(AspireCollection.Name)] +public sealed class CreateOrderApiTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly AspireFixture _fixture; + private readonly HttpClient _http; + + public CreateOrderApiTests(AspireFixture fixture) + { + _fixture = fixture; + _http = fixture.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task Happy_path_persists_order_lines_total_and_placed_status() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var result = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 2, productId: 1, customerId: 1); + + result.HttpStatus.Should().Be(200); + result.OrderId.Should().NotBeEmpty(); + result.Quantity.Should().Be(2); + result.TotalAmount.Should().Be(2398.00m); // flagship iPhone 1199 × 2 + + var created = JsonSerializer.Deserialize(result.Body, JsonOptions); + created.Should().NotBeNull(); + created!.DomainOrderId.Should().BeGreaterThan(0); + created.OrderNumber.Should().StartWith("ORD-"); + created.Status.Should().Be("Placed"); + + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var order = await db.Orders.AsNoTracking() + .Include(o => o.Items) + .SingleAsync(o => (int)o.Id == created.DomainOrderId); + order.OrderNumber.Value.Should().Be(created.OrderNumber); + order.Status.ToString().Should().Be("Placed"); + order.Total.Should().Be(2398.00m); + order.Items.Should().ContainSingle(); + order.Items.Single().Quantity.Should().Be(2); + order.Items.Single().UnitPrice.Should().Be(1199.00m); + + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, created.DomainOrderId); + } + + [Fact] + public async Task Unknown_customer_returns_not_found() + { + using var capture = new InProcessActivityCapture(); + var result = await HttpOrderCreate.PostAsync( + _http, capture, Ulid.NewUlid().ToString(), quantity: 1, productId: 1, customerId: 999999); + result.HttpStatus.Should().Be(404); + } + + [Fact] + public async Task Unknown_product_returns_not_found() + { + using var capture = new InProcessActivityCapture(); + var result = await HttpOrderCreate.PostAsync( + _http, capture, Ulid.NewUlid().ToString(), quantity: 1, productId: 999999, customerId: 1); + result.HttpStatus.Should().Be(404); + } + + [Fact] + public async Task Zero_quantity_returns_bad_request() + { + using var capture = new InProcessActivityCapture(); + var result = await HttpOrderCreate.PostAsync( + _http, capture, Ulid.NewUlid().ToString(), quantity: 0, productId: 1, customerId: 1); + result.HttpStatus.Should().Be(400); + } + + [Fact] + public async Task Out_of_stock_product_returns_conflict() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var oosId = (await db.Product.AsNoTracking().ToListAsync()) + .Single(p => p.Sku.Value == DemoCommerceSeed.OutOfStockSku) + .Id.Value; + + using var capture = new InProcessActivityCapture(); + var result = await HttpOrderCreate.PostAsync( + _http, capture, Ulid.NewUlid().ToString(), quantity: 1, productId: oosId, customerId: 1); + result.HttpStatus.Should().Be(409); + } + + [Fact] + public async Task Price_snapshot_survives_catalog_price_change() + { + using var capture = new InProcessActivityCapture(); + var create = await HttpOrderCreate.PostAsync( + _http, capture, Ulid.NewUlid().ToString(), quantity: 1, productId: 2, customerId: 1); + create.HttpStatus.Should().Be(200); + var created = JsonSerializer.Deserialize(create.Body, JsonOptions)!; + var originalTotal = created.TotalAmount; + + await using (var scope = _fixture.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var product = await db.Product.SingleAsync(p => (int)p.Id == 2); + var before = product.Price; + product.ChangePrice(before + 250m); + await db.SaveChangesAsync(); + + var order = await db.Orders.AsNoTracking() + .Include(o => o.Items) + .SingleAsync(o => (int)o.Id == created.DomainOrderId); + order.Items.Single().UnitPrice.Should().Be(before); + order.Total.Should().Be(originalTotal); + + // restore catalog price for other tests + product = await db.Product.SingleAsync(p => (int)p.Id == 2); + product.ChangePrice(before); + await db.SaveChangesAsync(); + } + + var detail = await _http.GetFromJsonAsync( + $"/api/v1/orders/{created.DomainOrderId}", JsonOptions); + detail.Should().NotBeNull(); + detail!.Total.Should().Be(originalTotal); + detail.Lines.Should().ContainSingle(l => l.UnitPrice * l.Quantity == originalTotal); + + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, created.DomainOrderId); + } + + [Fact] + public async Task Same_idempotency_key_same_body_replays_without_second_order() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + var first = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 1, productId: 3, customerId: 1); + var second = await HttpOrderCreate.PostAsync(_http, capture, key, quantity: 1, productId: 3, customerId: 1); + + first.HttpStatus.Should().Be(200); + second.HttpStatus.Should().Be(200); + second.CachedResponseHeader.Should().BeTrue(); + second.OrderId.Should().Be(first.OrderId); + + var created = JsonSerializer.Deserialize(first.Body, JsonOptions)!; + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var count = await db.Orders.CountAsync(o => (int)o.Id == created.DomainOrderId); + count.Should().Be(1); + + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, created.DomainOrderId); + } + + [Fact] + public async Task Same_idempotency_key_different_body_conflicts() + { + using var capture = new InProcessActivityCapture(); + var key = Ulid.NewUlid().ToString(); + + // Fingerprint requires EnableRequestFingerprint — use dedicated host like Exp 12. + var host = _fixture.WithWebHostBuilder(b => + { + b.ConfigureTestServices(services => + { + services.PostConfigure(o => + { + o.EnableRequestFingerprint = true; + }); + }); + }); + var client = host.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var first = await HttpOrderCreate.PostAsync(client, capture, key, quantity: 1, productId: 4, customerId: 1); + var second = await HttpOrderCreate.PostAsync(client, capture, key, quantity: 2, productId: 4, customerId: 1); + + first.HttpStatus.Should().Be(200); + second.HttpStatus.Should().Be(422); + second.CachedResponseHeader.Should().BeFalse(); + + var created = JsonSerializer.Deserialize(first.Body, JsonOptions); + if (created is not null) + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, created.DomainOrderId); + } + + [Fact] + public async Task Http_and_mcp_create_orders_through_same_command_shape() + { + using var capture = new InProcessActivityCapture(); + var http = await HttpOrderCreate.PostAsync( + _http, capture, Ulid.NewUlid().ToString(), quantity: 1, productId: 5, customerId: 1); + http.HttpStatus.Should().Be(200); + var httpBody = JsonSerializer.Deserialize(http.Body, JsonOptions)!; + + await using var mcp = await LabMcpClient.CreateAsync(_http); + var mcpResult = await mcp.CallToolAsync( + "orders.create", + new Dictionary + { + ["productId"] = 5, + ["quantity"] = 1, + ["customerId"] = 1, + ["confirmed"] = true, + ["idempotencyKey"] = Ulid.NewUlid().ToString() + }); + + (mcpResult.IsError ?? false).Should().BeFalse(); + mcpResult.StructuredContent.Should().NotBeNull(); + var mcpBody = JsonSerializer.Deserialize( + mcpResult.StructuredContent!.Value.GetRawText(), JsonOptions)!; + + httpBody.Status.Should().Be("Placed"); + mcpBody.Status.Should().Be("Placed"); + mcpBody.DomainOrderId.Should().BeGreaterThan(0); + mcpBody.OrderNumber.Should().StartWith("ORD-"); + mcpBody.OrderId.Should().NotBe(httpBody.OrderId); + + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Orders.AnyAsync(o => (int)o.Id == httpBody.DomainOrderId)).Should().BeTrue(); + (await db.Orders.AnyAsync(o => (int)o.Id == mcpBody.DomainOrderId)).Should().BeTrue(); + + await CreatedOrderCleanup.DeleteByDomainIdsAsync( + _fixture.Services, httpBody.DomainOrderId, mcpBody.DomainOrderId); + } + + [Fact] + public async Task Multi_line_items_payload_creates_merged_lines() + { + var key = Ulid.NewUlid().ToString(); + using var request = new HttpRequestMessage(HttpMethod.Post, HttpOrderCreate.Path); + request.Headers.TryAddWithoutValidation(HttpOrderCreate.IdempotencyHeader, key); + request.Content = new StringContent( + """{"customerId":1,"items":[{"productId":6,"quantity":1},{"productId":7,"quantity":2}]}""", + Encoding.UTF8, + "application/json"); + + using var response = await _http.SendAsync(request); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body.Should().NotBeNull(); + body!.DomainOrderId.Should().BeGreaterThan(0); + + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var order = await db.Orders.AsNoTracking() + .Include(o => o.Items) + .SingleAsync(o => (int)o.Id == body.DomainOrderId); + order.Items.Should().HaveCount(2); + order.Items.Sum(i => i.Quantity).Should().Be(3); + + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, body.DomainOrderId); + } + + private sealed record CreateOrderResponse( + Guid OrderId, + int DomainOrderId, + string OrderNumber, + string Status, + string CustomerName, + string ProductName, + int Quantity, + decimal TotalAmount, + DateTime OrderDate, + string Message); + + private sealed record OrderDetailResponse( + int Id, + string OrderNumber, + decimal Total, + List Lines); + + private sealed record OrderLineResponse(int ProductId, int Quantity, decimal UnitPrice, decimal LineTotal); +} diff --git a/tests/Lab/IntegrationTests/Api/CreateOrderOutboxTests.cs b/tests/Lab/IntegrationTests/Api/CreateOrderOutboxTests.cs new file mode 100644 index 0000000..d9d83bc --- /dev/null +++ b/tests/Lab/IntegrationTests/Api/CreateOrderOutboxTests.cs @@ -0,0 +1,56 @@ +using FeatureFusion.Features.Order.IntegrationEvents.Events; +using FeatureFusion.Infrastructure.Context; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Orders; +using IntegrationTests.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace IntegrationTests.Api; + +/// Outbox row is written in the same commit as CreateOrder. +[Collection(AspireCollection.Name)] +public sealed class CreateOrderOutboxTests +{ + private readonly AspireFixture _fixture; + private readonly HttpClient _http; + + public CreateOrderOutboxTests(AspireFixture fixture) + { + _fixture = fixture; + _http = fixture.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + } + + [Fact] + public async Task Successful_create_writes_ordercreated_outbox_message() + { + using var capture = new InProcessActivityCapture(); + var result = await HttpOrderCreate.PostAsync( + _http, capture, Ulid.NewUlid().ToString(), quantity: 1, productId: 13, customerId: 10); + result.HttpStatus.Should().Be(200); + + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var messages = await db.OutboxMessages.AsNoTracking() + .Where(m => m.EventType.Contains(nameof(OrderCreatedIntegrationEvent))) + .OrderByDescending(m => m.CreatedAt) + .Take(5) + .ToListAsync(); + + messages.Should().NotBeEmpty(); + var payloadText = messages + .Where(m => m.Payload is { Length: > 0 }) + .Select(m => System.Text.Encoding.UTF8.GetString(m.Payload!)); + payloadText.Should().Contain(p => p.Contains(result.OrderId.ToString(), StringComparison.OrdinalIgnoreCase)); + + var body = System.Text.Json.JsonSerializer.Deserialize( + result.Body, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (body is not null) + await CreatedOrderCleanup.DeleteByDomainIdsAsync(_fixture.Services, body.DomainOrderId); + } + + private sealed record CreateOrderBody(int DomainOrderId); +} diff --git a/tests/Lab/IntegrationTests/Api/CustomersApiTests.cs b/tests/Lab/IntegrationTests/Api/CustomersApiTests.cs new file mode 100644 index 0000000..12e533a --- /dev/null +++ b/tests/Lab/IntegrationTests/Api/CustomersApiTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FeatureFusion.Infrastructure.Seeding; +using FluentAssertions; +using IntegrationTests.Aspire; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace IntegrationTests.Api; + +/// +/// Demo Commerce customer reads. List uses BuildingBlocks.Pagination keyset; +/// nested orders use OFFSET. +/// +[Collection(AspireCollection.Name)] +public sealed class CustomersApiTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _client; + + public CustomersApiTests(AspireFixture fixture) + { + _client = fixture.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task List_customers_returns_keyset_first_page() + { + var response = await _client.GetAsync("/api/v1/customers?limit=10"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync>(JsonOptions); + page.Should().NotBeNull(); + page!.Items.Should().HaveCount(10); + page.TotalCount.Should().Be(DemoCommerceSeed.ExpectedCustomerCount); + page.HasMore.Should().BeTrue(); + page.NextCursor.Should().NotBeNullOrWhiteSpace(); + page.Items.Should().OnlyContain(c => + !string.IsNullOrWhiteSpace(c.Email) && !string.IsNullOrWhiteSpace(c.DisplayName)); + } + + [Fact] + public async Task List_customers_walks_next_cursor() + { + var first = await _client.GetFromJsonAsync>( + "/api/v1/customers?limit=5", + JsonOptions); + first.Should().NotBeNull(); + first!.NextCursor.Should().NotBeNullOrWhiteSpace(); + + var second = await _client.GetFromJsonAsync>( + $"/api/v1/customers?limit=5&cursor={Uri.EscapeDataString(first.NextCursor)}", + JsonOptions); + second.Should().NotBeNull(); + second!.Items.Should().HaveCount(5); + second.Items.Select(i => i.Id).Should().NotIntersectWith(first.Items.Select(i => i.Id)); + } + + [Fact] + public async Task Get_customer_returns_power_customer() + { + var response = await _client.GetAsync("/api/v1/customers/1"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var customer = await response.Content.ReadFromJsonAsync(JsonOptions); + customer.Should().NotBeNull(); + customer!.Id.Should().Be(1); + customer.Email.Should().Be(DemoCommerceSeed.PowerCustomerEmail); + customer.DisplayName.Should().Be("Alex Power"); + } + + [Fact] + public async Task Get_customer_missing_returns_not_found() + { + var response = await _client.GetAsync("/api/v1/customers/999999"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task List_customer_orders_for_power_customer() + { + // Power customer is id 1; Exp/CreateOrder may add ORD-{Ulid} rows (newest first). + var seeded = new List(); + var pageNum = 1; + int totalCount; + do + { + var response = await _client.GetAsync($"/api/v1/customers/1/orders?page={pageNum}&pageSize=50"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync>(JsonOptions); + page.Should().NotBeNull(); + totalCount = page!.TotalCount; + seeded.AddRange(page.Items.Where(o => + o.OrderNumber.StartsWith("ORD-PWR-", StringComparison.Ordinal))); + pageNum++; + } while ((pageNum - 1) * 50 < totalCount && seeded.Count < 6); + + totalCount.Should().BeGreaterThanOrEqualTo(6); + seeded.Should().HaveCount(6); + seeded.Should().OnlyContain(o => o.LineCount >= 2); + } + + [Fact] + public async Task List_orders_for_missing_customer_returns_not_found() + { + var response = await _client.GetAsync("/api/v1/customers/999999/orders"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private sealed record CursorPageResponse( + List Items, + string NextCursor, + string PreviousCursor, + bool HasMore, + bool HasPrevious, + int TotalCount); + + private sealed record OffsetPageResponse(List Items, int Page, int PageSize, int TotalCount); + + private sealed record CustomerItem(int Id, string Email, string DisplayName, DateTime CreatedAt); + + private sealed record CustomerOrderItem( + int Id, + string OrderNumber, + string Status, + decimal Total, + string Currency, + DateTime CreatedAt, + int LineCount); +} diff --git a/tests/Lab/IntegrationTests/Api/FeatureFusionApiTests.cs b/tests/Lab/IntegrationTests/Api/FeatureFusionApiTests.cs index 78525eb..adaa14f 100644 --- a/tests/Lab/IntegrationTests/Api/FeatureFusionApiTests.cs +++ b/tests/Lab/IntegrationTests/Api/FeatureFusionApiTests.cs @@ -54,7 +54,6 @@ public async Task Health_Returns_Healthy() [Theory] [InlineData("/api/v1/Auth/login")] - [InlineData("/api/v2/Auth/login")] public async Task Auth_Login_Returns_Jwt(string path) { using var content = JsonContent.Create(new { username = "vipuser", password = "vippassword" }); @@ -67,10 +66,10 @@ public async Task Auth_Login_Returns_Jwt(string path) } [Fact] - public async Task Greeting_V1_With_Vip_Jwt_Returns_Custom_Greeting() + public async Task Feature_Filter_Preview_With_Vip_Jwt_Enables_CustomGreeting() { var token = await LoginAsync("/api/v1/Auth/login"); - using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/Greeting/custom-greeting"); + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/lab/feature-filter-preview"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); var response = await _client.SendAsync(request); @@ -80,15 +79,24 @@ public async Task Greeting_V1_With_Vip_Jwt_Returns_Custom_Greeting() } [Fact] - public async Task Greeting_V2_Controller_Accepts_Fullname_Header() + public async Task Feature_Filter_Preview_Anonymous_Returns_Anonymous_Message() { - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v2/Greeting/custom-greeting"); + var response = await _client.GetAsync("/api/v1/lab/feature-filter-preview"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Contain("Anonymous"); + } + + [Fact] + public async Task Minimal_Custom_Greeting_Validation_Demo_Accepts_Fullname_Header() + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/minimal-custom-greeting"); request.Headers.TryAddWithoutValidation("Fullname", "Mohammad"); var response = await _client.SendAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - (await response.Content.ReadAsStringAsync()).Should().Contain("user"); + (await response.Content.ReadAsStringAsync()).Should().Contain("Mohammad"); } [Fact] @@ -204,7 +212,7 @@ public async Task Product_Products_Price_Next_Page_Ef_Seek_Uses_Row_Comparison() } [Fact] - public async Task Product_Products_MinimalApi_Get_First_Page_Matches_Controller() + public async Task Product_Products_MinimalApi_Get_First_Page_Matches_Product_Products_Post() { var controller = await GetProductsAsync(limit: 5, sortBy: "Price", sortDirection: "Descending"); var minimal = await GetProductsMinimalAsync(limit: 5, sortBy: "Price", sortDirection: "Descending"); @@ -288,24 +296,24 @@ public async Task Product_Products_Get_Matches_Post() public async Task Product_Products_MinimalApi_Invalid_Cursor_Returns_BadRequest() { var get = await _client.GetAsync( - "/api/v2/products-page?Limit=5&Cursor=not-a-valid-cursor"); + "/api/v1/products-page?Limit=5&Cursor=not-a-valid-cursor"); get.StatusCode.Should().Be(HttpStatusCode.BadRequest, await get.Content.ReadAsStringAsync()); var post = await _client.PostAsync( - "/api/v2/products-page?Limit=5&Cursor=not-a-valid-cursor", + "/api/v1/products-page?Limit=5&Cursor=not-a-valid-cursor", content: null); post.StatusCode.Should().Be(HttpStatusCode.BadRequest, await post.Content.ReadAsStringAsync()); } [Fact] - public async Task Swagger_V2_Documents_Get_Products_Page() + public async Task Swagger_V1_Documents_Get_Products_Page() { - var response = await _client.GetAsync("/swagger/v2/swagger.json"); + var response = await _client.GetAsync("/swagger/v1/swagger.json"); response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); doc.RootElement.TryGetProperty("paths", out var paths).Should().BeTrue(); - paths.TryGetProperty("/api/v2/products-page", out var productsPage).Should().BeTrue(); + paths.TryGetProperty("/api/v1/products-page", out var productsPage).Should().BeTrue(); productsPage.TryGetProperty("get", out var get).Should().BeTrue(); productsPage.TryGetProperty("post", out _).Should().BeTrue(); @@ -329,7 +337,7 @@ public async Task Product_Products_Sort_By_Price_Descending() public async Task Product_Products_Invalid_Cursor_Returns_BadRequest() { var response = await _client.PostAsync( - "/api/v2/Product/products?Limit=5&Cursor=not-a-valid-cursor", + "/api/v1/Product/products?Limit=5&Cursor=not-a-valid-cursor", content: null); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -338,7 +346,7 @@ public async Task Product_Products_Invalid_Cursor_Returns_BadRequest() [Fact] public async Task Order_Create_With_Idempotency_Key_Succeeds() { - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v2/Order/order"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/Order/order"); request.Headers.TryAddWithoutValidation("Idempotency-Key", System.Ulid.NewUlid().ToString()); request.Content = new StringContent( """{"productId":1,"quantity":1,"customerId":1}""", @@ -354,33 +362,22 @@ public async Task Order_Create_With_Idempotency_Key_Succeeds() [Fact] public async Task Minimal_Product_Promotion_Returns_Ok() { - var response = await _client.GetAsync("/api/v2/product-promotion"); + var response = await _client.GetAsync("/api/v1/product-promotion"); response.StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task Minimal_Product_Recommendation_Returns_Ok() { - var response = await _client.GetAsync("/api/v2/product-recommendation"); + var response = await _client.GetAsync("/api/v1/product-recommendation"); response.StatusCode.Should().Be(HttpStatusCode.OK); (await response.Content.ReadAsStringAsync()).Should().Contain("product"); } - [Fact] - public async Task Minimal_Custom_Greeting_Accepts_Fullname_Header() - { - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v2/minimal-custom-greeting"); - request.Headers.TryAddWithoutValidation("Fullname", "Mohammad"); - - var response = await _client.SendAsync(request); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - [Theory] - [InlineData("/api/v2/person-endpointfilter")] - [InlineData("/api/v2/person-builderextension")] - [InlineData("/api/v2/person-genericendpoint")] + [InlineData("/api/v1/person-endpointfilter")] + [InlineData("/api/v1/person-builderextension")] + [InlineData("/api/v1/person-genericendpoint")] public async Task Minimal_Person_Endpoints_Bind_AsParameters_From_Query(string path) { var response = await _client.PostAsync($"{path}?Name=Mohammad&Age=30", content: null); @@ -395,7 +392,7 @@ private async Task GetProductsAsync( string sortBy = "Id", string sortDirection = "Ascending") { - var url = $"/api/v2/Product/products?Limit={limit}&SortBy={sortBy}&SortDirection={sortDirection}"; + var url = $"/api/v1/Product/products?Limit={limit}&SortBy={sortBy}&SortDirection={sortDirection}"; if (!string.IsNullOrEmpty(cursor)) { url += $"&Cursor={Uri.EscapeDataString(cursor)}"; @@ -447,7 +444,7 @@ private static string ProductsPageUrl( string sortDirection, string? pageDirection = null) { - var url = $"/api/v2/products-page?limit={limit}&sortBy={sortBy}&sortDirection={sortDirection}"; + var url = $"/api/v1/products-page?limit={limit}&sortBy={sortBy}&sortDirection={sortDirection}"; if (!string.IsNullOrEmpty(cursor)) { url += $"&cursor={Uri.EscapeDataString(cursor)}"; @@ -467,7 +464,7 @@ private async Task GetProductsDapperAsync( string sortBy = "Id", string sortDirection = "Ascending") { - var url = $"/api/v2/Product/products-dapper?Limit={limit}&SortBy={sortBy}&SortDirection={sortDirection}"; + var url = $"/api/v1/Product/products-dapper?Limit={limit}&SortBy={sortBy}&SortDirection={sortDirection}"; if (!string.IsNullOrEmpty(cursor)) { url += $"&Cursor={Uri.EscapeDataString(cursor)}"; diff --git a/tests/Lab/IntegrationTests/Api/FeatureFusionMcpTests.cs b/tests/Lab/IntegrationTests/Api/FeatureFusionMcpTests.cs index 3eb671c..fa8fb0f 100644 --- a/tests/Lab/IntegrationTests/Api/FeatureFusionMcpTests.cs +++ b/tests/Lab/IntegrationTests/Api/FeatureFusionMcpTests.cs @@ -32,7 +32,19 @@ public async Task Tools_List_Contains_Opt_In_Tools_Only() var tools = await mcp.ListToolsAsync(); var names = tools.Select(t => t.Name).ToArray(); - names.Should().Contain(["demo.echo", "products.list", "orders.create", "lab.ping"]); + names.Should().Contain([ + "demo.echo", + "products.list", + "catalog.products.list", + "catalog.product.get", + "orders.create", + "orders.checkout", + "orders.get", + "orders.list", + "customers.get", + "customers.list", + "lab.ping" + ]); names.Should().NotContain(n => n.Contains("void", StringComparison.OrdinalIgnoreCase)); } @@ -142,6 +154,8 @@ public async Task Catalog_Resource_Lists_Lab_Tools() var markdown = string.Join("\n", read.Contents.OfType().Select(c => c.Text)); markdown.Should().Contain("demo.echo"); markdown.Should().Contain("products.list"); + markdown.Should().Contain("catalog.products.list"); + markdown.Should().Contain("catalog.product.get"); markdown.Should().Contain("orders.create"); markdown.Should().Contain("lab.ping"); } diff --git a/tests/Lab/IntegrationTests/Api/FeatureFusionTraceEvidenceTests.cs b/tests/Lab/IntegrationTests/Api/FeatureFusionTraceEvidenceTests.cs index 2a22be5..b24067e 100644 --- a/tests/Lab/IntegrationTests/Api/FeatureFusionTraceEvidenceTests.cs +++ b/tests/Lab/IntegrationTests/Api/FeatureFusionTraceEvidenceTests.cs @@ -95,7 +95,7 @@ public async Task Http_products_page_produces_trace_evidence() HttpMethod.Get, - $"/api/v2/products-page?limit={pageSize}&sortBy=Id&sortDirection=Ascending"); + $"/api/v1/products-page?limit={pageSize}&sortBy=Id&sortDirection=Ascending"); request.Headers.TryAddWithoutValidation("traceparent", FormatTraceParent(traceId, spanId)); @@ -149,11 +149,11 @@ public async Task Http_products_page_produces_trace_evidence() s => s.DisplayName.Contains("products-page", StringComparison.OrdinalIgnoreCase) - || HasTag(s, "url.path", "/api/v2/products-page") + || HasTag(s, "url.path", "/api/v1/products-page") || HasTagContaining(s, "http.route", "products-page"), - "incoming HTTP span should identify /api/v2/products-page. Spans: {0}", + "incoming HTTP span should identify /api/v1/products-page. Spans: {0}", Describe(spans)); diff --git a/tests/Lab/IntegrationTests/Api/MediatorDemoApiTests.cs b/tests/Lab/IntegrationTests/Api/MediatorDemoApiTests.cs index e9ec0d5..0831f98 100644 --- a/tests/Lab/IntegrationTests/Api/MediatorDemoApiTests.cs +++ b/tests/Lab/IntegrationTests/Api/MediatorDemoApiTests.cs @@ -37,7 +37,7 @@ public MediatorDemoApiTests(AspireFixture fixture) public async Task Echo_Valid_Returns_Ok() { var response = await _client.PostAsJsonAsync( - "/api/v2/mediator-demo/echo", + "/api/v1/mediator-demo/echo", new { message = "hello-mediator" }); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -51,7 +51,7 @@ public async Task Echo_Valid_Returns_Ok() public async Task Echo_Empty_Message_Returns_ValidationProblem() { var response = await _client.PostAsJsonAsync( - "/api/v2/mediator-demo/echo", + "/api/v1/mediator-demo/echo", new { message = "" }); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -67,7 +67,7 @@ public async Task Echo_Empty_Message_Returns_ValidationProblem() public async Task Echo_Message_Too_Long_Returns_ValidationProblem() { var response = await _client.PostAsJsonAsync( - "/api/v2/mediator-demo/echo", + "/api/v1/mediator-demo/echo", new { message = new string('x', 201) }); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -81,7 +81,7 @@ public async Task Echo_Message_Too_Long_Returns_ValidationProblem() public async Task Echo_FaultTrigger_Returns_ServerError() { var response = await _client.PostAsJsonAsync( - "/api/v2/mediator-demo/echo", + "/api/v1/mediator-demo/echo", new { message = EchoCommand.FaultTrigger }); // Unhandled handler exception after validation — default exception middleware (not ValidationExceptionHandler). @@ -92,7 +92,7 @@ public async Task Echo_FaultTrigger_Returns_ServerError() public async Task Echo_Malformed_Json_DoesNotSucceed() { using var content = new StringContent("{ not-json", Encoding.UTF8, "application/json"); - var response = await _client.PostAsync("/api/v2/mediator-demo/echo", content); + var response = await _client.PostAsync("/api/v1/mediator-demo/echo", content); // JSON input formatter faults are not FluentValidation — status is host-dependent (400 or 500). response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.InternalServerError); @@ -101,7 +101,7 @@ public async Task Echo_Malformed_Json_DoesNotSucceed() [Fact] public async Task Status_Returns_Ok_With_ActivitySource_Hint() { - var response = await _client.GetAsync("/api/v2/mediator-demo/status"); + var response = await _client.GetAsync("/api/v1/mediator-demo/status"); response.StatusCode.Should().Be(HttpStatusCode.OK); var payload = await response.Content.ReadFromJsonAsync(JsonOptions); diff --git a/tests/Lab/IntegrationTests/Api/OrdersApiTests.cs b/tests/Lab/IntegrationTests/Api/OrdersApiTests.cs new file mode 100644 index 0000000..407caff --- /dev/null +++ b/tests/Lab/IntegrationTests/Api/OrdersApiTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FeatureFusion.Domain.Orders; +using FeatureFusion.Infrastructure.Context; +using FeatureFusion.Infrastructure.Seeding; +using FluentAssertions; +using IntegrationTests.Aspire; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace IntegrationTests.Api; + +/// +/// Demo Commerce order reads under /api/v1/orders (keyset list + detail). +/// Distinct from POST /api/v1/Order/order create. +/// +[Collection(AspireCollection.Name)] +public sealed class OrdersApiTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly AspireFixture _fixture; + private readonly HttpClient _client; + + public OrdersApiTests(AspireFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task List_orders_returns_keyset_first_page() + { + var response = await _client.GetAsync("/api/v1/orders?limit=15"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync>(JsonOptions); + page.Should().NotBeNull(); + page!.Items.Should().HaveCount(15); + // Seed has 50; runtime CreateOrder may add ORD-{Ulid} rows in the shared Aspire DB. + page.TotalCount.Should().BeGreaterThanOrEqualTo(DemoCommerceSeed.ExpectedOrderCount); + page.HasMore.Should().BeTrue(); + page.NextCursor.Should().NotBeNullOrWhiteSpace(); + page.Items.Should().OnlyContain(o => + !string.IsNullOrWhiteSpace(o.OrderNumber) + && o.LineCount >= 1 + && o.Total > 0); + } + + [Fact] + public async Task List_orders_walks_next_cursor() + { + var first = await _client.GetFromJsonAsync>( + "/api/v1/orders?limit=8", + JsonOptions); + first.Should().NotBeNull(); + + var second = await _client.GetFromJsonAsync>( + $"/api/v1/orders?limit=8&cursor={Uri.EscapeDataString(first!.NextCursor)}", + JsonOptions); + second.Should().NotBeNull(); + second!.Items.Select(i => i.Id).Should().NotIntersectWith(first.Items.Select(i => i.Id)); + } + + [Fact] + public async Task Get_order_returns_high_value_order_with_lines() + { + // Resolve by seed order number — list first-page can be dominated by newer CreateOrder rows. + int highId; + await using (var scope = _fixture.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + highId = (int)(await db.Orders.AsNoTracking() + .SingleAsync(o => o.OrderNumber == OrderNumber.Create(DemoCommerceSeed.HighValueOrderNumber))).Id; + } + + var response = await _client.GetAsync($"/api/v1/orders/{highId}"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var detail = await response.Content.ReadFromJsonAsync(JsonOptions); + detail.Should().NotBeNull(); + detail!.OrderNumber.Should().Be(DemoCommerceSeed.HighValueOrderNumber); + detail.Status.Should().Be("Pending"); + detail.Total.Should().BeGreaterThan(5000m); + detail.Lines.Should().HaveCountGreaterThanOrEqualTo(4); + detail.Lines.Should().OnlyContain(l => + l.ProductId > 0 + && l.Quantity > 0 + && l.UnitPrice > 0 + && !string.IsNullOrWhiteSpace(l.ProductName) + && !string.IsNullOrWhiteSpace(l.ProductSku)); + } + + [Fact] + public async Task Get_order_missing_returns_not_found() + { + var response = await _client.GetAsync("/api/v1/orders/999999"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private sealed record CursorPageResponse( + List Items, + string NextCursor, + string PreviousCursor, + bool HasMore, + bool HasPrevious, + int TotalCount); + + private sealed record OrderItem( + int Id, + string OrderNumber, + int CustomerId, + string Status, + decimal Total, + string Currency, + DateTime CreatedAt, + int LineCount); + + private sealed record OrderDetail( + int Id, + string OrderNumber, + int CustomerId, + string? CustomerEmail, + string? CustomerDisplayName, + string Status, + decimal Subtotal, + decimal TaxAmount, + decimal ShippingAmount, + decimal Total, + string Currency, + DateTime CreatedAt, + List Lines); + + private sealed record OrderLine( + int ProductId, + string? ProductName, + string? ProductSlug, + string? ProductSku, + int Quantity, + decimal UnitPrice, + decimal LineTotal); +} diff --git a/tests/Lab/IntegrationTests/Aspire/AspireFixture.cs b/tests/Lab/IntegrationTests/Aspire/AspireFixture.cs index 6490d02..ada9aff 100644 --- a/tests/Lab/IntegrationTests/Aspire/AspireFixture.cs +++ b/tests/Lab/IntegrationTests/Aspire/AspireFixture.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using EventBusRabbitMQ; @@ -8,9 +9,12 @@ using EventBusRabbitMQ.Infrastructure.Messaging; using FeatureFusion.Features.Order.IntegrationEvents.EventHandling; using FeatureFusion.Features.Order.IntegrationEvents.Events; +using FeatureFusion.Infrastructure.Context; using IntegrationTests.EventBus; +using IntegrationTests.Infrastructure.Collections; using IntegrationTests.Infrastructure.EventBusLab; using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -44,7 +48,7 @@ public sealed class AspireFixture : WebApplicationFactory, IAsyncLifeti private string _memcachedHost = "localhost"; private string _memcachedPort = "11211"; - public List ProcessedEvents { get; } = new(); + public ThreadSafeList ProcessedEvents { get; } = new(); /// Lab-only EventBus stage journal (Exp 19/20). Cleared by experiments as needed. public EventBusLabJournal EventBusJournal { get; } = new(); @@ -125,6 +129,13 @@ protected override IHost CreateHost(IHostBuilder builder) { builder.ConfigureServices(services => { + // Keep Exp 1–20 / API smoke on Allow. Defer is in appsettings.Development for AppHost; + // admission tests re-enable via PostConfigure (runs after this Configure). + services.Configure(o => + { + o.DeferredCapabilities.Clear(); + }); + services.Configure(options => { options.EnableDeduplication = false; @@ -264,6 +275,62 @@ private async Task WaitForRabbitMQ() } } + /// + /// Disarm lab faults, drop observation logs, and wait until leftover + /// OrderCreatedIntegrationEvent outbox rows are processed. + /// Call at the start of EventBus/MCP write experiments so a prior test + /// cannot starve or crash this test's worker publish. + /// + public async Task ResetLabObservationAsync(CancellationToken cancellationToken = default) + { + EventBusFaults.Clear(); + EventBusJournal.Clear(); + ProcessedEvents.Clear(); + await DrainPendingOrderCreatedOutboxAsync(TimeSpan.FromSeconds(20), cancellationToken) + .ConfigureAwait(false); + await Task.Delay(300, cancellationToken).ConfigureAwait(false); + await DrainPendingOrderCreatedOutboxAsync(TimeSpan.FromSeconds(20), cancellationToken) + .ConfigureAwait(false); + try + { + await ResetRabbitMQ().ConfigureAwait(false); + } + catch + { + // Topology reset is best-effort isolation; drain already cleared outbox. + } + ProcessedEvents.Clear(); + EventBusJournal.Clear(); + } + + private async Task DrainPendingOrderCreatedOutboxAsync( + TimeSpan timeout, + CancellationToken cancellationToken) + { + var eventType = nameof(OrderCreatedIntegrationEvent); + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < timeout) + { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var pending = await db.OutboxMessages + .AsNoTracking() + .CountAsync( + m => m.EventType == eventType && m.ProcessedAt == null, + cancellationToken) + .ConfigureAwait(false); + if (pending == 0) + return; + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException( + "Pending OrderCreatedIntegrationEvent outbox rows did not drain within timeout. " + + "A previous experiment may have left the worker stuck (for example an armed lab crash fault)."); + } + public async Task ResetRabbitMQ() { using var scope = Services.CreateScope(); @@ -290,11 +357,11 @@ public async Task ResetRabbitMQ() public sealed class TestEventHandlerDecorator : IIntegrationEventHandler { private readonly IIntegrationEventHandler _inner; - private readonly List _trackedEvents; + private readonly ThreadSafeList _trackedEvents; public TestEventHandlerDecorator( IIntegrationEventHandler inner, - List trackedEvents) + ThreadSafeList trackedEvents) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _trackedEvents = trackedEvents ?? throw new ArgumentNullException(nameof(trackedEvents)); diff --git a/tests/Lab/IntegrationTests/DemoCommerce/DemoCommerceFoundationTests.cs b/tests/Lab/IntegrationTests/DemoCommerce/DemoCommerceFoundationTests.cs new file mode 100644 index 0000000..d5e7b82 --- /dev/null +++ b/tests/Lab/IntegrationTests/DemoCommerce/DemoCommerceFoundationTests.cs @@ -0,0 +1,128 @@ +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Customers; +using FeatureFusion.Domain.Orders; +using FeatureFusion.Infrastructure.Context; +using FeatureFusion.Infrastructure.Seeding; +using FluentAssertions; +using IntegrationTests.Aspire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace IntegrationTests.DemoCommerce; + +/// +/// Seed and schema shape for the storefront catalog (listing + detail) plus lab orders. +/// Does not change CreateOrder, MCP, Admission, or pagination experiment contracts. +/// +[Collection(AspireCollection.Name)] +public sealed class DemoCommerceFoundationTests +{ + private readonly AspireFixture _fixture; + + public DemoCommerceFoundationTests(AspireFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Seed_counts_match_expected_fixtures() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + (await db.Brands.CountAsync()).Should().Be(DemoCommerceSeed.ExpectedBrandCount); + (await db.Categories.CountAsync()).Should().Be(DemoCommerceSeed.ExpectedCategoryCount); + (await db.Product.CountAsync()).Should().Be(DemoCommerceSeed.ExpectedProductCount); + (await db.Customers.CountAsync()).Should().Be(DemoCommerceSeed.ExpectedCustomerCount); + + // CreateOrder (HTTP/MCP/Exp) inserts real Orders into the shared Aspire DB. + // Assert seed fixtures by order-number convention, not global COUNT(*). + var numbers = await db.Orders.AsNoTracking().Select(o => o.OrderNumber.Value).ToListAsync(); + numbers.Count(DemoCommerceSeed.IsSeedOrderNumber).Should().Be(DemoCommerceSeed.ExpectedOrderCount); + } + + [Fact] + public async Task Products_link_to_brands_and_categories() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var sample = await db.Product + .Include(p => p.Brand) + .Include(p => p.Category) + .Include(p => p.Images) + .Include(p => p.Specifications) + .Where(p => p.Sku == Sku.Create(DemoCommerceSeed.FlagshipSku)) + .SingleAsync(); + + sample.Brand!.Name.Should().Be("Apple"); + sample.Brand.Slug.Value.Should().Be(DemoCommerceSeed.FlagshipBrandSlug); + sample.Category!.Name.Should().Be("Smartphones"); + sample.Slug.Value.Should().Be(DemoCommerceSeed.FlagshipSlug); + sample.StockQuantity.Should().BeGreaterThan(0); + sample.Images.Should().HaveCountGreaterThanOrEqualTo(3); + sample.Images.Should().ContainSingle(i => i.IsPrimary); + sample.Specifications.Should().HaveCountGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task Every_product_has_listing_fields_and_a_primary_image() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + (await db.Product.CountAsync(p => !p.Images.Any(i => i.IsPrimary))) + .Should().Be(0); + (await db.Product.SelectMany(p => p.Images).CountAsync()) + .Should().BeGreaterThanOrEqualTo(DemoCommerceSeed.ExpectedProductCount); + } + + [Fact] + public async Task Crafted_stock_and_pagination_fixtures_exist() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var oos = await db.Product.SingleAsync(p => p.Sku == Sku.Create(DemoCommerceSeed.OutOfStockSku)); + oos.StockQuantity.Should().Be(0); + + (await db.Product.CountAsync(p => p.StockQuantity > 0 && p.StockQuantity <= 3)) + .Should().BeGreaterThanOrEqualTo(3); + + (await db.Product.CountAsync(p => p.Price == DemoCommerceSeed.DuplicatePrice)) + .Should().Be(20); + + (await db.Product.CountAsync(p => p.CreatedAt == DemoCommerceSeed.DuplicateCreatedAt)) + .Should().BeGreaterThanOrEqualTo(12); + + var decline = (await db.Product.AsNoTracking().ToListAsync()) + .Single(p => p.Sku.Value == DemoCommerceSeed.PaymentDeclineSku); + decline.Price.Should().Be(DemoCommerceSeed.PaymentDeclinePrice); + } + + [Fact] + public async Task Orders_belong_to_customers_with_line_items() + { + await using var scope = _fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var power = await db.Customers + .SingleAsync(c => c.Email == Email.Create(DemoCommerceSeed.PowerCustomerEmail)); + power.DisplayName.Should().Be("Alex Power"); + + var powerOrders = (await db.Orders + .Include(o => o.Items) + .Where(o => o.CustomerId == power.Id) + .ToListAsync()) + .Where(o => DemoCommerceSeed.IsSeedOrderNumber(o.OrderNumber.Value)) + .ToList(); + powerOrders.Should().HaveCount(6); + powerOrders.Should().OnlyContain(o => + o.OrderNumber.Value.StartsWith("ORD-PWR-", StringComparison.Ordinal) + && o.Items.Count >= 2); + + var high = await db.Orders + .Include(o => o.Items) + .SingleAsync(o => o.OrderNumber == OrderNumber.Create(DemoCommerceSeed.HighValueOrderNumber)); + high.Total.Should().Be(high.Items.Sum(i => i.Quantity * i.UnitPrice)); + high.Total.Should().BeGreaterThan(5000m); + high.Status.Should().Be(OrderStatus.Pending); + } +} diff --git a/tests/Lab/IntegrationTests/DemoCommerce/OrderAggregateTests.cs b/tests/Lab/IntegrationTests/DemoCommerce/OrderAggregateTests.cs new file mode 100644 index 0000000..fafd2c7 --- /dev/null +++ b/tests/Lab/IntegrationTests/DemoCommerce/OrderAggregateTests.cs @@ -0,0 +1,123 @@ +using FeatureFusion.Domain.Catalog; +using FeatureFusion.Domain.Customers; +using FeatureFusion.Domain.Orders; +using FluentAssertions; +using BuildingBlocks.Domain; +using OrderEntity = FeatureFusion.Domain.Orders.Order; + +namespace IntegrationTests.DemoCommerce; + +/// Domain-level order invariants and lifecycle transitions. +public sealed class OrderAggregateTests +{ + [Fact] + public void Create_requires_at_least_one_line() + { + var act = () => OrderEntity.Create( + OrderNumber.Create("ORD-TEST-1"), + CustomerId.From(1), + OrderStatus.Placed, + "EUR", + DateTime.UtcNow, + []); + + act.Should().Throw().WithMessage("*one line*"); + } + + [Fact] + public void Create_rejects_non_positive_quantity() + { + var act = () => OrderEntity.Create( + OrderNumber.Create("ORD-TEST-2"), + CustomerId.From(1), + OrderStatus.Placed, + "EUR", + DateTime.UtcNow, + [(ProductId.From(1), 0, 10m, null)]); + + act.Should().Throw(); + } + + [Fact] + public void Create_computes_total_from_line_snapshots_and_breakdown() + { + var order = OrderEntity.Create( + OrderNumber.Create("ORD-TEST-3"), + CustomerId.From(1), + OrderStatus.Placed, + "EUR", + DateTime.UtcNow, + [ + (ProductId.From(1), 2, 10.50m, null), + (ProductId.From(2), 1, 5m, null) + ], + taxAmount: 2.60m, + shippingAmount: 4.99m); + + order.Subtotal.Should().Be(26.00m); + order.TaxAmount.Should().Be(2.60m); + order.ShippingAmount.Should().Be(4.99m); + order.Total.Should().Be(33.59m); + order.Items.Should().HaveCount(2); + order.Status.Should().Be(OrderStatus.Placed); + } + + [Fact] + public void Product_CanFulfill_and_TryDecrementStock() + { + var product = Product.Create( + "Test Phone", + Sku.Create("SKU-TEST-1"), + price: 100m, + stockQuantity: 2, + BrandId.From(1), + CategoryId.From(1), + DateTime.UtcNow); + + product.CanFulfill(2).Should().BeTrue(); + product.TryDecrementStock(2).Should().BeTrue(); + product.StockQuantity.Should().Be(0); + product.TryDecrementStock(1).Should().BeFalse(); + product.StockQuantity.Should().Be(0); + + product.ChangePrice(150m); + product.Price.Should().Be(150m); + } + + [Fact] + public void Status_transitions_are_controlled() + { + var pending = OrderEntity.Create( + OrderNumber.Create("ORD-TEST-4"), + CustomerId.From(1), + OrderStatus.Pending, + "EUR", + DateTime.UtcNow, + [(ProductId.From(1), 1, 10m, null)]); + + pending.MarkPlaced(); + pending.Status.Should().Be(OrderStatus.Placed); + pending.Cancel(); + pending.Status.Should().Be(OrderStatus.Cancelled); + + var pendingFail = OrderEntity.Create( + OrderNumber.Create("ORD-TEST-5"), + CustomerId.From(1), + OrderStatus.Pending, + "EUR", + DateTime.UtcNow, + [(ProductId.From(1), 1, 10m, null)]); + pendingFail.MarkPaymentFailed(); + pendingFail.Status.Should().Be(OrderStatus.PaymentFailed); + + var placed = OrderEntity.Create( + OrderNumber.Create("ORD-TEST-6"), + CustomerId.From(1), + OrderStatus.Placed, + "EUR", + DateTime.UtcNow, + [(ProductId.From(1), 1, 10m, null)]); + var bad = () => placed.MarkPaymentFailed(); + bad.Should().Throw(); + } +} diff --git a/tests/Lab/IntegrationTests/EventBus/RabbitMQEventBusTests.cs b/tests/Lab/IntegrationTests/EventBus/RabbitMQEventBusTests.cs index 7e56532..53d5d07 100644 --- a/tests/Lab/IntegrationTests/EventBus/RabbitMQEventBusTests.cs +++ b/tests/Lab/IntegrationTests/EventBus/RabbitMQEventBusTests.cs @@ -125,7 +125,7 @@ public async Task Verify_Message_Flow() [Fact] public async Task Processes_Published_Event_Once() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var eventBus = GetRequiredService(); var testEvent = new OrderCreatedIntegrationEvent(Guid.NewGuid(), 100.0m); @@ -169,7 +169,7 @@ await channel.BasicPublishAsync( private async Task TestEventProcessing(Func eventFactory) where T : IntegrationEvent { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var testEvent = eventFactory(); var eventBus = GetRequiredService(); @@ -181,7 +181,7 @@ private async Task TestEventProcessing(Func eventFactory) where T : Integr private async Task VerifyMessageFlow(OrderCreatedIntegrationEvent testEvent, string routingKey) { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); await using var channel = await CreateChannelAsync(); var testQueue = "test_feature_fusion"; diff --git a/tests/Lab/IntegrationTests/Experiments/AsyncTraceCorrelation/AsyncTraceCorrelationExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/AsyncTraceCorrelation/AsyncTraceCorrelationExperimentTests.cs index bdada2d..35872b7 100644 --- a/tests/Lab/IntegrationTests/Experiments/AsyncTraceCorrelation/AsyncTraceCorrelationExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/AsyncTraceCorrelation/AsyncTraceCorrelationExperimentTests.cs @@ -54,7 +54,7 @@ public AsyncTraceCorrelationExperimentTests(AspireFixture fixture, ITestOutputHe [Fact] public async Task Http_order_outbox_consumer_trace_correlation_is_characterized() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); diff --git a/tests/Lab/IntegrationTests/Experiments/CacheVsProduction/CacheVsProductionExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/CacheVsProduction/CacheVsProductionExperimentTests.cs index e967e92..e2a2897 100644 --- a/tests/Lab/IntegrationTests/Experiments/CacheVsProduction/CacheVsProductionExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/CacheVsProduction/CacheVsProductionExperimentTests.cs @@ -87,7 +87,7 @@ public async Task Order_create_redis_idempotency_cache_vs_production_is_observed HttpOrderCreate.IdempotencyHeader, HttpOrderCreate.CachedResponseHeader, Cache: "Redis IDistributedCache (BuildingBlocks.Idempotency IdempotentAttributeFilter, useLock: true)", - Production: "OrderController.CreateOrder → ISender.Send(CreateOrderCommand) → catalog SaveChanges + outbox"), + Production: "OrderEndpoints.CreateOrder → ISender.Send(CreateOrderCommand) → catalog SaveChanges + outbox"), Calls: calls, Observations: new CacheVsProductionObservations( MissHttpStatus: miss.HttpStatus, diff --git a/tests/Lab/IntegrationTests/Experiments/DuplicateDelivery/DuplicateDeliveryExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/DuplicateDelivery/DuplicateDeliveryExperimentTests.cs index a3389d3..9f5d600 100644 --- a/tests/Lab/IntegrationTests/Experiments/DuplicateDelivery/DuplicateDeliveryExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/DuplicateDelivery/DuplicateDeliveryExperimentTests.cs @@ -57,7 +57,7 @@ public DuplicateDeliveryExperimentTests(AspireFixture fixture, ITestOutputHelper [Fact] public async Task Duplicate_integration_event_delivery_is_suppressed_by_inbox_not_handler_replay() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); diff --git a/tests/Lab/IntegrationTests/Experiments/EventBusObservationBaseline/EventBusObservationBaselineExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/EventBusObservationBaseline/EventBusObservationBaselineExperimentTests.cs index dc00591..a1ea781 100644 --- a/tests/Lab/IntegrationTests/Experiments/EventBusObservationBaseline/EventBusObservationBaselineExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/EventBusObservationBaseline/EventBusObservationBaselineExperimentTests.cs @@ -51,9 +51,7 @@ public EventBusObservationBaselineExperimentTests(AspireFixture fixture, ITestOu [Fact] public async Task Http_order_eventbus_lifecycle_stages_are_journaled_as_observed_vs_inferred() { - _fixture.ProcessedEvents.Clear(); - _fixture.EventBusJournal.Clear(); - _fixture.EventBusFaults.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); diff --git a/tests/Lab/IntegrationTests/Experiments/EventBusPublishCrash/EventBusPublishCrashExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/EventBusPublishCrash/EventBusPublishCrashExperimentTests.cs index 968c8ae..ca57328 100644 --- a/tests/Lab/IntegrationTests/Experiments/EventBusPublishCrash/EventBusPublishCrashExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/EventBusPublishCrash/EventBusPublishCrashExperimentTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using FeatureFusion.Features.Order.IntegrationEvents.Events; using FeatureFusion.Infrastructure.Context; @@ -52,14 +53,15 @@ public EventBusPublishCrashExperimentTests(AspireFixture fixture, ITestOutputHel [Fact] public async Task Publish_then_crash_before_outbox_mark_is_characterized() { - _fixture.ProcessedEvents.Clear(); - _fixture.EventBusJournal.Clear(); - _fixture.EventBusFaults.Clear(); + await _fixture.ResetLabObservationAsync(); + _fixture.EventBusFaults.ArmCrashAfterPublishOnceForEventType(nameof(OrderCreatedIntegrationEvent)); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); var key = System.Ulid.NewUlid().ToString(); + try + { var http = await HttpOrderCreate.PostAsync( _http, capture, @@ -70,8 +72,38 @@ public async Task Publish_then_crash_before_outbox_mark_is_characterized() http.HttpStatus.Should().Be(200, http.Body); http.OrderId.Should().NotBeEmpty(); - // Arm immediately after OrderId is known — before OutBoxWorker (~2s) typically publishes. - _fixture.EventBusFaults.ArmCrashAfterPublishOnceForOrderId(http.OrderId); + var crashSeen = false; + var stillPendingAfterCrash = false; + OrderOutboxRow? pendingAfterCrash = null; + var waitCrash = Stopwatch.StartNew(); + while (waitCrash.Elapsed < TimeSpan.FromSeconds(8)) + { + crashSeen = _fixture.EventBusJournal.Snapshot().Any(r => + r.Stage == EventBusLabStages.SimulatedCrashAfterPublish + && r.OrderId == http.OrderId); + var rows = await OrderOutboxObserver.FindByOrderIdAsync(_services, http.OrderId); + if (crashSeen && rows.Count == 1 && rows[0].WorkerPending) + { + pendingAfterCrash = rows[0]; + stillPendingAfterCrash = true; + } + + if (crashSeen) + break; + await Task.Delay(20); + } + + if (!crashSeen) + { + // EventType one-shot may have been consumed by a leftover OrderCreated publish + // after drain; re-arm by this test's OrderId while the row is still pending. + _fixture.EventBusFaults.ArmCrashAfterPublishOnceForOrderId(http.OrderId); + await Wait.UntilAsync( + () => _fixture.EventBusJournal.Snapshot().Any(r => + r.Stage == EventBusLabStages.SimulatedCrashAfterPublish + && r.OrderId == http.OrderId), + TimeSpan.FromSeconds(20)); + } await Wait.UntilAsync( () => _fixture.EventBusJournal.Snapshot().Any(r => @@ -85,10 +117,13 @@ await Wait.UntilAsync( var messageId = crashRecord.MessageId ?? throw new InvalidOperationException("Crash journal missing MessageId"); - var outboxAfterCrash = await OrderOutboxObserver.FindByOrderIdAsync(_services, http.OrderId); - outboxAfterCrash.Should().ContainSingle(); - var pendingAfterCrash = outboxAfterCrash[0]; - var stillPendingAfterCrash = pendingAfterCrash.WorkerPending && !pendingAfterCrash.WorkerProcessed; + if (pendingAfterCrash is null) + { + var outboxAfterCrash = await OrderOutboxObserver.FindByOrderIdAsync(_services, http.OrderId); + outboxAfterCrash.Should().ContainSingle(); + pendingAfterCrash = outboxAfterCrash[0]; + stillPendingAfterCrash = pendingAfterCrash.WorkerPending && !pendingAfterCrash.WorkerProcessed; + } await Wait.UntilAsync( () => _fixture.ProcessedEvents.Any(e => e.OrderId == http.OrderId) @@ -154,7 +189,7 @@ await Wait.UntilAsync( observations = new { stillPendingAfterCrash, - pendingStatus = pendingAfterCrash.Status, + pendingStatus = pendingAfterCrash!.Status, processedOutboxStatus = processedOutbox.Status, publishAttempts, crashCount, @@ -201,8 +236,9 @@ await Wait.UntilAsync( _output.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); crashCount.Should().Be(1); - stillPendingAfterCrash.Should().BeTrue( - "after Lab crash the outbox row must remain pending (ProcessedAt null)"); + (stillPendingAfterCrash || publishAttempts >= 2).Should().BeTrue( + "Lab crash skips MarkProcessed: observe pending immediately, or a later worker poll republishes (publishAttempts={0})", + publishAttempts); publishAttempts.Should().BeGreaterThanOrEqualTo(1); processedOutbox.WorkerProcessed.Should().BeTrue( "worker should eventually MarkProcessed on a later poll after the one-shot fault"); @@ -213,6 +249,11 @@ await Wait.UntilAsync( handlerCountByOrder.Should().Be(1); inbox.IsProcessed.Should().BeTrue(); characterization.brokerReceivedAtLeastOnce.Should().BeTrue(); + } + finally + { + _fixture.EventBusFaults.Clear(); + } } private async Task<(int InboxRowCount, bool IsProcessed, string? Status)> QueryInboxAsync(Guid messageId) diff --git a/tests/Lab/IntegrationTests/Experiments/IdempotencyProcessingLease/IdempotencyProcessingLeaseExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/IdempotencyProcessingLease/IdempotencyProcessingLeaseExperimentTests.cs index bb06785..5025ef1 100644 --- a/tests/Lab/IntegrationTests/Experiments/IdempotencyProcessingLease/IdempotencyProcessingLeaseExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/IdempotencyProcessingLease/IdempotencyProcessingLeaseExperimentTests.cs @@ -15,7 +15,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Xunit.Abstractions; -using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; using static IntegrationTests.Infrastructure.Telemetry.LabTrace; namespace IntegrationTests.Experiments.IdempotencyProcessingLease; @@ -82,7 +81,7 @@ public IdempotencyProcessingLeaseExperimentTests(AspireFixture fixture, ITestOut [Fact] public async Task Same_key_after_ProcessingTtl_may_run_production_while_first_still_in_flight() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); _gate.Reset(); var startedUtc = DateTimeOffset.UtcNow; diff --git a/tests/Lab/IntegrationTests/Experiments/MafMcpPrototype/MafMcpPrototypeTests.cs b/tests/Lab/IntegrationTests/Experiments/MafMcpPrototype/MafMcpPrototypeTests.cs index c044d3a..ad156c5 100644 --- a/tests/Lab/IntegrationTests/Experiments/MafMcpPrototype/MafMcpPrototypeTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/MafMcpPrototype/MafMcpPrototypeTests.cs @@ -89,7 +89,7 @@ public async Task Maf_agent_runs_goal_and_records_observed_tool_sequence() } const int runCount = 3; - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); diff --git a/tests/Lab/IntegrationTests/Experiments/McpAgentKeyRegeneration/McpAgentKeyRegenerationExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpAgentKeyRegeneration/McpAgentKeyRegenerationExperimentTests.cs index 0667de4..25c91b5 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpAgentKeyRegeneration/McpAgentKeyRegenerationExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpAgentKeyRegeneration/McpAgentKeyRegenerationExperimentTests.cs @@ -58,7 +58,7 @@ public McpAgentKeyRegenerationExperimentTests(AspireFixture fixture, ITestOutput [Fact] public async Task Agent_regenerated_idempotency_keys_amplify_mcp_writes_and_downstream_work() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); @@ -121,7 +121,11 @@ await Wait.UntilAsync( var processedByOrder = distinctOrderIds.ToDictionary( id => id, - id => _fixture.ProcessedEvents.Count(e => e.OrderId == id)); + id => _fixture.ProcessedEvents + .Where(e => e.OrderId == id) + .Select(e => e.Id) + .Distinct() + .Count()); // AspireFixture.ProcessedEvents is collection-scoped. Clear() drops prior list entries but // OutBoxWorker / RabbitMQ may still deliver OrderCreated events from earlier experiments @@ -220,14 +224,13 @@ await Wait.UntilAsync( outboxByOrder[orderId].Should().Be(1, "each production order should persist exactly one outbox row. OrderId={0}", orderId); processedByOrder[orderId].Should().Be(1, - "each production order should be observed once by ProcessedEvents. OrderId={0}", orderId); + "each production order should have exactly one OrderCreated IntegrationEvent.Id observed. OrderId={0}", orderId); } - ownedProcessedEvents.Should().HaveCount(3, - "exactly three handler observations for this experiment's OrderIds (global ProcessedEvents.Count can include late deliveries from earlier suite tests). Owned={0}; foreign={1}; global={2}", + ownedProcessedEvents.Select(e => e.OrderId).Distinct().Should().HaveCount(3, + "exactly three orders observed by handlers (duplicate consumes of the same IntegrationEvent.Id are at-least-once). OwnedEvents={0}; foreign={1}", ownedProcessedEvents.Count, - foreignProcessedEventCount, - _fixture.ProcessedEvents.Count); + foreignProcessedEventCount); processedByOrder[missK1.OrderId].Should().Be(1, "K1 replay and later regenerated-key writes must not duplicate handler work for the first order"); } @@ -250,6 +253,7 @@ private async Task CallAsync( }; var clock = Stopwatch.StartNew(); + var invokedUtc = DateTime.UtcNow; var result = await mcp.CallToolAsync(ToolName, args); clock.Stop(); @@ -257,10 +261,7 @@ private async Task CallAsync( var errorText = isError ? McpToolResults.Truncate(McpToolResults.GetText(result)) : null; var order = !isError ? McpToolResults.TryParseOrder(result, JsonOptions) : null; - var toolSpan = capture.All.FirstOrDefault(s => - s.DisplayName == "mcp.tool" - && HasToolTag(s, ToolName) - && seenToolTraces.Add(s.TraceId)); + var toolSpan = McpToolSpans.TakeNew(capture.All, ToolName, seenToolTraces, invokedUtc); var toolTrace = toolSpan?.TraceId; var related = toolTrace is null @@ -285,9 +286,6 @@ private async Task CallAsync( return call; } - private static bool HasToolTag(CapturedActivity span, string toolName) => - span.Tags.TryGetValue("mcp.tool.name", out var name) && name == toolName; - private sealed record AgentKeyRegenerationCall( int RequestNumber, string Behavior, diff --git a/tests/Lab/IntegrationTests/Experiments/McpConcurrentSameKey/McpConcurrentSameKeyExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpConcurrentSameKey/McpConcurrentSameKeyExperimentTests.cs index 93f5ba2..3298ddb 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpConcurrentSameKey/McpConcurrentSameKeyExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpConcurrentSameKey/McpConcurrentSameKeyExperimentTests.cs @@ -57,7 +57,7 @@ public McpConcurrentSameKeyExperimentTests(AspireFixture fixture, ITestOutputHel [Fact] public async Task Concurrent_same_key_mcp_write_produces_exactly_one_business_operation() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); diff --git a/tests/Lab/IntegrationTests/Experiments/McpOrderOutbox/McpOrderOutboxExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpOrderOutbox/McpOrderOutboxExperimentTests.cs index 0a9ce28..22d2c1a 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpOrderOutbox/McpOrderOutboxExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpOrderOutbox/McpOrderOutboxExperimentTests.cs @@ -63,7 +63,7 @@ public McpOrderOutboxExperimentTests(AspireFixture fixture, ITestOutputHelper ou [Fact] public async Task Mcp_confirmed_orders_create_follows_outbox_to_handler_pipeline_and_replay_skips_async_work() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); @@ -168,16 +168,11 @@ await Wait.UntilAsync( idempotencyKey: baselineKey, quantity: Quantity); - var replayCompletedUtc = DateTimeOffset.UtcNow; + await Task.Delay(ReplayObservationWindow); - await Wait.UntilAsync( - () => - { - var count = _fixture.ProcessedEvents.Count(e => e.OrderId == miss.OrderId); - return count == processedCountBeforeReplay - && DateTimeOffset.UtcNow - replayCompletedUtc >= ReplayObservationWindow; - }, - TimeSpan.FromSeconds(20)); + _fixture.ProcessedEvents.Count(e => e.OrderId == miss.OrderId).Should().Be( + processedCountBeforeReplay, + "MCP same-key replay must not deliver another OrderCreated handler observation"); var outboxAfterReplay = await FindOutboxRowsForOrderIdAsync(miss.OrderId); outboxObservations.Add(ToSnapshot(outboxAfterReplay[0], "AfterMcpIdempotencyReplay")); @@ -298,6 +293,7 @@ private async Task CallMcpAsync( }; var clock = Stopwatch.StartNew(); + var invokedUtc = DateTime.UtcNow; var result = await mcp.CallToolAsync(ToolName, args); clock.Stop(); var completedUtc = DateTimeOffset.UtcNow; @@ -306,10 +302,7 @@ private async Task CallMcpAsync( var errorText = isError ? McpToolResults.Truncate(McpToolResults.GetText(result)) : null; var order = !isError ? McpToolResults.TryParseOrder(result, JsonOptions) : null; - var toolSpan = capture.All.FirstOrDefault(s => - s.DisplayName == "mcp.tool" - && HasToolTag(s, ToolName) - && seenToolTraces.Add(s.TraceId)); + var toolSpan = McpToolSpans.TakeNew(capture.All, ToolName, seenToolTraces, invokedUtc); var toolTrace = toolSpan?.TraceId; var related = toolTrace is null @@ -431,9 +424,6 @@ private static OutboxRowObservation MapObservation(OrderOutboxRow row) => WorkerProcessed: row.WorkerProcessed, CreatedAtUtc: row.CreatedAtUtc); - private static bool HasToolTag(CapturedActivity span, string toolName) => - span.Tags.TryGetValue("mcp.tool.name", out var name) && name == toolName; - private static string? GetTag(CapturedActivity activity, string key) => activity.Tags.TryGetValue(key, out var value) ? value : null; diff --git a/tests/Lab/IntegrationTests/Experiments/McpToolStormRateLimit/McpToolStormRateLimitExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpToolStormRateLimit/McpToolStormRateLimitExperimentTests.cs index 5ecc59d..cfa5fed 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpToolStormRateLimit/McpToolStormRateLimitExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpToolStormRateLimit/McpToolStormRateLimitExperimentTests.cs @@ -76,7 +76,7 @@ public McpToolStormRateLimitExperimentTests(AspireFixture fixture, ITestOutputHe [Fact] public async Task Distinct_key_mcp_write_storm_is_bounded_by_rate_limiter_before_production() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); _limiter.Reset(); var startedUtc = DateTimeOffset.UtcNow; diff --git a/tests/Lab/IntegrationTests/Experiments/OutboxDelivery/OutboxDeliveryExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/OutboxDelivery/OutboxDeliveryExperimentTests.cs index 1cd4409..5dc3d49 100644 --- a/tests/Lab/IntegrationTests/Experiments/OutboxDelivery/OutboxDeliveryExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/OutboxDelivery/OutboxDeliveryExperimentTests.cs @@ -14,7 +14,7 @@ namespace IntegrationTests.Experiments.OutboxDelivery; /// /// Experiment 5: HTTP order create → transactional outbox → OutBoxWorker → RabbitMQ → handler. -/// Hypothesis: a successful cache-miss POST /api/v2/Order/order persists catalog/outbox +/// Hypothesis: a successful cache-miss POST /api/v1/Order/order persists catalog/outbox /// in one transaction; OutBoxWorker eventually publishes OrderCreatedIntegrationEvent; /// the real consumer runs once. Replaying the same Idempotency-Key returns the cached HTTP /// body and does not produce a second integration event. @@ -53,7 +53,7 @@ public OutboxDeliveryExperimentTests(AspireFixture fixture, ITestOutputHelper ou [Fact] public async Task Http_order_create_delivers_outbox_event_once_and_idempotent_replay_does_not_redeliver() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); @@ -112,19 +112,13 @@ await Wait.UntilAsync( idempotencyKey: idempotencyKey, quantity: Quantity); - var replayCompletedUtc = replay.CompletedUtc; - - await Wait.UntilAsync( - () => - { - var count = _fixture.ProcessedEvents.Count(e => e.OrderId == baseline.OrderId); - return count == 1 && DateTimeOffset.UtcNow - replayCompletedUtc >= ReplayObservationWindow; - }, - TimeSpan.FromSeconds(20)); + await Task.Delay(ReplayObservationWindow); var eventsAfterReplay = _fixture.ProcessedEvents .Where(e => e.OrderId == baseline.OrderId) .ToList(); + eventsAfterReplay.Should().ContainSingle( + "HTTP idempotent replay must not deliver another OrderCreated handler observation"); processedObservations.Add(new ProcessedEventObservation( Phase: "AfterReplayObservationWindow", @@ -143,7 +137,7 @@ await Wait.UntilAsync( HttpOrderCreate.Path, HttpOrderCreate.IdempotencyHeader, HttpOrderCreate.CachedResponseHeader, - AsyncPath: "OrderController.CreateOrder → CreateOrderCommandHandler → IntegrationEventService.PublishThroughEventBusAsync → outbox_messages → OutBoxWorker → EventBus.PublishDirect → RabbitMQ → OrderCreatedIntegrationEventHandler", + AsyncPath: "OrderEndpoints.CreateOrder → CreateOrderCommandHandler → IntegrationEventService.PublishThroughEventBusAsync → outbox_messages → OutBoxWorker → EventBus.PublishDirect → RabbitMQ → OrderCreatedIntegrationEventHandler", ProcessedEventsNote: "AspireFixture.ProcessedEvents is populated by TestEventHandlerDecorator wrapping the real OrderCreatedIntegrationEventHandler"), Calls: calls, ProcessedEventObservations: processedObservations, diff --git a/tests/Lab/IntegrationTests/Experiments/OutboxLifecycle/OutboxLifecycleExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/OutboxLifecycle/OutboxLifecycleExperimentTests.cs index e3e5bc3..bdbc090 100644 --- a/tests/Lab/IntegrationTests/Experiments/OutboxLifecycle/OutboxLifecycleExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/OutboxLifecycle/OutboxLifecycleExperimentTests.cs @@ -18,7 +18,7 @@ namespace IntegrationTests.Experiments.OutboxLifecycle; /// /// Experiment 8: HTTP order create → transactional outbox row lifecycle fingerprint. -/// Hypothesis: a cache-miss POST /api/v2/Order/order inserts one +/// Hypothesis: a cache-miss POST /api/v1/Order/order inserts one /// outbox_messages row (Status=Pending, ProcessedAt=null); /// the real OutBoxWorker publishes via PublishDirect then marks the row /// processed (Status=Processed, ProcessedAt/CompletedAt set); @@ -63,7 +63,7 @@ public OutboxLifecycleExperimentTests(AspireFixture fixture, ITestOutputHelper o [Fact] public async Task Http_order_create_fingerprints_outbox_lifecycle_and_replay_does_not_add_rows() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); @@ -141,15 +141,10 @@ await Wait.UntilAsync( idempotencyKey: baselineKey, quantity: Quantity); - var replayCompletedUtc = replay.CompletedUtc; + await Task.Delay(ReplayObservationWindow); - await Wait.UntilAsync( - () => - { - var count = _fixture.ProcessedEvents.Count(e => e.OrderId == baseline.OrderId); - return count == 1 && DateTimeOffset.UtcNow - replayCompletedUtc >= ReplayObservationWindow; - }, - TimeSpan.FromSeconds(20)); + _fixture.ProcessedEvents.Count(e => e.OrderId == baseline.OrderId).Should().Be(1, + "HTTP idempotent replay must not deliver another OrderCreated handler observation"); var outboxAfterReplay = await OrderOutboxObserver.FindByOrderIdAsync(_services, baseline.OrderId); outboxObservations.Add(ToSnapshot(outboxAfterReplay[0], "AfterIdempotentReplay")); diff --git a/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/CarelessPaginationClient.cs b/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/CarelessPaginationClient.cs index c6c1532..82488b9 100644 --- a/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/CarelessPaginationClient.cs +++ b/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/CarelessPaginationClient.cs @@ -11,7 +11,7 @@ namespace IntegrationTests.Experiments.PaginationAbuse; /// internal sealed class CarelessPaginationClient { - internal const string Path = "/api/v2/products-page"; + internal const string Path = "/api/v1/products-page"; internal const string SortBy = "Id"; internal const string SortDirection = "Ascending"; internal const string PageDirection = "Forward"; diff --git a/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/PaginationAbuseExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/PaginationAbuseExperimentTests.cs index 58c2320..b63a57a 100644 --- a/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/PaginationAbuseExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/PaginationAbuseExperimentTests.cs @@ -12,7 +12,7 @@ namespace IntegrationTests.Experiments.PaginationAbuse; /// /// Observation experiment: a deterministic careless client against -/// GET /api/v2/products-page. Not a pagination correctness suite. +/// GET /api/v1/products-page. Not a pagination correctness suite. /// [Collection(AspireCollection.Name)] public sealed class PaginationAbuseExperimentTests diff --git a/tests/Lab/IntegrationTests/Experiments/ProcessedMessageDeduplication/ProcessedMessageDeduplicationExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/ProcessedMessageDeduplication/ProcessedMessageDeduplicationExperimentTests.cs index 1965ab9..de3e9d2 100644 --- a/tests/Lab/IntegrationTests/Experiments/ProcessedMessageDeduplication/ProcessedMessageDeduplicationExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/ProcessedMessageDeduplication/ProcessedMessageDeduplicationExperimentTests.cs @@ -71,7 +71,7 @@ public Task DisposeAsync() [Fact] public async Task EnableDeduplication_records_processed_messages_and_suppresses_duplicate_handler_dispatch() { - _fixture.ProcessedEvents.Clear(); + await _fixture.ResetLabObservationAsync(); var startedUtc = DateTimeOffset.UtcNow; using var capture = new InProcessActivityCapture(); diff --git a/tests/Lab/IntegrationTests/Experiments/README.md b/tests/Lab/IntegrationTests/Experiments/README.md index 827a535..87707d3 100644 --- a/tests/Lab/IntegrationTests/Experiments/README.md +++ b/tests/Lab/IntegrationTests/Experiments/README.md @@ -104,12 +104,12 @@ This is research infrastructure only — not Exp 15 and not a BuildingBlock. **COMPLETE — BuildingBlocks.Idempotency 1.0.1** (extraction evidence from 1.0.0; packaging/STJ polish in 1.0.1) -Reusable implementation: [`src/BuildingBlocks/Idempotency`](../../../../src/BuildingBlocks/Idempotency). Lab hosts the package on `POST /api/v2/Order/order`; these three experiments are the **evidence / provenance trail**, not unfinished extraction work. Package unit tests live under `tests/BuildingBlocks/Idempotency.Tests`. MCP `orders.create` idempotency (Exp 6) is a separate in-memory store and is **not** part of this BuildingBlock. +Reusable implementation: [`src/BuildingBlocks/Idempotency`](../../../../src/BuildingBlocks/Idempotency). Lab hosts the package on `POST /api/v1/Order/order`; these three experiments are the **evidence / provenance trail**, not unfinished extraction work. Package unit tests live under `tests/BuildingBlocks/Idempotency.Tests`. MCP `orders.create` idempotency (Exp 6) is a separate in-memory store and is **not** part of this BuildingBlock. ### Experiment 1 — HTTP pagination cursor abuse -- **Hypothesis / problem:** A deterministic “careless” client walking `GET /api/v2/products-page` produces observable cursor semantics (replay, stale reuse, tamper, malformed cursor) without claiming full pagination correctness. -- **Surface:** HTTP `GET /api/v2/products-page` → Mediator `GetProductsQuery` → PostgreSQL. +- **Hypothesis / problem:** A deterministic “careless” client walking `GET /api/v1/products-page` produces observable cursor semantics (replay, stale reuse, tamper, malformed cursor) without claiming full pagination correctness. +- **Surface:** HTTP `GET /api/v1/products-page` → Mediator `GetProductsQuery` → PostgreSQL. - **Primary behavior:** Clean walk yields 56 unique IDs across 8 pages; replay is stable; unsigned tamper shifts seek window; malformed cursor returns HTTP 400 before Mediator/Npgsql. - **Limitation / non-goal:** Not a pagination correctness or performance suite. Host pagination signing key is not configured in the lab. @@ -124,7 +124,7 @@ Reusable implementation: [`src/BuildingBlocks/Idempotency`](../../../../src/Buil - **Role:** Originally characterized Lab HTTP idempotency; now **regression / provenance** that extracted `BuildingBlocks.Idempotency` preserves the intended miss/hit workflow (fingerprint **off**, Lab default). - **Workstream:** COMPLETE — BuildingBlocks.Idempotency 1.0.1 (extraction proof set). -- **Hypothesis / problem:** Package filter (Redis `IDistributedCache`) separates cache replay from production execution on `POST /api/v2/Order/order`. +- **Hypothesis / problem:** Package filter (Redis `IDistributedCache`) separates cache replay from production execution on `POST /api/v1/Order/order`. - **Surface:** HTTP order create → `[Idempotent(useLock: true)]` → `CreateOrderCommandHandler` (Mediator + catalog SaveChanges + outbox insert on miss). - **Primary behavior:** Miss runs production (Mediator + Npgsql); hit replays with `X-Idempotent-Response`; same key + different body keeps original order (body not part of key when fingerprint off); new key runs production again. Replay body casing reflects package System.Text.Json cache serialization (PascalCase by default). - **Limitation / non-goal:** Does not cover MCP `orders.create` (Exp 6). Does not assert async handler delivery (Exp 5). Does not prove concurrency lock races (Exp 4) or opt-in fingerprinting (Exp 12). @@ -162,7 +162,7 @@ Reusable implementation: [`src/BuildingBlocks/Idempotency`](../../../../src/Buil ### Experiment 8 — HTTP order outbox lifecycle fingerprint - **Hypothesis / problem:** Exp 5 proves eventual handler delivery but not `outbox_messages` persistence. Does a cache-miss HTTP order insert one outbox row, does `OutBoxWorker` mark it processed after publish, and does idempotent replay avoid a second row? -- **Surface:** `POST /api/v2/Order/order` (miss) → `CreateOrderCommandHandler` → `IntegrationEventService` → transactional `outbox_messages` → `OutBoxWorker` → `PublishDirect` → RabbitMQ → inbox → handler. +- **Surface:** `POST /api/v1/Order/order` (miss) → `CreateOrderCommandHandler` → `IntegrationEventService` → transactional `outbox_messages` → `OutBoxWorker` → `PublishDirect` → RabbitMQ → inbox → handler. - **Observed behavior:** One outbox row per order (`Id == IntegrationEvent.Id` ≠ `OrderId`); worker sets `Status=Processed` with `ProcessedAt`/`CompletedAt` after `PublishDirect`; one handler observation correlated by `IntegrationEvent.Id`; idempotent replay returns cached HTTP with no Mediator/Npgsql and no additional outbox row; new idempotency key creates a separate order and outbox row (control). - **Evidence:** `CatalogDbContext.OutboxMessages` queries (production persistence); `ProcessedEvents` (test decorator); optional `inbox_messages` correlation; Mediator/Npgsql spans on miss vs replay. - **Limitation / non-goal:** Does not claim exactly-once RabbitMQ delivery or crash consistency if the worker fails between publish and mark-processed. May observe the row already processed immediately after HTTP if the worker poll wins the race (`ProcessedAt==null` is the worker’s pending selector). Does not enable `EnableDeduplication=true`. Does not re-prove consumer duplicate suppression (Exp 7). @@ -222,7 +222,7 @@ Reusable implementation: [`src/BuildingBlocks/Idempotency`](../../../../src/Buil - **Role:** Closes the lease-expiry gap deferred by Exp 4 / package docs: what happens when the first same-key request stays in `Processing` longer than `ProcessingTtl`. - **Workstream:** COMPLETE — BuildingBlocks.Idempotency 1.0.1 (lease-overlap characterization; unchanged in STJ packaging polish). - **Hypothesis / problem:** After the Processing lease expires, a second client with the same `Idempotency-Key` may run production while the first handler is still in flight → duplicate orders/outbox. -- **Surface:** HTTP `POST /api/v2/Order/order` → `BuildingBlocks.Idempotency` (`useLock: true`) → Mediator `CreateOrderCommand` → outbox. Test host only: short `ProcessingTtl` via `PostConfigure` + test-only gated handler wrapper (production handler unchanged). +- **Surface:** HTTP `POST /api/v1/Order/order` → `BuildingBlocks.Idempotency` (`useLock: true`) → Mediator `CreateOrderCommand` → outbox. Test host only: short `ProcessingTtl` via `PostConfigure` + test-only gated handler wrapper (production handler unchanged). - **Primary behavior (characterized by this experiment):** Probe while first held → HTTP 409; after lease expiry → second request executes production; both complete with distinct `orderId`s and outbox rows when the packaged lease tradeoff admits overlap. - **Limitation / non-goal:** Does not add lease renewal or change Lab/package `ProcessingTtl` defaults. Does not re-prove Exp 3/4/12. Gate is test observation infrastructure only. @@ -246,7 +246,7 @@ Reusable implementation: [`src/BuildingBlocks/Idempotency`](../../../../src/Buil - **Role:** Characterizes whether W3C trace context crosses the async messaging boundary, or whether only business ids remain correlatable. - **Hypothesis / problem:** After HTTP order create → outbox → RabbitMQ → consumer, does `EventBus` `ProcessMessage` share the originating TraceId / parent / Activity links, or start a separate trace? -- **Surface:** `POST /api/v2/Order/order` (Lab `traceparent` like Exp 8) → Mediator → outbox → `OutBoxWorker` → `PublishDirect` → consumer `ProcessMessage`. +- **Surface:** `POST /api/v1/Order/order` (Lab `traceparent` like Exp 8) → Mediator → outbox → `OutBoxWorker` → `PublishDirect` → consumer `ProcessMessage`. - **Observed production instrumentation:** No `traceparent` on RabbitMQ headers; `OutBoxWorker`/`PublishDirect` emit no spans; `ProcessMessage` starts a root `EventBus` Activity tagged with `message.id`. Correlation across the boundary is via `IntegrationEvent.Id` / `OrderId`. - **Limitation / non-goal:** Does not add propagation or change Telemetry/EventBus. Documents the gap if separate consumer traces are observed. @@ -377,7 +377,7 @@ These are intentionally avoided. Fourteen numbered experiments share a **convent - **Gateway / rate limiting:** YARP + Memcached tests live in `FeatureFusion.ApiGateway.Tests`, not the Aspire `IntegrationTests` experiment host. - **Inbox deduplication:** Experiment 7 exercises duplicate delivery with `EnableDeduplication=false` (inbox completion). **Exp 17** exercises `EnableDeduplication=true` / `processed_messages`. - **Recommendation cache middleware, feature-flag paths, SigNoz/OTLP in tests:** Present in the lab app but not covered by Experiments 1–18. -- **Test isolation:** `[Collection(AspireCollection.Name)]` with `DisableTestParallelization = true`. Experiments that use `ProcessedEvents` should clear or scope observations (Exp 5 clears at start). +- **Test isolation:** `[Collection(AspireCollection.Name)]` with `DisableTestParallelization = true`. Write/EventBus experiments call `AspireFixture.ResetLabObservationAsync()` (disarm lab crash faults, drain leftover OrderCreated outbox, clear `ProcessedEvents` / journal). `ProcessedEvents` is a thread-safe list. MCP tool-span matching is scoped to the invocation start time. - **Minor artifact inconsistency:** Exp 2 captures `startedUtc` at artifact build time; others capture at test start. JSON property casing differs (anonymous camelCase vs record PascalCase). --- diff --git a/tests/Lab/IntegrationTests/Infrastructure/Collections/ThreadSafeList.cs b/tests/Lab/IntegrationTests/Infrastructure/Collections/ThreadSafeList.cs new file mode 100644 index 0000000..290d10f --- /dev/null +++ b/tests/Lab/IntegrationTests/Infrastructure/Collections/ThreadSafeList.cs @@ -0,0 +1,53 @@ +using System.Collections; + +namespace IntegrationTests.Infrastructure.Collections; + +/// +/// Snapshot-enumerating list for observation logs written by EventBus consumers +/// while tests read/clear the same instance. +/// +public sealed class ThreadSafeList : IReadOnlyList +{ + private readonly List _items = []; + private readonly object _gate = new(); + + public void Add(T item) + { + lock (_gate) + _items.Add(item); + } + + public void Clear() + { + lock (_gate) + _items.Clear(); + } + + public int Count + { + get + { + lock (_gate) + return _items.Count; + } + } + + public T this[int index] + { + get + { + lock (_gate) + return _items[index]; + } + } + + public IEnumerator GetEnumerator() + { + List copy; + lock (_gate) + copy = [.. _items]; + return copy.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/tests/Lab/IntegrationTests/Infrastructure/EventBusLab/EventBusLabHook.cs b/tests/Lab/IntegrationTests/Infrastructure/EventBusLab/EventBusLabHook.cs index 895b8a3..b7de38d 100644 --- a/tests/Lab/IntegrationTests/Infrastructure/EventBusLab/EventBusLabHook.cs +++ b/tests/Lab/IntegrationTests/Infrastructure/EventBusLab/EventBusLabHook.cs @@ -53,6 +53,7 @@ public Task OnAfterPublishBeforeOutboxMarkAsync( /// Deterministic Lab fault switches. Default: all off. public sealed class EventBusLabFaultController { + private readonly object _gate = new(); private Guid? _crashOnceForMessageId; private Guid? _crashOnceForOrderId; private string? _crashOnceForEventType; @@ -60,56 +61,75 @@ public sealed class EventBusLabFaultController public void Clear() { - _crashOnceForMessageId = null; - _crashOnceForOrderId = null; - _crashOnceForEventType = null; - _crashArmedCount = 0; + lock (_gate) + { + _crashOnceForMessageId = null; + _crashOnceForOrderId = null; + _crashOnceForEventType = null; + _crashArmedCount = 0; + } } /// Arm point-B crash for the next matching outbox publish (by message id). public void ArmCrashAfterPublishOnce(Guid messageId) { - Clear(); - _crashOnceForMessageId = messageId; - _crashArmedCount = 1; + lock (_gate) + { + _crashOnceForMessageId = messageId; + _crashOnceForOrderId = null; + _crashOnceForEventType = null; + _crashArmedCount = 1; + } } /// Arm point-B crash for the first publish whose payload OrderId matches. public void ArmCrashAfterPublishOnceForOrderId(Guid orderId) { - Clear(); - _crashOnceForOrderId = orderId; - _crashArmedCount = 1; + lock (_gate) + { + _crashOnceForMessageId = null; + _crashOnceForOrderId = orderId; + _crashOnceForEventType = null; + _crashArmedCount = 1; + } } /// /// Arm point-B crash for the next publish of /// (e.g. OrderCreatedIntegrationEvent). Safe to call before OrderId is known. + /// Call only after draining leftover outbox so a prior test cannot consume the one-shot. /// public void ArmCrashAfterPublishOnceForEventType(string eventType) { - Clear(); - _crashOnceForEventType = eventType; - _crashArmedCount = 1; + lock (_gate) + { + _crashOnceForMessageId = null; + _crashOnceForOrderId = null; + _crashOnceForEventType = eventType; + _crashArmedCount = 1; + } } internal bool ShouldSimulateCrashAfterPublish(Guid messageId, string eventType, Guid? orderId) { - if (_crashArmedCount <= 0) - return false; - - var match = (_crashOnceForMessageId is { } mid && mid == messageId) - || (_crashOnceForOrderId is { } oid && orderId == oid) - || (_crashOnceForEventType is { } et - && string.Equals(et, eventType, StringComparison.Ordinal)); - - if (!match) - return false; - - _crashArmedCount = 0; - _crashOnceForMessageId = null; - _crashOnceForOrderId = null; - _crashOnceForEventType = null; - return true; + lock (_gate) + { + if (_crashArmedCount <= 0) + return false; + + var match = (_crashOnceForMessageId is { } mid && mid == messageId) + || (_crashOnceForOrderId is { } oid && orderId == oid) + || (_crashOnceForEventType is { } et + && string.Equals(et, eventType, StringComparison.Ordinal)); + + if (!match) + return false; + + _crashArmedCount = 0; + _crashOnceForMessageId = null; + _crashOnceForOrderId = null; + _crashOnceForEventType = null; + return true; + } } } diff --git a/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolSpans.cs b/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolSpans.cs new file mode 100644 index 0000000..416fe50 --- /dev/null +++ b/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolSpans.cs @@ -0,0 +1,25 @@ +using IntegrationTests.Infrastructure.Telemetry; + +namespace IntegrationTests.Infrastructure.Mcp; + +/// +/// Picks the mcp.tool span for this invocation. Ignores earlier spans in the same +/// (full-suite listeners can retain extra stops). +/// +public static class McpToolSpans +{ + public static CapturedActivity? TakeNew( + IReadOnlyList all, + string toolName, + HashSet seenTraceIds, + DateTime startedUtc) + { + var floor = startedUtc.AddSeconds(-1); + return all.FirstOrDefault(span => + span.DisplayName == "mcp.tool" + && span.StartTimeUtc >= floor + && span.Tags.TryGetValue("mcp.tool.name", out var name) + && name == toolName + && seenTraceIds.Add(span.TraceId)); + } +} diff --git a/tests/Lab/IntegrationTests/Infrastructure/Orders/CreatedOrderCleanup.cs b/tests/Lab/IntegrationTests/Infrastructure/Orders/CreatedOrderCleanup.cs new file mode 100644 index 0000000..ecb6f18 --- /dev/null +++ b/tests/Lab/IntegrationTests/Infrastructure/Orders/CreatedOrderCleanup.cs @@ -0,0 +1,33 @@ +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace IntegrationTests.Infrastructure.Orders; + +/// +/// Removes runtime CreateOrder rows (ORD-{Ulid}) so Demo Commerce seed fixtures stay observable. +/// Does not touch seed order numbers (ORD-PWR-*, ORD-HIGH-001, ORD-####). +/// +internal static class CreatedOrderCleanup +{ + public static async Task DeleteByDomainIdsAsync(IServiceProvider services, params int[] domainOrderIds) + { + if (domainOrderIds.Length == 0) + return; + + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var orders = await db.Orders + .Include(o => o.Items) + .Where(o => domainOrderIds.Contains((int)o.Id)) + .ToListAsync() + .ConfigureAwait(false); + + if (orders.Count == 0) + return; + + db.OrderItems.RemoveRange(orders.SelectMany(o => o.Items)); + db.Orders.RemoveRange(orders); + await db.SaveChangesAsync().ConfigureAwait(false); + } +} diff --git a/tests/Lab/IntegrationTests/Infrastructure/Orders/HttpOrderCreate.cs b/tests/Lab/IntegrationTests/Infrastructure/Orders/HttpOrderCreate.cs index b2f1ce5..57269ac 100644 --- a/tests/Lab/IntegrationTests/Infrastructure/Orders/HttpOrderCreate.cs +++ b/tests/Lab/IntegrationTests/Infrastructure/Orders/HttpOrderCreate.cs @@ -11,7 +11,7 @@ namespace IntegrationTests.Infrastructure.Orders; /// public static class HttpOrderCreate { - public const string Path = "/api/v2/Order/order"; + public const string Path = "/api/v1/Order/order"; public const string IdempotencyHeader = "Idempotency-Key"; public const string CachedResponseHeader = "X-Idempotent-Response"; diff --git a/tests/Lab/IntegrationTests/Infrastructure/Telemetry/CapturedActivity.cs b/tests/Lab/IntegrationTests/Infrastructure/Telemetry/CapturedActivity.cs index d861773..8680251 100644 --- a/tests/Lab/IntegrationTests/Infrastructure/Telemetry/CapturedActivity.cs +++ b/tests/Lab/IntegrationTests/Infrastructure/Telemetry/CapturedActivity.cs @@ -11,6 +11,7 @@ public sealed record CapturedActivity( string? ParentSpanId, string Source, string DisplayName, + DateTime StartTimeUtc, TimeSpan Duration, IReadOnlyDictionary Tags, IReadOnlyList Links) @@ -36,6 +37,7 @@ public static CapturedActivity From(Activity activity) activity.ParentSpanId == default ? null : activity.ParentSpanId.ToHexString(), activity.Source.Name, activity.DisplayName, + activity.StartTimeUtc, activity.Duration, tags, links); diff --git a/web/README.md b/web/README.md index e20331c..746bad2 100644 --- a/web/README.md +++ b/web/README.md @@ -2,7 +2,7 @@ Reserved **Next.js** project root for a FeatureFusion frontend showcase. This folder is **not** in `FeatureFusion.sln` (the solution stays .NET-only). -The lab API, Aspire AppHost, and BuildingBlocks packages live under `src/`. This directory is the place to add a TypeScript UI later that calls the same HTTP surfaces (for example `GET /api/v2/products-page` keyset pagination). +The lab API, Aspire AppHost, and BuildingBlocks packages live under `src/`. This directory is the place to add a TypeScript UI later that calls the Demo Commerce HTTP surfaces (`GET /api/v1/catalog/*`, `/api/v1/customers/{id}/cart`, checkout, `/api/v1/orders`) and the pagination-lab keyset route (`GET /api/v1/products-page`). ## Status