diff --git a/src/AGENTS.md b/src/AGENTS.md index 0cee3e5b2..8810b3e27 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -28,7 +28,7 @@ record you must read before changing the rule. Persistence edits also read - **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. Every contract type sits top-level in its own file in the module's `Modules//Contracts/` folder and namespace (#1087): the interface, its commands and its `*Details` result records, and in Domain the enums entities use. `RealModuleLedger` collects them into the owner's `Contract` through `ModuleContracts.OwnerOf`, the rule CW1004 applies too. A type nested in a contract type through public types only, such as `FlockNameResolution.Found`, is contract as well, and the purity guard walks it; a nested type behind a private or internal level is not (#1116). A `Contracts` namespace naming no owner fails. `NamespaceFolderAgreementTests` keeps every file's namespace equal to its folder, so a file moved out of `Contracts/` cannot keep its contract namespace. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations, Commerce and Insights are contracted today. `IInsightsModule` inherits `IReportQueries` and `IExportQueries`; never move either into `Contracts/`, because doing so lets any adapter or peer inject it past the ledger guards and skip the facade. → [`849-module-contract.md`](../docs/decisions/849-module-contract.md) **Peer modules reach a contracted module only through its contract or `seam` (#1023).** `PeerContractRealTreeTests` runs the adapter walk over every module's own namespaces and fails when a member takes another contracted module's type listed in neither its `Contract` nor its `Seam`. Adapters never get the seam. Farm's seam is `IAccountRepository`, `Account`, `UserRoleAssignment` and `ICurrencyBoundRowSource`, the currency probe port peer repositories implement (#1116); add to it only on purpose. Every `contract` and `seam` entry names a non-generic type, because the walk drops generic arity, and a `seam` entry a top-level one; a nested contract entry's reflection `+` is compared as the source's `.`. Like the adapter check, it reads parameter types and service resolutions, never return types or method bodies. → [`1023-peer-contract-guard.md`](../docs/decisions/1023-peer-contract-guard.md) **Farm's contract stops at settings, logo and banner (#851), plus the cross-farm `IFarmDirectory` (#858).** `IFarmDirectory` ignores the tenant filter so the operator verbs and the lock sweep can list farms and resolve a farm code; `FarmDirectoryCallerTests` fails when a file outside `Api/Cli`, `Infrastructure/Jobs` and the directory's declaration, implementation and registration names it or `AccountRepository`, because a request path that reached it could show one farm another's. Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. That method is the only caller of the seam's cross-farm `IAccountRepository.FindBySlugAsync`; `FindBySlugCallerTests` fails when any other member references it (#1053). `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](../docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. A peer that injects `IFlockRepository` again fails `PeerContractRealTreeTests` (#1023). → [`852-flock-contract.md`](../docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](../docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](../docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a planner over the locked lots that changes nothing, so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. The port refuses to run without a transaction, and a reservation refuses to plan or draw once the transaction that locked it is no longer current (`EggStockTransactionTests`). Commerce never sees `EggLot`: `PeerContractRealTreeTests` fails when a Commerce type takes an Egg Operations type outside its contract, though it reads only parameter types and service resolutions, not return types or method bodies. `ConfirmSaleAtomicityTests` fails when the confirm runs outside its transaction or saves a draw before the end, each on its own. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](../docs/decisions/854-commerce-contract.md) - **Access has an aggregate-free contract and owns its Common identity ports (#857/#1023).** Its 29 ledger entries separate request/profile operations, authorization lookup, epoch verification, purge, operations, lifecycle and fixture reads. `IIdentityProvider` and `IStepUpGrantService` sit in Access's own namespace (`Application/Modules/Access/Users`), outside the contract; a peer or adapter taking either fails the ownership walk. Fixture reads use `IAccessSeedLookup`, registered outside Production, so demo does not construct IdentityProvider or Redis. `ICurrentUser` and `CurrentUserContext` remain one scoped instance; the context and `SimulationOptions` belong to Persistence. Assignment reads return null only for zero rows or any null-flock row; failures propagate, and otherwise every assigned flock remains in the set. Lifecycle verbs must resolve without UserManager, Identity or Redis. Simulation's flock assignment goes through `IAccessFixture`, also registered outside Production: it skips only the interactive handler's step-up and Worker-role checks, and refuses another farm's user, flock or tenant (#858). Both seeders read the Owner-role prerequisite through `IAccessSeedLookup` (#858); `AccessModule` names a user's flock assignments through `IFlockLookup.GetDisplayNamesAsync` (#859). Session reshaping needs a separate design. → [`857-access-contract.md`](../docs/decisions/857-access-contract.md) - **Register every read of a contracted module's tables from outside it (#514/#850).** `CompatibilityExceptionRealTreeTests` compiles `src/Cluckwork.Infrastructure` from source, and the projects referencing it with errors tolerated. It finds every member that obtains a `DbSet`, maps `T` through the EF model to the tables a query touches (its own, owned values mapped apart and derived types), and takes each table's owner from the table-owner walk, so a `TableOwnerOverrides` row outranks the entity's namespace. A read of a contracted module's table is allowed for the module's own types (its repositories included, since #1087 S9), a type its ledger edge's `Symbols` names, and a `DbSet` property whose whole body is `Set()`. Every other read needs a `RealModuleLedger.CompatibilityExceptions` row, keyed by namespace, type and member, with `Reaches`, the `Tables` read, a ledger `Owner`, a `Reason` and `DeleteWhen`, the slice issue that deletes it. `DeleteWhen` is `#`, never a date. A new read, a table the row does not name, a `DbSet` of a type parameter and an unbound candidate name fail; a row or table no longer read fails as stale; an Infrastructure compile error fails closed. SQL text is not read. → [`850-compatibility-exceptions.md`](../docs/decisions/850-compatibility-exceptions.md) -- **Seeders and operator verbs compose modules through contracts and fixture ports (#514/#858).** Seeder-only reads and writes go through `IFixture` (Access: `IAccessSeedLookup`) in the module's `Contracts/` folder, and registered only inside `AddCluckworkPersistence`'s `!IsProduction()` block; members return ids, counts, records or a `Result`, never entities or queries, and purges join the caller's transaction and never commit. `FixturePortRegistrationTests` runs the real API per process role and environment and fails if Production resolves any `I*Fixture`; name a new port that way or add it beside `IAccessSeedLookup`. Cross-farm operator reads use Farm's `IFarmDirectory`, named only where `FarmDirectoryCallerTests` allows and never behind a forwarder. Register services in the owner's file under `Cluckwork.Api/Modules//` and list a new module once in `AddCluckworkModules`. `RealModuleLedger.CompatibilityExceptions` is empty since #859. Order the guards cannot see is kept, not enforced: the first two `ICurrencyBoundRowSource` registrations and demo cleanup's flocks-last delete. → [`858-platform-composition.md`](../docs/decisions/858-platform-composition.md) +- **Seeders and operator verbs compose modules through contracts and fixture ports (#514/#858).** Seeder-only reads and writes go through `IFixture` (Access: `IAccessSeedLookup`) in the module's `Contracts/` folder, and registered only inside `AddCluckworkPersistence`'s `!IsProduction()` block; members return ids, counts, records or a `Result`, never entities or queries, and purges join the caller's transaction and never commit. `FixturePortRegistrationTests` runs the real API per process role and environment and fails if Production resolves any `I*Fixture`; name a new port that way or add it beside `IAccessSeedLookup`. Cross-farm operator reads use Farm's `IFarmDirectory`, named only where `FarmDirectoryCallerTests` allows and never behind a forwarder. Register services in the owner's file under `Cluckwork.Api/Modules//` and list a new module once in `AddCluckworkModules`. Farm provisioning is the one Production writer into peers: `AccountProvisioner` stages a new farm's default grades and unit conversions through Egg Operations' `IEggGradeProvisioning` and Commerce's `IEggUnitConversionProvisioning`, which stage for the resolved tenant into the caller's context, refuse to run outside its transaction, and never save (#1116, `ProvisioningPortTests`). `RealModuleLedger.CompatibilityExceptions` is empty since #859. Order the guards cannot see is kept, not enforced: the first two `ICurrencyBoundRowSource` registrations and demo cleanup's flocks-last delete. → [`858-platform-composition.md`](../docs/decisions/858-platform-composition.md) ### Data and correctness diff --git a/src/Cluckwork.Api/Modules/Commerce/CommerceModuleServiceCollectionExtensions.cs b/src/Cluckwork.Api/Modules/Commerce/CommerceModuleServiceCollectionExtensions.cs index e9cc1387a..97d444b86 100644 --- a/src/Cluckwork.Api/Modules/Commerce/CommerceModuleServiceCollectionExtensions.cs +++ b/src/Cluckwork.Api/Modules/Commerce/CommerceModuleServiceCollectionExtensions.cs @@ -81,6 +81,7 @@ public static IServiceCollection AddCommerceModule(this IServiceCollection servi services.AddScoped< Cluckwork.Application.Modules.Commerce.Contracts.IEggUnitConversionLookup, Cluckwork.Application.Modules.Commerce.Catalog.EggUnitConversionLookup>(); + services.AddScoped(); services.AddScoped(); services.AddScoped< Cluckwork.Application.Modules.Commerce.Catalog.CreateProduct.CreateProductHandler>(); diff --git a/src/Cluckwork.Api/Modules/EggOperations/EggOperationsModuleServiceCollectionExtensions.cs b/src/Cluckwork.Api/Modules/EggOperations/EggOperationsModuleServiceCollectionExtensions.cs index 324c0040e..e82c03094 100644 --- a/src/Cluckwork.Api/Modules/EggOperations/EggOperationsModuleServiceCollectionExtensions.cs +++ b/src/Cluckwork.Api/Modules/EggOperations/EggOperationsModuleServiceCollectionExtensions.cs @@ -58,6 +58,7 @@ public static IServiceCollection AddEggOperationsModule(this IServiceCollection Cluckwork.Application.Modules.EggOperations.Eggs.EggOperationsModule>(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/src/Cluckwork.Application/Modules/Commerce/Contracts/IEggUnitConversionProvisioning.cs b/src/Cluckwork.Application/Modules/Commerce/Contracts/IEggUnitConversionProvisioning.cs new file mode 100644 index 000000000..95a1d4997 --- /dev/null +++ b/src/Cluckwork.Application/Modules/Commerce/Contracts/IEggUnitConversionProvisioning.cs @@ -0,0 +1,8 @@ +namespace Cluckwork.Application.Modules.Commerce.Contracts; + +// #1116: Access provisioning's one write into Commerce. Stages a new farm's default unit conversions for the +// resolved tenant into the caller's unit of work, inside the caller's transaction, and never saves; the caller commits. +public interface IEggUnitConversionProvisioning +{ + void StageDefaults(); +} diff --git a/src/Cluckwork.Application/Modules/EggOperations/Contracts/IEggGradeProvisioning.cs b/src/Cluckwork.Application/Modules/EggOperations/Contracts/IEggGradeProvisioning.cs new file mode 100644 index 000000000..fee6d2e75 --- /dev/null +++ b/src/Cluckwork.Application/Modules/EggOperations/Contracts/IEggGradeProvisioning.cs @@ -0,0 +1,8 @@ +namespace Cluckwork.Application.Modules.EggOperations.Contracts; + +// #1116: Access provisioning's one write into Egg Operations. Stages a new farm's default grades for the resolved +// tenant into the caller's unit of work, inside the caller's transaction, and never saves; the caller commits. +public interface IEggGradeProvisioning +{ + void StageDefaults(); +} diff --git a/src/Cluckwork.Domain/Common/Architecture/Modules/Access.cs b/src/Cluckwork.Domain/Common/Architecture/Modules/Access.cs index 1ba73a4fc..1e150f6f9 100644 --- a/src/Cluckwork.Domain/Common/Architecture/Modules/Access.cs +++ b/src/Cluckwork.Domain/Common/Architecture/Modules/Access.cs @@ -7,7 +7,7 @@ namespace Cluckwork.Domain.Common.Architecture; ])] [ModuleEdge( "Access", "Commerce", "W", - "W because AccountProvisioner inserts EggUnitConversion.Defaults for a new farm inside its provisioning transaction; the rest of the cell reads. Identity carries the per-user stepper-unit preference: ApplicationUser stores Domain.Catalog.EggUnit, IIdentityProvider, the Identity port Access claims, takes it in SetStepperUnitAsync, IdentityProvider reads and writes it, SetStepperUnitHandler resolves it through Commerce's IEggUnitConversionLookup port (#854) and rejects inactive conversions, and SetStepperUnitValidator validates only the enum name. Nothing prevents a stored preference from pointing at a conversion later deactivated through UpdateEggUnitConversionHandler. Design 3.4 shows Access -> Commerce as none; this is live coupling the target design has still to remove. ApplicationUserConfiguration maps the stepper-unit preference column as the Domain.Catalog.EggUnit enum name (#1087 S9). The Access contract's UserProfile returns that preference to the /me adapter typed as Commerce's published Cluckwork.Domain.Modules.Commerce.Contracts.EggUnit, as Farm's FarmSettingsDetails returns the farm default (#1103).", + "W because AccountProvisioner stages a new farm's default unit conversions through Commerce's IEggUnitConversionProvisioning port inside its provisioning transaction (#1116); the rest of the cell reads. Identity carries the per-user stepper-unit preference: ApplicationUser stores Domain.Catalog.EggUnit, IIdentityProvider, the Identity port Access claims, takes it in SetStepperUnitAsync, IdentityProvider reads and writes it, SetStepperUnitHandler resolves it through Commerce's IEggUnitConversionLookup port (#854) and rejects inactive conversions, and SetStepperUnitValidator validates only the enum name. Nothing prevents a stored preference from pointing at a conversion later deactivated through UpdateEggUnitConversionHandler. Design 3.4 shows Access -> Commerce as none; this is live coupling the target design has still to remove. ApplicationUserConfiguration maps the stepper-unit preference column as the Domain.Catalog.EggUnit enum name (#1087 S9). The Access contract's UserProfile returns that preference to the /me adapter typed as Commerce's published Cluckwork.Domain.Modules.Commerce.Contracts.EggUnit, as Farm's FarmSettingsDetails returns the farm default (#1103).", "Cluckwork.Application.Modules.Access.Contracts.UserProfile", "Cluckwork.Application.Modules.Access.Users.IIdentityProvider", "Cluckwork.Application.Modules.Access.Users.SetStepperUnit.SetStepperUnitHandler", @@ -18,7 +18,7 @@ namespace Cluckwork.Domain.Common.Architecture; "Cluckwork.Infrastructure.Modules.Access.Configurations.ApplicationUserConfiguration")] [ModuleEdge( "Access", "EggOperations", "W", - "W because AccountProvisioner inserts a new farm's default grades, Domain.Eggs.EggGrade.Defaults(accountId, SeedDefaults.FarmId), inside its provisioning transaction, so farm provisioning writes Egg Operations' table and depends on its grade shape. Design 3.4 shows Access -> Egg Ops as none; this is live coupling the target design has still to remove.", + "W because AccountProvisioner stages a new farm's default grades through Egg Operations' IEggGradeProvisioning port inside its provisioning transaction (#1116), so farm provisioning writes Egg Operations' table while Egg Operations builds the rows and owns their shape. Design 3.4 shows Access -> Egg Ops as none; this is live coupling the target design has still to remove.", "Cluckwork.Infrastructure.Modules.Access.Identity.AccountProvisioner")] [ModuleEdge( "Access", "Farm", "W", diff --git a/src/Cluckwork.Domain/Common/Architecture/Modules/EggOperations.cs b/src/Cluckwork.Domain/Common/Architecture/Modules/EggOperations.cs index 9e8dc9ddf..d02a5d4ef 100644 --- a/src/Cluckwork.Domain/Common/Architecture/Modules/EggOperations.cs +++ b/src/Cluckwork.Domain/Common/Architecture/Modules/EggOperations.cs @@ -8,9 +8,10 @@ namespace Cluckwork.Domain.Common.Architecture; ])] [ModuleEdge( "EggOperations", "Farm", "R", - "CreateEggGradeHandler attaches a new grade to Domain.Accounts.SeedDefaults.FarmId, the single-farm stand-in for the farm the grade belongs to; EggGradeFloorPolicy resolves the caller's effective role through Domain.Accounts.Roles to decide whether a grade's low-stock floor may move (#911, Owner-only per #729). Design 3.4 row Egg Ops -> Farm = R. EggOperationsFixture counts the simulation fixture's daily entries at SeedDefaults.FarmId and SeedDefaults.HouseId (#858, #1087 S9).", + "CreateEggGradeHandler attaches a new grade to Domain.Accounts.SeedDefaults.FarmId, the single-farm stand-in for the farm the grade belongs to; EggGradeFloorPolicy resolves the caller's effective role through Domain.Accounts.Roles to decide whether a grade's low-stock floor may move (#911, Owner-only per #729). Design 3.4 row Egg Ops -> Farm = R. EggOperationsFixture counts the simulation fixture's daily entries at SeedDefaults.FarmId and SeedDefaults.HouseId (#858, #1087 S9), and EggGradeProvisioning places a new farm's default grades on SeedDefaults.FarmId (#1116).", "Cluckwork.Application.Modules.EggOperations.EggGrades.CreateEggGrade.CreateEggGradeHandler", "Cluckwork.Application.Modules.EggOperations.EggGrades.EggGradeFloorPolicy", + "Cluckwork.Infrastructure.Modules.EggOperations.Repositories.EggGradeProvisioning", "Cluckwork.Infrastructure.Modules.EggOperations.Repositories.EggOperationsFixture")] [ModuleEdge( "EggOperations", "FlockManagement", "W", diff --git a/src/Cluckwork.Infrastructure/Modules/Access/Identity/AccountProvisioner.cs b/src/Cluckwork.Infrastructure/Modules/Access/Identity/AccountProvisioner.cs index f2e730f68..1f6352d86 100644 --- a/src/Cluckwork.Infrastructure/Modules/Access/Identity/AccountProvisioner.cs +++ b/src/Cluckwork.Infrastructure/Modules/Access/Identity/AccountProvisioner.cs @@ -1,10 +1,10 @@ using Cluckwork.Application.Common; using Cluckwork.Application.Modules.Access.Contracts; using Cluckwork.Application.Modules.Access.Users; +using Cluckwork.Application.Modules.Commerce.Contracts; +using Cluckwork.Application.Modules.EggOperations.Contracts; using Cluckwork.Application.Modules.Farm.Contracts; using Cluckwork.Domain.Common; -using Cluckwork.Domain.Modules.Commerce.Catalog; -using Cluckwork.Domain.Modules.EggOperations.Eggs; using Cluckwork.Domain.Modules.Farm.Accounts; using Cluckwork.Domain.Modules.Farm.Contracts; using Cluckwork.Infrastructure.Persistence; @@ -19,7 +19,9 @@ public sealed class AccountProvisioner( CurrentUserContext currentUser, IAccountUserDirectory users, IIdentityProvider identity, - IAuditWriter audit) + IAuditWriter audit, + IEggGradeProvisioning eggGrades, + IEggUnitConversionProvisioning eggUnitConversions) { public async Task> ProvisionAsync( string? name, @@ -104,8 +106,8 @@ private async Task> ProvisionValidatedAsync( db.Accounts.Add(account); await db.SaveChangesAsync(token); - db.EggGrades.AddRange(EggGrade.Defaults(accountId, SeedDefaults.FarmId)); - db.EggUnitConversions.AddRange(EggUnitConversion.Defaults(accountId)); + eggGrades.StageDefaults(); + eggUnitConversions.StageDefaults(); await audit.WriteAsync( AuditActions.AccountProvisioned, "Account", accountId, details: new { input.Slug }, ct: token); diff --git a/src/Cluckwork.Infrastructure/Modules/Commerce/Repositories/EggUnitConversionProvisioning.cs b/src/Cluckwork.Infrastructure/Modules/Commerce/Repositories/EggUnitConversionProvisioning.cs new file mode 100644 index 000000000..d81df07dd --- /dev/null +++ b/src/Cluckwork.Infrastructure/Modules/Commerce/Repositories/EggUnitConversionProvisioning.cs @@ -0,0 +1,19 @@ +using Cluckwork.Application.Modules.Commerce.Contracts; +using Cluckwork.Domain.Modules.Commerce.Catalog; +using Cluckwork.Infrastructure.Persistence; + +namespace Cluckwork.Infrastructure.Modules.Commerce.Repositories; + +// The farm is the resolved tenant, never an argument, so no caller can stage another farm's conversions. +public sealed class EggUnitConversionProvisioning(AppDbContext db, TenantContext tenant) : IEggUnitConversionProvisioning +{ + public void StageDefaults() + { + if (!tenant.IsResolved) + throw new InvalidOperationException("Default unit conversions are staged only for a resolved tenant."); + if (db.Database.CurrentTransaction is null) + throw new InvalidOperationException("Default unit conversions are staged only inside the provisioning transaction."); + + db.EggUnitConversions.AddRange(EggUnitConversion.Defaults(tenant.AccountId)); + } +} diff --git a/src/Cluckwork.Infrastructure/Modules/EggOperations/Repositories/EggGradeProvisioning.cs b/src/Cluckwork.Infrastructure/Modules/EggOperations/Repositories/EggGradeProvisioning.cs new file mode 100644 index 000000000..9d828a167 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Modules/EggOperations/Repositories/EggGradeProvisioning.cs @@ -0,0 +1,20 @@ +using Cluckwork.Application.Modules.EggOperations.Contracts; +using Cluckwork.Domain.Modules.EggOperations.Eggs; +using Cluckwork.Domain.Modules.Farm.Contracts; +using Cluckwork.Infrastructure.Persistence; + +namespace Cluckwork.Infrastructure.Modules.EggOperations.Repositories; + +// The farm is the resolved tenant, never an argument, so no caller can stage another farm's grades. +public sealed class EggGradeProvisioning(AppDbContext db, TenantContext tenant) : IEggGradeProvisioning +{ + public void StageDefaults() + { + if (!tenant.IsResolved) + throw new InvalidOperationException("Default egg grades are staged only for a resolved tenant."); + if (db.Database.CurrentTransaction is null) + throw new InvalidOperationException("Default egg grades are staged only inside the provisioning transaction."); + + db.EggGrades.AddRange(EggGrade.Defaults(tenant.AccountId, SeedDefaults.FarmId)); + } +} diff --git a/tests/Cluckwork.Api.IntegrationTests/ProvisioningPortTests.cs b/tests/Cluckwork.Api.IntegrationTests/ProvisioningPortTests.cs new file mode 100644 index 000000000..5b2dc7de8 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/ProvisioningPortTests.cs @@ -0,0 +1,93 @@ +using Cluckwork.Application.Modules.Commerce.Contracts; +using Cluckwork.Application.Modules.EggOperations.Contracts; +using Cluckwork.Domain.Modules.Commerce.Catalog; +using Cluckwork.Domain.Modules.EggOperations.Eggs; +using Cluckwork.Domain.Modules.Farm.Contracts; +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cluckwork.Api.IntegrationTests; + +public sealed class ProvisioningPortFactory : CluckworkWebApplicationFactory; + +// #1116: AccountProvisioner stages a new farm's default grades and unit conversions through these two Production +// ports. Each stages for the resolved tenant into the caller's scoped context, only inside the caller's +// transaction, and never saves. AccountProvisioningTests.Provision_WhenIdentityRejectsOwner_RollsBackTheAccount +// covers the rollback when Owner creation fails after they have run. +public sealed class ProvisioningPortTests(ProvisioningPortFactory factory) : IClassFixture +{ + public static TheoryData Ports => ["grades", "conversions"]; + + [Theory] + [MemberData(nameof(Ports))] + public async Task StageDefaults_AddsTheResolvedTenantsRowsToTheCallersContext_WithoutSaving(string port) + { + var accountId = Guid.NewGuid(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + scope.ServiceProvider.GetRequiredService().Resolve(accountId); + + var (staged, stored) = await AmbientTransaction.RunAsync(db.Database, async (transaction, ct) => + { + Stage(scope.ServiceProvider, port); + // Read inside the same transaction: a save would show here. + var result = (StagedAccountIds(db, port), await StoredCountAsync(db, port, accountId)); + await transaction.RollbackAsync(ct); + return result; + }); + + Assert.Equal(DefaultCount(port, accountId), staged.Count); + Assert.All(staged, id => Assert.Equal(accountId, id)); + Assert.Equal(0, stored); + } + + [Theory] + [MemberData(nameof(Ports))] + public async Task StageDefaults_RefusesAnUnresolvedTenant(string port) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await AmbientTransaction.RunAsync(db.Database, async (transaction, ct) => + { + Assert.Throws(() => Stage(scope.ServiceProvider, port)); + await transaction.RollbackAsync(ct); + return 0; + }); + Assert.Empty(db.ChangeTracker.Entries()); + } + + [Theory] + [MemberData(nameof(Ports))] + public void StageDefaults_RefusesToRunOutsideATransaction(string port) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + scope.ServiceProvider.GetRequiredService().Resolve(Guid.NewGuid()); + + Assert.Throws(() => Stage(scope.ServiceProvider, port)); + Assert.Empty(db.ChangeTracker.Entries()); + } + + private static void Stage(IServiceProvider services, string port) + { + if (port == "grades") + services.GetRequiredService().StageDefaults(); + else + services.GetRequiredService().StageDefaults(); + } + + private static List StagedAccountIds(AppDbContext db, string port) => port == "grades" + ? [.. db.ChangeTracker.Entries().Where(e => e.State == EntityState.Added).Select(e => e.Entity.AccountId)] + : [.. db.ChangeTracker.Entries().Where(e => e.State == EntityState.Added).Select(e => e.Entity.AccountId)]; + + private static int DefaultCount(string port, Guid accountId) => port == "grades" + ? EggGrade.Defaults(accountId, SeedDefaults.FarmId).Count + : EggUnitConversion.Defaults(accountId).Count; + + private static Task StoredCountAsync(AppDbContext db, string port, Guid accountId) => port == "grades" + ? db.EggGrades.IgnoreQueryFilters().AsNoTracking().CountAsync(g => g.AccountId == accountId) + : db.EggUnitConversions.IgnoreQueryFilters().AsNoTracking().CountAsync(c => c.AccountId == accountId); +} diff --git a/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md b/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md index 77757befd..4d9960103 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md +++ b/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md @@ -7,7 +7,7 @@ | Access | — | W (21) fk:1 | R (3) | W (1) | W (8) | — | — | — | P | | Farm | — | — | — | — | R (6) | — | — | — | P | | FlockManagement | — | R (1) | — | R (1) fk:1 | — | — | — | — | P | -| EggOperations | — | R (3) | W (4) | — | — | — | — | — | P | +| EggOperations | — | R (4) | W (4) | — | — | — | — | — | P | | Commerce | R (1) | R (7) | — | W (8) fk:3 | — | — | — | — | P | | GeneralInventory | — | R (6) | R (6) fk:3 | R (4) fk:2 | — | — | — | — | P | | Finance | — | R (4) | R (3) fk:1 | — | — | — | — | — | P |