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